diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 1b1a0b62..c7c7025c 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -1,87 +1,86 @@ name: Browser conformance -# Browser matrix ( issue #102, фаза D): сверка СОБ� �АННОГО dist с реальным -# по ведением трёх движков (Chr omium/Firefox/WebKit). Гейт запуск� �ется для каждого -# PR: require d check не должен исчезать и з-за paths-фильтра. Артефак� �ы -# (trace/video) выгружаются т олько при сбое. +# Browser matrix (issue #102, фаза D): сверка СОБРАННОГО dist с реальным +# поведением трёх движков (Chromium/Firefox/WebKit). Гейт запускается для каждого +# PR: required check не должен исчезать из-за paths-фильтра. Артефакты +# (trace/video) выгружаются только при сбое. on: push: - branches: ["main"] + branches: ["main"] pull_request: - branch es: ["main"] + branches: ["main"] merge_group: - workflow_dispat ch: + workflow_dispatch: permissions: contents: read -concurren cy: - group: browser-${{ github.workflow }}-$ {{ github.event.pull_request.number || github .ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +concurrency: + group: browser-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - COREPACK_ENAB LE_DOWNLOAD_PROMPT: "0" - COREPACK_DEFAULT_TO _LATEST: "0" + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + COREPACK_DEFAULT_TO_LATEST: "0" jobs: conformance: - name: ${{ matrix.browser }} - runs-on: self-hoste d + name: ${{ matrix.browser }} + runs-on: ubuntu-latest timeout-minutes: 25 strategy: - fail-fast: false + fail-fast: false matrix: - brows er: [chromium, firefox, webkit] + browser: [chromium, firefox, webkit] steps: - - name: Checkout - uses: actions/c heckout@3d3c42e5aac5ba805825da76410c181273ba9 0b1 # v7 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - # Токен не нужен после checkout (пуш� � нет) — не оставляем его на диске - # перед pnpm i nstall (postinstall-скрипты треть их сторон). SAST: artipacked. - persist-credentials: false + # Токен не нужен после checkout (пуша нет) — не оставляем его на диске + # перед pnpm install (postinstall-скрипты третьих сторон). SAST: artipacked. + persist-credentials: false - - name: Setup Node - uses: actions/setup-node@8 20762786026740c76f36085b0efc47a31fe5020 # v6 - with: + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: node-version: "24" - - name: Activate pinned pnpm - run : | + - name: Activate pinned pnpm + run: | corepack enable - corep ack install --global pnpm@11.11.0 + corepack install --global pnpm@11.11.0 - - na me: Install dependencies - run: pnpm in stall --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - # Устана вливаем ТОЛЬКО нужный дв ижок матрицы (+ системные зависимости). - - name: Insta ll Playwright browser - run: pnpm exec playwright install --with-deps ${{ matrix.bro wser }} + # Устанавливаем ТОЛЬКО нужный движок матрицы (+ системные зависимости). + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps ${{ matrix.browser }} - # Типопроверка bro wser-спеков (отдельный tsconfi g: DOM lib + /dist/*→src/* - # path-map ping) — специфичный tsc не в� �одит в основной `pnpm typecheck `. + # Типопроверка browser-спеков (отдельный tsconfig: DOM lib + /dist/*→src/* + # path-mapping) — специфичный tsc не входит в основной `pnpm typecheck`. - name: Typecheck browser specs - run: pnpm typecheck:browser + run: pnpm typecheck:browser - # Сп еки грузят СОБРАННЫЕ librar y dist и showcase site/dist — канони ческий - # producer обязан з авершиться до Playwright в ка� �дом движке матрицы. - - name: Build library and showcase - run: pnpm site:build + # Спеки грузят СОБРАННЫЕ library dist и showcase site/dist — канонический + # producer обязан завершиться до Playwright в каждом движке матрицы. + - name: Build library and showcase + run: pnpm site:build - - name: Run conforman ce (${{ matrix.browser }}) - run: pnpm exec playwright test --project=${{ matrix.bro wser }} + - name: Run conformance (${{ matrix.browser }}) + run: pnpm exec playwright test --project=${{ matrix.browser }} - # Trace/video/screenshot пи� �утся только on-failure (playwright .config) — - # выгружаем их лишь когда шаг упал. - - name: Upload failure artifacts - if: fa ilure() - uses: actions/upload-artifact @043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v 7 + # Trace/video/screenshot пишутся только on-failure (playwright.config) — + # выгружаем их лишь когда шаг упал. + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: browser-confo rmance-${{ matrix.browser }}-${{ github.run_i d }} + name: browser-conformance-${{ matrix.browser }}-${{ github.run_id }} path: | - test-resul ts/ + test-results/ playwright-report/ - if-no-files-found: ignore - retention -days: 7 - \ No newline at end of file + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0f2a7a1..4315a2bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,191 +3,190 @@ name: CI on: push: branches: ["main"] - pull_request: + pull_request: branches: ["main"] - merg e_group: + merge_group: workflow_dispatch: permissions: - contents: read + contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_req uest.number || github.ref }} - # push в main не отменяем: каждый merge-к оммит обязан получить за вершённый - # прогон (инач е красный промежуточный main живёт незамеченным, bis ect ломается). - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # push в main не отменяем: каждый merge-коммит обязан получить завершённый + # прогон (иначе красный промежуточный main живёт незамеченным, bisect ломается). + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" - CORE PACK_DEFAULT_TO_LATEST: "0" + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + COREPACK_DEFAULT_TO_LATEST: "0" jobs: verify: - name: typecheck · build · test · fuzz · size · package + name: typecheck · build · test · fuzz · size · package runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 25 steps: - - na me: Checkout - uses: actions/checkout@3 d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - # docs-facts сверяет � �айты с immutable ревизией за пределами HEAD. - fetch-dept h: 0 - - # Бинарник и его dige st зафиксированы, чтобы в� �лидация workflow не зависел� � от mutable action. - - name: GitHub A ctions contract + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + # docs-facts сверяет байты с immutable ревизией за пределами HEAD. + fetch-depth: 0 + + # Бинарник и его digest зафиксированы, чтобы валидация workflow не зависела от mutable action. + - name: GitHub Actions contract shell: bash - e nv: + env: ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_ARCHIVE_SHA256: 8aca8db96f1b 94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b 349a3d8 + ACTIONLINT_ARCHIVE_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | - archive="$RU NNER_TEMP/actionlint.tar.gz" - instal l_dir="$RUNNER_TEMP/actionlint" - cur l --proto '=https' --tlsv1.2 --fail --locatio n --silent --show-error \ - "https: //github.com/rhysd/actionlint/releases/downlo ad/v${ACTIONLINT_VERSION}/actionlint_${ACTION LINT_VERSION}_linux_amd64.tar.gz" \ - --output "$archive" - printf '%s %s\n' "$ACTIONLINT_ARCHIVE_SHA256" "$archive" | sha256sum --check --status - - mkd ir "$install_dir" - tar -xzf "$archiv e" -C "$install_dir" - "$install_dir/ actionlint" + archive="$RUNNER_TEMP/actionlint.tar.gz" + install_dir="$RUNNER_TEMP/actionlint" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "$archive" + printf '%s %s\n' "$ACTIONLINT_ARCHIVE_SHA256" "$archive" | sha256sum --check --status - + mkdir "$install_dir" + tar -xzf "$archive" -C "$install_dir" + "$install_dir/actionlint" - name: Setup Node - uses: actions/setup-node@820762786026740c76f 36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 with: - node-version: "24" + node-version: "24" - - name: Activ ate pinned pnpm + - name: Activate pinned pnpm run: | - core pack enable - corepack install --glob al pnpm@11.11.0 + corepack enable + corepack install --global pnpm@11.11.0 - - name: Install depend encies - run: pnpm install --frozen-loc kfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Static contract - run: pnpm check:static - - # Нативн� �й TypeScript — обязательный m erge-гейт (бриф D3), но отдел� �ным - # шагом, а не внут� �и check:static: его отказ долж� �н быть виден как свой - # красный чек, а release.yml зо вёт check:static и не обязан за висеть от - # preview-компи� �ятора. Согласованность � �ага с package.json стережёт - # check:native-gate — выпадение native-скрипта из CI ловится тестом. - - name: Native typecheck (TS7) + run: pnpm check:static + + # Нативный TypeScript — обязательный merge-гейт (бриф D3), но отдельным + # шагом, а не внутри check:static: его отказ должен быть виден как свой + # красный чек, а release.yml зовёт check:static и не обязан зависеть от + # preview-компилятора. Согласованность шага с package.json стережёт + # check:native-gate — выпадение native-скрипта из CI ловится тестом. + - name: Native typecheck (TS7) run: pnpm typecheck:native - - name: Build + - name: Build run: pnpm build - - name: Factual documentation drift - run: node scripts/check-docs-facts.mjs + - name: Factual documentation drift + run: node scripts/check-docs-facts.mjs - - name: Structural documentation drift - run: node scripts/check-docs-drift.mjs + - name: Structural documentation drift + run: node scripts/check-docs-drift.mjs - - name: Repository issue forms contract - run: node scripts/check-issue-forms. mjs + - name: Repository issue forms contract + run: node scripts/check-issue-forms.mjs - name: Release contract smoke - shell: bash + shell: bash run: | - ver sion="$(node -p "require('./package.json').ve rsion")" - node scripts/check-release .mjs "v${version}" --validate-stored-date + version="$(node -p "require('./package.json').version")" + node scripts/check-release.mjs "v${version}" --validate-stored-date - - name: Vitest + - name: Vitest id: vitest - shell: bash + shell: bash run: | set +e - pnpm vitest run 2>&1 | tee vitest.l og + pnpm vitest run 2>&1 | tee vitest.log status=${PIPESTATUS[0]} - exit "$status" + exit "$status" - - name: Upload Vitest diagnostics - if: steps.vitest.outcome == 'failure' - uses: actions/upload-ar tifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a 0a # v7 + - name: Upload Vitest diagnostics + if: steps.vitest.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: vitest- diagnostics-${{ github.run_id }} - pa th: vitest.log - if-no-files-found: e rror + name: vitest-diagnostics-${{ github.run_id }} + path: vitest.log + if-no-files-found: error retention-days: 7 - # Г� �об вместо ручного списк� �: новый test/*-finiteness-fuzz.test.ts попадает в - # гейт авто матически (класс дрейфа � �забытый файл в трёх копи ях списка» закрыт; - # н� �ходка ревью #101 — потеря projection-fuzz при наивном раз� �ешении конфликта). - - na me: Fuzz · finiteness gate (virtual-time, se eded LCG, zero-dep) - run: pnpm vitest run --reporter=verbose test/*finiteness-fuzz. test.ts + # Глоб вместо ручного списка: новый test/*-finiteness-fuzz.test.ts попадает в + # гейт автоматически (класс дрейфа «забытый файл в трёх копиях списка» закрыт; + # находка ревью #101 — потеря projection-fuzz при наивном разрешении конфликта). + - name: Fuzz · finiteness gate (virtual-time, seeded LCG, zero-dep) + run: pnpm vitest run --reporter=verbose test/*finiteness-fuzz.test.ts - name: Size gate - run: pnpm size + run: pnpm size - name: Pack smoke - run: pnpm pack:smoke + run: pnpm pack:smoke - - name: Consumer compatibility + - name: Consumer compatibility run: pnpm pack:compat - # Приёмка компиляторн ого среза (#208): реальный Vi te-build fixture'а с - # плагино м и без — доказывает эли� �инацию solver/parser/compiler из б� �ндла - # потребителя и � �трого меньший вес. dist уж� � собран шагом Build выше. - - name: Compiler lowering acceptance - run: pnpm acceptance:compiler + # Приёмка компиляторного среза (#208): реальный Vite-build fixture'а с + # плагином и без — доказывает элиминацию solver/parser/compiler из бандла + # потребителя и строго меньший вес. dist уже собран шагом Build выше. + - name: Compiler lowering acceptance + run: pnpm acceptance:compiler - # Exa ct Node-floor job не может запус� �ать pnpm 11 (его собственны� � floor - # выше потребител ьского). Собираем кандид� �т на Node 24 и передаём байт ы. + # Exact Node-floor job не может запускать pnpm 11 (его собственный floor + # выше потребительского). Собираем кандидат на Node 24 и передаём байты. - name: Pack Node-floor candidate - run: | - mkdir node-floor-arti fact - pnpm pack --pack-destination n ode-floor-artifact + run: | + mkdir node-floor-artifact + pnpm pack --pack-destination node-floor-artifact - - name: Upload Node -floor candidate - uses: actions/upload -artifact@043fb46d1a93c77aae656e7c1c64a875d1f c6a0a # v7 + - name: Upload Node-floor candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: node -floor-package-${{ github.sha }}-${{ github.r un_id }} - path: node-floor-artifact/ *.tgz + name: node-floor-package-${{ github.sha }}-${{ github.run_id }} + path: node-floor-artifact/*.tgz if-no-files-found: error - retention-days: 1 - compression -level: 0 + retention-days: 1 + compression-level: 0 overwrite: false - node- floor: - name: consumer contract · Node ${ { matrix.node }} + node-floor: + name: consumer contract · Node ${{ matrix.node }} needs: [verify] - runs -on: self-hosted + runs-on: ubuntu-latest timeout-minutes: 20 - strategy: + strategy: fail-fast: false - matrix : + matrix: node: ["22.0.0", "24"] - permiss ions: + permissions: actions: read - contents: rea d + contents: read steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da 76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - persist-credentials: false + persist-credentials: false - - name: Setup Node - uses: actions/setup-node@8 20762786026740c76f36085b0efc47a31fe5020 # v6 - with: - node-version: ${{ mat rix.node }} + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: ${{ matrix.node }} - - name: Download Node-floo r candidate - uses: actions/download-ar tifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e 7c # v8 + - name: Download Node-floor candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: node-fl oor-package-${{ github.sha }}-${{ github.run_ id }} + name: node-floor-package-${{ github.sha }}-${{ github.run_id }} path: node-floor-artifact - digest-mismatch: error + digest-mismatch: error - - name: Exact runtime consumer smoke - shell: b ash + - name: Exact runtime consumer smoke + shell: bash run: | - shopt -s nullglo b - archives=(node-floor-artifact/*.t gz) + shopt -s nullglob + archives=(node-floor-artifact/*.tgz) [[ ${#archives[@]} -eq 1 ]] \ - || { echo "::error::ожидалс я ровно один tgz, найдено: $ {#archives[@]}"; exit 1; } - node scr ipts/pack-smoke.mjs "${archives[0]}" - \ No newline at end of file + || { echo "::error::ожидался ровно один tgz, найдено: ${#archives[@]}"; exit 1; } + node scripts/pack-smoke.mjs "${archives[0]}" diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 54a607f0..4cbb8a88 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -2,78 +2,77 @@ name: Mutation on: schedule: - - cron: " 17 3 * * 1" + - cron: "17 3 * * 1" workflow_dispatch: -# Кажд� �й job объявляет только со� �ственные полномочия. -permi ssions: {} +# Каждый job объявляет только собственные полномочия. +permissions: {} concurrency: - group: mutation-${ { github.ref }} + group: mutation-${{ github.ref }} cancel-in-progress: true -e nv: +env: COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" - COREPACK_DEFAULT_TO_LATEST: "0" + COREPACK_DEFAULT_TO_LATEST: "0" jobs: - core : + core: name: critical numerical core - runs- on: self-hosted + runs-on: ubuntu-latest timeout-minutes: 60 - p ermissions: + permissions: contents: read steps: - - name: Checkout - uses: actions/ checkout@3d3c42e5aac5ba805825da76410c181273ba 90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist -credentials: false + persist-credentials: false - - name: Setup Node - uses: actions/setup-node@82076278602 6740c76f36085b0efc47a31fe5020 # v7.0.0 - with: + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: node-version: "24" - - name: Activate pinned pnpm + - name: Activate pinned pnpm run: | - corepack enable - corepack in stall --global pnpm@11.11.0 + corepack enable + corepack install --global pnpm@11.11.0 - - name: In stall dependencies - run: pnpm install --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build - run: pnpm build + run: pnpm build - - name: Mutation gat e + - name: Mutation gate run: pnpm mutation - - name: U pload mutation report + - name: Upload mutation report if: always() - uses: actions/upload-artifact@043fb46d1 a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: mutation-report-${{ github.run_id }} - path: reports/mut ation/ + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mutation-report-${{ github.run_id }} + path: reports/mutation/ if-no-files-found: ignore - retention-days: 14 + retention-days: 14 - # Право з аписи issues существует тол ько в job без кода репозит� �рия и зависимостей. - report -failure: - name: Report scheduled mutation failure + # Право записи issues существует только в job без кода репозитория и зависимостей. + report-failure: + name: Report scheduled mutation failure needs: [core] - if: always() & & github.event_name == 'schedule' && needs.co re.result != 'success' - runs-on: self-host ed + if: always() && github.event_name == 'schedule' && needs.core.result != 'success' + runs-on: ubuntu-latest timeout-minutes: 5 permissions: - issues: write + issues: write steps: - - name: Cr eate failure issue + - name: Create failure issue env: - GH_ TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} run: | - title="mutation: ночной прог он красный ($(date -u +%F))" - gh issue create --repo "$GITHUB_REPOSITORY " --title "$title" \ - --label "typ e:hardening,priority:P1" \ - --body "Ночной mutation-прогон не з� �вершился успешно: $GITHUB_SER VER_URL/$GITHUB_REPOSITORY/actions/runs/$GITH UB_RUN_ID — требуется разобр ать причину до ослаблени я mutation-гейта." - \ No newline at end of file + title="mutation: ночной прогон красный ($(date -u +%F))" + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" \ + --label "type:hardening,priority:P1" \ + --body "Ночной mutation-прогон не завершился успешно: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID — требуется разобрать причину до ослабления mutation-гейта." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb4b0fba..784ef35b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,794 +2,793 @@ name: Release on: workflow_dispatch: - i nputs: + inputs: version: - description: "� �ерсия x.y.z — тег v бу� �ет создан из HEAD main" - re quired: true + description: "Версия x.y.z — тег v будет создан из HEAD main" + required: true type: string -# Кажд ый job объявляет только со бственные полномочия. -perm issions: {} +# Каждый job объявляет только собственные полномочия. +permissions: {} concurrency: - # Весь пак ет имеет одну очередь: ра зные версии не должны го� �яться за npm latest. - group: release -labpics-motion + # Весь пакет имеет одну очередь: разные версии не должны гоняться за npm latest. + group: release-labpics-motion cancel-in-progress: false - env: +env: COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" - COREPACK_DEFAULT_TO_LATEST: "0" + COREPACK_DEFAULT_TO_LATEST: "0" jobs: - # � �начала фиксируем намере ние релиза без права мен� �ть репозиторий. + # Сначала фиксируем намерение релиза без права менять репозиторий. resolve: - if: github.repository == 'Labpics-Team/lab-mo tion' + if: github.repository == 'Labpics-Team/lab-motion' runs-on: ubuntu-latest - timeout-mi nutes: 5 + timeout-minutes: 5 permissions: - contents: rea d + contents: read outputs: - release_tag: ${{ steps.r esolve.outputs.release_tag }} - release_d ate: ${{ steps.resolve.outputs.release_date } } - version: ${{ steps.resolve.outputs.ve rsion }} - source_sha: ${{ steps.resolve. outputs.source_sha }} + release_tag: ${{ steps.resolve.outputs.release_tag }} + release_date: ${{ steps.resolve.outputs.release_date }} + version: ${{ steps.resolve.outputs.version }} + source_sha: ${{ steps.resolve.outputs.source_sha }} steps: - - name : Resolve and validate version - id: re solve + - name: Resolve and validate version + id: resolve env: - INPUT_VERSION: $ {{ inputs.version }} - GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} run: | - VERS ION="$INPUT_VERSION" - [[ "$GITHUB_RE F" == "refs/heads/main" ]] \ - || { echo "::error::релиз разрешён � �олько ручным запуском с main, получено: $GITHUB_REF"; exit 1; } - [[ "$VERSION" =~ ^[0-9]+\.[0-9]+ \.[0-9]+$ ]] \ - || { echo "::error ::version обязана быть x.y.z, по лучено: $VERSION"; exit 1; } - RELEASE_TAG="v$VERSION" - SOURCE_SHA= "$GITHUB_SHA" - # Повторный диспатч после сбоя downstream -job: существующий тег — - # источник истины рел иза (теги v*.*.* неизменяем� � ruleset). - # HEAD main мог уй ти вперёд: расхождение с тегом — не ошибка, а rerun. - TAG_REF=$(gh api "repos/$GITHUB_REPO SITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null || true) - if [[ -n "$TAG _REF" ]]; then - read -r OBJECT_TYP E OBJECT_SHA <<< "$TAG_REF" - for _ in {1..8}; do - if [[ "$OBJECT_T YPE" == "commit" ]]; then - TAG _SOURCE_SHA="$OBJECT_SHA" - bre ak + VERSION="$INPUT_VERSION" + [[ "$GITHUB_REF" == "refs/heads/main" ]] \ + || { echo "::error::релиз разрешён только ручным запуском с main, получено: $GITHUB_REF"; exit 1; } + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || { echo "::error::version обязана быть x.y.z, получено: $VERSION"; exit 1; } + RELEASE_TAG="v$VERSION" + SOURCE_SHA="$GITHUB_SHA" + # Повторный диспатч после сбоя downstream-job: существующий тег — + # источник истины релиза (теги v*.*.* неизменяемы ruleset). + # HEAD main мог уйти вперёд: расхождение с тегом — не ошибка, а rerun. + TAG_REF=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null || true) + if [[ -n "$TAG_REF" ]]; then + read -r OBJECT_TYPE OBJECT_SHA <<< "$TAG_REF" + for _ in {1..8}; do + if [[ "$OBJECT_TYPE" == "commit" ]]; then + TAG_SOURCE_SHA="$OBJECT_SHA" + break fi - [[ "$OBJECT _TYPE" == "tag" ]] \ - || { ech o "::error::$RELEASE_TAG указывает � �а недопустимый объект $OB JECT_TYPE"; exit 1; } - read -r O BJECT_TYPE OBJECT_SHA < <( - gh api "repos/$GITHUB_REPOSITORY/git/tags/$OBJE CT_SHA" \ - --jq '[.object.ty pe, .object.sha] | @tsv' + [[ "$OBJECT_TYPE" == "tag" ]] \ + || { echo "::error::$RELEASE_TAG указывает на недопустимый объект $OBJECT_TYPE"; exit 1; } + read -r OBJECT_TYPE OBJECT_SHA < <( + gh api "repos/$GITHUB_REPOSITORY/git/tags/$OBJECT_SHA" \ + --jq '[.object.type, .object.sha] | @tsv' ) - done - [[ -n "${TAG_SOURCE_ SHA:-}" ]] \ - || { echo "::error ::$RELEASE_TAG не раскрывается в commit"; exit 1; } - if [[ "$TAG _SOURCE_SHA" != "$GITHUB_SHA" ]]; then - echo "::warning::$RELEASE_TAG уже существует на $TAG_SOURCE_SHA; р елиз сверяется с тегом в� �есто HEAD main $GITHUB_SHA" - fi + done + [[ -n "${TAG_SOURCE_SHA:-}" ]] \ + || { echo "::error::$RELEASE_TAG не раскрывается в commit"; exit 1; } + if [[ "$TAG_SOURCE_SHA" != "$GITHUB_SHA" ]]; then + echo "::warning::$RELEASE_TAG уже существует на $TAG_SOURCE_SHA; релиз сверяется с тегом вместо HEAD main $GITHUB_SHA" + fi SOURCE_SHA="$TAG_SOURCE_SHA" - fi + fi { - echo "rel ease_tag=$RELEASE_TAG" - echo "rele ase_date=$(date -u +%F)" - echo "ve rsion=$VERSION" - echo "source_sha= $SOURCE_SHA" + echo "release_tag=$RELEASE_TAG" + echo "release_date=$(date -u +%F)" + echo "version=$VERSION" + echo "source_sha=$SOURCE_SHA" } >> "$GITHUB_OUTPUT" - + verify: - name: Verify and seal npm pack age + name: Verify and seal npm package needs: [resolve] if: >- - git hub.repository == 'Labpics-Team/lab-motion' & & !cancelled() && - needs.resolve.result == 'success' + github.repository == 'Labpics-Team/lab-motion' && !cancelled() && + needs.resolve.result == 'success' runs-on: ubuntu-latest - tim eout-minutes: 45 + timeout-minutes: 45 permissions: - conte nts: read + contents: read env: - RELEASE_TAG: ${{ nee ds.resolve.outputs.release_tag }} - RELEA SE_DATE: ${{ needs.resolve.outputs.release_da te }} - EXPECTED_SOURCE_SHA: ${{ needs.re solve.outputs.source_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + RELEASE_DATE: ${{ needs.resolve.outputs.release_date }} + EXPECTED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} outputs: - source_sha: ${{ steps.source.outputs.source _sha }} - artifact_name: ${{ steps.source .outputs.artifact_name }} - tarball_name: ${{ steps.seal.outputs.tarball_name }} - tarball_sha256: ${{ steps.seal.outputs.tarba ll_sha256 }} - package_identity: ${{ step s.seal.outputs.package_identity }} - arti fact_id: ${{ steps.upload.outputs.artifact-id }} - artifact_digest: ${{ steps.upload.o utputs.artifact-digest }} + source_sha: ${{ steps.source.outputs.source_sha }} + artifact_name: ${{ steps.source.outputs.artifact_name }} + tarball_name: ${{ steps.seal.outputs.tarball_name }} + tarball_sha256: ${{ steps.seal.outputs.tarball_sha256 }} + package_identity: ${{ steps.seal.outputs.package_identity }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} steps: - - name: Checkout - uses: actions/checkou t@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs .resolve.outputs.source_sha }} - fetc h-depth: 0 - persist-credentials: fal se + ref: ${{ needs.resolve.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false - name: Setup Node - uses: ac tions/setup-node@820762786026740c76f36085b0ef c47a31fe5020 # v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" - package-manager -cache: false + node-version: "24" + package-manager-cache: false - - name: Activate pinned pnpm + - name: Activate pinned pnpm run: | - corepack enable - corepack install --global pnpm@11. 11.0 + corepack enable + corepack install --global pnpm@11.11.0 - # Проверяем фактич ески извлечённый коммит до появления релизного т ега. + # Проверяем фактически извлечённый коммит до появления релизного тега. - name: Verify release source - id: source + id: source run: | - gi t fetch origin main --quiet - SOURCE_ SHA=$(git rev-parse HEAD) - [[ "$SOUR CE_SHA" == "$EXPECTED_SOURCE_SHA" ]] \ - || { echo "::error::извлечён $S OURCE_SHA вместо ожидаемого $ EXPECTED_SOURCE_SHA"; exit 1; } - git merge-base --is-ancestor "$SOURCE_SHA" origi n/main \ - || { echo "::error::ре лизный коммит находится вне main"; exit 1; } - echo "sourc e_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - echo "artifact_name=npm-package-$SOURCE_S HA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$G ITHUB_OUTPUT" - - - name: Install dependen cies - run: pnpm install --frozen-lockf ile + git fetch origin main --quiet + SOURCE_SHA=$(git rev-parse HEAD) + [[ "$SOURCE_SHA" == "$EXPECTED_SOURCE_SHA" ]] \ + || { echo "::error::извлечён $SOURCE_SHA вместо ожидаемого $EXPECTED_SOURCE_SHA"; exit 1; } + git merge-base --is-ancestor "$SOURCE_SHA" origin/main \ + || { echo "::error::релизный коммит находится вне main"; exit 1; } + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + echo "artifact_name=npm-package-$SOURCE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Static contract - ru n: pnpm check:static + run: pnpm check:static + + - name: Verify release identity + run: node scripts/check-release.mjs "$RELEASE_TAG" "$RELEASE_DATE" - - name: Verify re lease identity - run: node scripts/chec k-release.mjs "$RELEASE_TAG" "$RELEASE_DATE" - - name: Issue forms schema - run : node scripts/check-issue-forms.mjs + run: node scripts/check-issue-forms.mjs - - name: Build + - name: Build run: pnpm build - - name: Install browser conformance engines - run: pnpm exec playwright install --wit h-deps chromium firefox webkit + - name: Install browser conformance engines + run: pnpm exec playwright install --with-deps chromium firefox webkit - - name: Browser conformance - run: pnpm test:b rowser:all + - name: Browser conformance + run: pnpm test:browser:all - - name: Factual documentati on drift - run: node scripts/check-docs -facts.mjs + - name: Factual documentation drift + run: node scripts/check-docs-facts.mjs - name: Test - run: p npm test + run: pnpm test - name: Fuzz finiteness gate - run: pnpm vitest run --reporter=verbo se test/*finiteness-fuzz.test.ts + run: pnpm vitest run --reporter=verbose test/*finiteness-fuzz.test.ts - - nam e: Size gate + - name: Size gate run: pnpm size - # Кандидат создаётся один раз. Все следующие прове� �ки читают именно его; - # повторная упаковка пер ед publish запрещена. - - name : Pack release candidate once - id: pac k + # Кандидат создаётся один раз. Все следующие проверки читают именно его; + # повторная упаковка перед publish запрещена. + - name: Pack release candidate once + id: pack shell: bash run: | - mkdir release-artifact - pnpm pack --pack-destination release-artifact - shopt -s nullglob - archives=(releas e-artifact/*.tgz) - [[ ${#archives[@] } -eq 1 ]] \ - || { echo "::error:: ожидался ровно один tgz, н� �йдено: ${#archives[@]}"; exit 1; } - echo "tarball_name=$(basename "${archiv es[0]}")" >> "$GITHUB_OUTPUT" - - - name: Pack smoke on release candidate + mkdir release-artifact + pnpm pack --pack-destination release-artifact + shopt -s nullglob + archives=(release-artifact/*.tgz) + [[ ${#archives[@]} -eq 1 ]] \ + || { echo "::error::ожидался ровно один tgz, найдено: ${#archives[@]}"; exit 1; } + echo "tarball_name=$(basename "${archives[0]}")" >> "$GITHUB_OUTPUT" + + - name: Pack smoke on release candidate env: - TARBALL: release-artifact/${{ steps .pack.outputs.tarball_name }} - run: no de scripts/pack-smoke.mjs "$TARBALL" + TARBALL: release-artifact/${{ steps.pack.outputs.tarball_name }} + run: node scripts/pack-smoke.mjs "$TARBALL" - - name: Consumer compatibility on release cand idate + - name: Consumer compatibility on release candidate env: - TARBALL: release -artifact/${{ steps.pack.outputs.tarball_name }} - run: node scripts/pack-compat.mjs "$TARBALL" + TARBALL: release-artifact/${{ steps.pack.outputs.tarball_name }} + run: node scripts/pack-compat.mjs "$TARBALL" - # pnpm 11 сам треб� �ет более новый patch Node 22, п оэтому floor-проверка - # � �олучает уже собранный tgz и исполняет только consumer runtime. - - name: Setup exact consumer N ode floor - uses: actions/setup-node@82 0762786026740c76f36085b0efc47a31fe5020 # v7.0 .0 + # pnpm 11 сам требует более новый patch Node 22, поэтому floor-проверка + # получает уже собранный tgz и исполняет только consumer runtime. + - name: Setup exact consumer Node floor + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22. 0.0" + node-version: "22.0.0" package-manager-cache: false - - name: Exact Node 22.0 artifact smoke - env: - TARBALL: release-artif act/${{ steps.pack.outputs.tarball_name }} - run: node scripts/pack-smoke.mjs "$TARB ALL" + - name: Exact Node 22.0 artifact smoke + env: + TARBALL: release-artifact/${{ steps.pack.outputs.tarball_name }} + run: node scripts/pack-smoke.mjs "$TARBALL" - # После consumer-гейто� � фиксируем идентичность ровно тех байтов, которы е - # upload-artifact передаст p ublish-job. - - name: Seal release artifa ct identity + # После consumer-гейтов фиксируем идентичность ровно тех байтов, которые + # upload-artifact передаст publish-job. + - name: Seal release artifact identity id: seal env: - SOURCE_SHA: ${{ steps.source.outputs.s ource_sha }} - TARBALL: release-artif act/${{ steps.pack.outputs.tarball_name }} - run: | - node scripts/check-rel ease-artifact.mjs \ - "$TARBALL" "$ RELEASE_TAG" "$SOURCE_SHA" \ - rele ase-artifact/release-manifest.json >> "$GITHU B_OUTPUT" - - - name: Upload sealed packag e + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + TARBALL: release-artifact/${{ steps.pack.outputs.tarball_name }} + run: | + node scripts/check-release-artifact.mjs \ + "$TARBALL" "$RELEASE_TAG" "$SOURCE_SHA" \ + release-artifact/release-manifest.json >> "$GITHUB_OUTPUT" + + - name: Upload sealed package id: upload - uses: actions/up load-artifact@043fb46d1a93c77aae656e7c1c64a87 5d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - na me: ${{ steps.source.outputs.artifact_name }} + name: ${{ steps.source.outputs.artifact_name }} path: release-artifact/ - if-no-files-found: error - retention- days: 7 + if-no-files-found: error + retention-days: 7 compression-level: 0 - overwrite: false - include-hidden -files: false + overwrite: false + include-hidden-files: false publish: - name: Publish s ealed npm package + name: Publish sealed npm package concurrency: - grou p: npm-publish-${{ needs.resolve.outputs.rele ase_tag }} + group: npm-publish-${{ needs.resolve.outputs.release_tag }} cancel-in-progress: false - needs: [resolve, verify, tag] + needs: [resolve, verify, tag] if: >- - github.repository == 'Labpics-Team/lab-mo tion' && !cancelled() && - needs.resolve. result == 'success' && needs.verify.result == 'success' && - needs.tag.result == 'succ ess' + github.repository == 'Labpics-Team/lab-motion' && !cancelled() && + needs.resolve.result == 'success' && needs.verify.result == 'success' && + needs.tag.result == 'success' runs-on: ubuntu-latest - timeout-min utes: 10 + timeout-minutes: 10 environment: npm - permissions : + permissions: actions: read contents: read - id-token: write + id-token: write env: - RELEASE_TA G: ${{ needs.resolve.outputs.release_tag }} - EXPECTED_SOURCE_SHA: ${{ needs.verify.ou tputs.source_sha }} - EXPECTED_TARBALL_NA ME: ${{ needs.verify.outputs.tarball_name }} - EXPECTED_TARBALL_SHA256: ${{ needs.veri fy.outputs.tarball_sha256 }} - EXPECTED_P ACKAGE_IDENTITY: ${{ needs.verify.outputs.pac kage_identity }} - EXPECTED_ARTIFACT_ID: ${{ needs.verify.outputs.artifact_id }} - EXPECTED_ARTIFACT_NAME: ${{ needs.verify.out puts.artifact_name }} - EXPECTED_ARTIFACT _DIGEST: ${{ needs.verify.outputs.artifact_di gest }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + EXPECTED_TARBALL_NAME: ${{ needs.verify.outputs.tarball_name }} + EXPECTED_TARBALL_SHA256: ${{ needs.verify.outputs.tarball_sha256 }} + EXPECTED_PACKAGE_IDENTITY: ${{ needs.verify.outputs.package_identity }} + EXPECTED_ARTIFACT_ID: ${{ needs.verify.outputs.artifact_id }} + EXPECTED_ARTIFACT_NAME: ${{ needs.verify.outputs.artifact_name }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.verify.outputs.artifact_digest }} steps: - - name: Setup Node for npm trusted publishing - uses: acti ons/setup-node@820762786026740c76f36085b0efc4 7a31fe5020 # v7.0.0 + - name: Setup Node for npm trusted publishing + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - n ode-version: "24" - registry-url: htt ps://registry.npmjs.org - package-man ager-cache: false + node-version: "24" + registry-url: https://registry.npmjs.org + package-manager-cache: false - - name: Download sea led package by artifact ID - uses: acti ons/download-artifact@3e5f45b2cfb9172054b4087 a40e8e0b5a5461e7c # v8.0.1 + - name: Download sealed package by artifact ID + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - artifact-ids: ${{ needs.verify.outputs. artifact_id }} - path: release-artifa ct + artifact-ids: ${{ needs.verify.outputs.artifact_id }} + path: release-artifact merge-multiple: true - d igest-mismatch: error + digest-mismatch: error - # Хеш арх� �ва GitHub защищает транспо� �т, хеш tgz — публикуемые б айты. - - name: Verify artifact trans port, digest and identity - shell: bash + # Хеш архива GitHub защищает транспорт, хеш tgz — публикуемые байты. + - name: Verify artifact transport, digest and identity + shell: bash env: - GH_TOKEN: ${{ github. token }} + GH_TOKEN: ${{ github.token }} run: | - [[ "$EXPECT ED_ARTIFACT_ID" =~ ^[0-9]+$ ]] \ - || { echo "::error::некорректный идентификатор артефакта "; exit 1; } - [[ "$EXPECTED_ARTIFACT _DIGEST" =~ ^[0-9a-f]{64}$ ]] \ - | | { echo "::error::некорректный � �еш артефакта"; exit 1; } - IFS=$'\t' read -r API_DIGEST API_NAME < <( - gh api "repos/$GITHUB_REPOSITORY/ actions/artifacts/$EXPECTED_ARTIFACT_ID" \ - --jq '[.digest // "", .name // "" ] | @tsv' + [[ "$EXPECTED_ARTIFACT_ID" =~ ^[0-9]+$ ]] \ + || { echo "::error::некорректный идентификатор артефакта"; exit 1; } + [[ "$EXPECTED_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "::error::некорректный хеш артефакта"; exit 1; } + IFS=$'\t' read -r API_DIGEST API_NAME < <( + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$EXPECTED_ARTIFACT_ID" \ + --jq '[.digest // "", .name // ""] | @tsv' ) - [[ "${API_DIG EST#sha256:}" == "$EXPECTED_ARTIFACT_DIGEST" ]] \ - || { echo "::error::хеш � �ртефакта в API не совпада� �т с выходом verify job"; exit 1; } - [[ "$API_NAME" == "$EXPECTED_ARTIFA CT_NAME" ]] \ - || { echo "::error: :имя артефакта в API не сов падает с выходом verify job"; e xit 1; } - - mapfile -t files < <(find release-artifact -type f -print | sort) - [[ ${#files[@]} -eq 2 ]] \ - || { echo "::error::артефакт обяз ан содержать ровно tgz и м� �нифест"; exit 1; } - [[ -z "$( find release-artifact -type l -print -quit)" ]] \ - || { echo "::error::арте факт не должен содержать ссылки"; exit 1; } - [[ ! -L r elease-artifact/release-manifest.json ]] \ - || { echo "::error::манифес� � не может быть ссылкой"; e xit 1; } - TARBALL="release-artifact/ $EXPECTED_TARBALL_NAME" - [[ -f "$TAR BALL" && ! -L "$TARBALL" ]] \ - || { echo "::error::ожидаемый tgz от� �утствует или является с� �ылкой"; exit 1; } - ACTUAL_SHA2 56=$(sha256sum "$TARBALL" | cut -d' ' -f1) - [[ "$ACTUAL_SHA256" == "$EXPECTED_TAR BALL_SHA256" ]] \ - || { echo "::er ror::SHA-256 tgz не совпадает с � �роверенным"; exit 1; } - - node --input-type=module <<'NODE' - i mport { readFileSync } from 'node:fs'; - - const fail = (message) => { - console.error(`::error::${message}`); - process.exit(1); + [[ "${API_DIGEST#sha256:}" == "$EXPECTED_ARTIFACT_DIGEST" ]] \ + || { echo "::error::хеш артефакта в API не совпадает с выходом verify job"; exit 1; } + [[ "$API_NAME" == "$EXPECTED_ARTIFACT_NAME" ]] \ + || { echo "::error::имя артефакта в API не совпадает с выходом verify job"; exit 1; } + + mapfile -t files < <(find release-artifact -type f -print | sort) + [[ ${#files[@]} -eq 2 ]] \ + || { echo "::error::артефакт обязан содержать ровно tgz и манифест"; exit 1; } + [[ -z "$(find release-artifact -type l -print -quit)" ]] \ + || { echo "::error::артефакт не должен содержать ссылки"; exit 1; } + [[ ! -L release-artifact/release-manifest.json ]] \ + || { echo "::error::манифест не может быть ссылкой"; exit 1; } + TARBALL="release-artifact/$EXPECTED_TARBALL_NAME" + [[ -f "$TARBALL" && ! -L "$TARBALL" ]] \ + || { echo "::error::ожидаемый tgz отсутствует или является ссылкой"; exit 1; } + ACTUAL_SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1) + [[ "$ACTUAL_SHA256" == "$EXPECTED_TARBALL_SHA256" ]] \ + || { echo "::error::SHA-256 tgz не совпадает с проверенным"; exit 1; } + + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + + const fail = (message) => { + console.error(`::error::${message}`); + process.exit(1); }; - const manifest = JSON.parse(readFileSync('rel ease-artifact/release-manifest.json', 'utf8') ); - if (manifest.schema !== 1) fail( 'неподдерживаемая схема release-манифеста'); - if (m anifest.release?.tag !== process.env.RELEASE_ TAG) fail('тег манифеста не с� �впадает'); - if (manifest.rel ease?.sourceSha !== process.env.EXPECTED_SOUR CE_SHA) fail('source SHA манифеста � �е совпадает'); - if (manif est.package?.identity !== process.env.EXPECTE D_PACKAGE_IDENTITY) fail('идентично� �ть пакета не совпадает'); - if (manifest.artifact?.file !== pr ocess.env.EXPECTED_TARBALL_NAME) fail('имя tgz манифеста не совпадае т'); - if (manifest.artifact?.sha256 !== process.env.EXPECTED_TARBALL_SHA256) fai l('digest tgz манифеста не сов� �адает'); + const manifest = JSON.parse(readFileSync('release-artifact/release-manifest.json', 'utf8')); + if (manifest.schema !== 1) fail('неподдерживаемая схема release-манифеста'); + if (manifest.release?.tag !== process.env.RELEASE_TAG) fail('тег манифеста не совпадает'); + if (manifest.release?.sourceSha !== process.env.EXPECTED_SOURCE_SHA) fail('source SHA манифеста не совпадает'); + if (manifest.package?.identity !== process.env.EXPECTED_PACKAGE_IDENTITY) fail('идентичность пакета не совпадает'); + if (manifest.artifact?.file !== process.env.EXPECTED_TARBALL_NAME) fail('имя tgz манифеста не совпадает'); + if (manifest.artifact?.sha256 !== process.env.EXPECTED_TARBALL_SHA256) fail('digest tgz манифеста не совпадает'); NODE - - name: Verify npm supports trusted publishing - shell: bash + - name: Verify npm supports trusted publishing + shell: bash run: | - node - -input-type=module <<'NODE' - import { execFileSync } from 'node:child_process'; - const raw = execFileSync('npm', ['-- version'], { encoding: 'utf8' }).trim(); - const [major, minor, patch] = raw.split ('.').map(Number); - const supported = major > 11 || (major === 11 && (minor > 5 | | (minor === 5 && patch >= 1))); - if (!supported) { - console.error(`np m ${raw} не поддерживает truste d publishing; требуется >= 11.5.1`); + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + const raw = execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(); + const [major, minor, patch] = raw.split('.').map(Number); + const supported = major > 11 || (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))); + if (!supported) { + console.error(`npm ${raw} не поддерживает trusted publishing; требуется >= 11.5.1`); process.exit(1); } - console.log(`npm ${raw}: trusted publi shing поддержан`); + console.log(`npm ${raw}: trusted publishing поддержан`); NODE - # Tag-job уже зафиксирова� � ref без OIDC. Перед публика� �ией - # повторно читаем серверное состояние и п� �инимаем только наш SHA. - - name: Verify locked release tag binding - shell: bash + # Tag-job уже зафиксировал ref без OIDC. Перед публикацией + # повторно читаем серверное состояние и принимаем только наш SHA. + - name: Verify locked release tag binding + shell: bash env: - GH_ TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} run: | - OWNER="${GITHUB_REPOSITORY%%/*}" - REPOSITORY_NAME="${GITHUB_REPOSITORY#*/} " + OWNER="${GITHUB_REPOSITORY%%/*}" + REPOSITORY_NAME="${GITHUB_REPOSITORY#*/}" EXISTING_REF=$( - # $ow ner/$name/$qualifiedName ниже прина� �лежат GraphQL, а не shell. - # shellcheck disable=SC2016 - gh api graphql \ - -f query='query( $owner: String!, $name: String!, $qualifiedNa me: String!) { - repository(own er: $owner, name: $name) { - ref(qualifiedName: $qualifiedName) { target { __typename oid } } + # $owner/$name/$qualifiedName ниже принадлежат GraphQL, а не shell. + # shellcheck disable=SC2016 + gh api graphql \ + -f query='query($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualifiedName) { target { __typename oid } } } - }' \ - -F owner="$OWNER" \ + }' \ + -F owner="$OWNER" \ -F name="$REPOSITORY_NAME" \ - -F qualifiedName="refs/tags/$REL EASE_TAG" \ - --jq '(.data.reposi tory.ref.target // empty) | [.__typename, .oi d] | @tsv' + -F qualifiedName="refs/tags/$RELEASE_TAG" \ + --jq '(.data.repository.ref.target // empty) | [.__typename, .oid] | @tsv' ) - if [[ -z "$E XISTING_REF" ]]; then - echo "::err or::$RELEASE_TAG не создан tag-job" - exit 1 + if [[ -z "$EXISTING_REF" ]]; then + echo "::error::$RELEASE_TAG не создан tag-job" + exit 1 fi - rea d -r OBJECT_TYPE OBJECT_SHA <<< "$EXISTING_RE F" + read -r OBJECT_TYPE OBJECT_SHA <<< "$EXISTING_REF" case "$OBJECT_TYPE" in - Commit) OBJECT_TYPE=commit ;; - Tag) OBJECT_TYPE=tag ;; + Commit) OBJECT_TYPE=commit ;; + Tag) OBJECT_TYPE=tag ;; esac - for _ in {1..8}; do - if [[ "$O BJECT_TYPE" == "commit" ]]; then - EXISTING="$OBJECT_SHA" + for _ in {1..8}; do + if [[ "$OBJECT_TYPE" == "commit" ]]; then + EXISTING="$OBJECT_SHA" break - fi - [[ "$OBJECT_TYPE" == "tag" ]] \ - || { echo "::erro r::тег указывает на недоп� �стимый объект $OBJECT_TYPE"; exi t 1; } - read -r OBJECT_TYPE OBJECT _SHA < <( - gh api "repos/$GITHUB _REPOSITORY/git/tags/$OBJECT_SHA" \ - --jq '[.object.type, .object.sha] | @t sv' + fi + [[ "$OBJECT_TYPE" == "tag" ]] \ + || { echo "::error::тег указывает на недопустимый объект $OBJECT_TYPE"; exit 1; } + read -r OBJECT_TYPE OBJECT_SHA < <( + gh api "repos/$GITHUB_REPOSITORY/git/tags/$OBJECT_SHA" \ + --jq '[.object.type, .object.sha] | @tsv' ) done - [[ -n "${EXISTING:-}" ]] \ - || { ech o "::error::не удалось раскры� �ь $RELEASE_TAG до commit"; exit 1; } - [[ "$EXISTING" == "$EXPECTED_SOURCE_SHA " ]] \ - || { echo "::error::$RELEA SE_TAG уже указывает на $EXISTI NG"; exit 1; } - echo "$RELEASE_TAG � �же указывает на провере� �ный коммит" + [[ -n "${EXISTING:-}" ]] \ + || { echo "::error::не удалось раскрыть $RELEASE_TAG до commit"; exit 1; } + [[ "$EXISTING" == "$EXPECTED_SOURCE_SHA" ]] \ + || { echo "::error::$RELEASE_TAG уже указывает на $EXISTING"; exit 1; } + echo "$RELEASE_TAG уже указывает на проверенный коммит" - - name: Publish or reconcile verified tarball through npm OID C + - name: Publish or reconcile verified tarball through npm OIDC env: - TARBALL: ./release-a rtifact/${{ needs.verify.outputs.tarball_name }} + TARBALL: ./release-artifact/${{ needs.verify.outputs.tarball_name }} run: | - node --input-typ e=module <<'NODE' - import { execFile Sync } from 'node:child_process'; - i mport { createHash } from 'node:crypto'; - import { readFileSync } from 'node:fs'; - import { setTimeout as delay } fro m 'node:timers/promises'; - - const fa il = (message) => { - throw new Err or(message); + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; + import { readFileSync } from 'node:fs'; + import { setTimeout as delay } from 'node:timers/promises'; + + const fail = (message) => { + throw new Error(message); }; - const ide ntity = process.env.EXPECTED_PACKAGE_IDENTITY ; - const separator = identity.lastIn dexOf('@'); - if (separator <= 0) fai l(`некорректная идентичн� �сть пакета: ${identity}`); - const name = identity.slice(0, separator); - const version = identity.slice(sepa rator + 1); - const integrity = `sha5 12-${createHash('sha512') - .update (readFileSync(process.env.TARBALL)) - .digest('base64')}`; - const prove nanceType = 'https://slsa.dev/provenance/v1'; - const packageUrl = `https://regist ry.npmjs.org/${encodeURIComponent(name)}`; - const registryUrl = `${packageUrl}/${ encodeURIComponent(version)}`; - - fun ction stableVersion(value, label) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec (value); - if (!match) fail(`${labe l} не является стабильной версией x.y.z: ${String(value)}`); - return match.slice(1).map((part) => BigInt(part)); + const identity = process.env.EXPECTED_PACKAGE_IDENTITY; + const separator = identity.lastIndexOf('@'); + if (separator <= 0) fail(`некорректная идентичность пакета: ${identity}`); + const name = identity.slice(0, separator); + const version = identity.slice(separator + 1); + const integrity = `sha512-${createHash('sha512') + .update(readFileSync(process.env.TARBALL)) + .digest('base64')}`; + const provenanceType = 'https://slsa.dev/provenance/v1'; + const packageUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}`; + const registryUrl = `${packageUrl}/${encodeURIComponent(version)}`; + + function stableVersion(value, label) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); + if (!match) fail(`${label} не является стабильной версией x.y.z: ${String(value)}`); + return match.slice(1).map((part) => BigInt(part)); } - functi on compareVersions(left, right) { - for (let index = 0; index < left.length; ind ex++) { - if (left[index] < right [index]) return -1; - if (left[in dex] > right[index]) return 1; + function compareVersions(left, right) { + for (let index = 0; index < left.length; index++) { + if (left[index] < right[index]) return -1; + if (left[index] > right[index]) return 1; } - return 0; + return 0; } - const candidate = stableVersion(version, 'к� �ндидат'); + const candidate = stableVersion(version, 'кандидат'); - async function lo okupLatest() { - const response = a wait fetch(`${packageUrl}?latest=${Date.now() }`, { + async function lookupLatest() { + const response = await fetch(`${packageUrl}?latest=${Date.now()}`, { cache: 'no-store', - headers: { accept: 'application/json' }, + headers: { accept: 'application/json' }, }); - if (response. status === 404) return null; - if ( !response.ok) fail(`npm packument ответ� �л ${response.status}`); - const p ackument = await response.json(); - const raw = packument['dist-tags']?.latest; - if (typeof raw !== 'string') fail ('npm packument не содержит dist-ta gs.latest'); - return { raw, parsed : stableVersion(raw, 'npm latest') }; - } - - async function lookupVersion( ) { - const response = await fetch( `${registryUrl}?reconcile=${Date.now()}`, { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - if (response.status === 404) return null; - if (!respon se.ok) fail(`npm registry ответил ${re sponse.status}`); - return response .json(); + if (response.status === 404) return null; + if (!response.ok) fail(`npm packument ответил ${response.status}`); + const packument = await response.json(); + const raw = packument['dist-tags']?.latest; + if (typeof raw !== 'string') fail('npm packument не содержит dist-tags.latest'); + return { raw, parsed: stableVersion(raw, 'npm latest') }; } - function asse rtPublishPreflight(latest) { - if ( latest === null) return; - if (comp areVersions(candidate, latest.parsed) <= 0) { - fail(`новый кандид� �т ${version} должен быть стро го выше npm latest ${latest.raw} и н� � может откатить npm latest`); - } + async function lookupVersion() { + const response = await fetch(`${registryUrl}?reconcile=${Date.now()}`, { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (response.status === 404) return null; + if (!response.ok) fail(`npm registry ответил ${response.status}`); + return response.json(); + } + + function assertPublishPreflight(latest) { + if (latest === null) return; + if (compareVersions(candidate, latest.parsed) <= 0) { + fail(`новый кандидат ${version} должен быть строго выше npm latest ${latest.raw} и не может откатить npm latest`); + } } - async fun ction reconcile(attempts, mode) { - if (mode !== 'idempotent' && mode !== 'publi shed') fail(`неизвестный режи� � reconcile: ${mode}`); - for (let attempt = 1; attempt <= attempts; attempt++) { - const [metadata, latest] = aw ait Promise.all([lookupVersion(), lookupLates t()]); - if (metadata !== null) { - const published = metadata.d ist?.integrity; - if (published !== integrity) { - fail(`в� �рсия уже существует с др угим integrity: ${String(published)}`); - } - const proven anceReady = - metadata.dist?. attestations?.provenance?.predicateType === p rovenanceType && - typeof met adata.dist?.attestations?.url === 'string'; - if (latest !== null) { - const comparison = compareVersions (latest.parsed, candidate); - if (mode === 'published') { - if (comparison >= 0 && provenanceReady) { + async function reconcile(attempts, mode) { + if (mode !== 'idempotent' && mode !== 'published') fail(`неизвестный режим reconcile: ${mode}`); + for (let attempt = 1; attempt <= attempts; attempt++) { + const [metadata, latest] = await Promise.all([lookupVersion(), lookupLatest()]); + if (metadata !== null) { + const published = metadata.dist?.integrity; + if (published !== integrity) { + fail(`версия уже существует с другим integrity: ${String(published)}`); + } + const provenanceReady = + metadata.dist?.attestations?.provenance?.predicateType === provenanceType && + typeof metadata.dist?.attestations?.url === 'string'; + if (latest !== null) { + const comparison = compareVersions(latest.parsed, candidate); + if (mode === 'published') { + if (comparison >= 0 && provenanceReady) { if (comparison > 0) { - console.log( - `${identity}: моното� �ная конкурентная публик ация подняла npm latest ` + - `до ${latest.raw}; ка ндидат ${version} проверен и � �е требует retag`, - ); + console.log( + `${identity}: монотонная конкурентная публикация подняла npm latest ` + + `до ${latest.raw}; кандидат ${version} проверен и не требует retag`, + ); } - return true; + return true; } - } - if (mod e === 'idempotent') { - if (comparison < 0) { - fail (`idempotent latest ниже кандидат а: ${latest.raw} < ${version}`); - } - if (provenance Ready) return true; } - } + if (mode === 'idempotent') { + if (comparison < 0) { + fail(`idempotent latest ниже кандидата: ${latest.raw} < ${version}`); + } + if (provenanceReady) return true; + } + } } - if (attempt < attempts) await delay(5_000); - } + if (attempt < attempts) await delay(5_000); + } return false; - } + } - const existing = await lookupV ersion(); + const existing = await lookupVersion(); if (existing !== null) { - if (await reconcile(12, 'idempote nt')) { - console.log(`${identity } уже опубликован с прове ренными байтами, provenance и latest не ниже кандидата`); - process.exit(0); + if (await reconcile(12, 'idempotent')) { + console.log(`${identity} уже опубликован с проверенными байтами, provenance и latest не ниже кандидата`); + process.exit(0); } - fail(`${identity}: idempotent regis try state не подтвердился`); - } + fail(`${identity}: idempotent registry state не подтвердился`); + } - await assertPublishPrefl ight(await lookupLatest()); + await assertPublishPreflight(await lookupLatest()); - let pu blishError; + let publishError; try { - execF ileSync( + execFileSync( 'npm', - [ 'publish', process.env.TARBALL, '--registry=h ttps://registry.npmjs.org', '--tag', 'latest' , '--access', 'public', '--provenance', '--ig nore-scripts'], - { stdio: 'inher it' }, + ['publish', process.env.TARBALL, '--registry=https://registry.npmjs.org', '--tag', 'latest', '--access', 'public', '--provenance', '--ignore-scripts'], + { stdio: 'inherit' }, ); - } catch (erro r) { + } catch (error) { publishError = error; - } + } - if (await reconcile(12, 'pub lished')) { - console.log(`${identi ty}: registry integrity, provenance и мон отонный latest подтвержден� �`); + if (await reconcile(12, 'published')) { + console.log(`${identity}: registry integrity, provenance и монотонный latest подтверждены`); process.exit(0); - } - if (publishError) throw publishErr or; - fail(`${identity}: registry н� � подтвердил integrity, provenance и монотонный latest после npm publish`); + } + if (publishError) throw publishError; + fail(`${identity}: registry не подтвердил integrity, provenance и монотонный latest после npm publish`); NODE - - name: Ver ify registry provenance statement and signatu res + - name: Verify registry provenance statement and signatures env: - EXPECTED_RUN_SHA: ${{ github.sha }} - GH_TOKEN: ${{ git hub.token }} - TARBALL: ./release-art ifact/${{ needs.verify.outputs.tarball_name } } + EXPECTED_RUN_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + TARBALL: ./release-artifact/${{ needs.verify.outputs.tarball_name }} run: | - node --input-type= module <<'NODE' - import { execFileSy nc } from 'node:child_process'; - imp ort { createHash } from 'node:crypto'; - import { mkdtempSync, readFileSync, rmSyn c, writeFileSync } from 'node:fs'; - import { tmpdir } from 'node:os'; - i mport { join } from 'node:path'; - - c onst fail = (message) => { throw new Error(me ssage); }; - const identity = process .env.EXPECTED_PACKAGE_IDENTITY; - con st separator = identity.lastIndexOf('@'); - const name = identity.slice(0, separat or); - const version = identity.slice (separator + 1); - const sha512 = cre ateHash('sha512').update(readFileSync(process .env.TARBALL)).digest('hex'); - const versionUrl = `https://registry.npmjs.org/${e ncodeURIComponent(name)}/${encodeURIComponent (version)}`; - const metadataResponse = await fetch(`${versionUrl}?provenance=${Da te.now()}`, { cache: 'no-store' }); - if (!metadataResponse.ok) fail(`metadata pro venance: HTTP ${metadataResponse.status}`); - const metadata = await metadataRespo nse.json(); - const attestationUrl = metadata.dist?.attestations?.url; - i f (metadata.dist?.attestations?.provenance?.p redicateType !== 'https://slsa.dev/provenance /v1') { - fail('registry metadata � �е подтверждает SLSA provenance v1'); + node --input-type=module <<'NODE' + import { execFileSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; + import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + + const fail = (message) => { throw new Error(message); }; + const identity = process.env.EXPECTED_PACKAGE_IDENTITY; + const separator = identity.lastIndexOf('@'); + const name = identity.slice(0, separator); + const version = identity.slice(separator + 1); + const sha512 = createHash('sha512').update(readFileSync(process.env.TARBALL)).digest('hex'); + const versionUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`; + const metadataResponse = await fetch(`${versionUrl}?provenance=${Date.now()}`, { cache: 'no-store' }); + if (!metadataResponse.ok) fail(`metadata provenance: HTTP ${metadataResponse.status}`); + const metadata = await metadataResponse.json(); + const attestationUrl = metadata.dist?.attestations?.url; + if (metadata.dist?.attestations?.provenance?.predicateType !== 'https://slsa.dev/provenance/v1') { + fail('registry metadata не подтверждает SLSA provenance v1'); } - const parsedUrl = new URL(attestationUrl); - if (parse dUrl.origin !== 'https://registry.npmjs.org') { - fail(`недоверенный endpoint attestations: ${parsedUrl.origin}`) ; + const parsedUrl = new URL(attestationUrl); + if (parsedUrl.origin !== 'https://registry.npmjs.org') { + fail(`недоверенный endpoint attestations: ${parsedUrl.origin}`); } - // registry мате� �иализует attestation bundle асин хронно: metadata уже - // с сылается на endpoint, которы� � первые минуты отвечает 404 (падение - // run 30901712 886 через 1.2s после publish). Retr y только на 404; - // ост� �льные статусы fail-closed нем едленно. - let attestationResp onse; - for (let attempt = 1; attempt <= 16; attempt++) { - attestationR esponse = await fetch(parsedUrl, { cache: 'no -store' }); - if (attestationRespon se.ok || attestationResponse.status !== 404) break; - await new Promise((resolve ) => { setTimeout(resolve, 15000); }); - } - if (!attestationResponse.ok) fail(`attestations: HTTP ${attestationRespons e.status}`); - const collection = awa it attestationResponse.json(); - cons t attestation = collection.attestations?.find ( - (item) => item.predicateType == = 'https://slsa.dev/provenance/v1', - ); - if (!attestation?.bundle?.dsseE nvelope?.payload) fail('SLSA attestation от сутствует'); - const stateme nt = JSON.parse( - Buffer.from(atte station.bundle.dsseEnvelope.payload, 'base64' ).toString('utf8'), + // registry материализует attestation bundle асинхронно: metadata уже + // ссылается на endpoint, который первые минуты отвечает 404 (падение + // run 30901712886 через 1.2s после publish). Retry только на 404; + // остальные статусы fail-closed немедленно. + let attestationResponse; + for (let attempt = 1; attempt <= 16; attempt++) { + attestationResponse = await fetch(parsedUrl, { cache: 'no-store' }); + if (attestationResponse.ok || attestationResponse.status !== 404) break; + await new Promise((resolve) => { setTimeout(resolve, 15000); }); + } + if (!attestationResponse.ok) fail(`attestations: HTTP ${attestationResponse.status}`); + const collection = await attestationResponse.json(); + const attestation = collection.attestations?.find( + (item) => item.predicateType === 'https://slsa.dev/provenance/v1', + ); + if (!attestation?.bundle?.dsseEnvelope?.payload) fail('SLSA attestation отсутствует'); + const statement = JSON.parse( + Buffer.from(attestation.bundle.dsseEnvelope.payload, 'base64').toString('utf8'), ); - co nst expectedSubject = `pkg:npm/${name.replace (/^@/, '%40')}@${version}`; - const s ubject = statement.subject?.find((item) => it em.name === expectedSubject); - if (s ubject?.digest?.sha512 !== sha512) fail('subj ect digest provenance не совпадает с tgz'); - - const definition = stat ement.predicate?.buildDefinition; - c onst workflow = definition?.externalParameter s?.workflow; - if (workflow?.reposito ry !== 'https://github.com/Labpics-Team/lab-m otion') { - fail('provenance ука зывает на другой репозит орий'); + const expectedSubject = `pkg:npm/${name.replace(/^@/, '%40')}@${version}`; + const subject = statement.subject?.find((item) => item.name === expectedSubject); + if (subject?.digest?.sha512 !== sha512) fail('subject digest provenance не совпадает с tgz'); + + const definition = statement.predicate?.buildDefinition; + const workflow = definition?.externalParameters?.workflow; + if (workflow?.repository !== 'https://github.com/Labpics-Team/lab-motion') { + fail('provenance указывает на другой репозиторий'); } - if (workflo w?.path !== '.github/workflows/release.yml' | | workflow?.ref !== 'refs/heads/main') { - fail('provenance указывает � �а другой workflow/ref'); + if (workflow?.path !== '.github/workflows/release.yml' || workflow?.ref !== 'refs/heads/main') { + fail('provenance указывает на другой workflow/ref'); } - // OIDC-provenance привязан к workflow-run: gitCommit — SHA рана, - // подписавшего пак ет. При idempotent-rerun это ранн ий диспатч - // (run 3090171 2886: пакет подписан на 0d4534 c8, github.sha уже новее), - // поэтому точное равенст во нежизнеспособно. Инва риант fail-closed: - // gitCommi t == github.sha текущего рана и� �и его предок по main; - // подпись diverged/чужого ко� �мита отвергается. Байты дерева источника - // связаны с пакетом через s ubject sha512 выше. - const prove nanceSha = definition?.resolvedDependencies?. find( - (item) => typeof item.diges t?.gitCommit === 'string' - && ty peof item.uri === 'string' - && i tem.uri.startsWith('git+https://github.com/La bpics-Team/lab-motion@'), - )?.digest ?.gitCommit; - if (!provenanceSha) fa il('provenance не содержит gitCommi t нашего репозитория'); - const compareResponse = await fetch( - `https://api.github.com/repos/Labpi cs-Team/lab-motion/compare/${process.env.EXPE CTED_RUN_SHA}...${provenanceSha}`, - { + // OIDC-provenance привязан к workflow-run: gitCommit — SHA рана, + // подписавшего пакет. При idempotent-rerun это ранний диспатч + // (run 30901712886: пакет подписан на 0d4534c8, github.sha уже новее), + // поэтому точное равенство нежизнеспособно. Инвариант fail-closed: + // gitCommit == github.sha текущего рана или его предок по main; + // подпись diverged/чужого коммита отвергается. Байты дерева источника + // связаны с пакетом через subject sha512 выше. + const provenanceSha = definition?.resolvedDependencies?.find( + (item) => typeof item.digest?.gitCommit === 'string' + && typeof item.uri === 'string' + && item.uri.startsWith('git+https://github.com/Labpics-Team/lab-motion@'), + )?.digest?.gitCommit; + if (!provenanceSha) fail('provenance не содержит gitCommit нашего репозитория'); + const compareResponse = await fetch( + `https://api.github.com/repos/Labpics-Team/lab-motion/compare/${process.env.EXPECTED_RUN_SHA}...${provenanceSha}`, + { headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${process.en v.GH_TOKEN}`, + accept: 'application/vnd.github+json', + authorization: `Bearer ${process.env.GH_TOKEN}`, }, - cache: 'no-store', + cache: 'no-store', }, - ) ; - if (!compareResponse.ok) fail(`co mpare provenance SHA: HTTP ${compareResponse. status}`); - const compare = await co mpareResponse.json(); - if (compare.s tatus !== 'identical' && compare.status !== ' behind') { - fail(`provenance SHA $ {provenanceSha} вне линии main: ${com pare.status}`); + ); + if (!compareResponse.ok) fail(`compare provenance SHA: HTTP ${compareResponse.status}`); + const compare = await compareResponse.json(); + if (compare.status !== 'identical' && compare.status !== 'behind') { + fail(`provenance SHA ${provenanceSha} вне линии main: ${compare.status}`); + } + if (statement.predicate?.runDetails?.builder?.id !== 'https://github.com/actions/runner/github-hosted') { + fail('provenance создан не GitHub-hosted runner'); } - if (sta tement.predicate?.runDetails?.builder?.id !== 'https://github.com/actions/runner/github-ho sted') { - fail('provenance соз� �ан не GitHub-hosted runner'); - } - const invocation = statement.pred icate?.runDetails?.metadata?.invocationId; - if (!invocation?.startsWith('https:// github.com/Labpics-Team/lab-motion/actions/ru ns/')) { - fail('provenance invocat ion относится к другому ре позиторию'); + const invocation = statement.predicate?.runDetails?.metadata?.invocationId; + if (!invocation?.startsWith('https://github.com/Labpics-Team/lab-motion/actions/runs/')) { + fail('provenance invocation относится к другому репозиторию'); } - // npm CLI криптографически � �роверяет registry- и SLSA-подп� �си; - // разбор выше д� �полнительно связывает st atement с нашим SHA/workflow. - const audit = mkdtempSync(join(tmpdir(), 'la bmotion-provenance-')); + // npm CLI криптографически проверяет registry- и SLSA-подписи; + // разбор выше дополнительно связывает statement с нашим SHA/workflow. + const audit = mkdtempSync(join(tmpdir(), 'labmotion-provenance-')); try { - writeFileSync(join(audit, 'package.jso n'), JSON.stringify({ name: 'provenance-audit ', private: true })); - execFileSyn c( + writeFileSync(join(audit, 'package.json'), JSON.stringify({ name: 'provenance-audit', private: true })); + execFileSync( 'npm', - ['insta ll', '--ignore-scripts', '--no-audit', '--no- fund', '--registry=https://registry.npmjs.org ', identity], - { cwd: audit, std io: 'inherit' }, + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org', identity], + { cwd: audit, stdio: 'inherit' }, ); - e xecFileSync('npm', ['audit', 'signatures'], { cwd: audit, stdio: 'inherit' }); - } finally { - rmSync(audit, { recurs ive: true, force: true }); + execFileSync('npm', ['audit', 'signatures'], { cwd: audit, stdio: 'inherit' }); + } finally { + rmSync(audit, { recursive: true, force: true }); } - console.log(`${identity}: provenance пр ивязан к проверенному sour ce и подписи валидны`); - NODE + console.log(`${identity}: provenance привязан к проверенному source и подписи валидны`); + NODE tag: - name: Create verified re lease tag + name: Create verified release tag needs: [resolve, verify] - if : >- - github.repository == 'Labpics-Team /lab-motion' && !cancelled() && - needs.r esolve.result == 'success' && needs.verify.re sult == 'success' + if: >- + github.repository == 'Labpics-Team/lab-motion' && !cancelled() && + needs.resolve.result == 'success' && needs.verify.result == 'success' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 5 permissions: - c ontents: write + contents: write env: - RELEASE_TAG: ${ { needs.resolve.outputs.release_tag }} - EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs .source_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} steps: - # После всех гейтов, но до выдач� � OIDC, фиксируем точный ref. - # Сбой npm оставит пров� �ренный тег, который безо пасно переиспользует rerun . - - name: Create or verify tag idempote ntly + # После всех гейтов, но до выдачи OIDC, фиксируем точный ref. + # Сбой npm оставит проверенный тег, который безопасно переиспользует rerun. + - name: Create or verify tag idempotently shell: bash env: - GH_TOKEN: ${{ github.token }} - run: | - OWNER="${GITHUB_REPOSITORY%%/*}" - REPOSITORY_NAME="${GITHUB_REPOSITO RY#*/}" + GH_TOKEN: ${{ github.token }} + run: | + OWNER="${GITHUB_REPOSITORY%%/*}" + REPOSITORY_NAME="${GITHUB_REPOSITORY#*/}" EXISTING_REF=$( - # $owner/$name/$qualifiedName ниже пр� �надлежат GraphQL, а не shell. - # shellcheck disable=SC2016 - gh api graphql \ - -f query=' query($owner: String!, $name: String!, $quali fiedName: String!) { - reposito ry(owner: $owner, name: $name) { - ref(qualifiedName: $qualifiedName) { ta rget { __typename oid } } + # $owner/$name/$qualifiedName ниже принадлежат GraphQL, а не shell. + # shellcheck disable=SC2016 + gh api graphql \ + -f query='query($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualifiedName) { target { __typename oid } } } - }' \ - -F owner="$OW NER" \ - -F name="$REPOSITORY_NAM E" \ - -F qualifiedName="refs/tag s/$RELEASE_TAG" \ - --jq '(.data. repository.ref.target // empty) | [.__typenam e, .oid] | @tsv' + }' \ + -F owner="$OWNER" \ + -F name="$REPOSITORY_NAME" \ + -F qualifiedName="refs/tags/$RELEASE_TAG" \ + --jq '(.data.repository.ref.target // empty) | [.__typename, .oid] | @tsv' ) - if [[ -z "$EXISTING_REF" ]]; then - # К� �нкурентный job мог создат ь ref после lookup. В любом ис� �оде - # create перечиты ваем серверное состояни� � и принимаем только наш S HA. - if ! gh api "repos/$GITHUB_RE POSITORY/git/refs" \ - -f ref="re fs/tags/$RELEASE_TAG" -f sha="$EXPECTED_SOURC E_SHA"; then - echo "::warning::� �оздание $RELEASE_TAG не подтв� �рждено; проверяем итого� �ый ref" + if [[ -z "$EXISTING_REF" ]]; then + # Конкурентный job мог создать ref после lookup. В любом исходе + # create перечитываем серверное состояние и принимаем только наш SHA. + if ! gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" -f sha="$EXPECTED_SOURCE_SHA"; then + echo "::warning::создание $RELEASE_TAG не подтверждено; проверяем итоговый ref" fi - # refs API не гарантирует read-after-wr ite: свежий ref может - # читаться как 404 с реплик и (падение run 30892921662). Огра ниченный - # retry сохр аняет fail-closed: после исчер пания попыток шаг падает . + # refs API не гарантирует read-after-write: свежий ref может + # читаться как 404 с реплики (падение run 30892921662). Ограниченный + # retry сохраняет fail-closed: после исчерпания попыток шаг падает. for _ in {1..5}; do - if EXISTING_REF=$(gh api "repos/$GITHUB_RE POSITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null); then - brea k + if EXISTING_REF=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null); then + break fi sleep 0.5 - done - [[ -n "$EXISTING_R EF" ]] \ - || { echo "::error::$R ELEASE_TAG создан, но не читае тся после повторных попы ток"; exit 1; } + done + [[ -n "$EXISTING_REF" ]] \ + || { echo "::error::$RELEASE_TAG создан, но не читается после повторных попыток"; exit 1; } fi - re ad -r OBJECT_TYPE OBJECT_SHA <<< "$EXISTING_R EF" + read -r OBJECT_TYPE OBJECT_SHA <<< "$EXISTING_REF" case "$OBJECT_TYPE" in - Commit) OBJECT_TYPE=commit ;; - Tag) OBJECT_TYPE=tag ;; + Commit) OBJECT_TYPE=commit ;; + Tag) OBJECT_TYPE=tag ;; esac - for _ in {1..8}; do - if [[ "$ OBJECT_TYPE" == "commit" ]]; then - EXISTING="$OBJECT_SHA" - break + for _ in {1..8}; do + if [[ "$OBJECT_TYPE" == "commit" ]]; then + EXISTING="$OBJECT_SHA" + break fi - [[ "$OBJECT_TYPE" == "tag" ]] \ - || { echo "::err or::тег указывает на недоп устимый объект $OBJECT_TYPE"; ex it 1; } - read -r OBJECT_TYPE OBJEC T_SHA < <( - gh api "repos/$GITHU B_REPOSITORY/git/tags/$OBJECT_SHA" \ - --jq '[.object.type, .object.sha] | @ tsv' + [[ "$OBJECT_TYPE" == "tag" ]] \ + || { echo "::error::тег указывает на недопустимый объект $OBJECT_TYPE"; exit 1; } + read -r OBJECT_TYPE OBJECT_SHA < <( + gh api "repos/$GITHUB_REPOSITORY/git/tags/$OBJECT_SHA" \ + --jq '[.object.type, .object.sha] | @tsv' ) done - [ [ "${EXISTING:-}" == "$EXPECTED_SOURCE_SHA" ] ] \ - || { echo "::error::$RELEASE_ TAG указывает на ${EXISTING:-unkno wn}"; exit 1; } - echo "$RELEASE_TAG зафиксирован на провере� �ном коммите" + [[ "${EXISTING:-}" == "$EXPECTED_SOURCE_SHA" ]] \ + || { echo "::error::$RELEASE_TAG указывает на ${EXISTING:-unknown}"; exit 1; } + echo "$RELEASE_TAG зафиксирован на проверенном коммите" github-release: - name: GitHub Release - needs: [resolve, p ublish, tag] + name: GitHub Release + needs: [resolve, publish, tag] if: >- - github.reposito ry == 'Labpics-Team/lab-motion' && !cancelled () && - needs.publish.result == 'success' && needs.tag.result == 'success' - runs-on : self-hosted + github.repository == 'Labpics-Team/lab-motion' && !cancelled() && + needs.publish.result == 'success' && needs.tag.result == 'success' + runs-on: ubuntu-latest timeout-minutes: 10 - per missions: + permissions: contents: write env: - RELEASE_TAG: ${{ needs.resolve.outputs.rele ase_tag }} - EXPECTED_SOURCE_SHA: ${{ nee ds.resolve.outputs.source_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + EXPECTED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} steps: - - name: Create immutable release record + - name: Create immutable release record env: - GH_TOKEN: ${{ github. token }} + GH_TOKEN: ${{ github.token }} run: | - TAG_REF=$(g h api "repos/$GITHUB_REPOSITORY/git/ref/tags/ $RELEASE_TAG" \ - --jq '[.object.ty pe, .object.sha] | @tsv') - read -r O BJECT_TYPE OBJECT_SHA <<< "$TAG_REF" - for _ in {1..8}; do - if [[ "$OBJ ECT_TYPE" == "commit" ]]; then - TAG_SOURCE_SHA="$OBJECT_SHA" - br eak + TAG_REF=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv') + read -r OBJECT_TYPE OBJECT_SHA <<< "$TAG_REF" + for _ in {1..8}; do + if [[ "$OBJECT_TYPE" == "commit" ]]; then + TAG_SOURCE_SHA="$OBJECT_SHA" + break fi - [[ "$OBJECT_TY PE" == "tag" ]] \ - || { echo ":: error::тег указывает на нед� �пустимый объект $OBJECT_TYPE"; exit 1; } - read -r OBJECT_TYPE OB JECT_SHA < <( - gh api "repos/$GI THUB_REPOSITORY/git/tags/$OBJECT_SHA" \ - --jq '[.object.type, .object.sha] | @tsv' + [[ "$OBJECT_TYPE" == "tag" ]] \ + || { echo "::error::тег указывает на недопустимый объект $OBJECT_TYPE"; exit 1; } + read -r OBJECT_TYPE OBJECT_SHA < <( + gh api "repos/$GITHUB_REPOSITORY/git/tags/$OBJECT_SHA" \ + --jq '[.object.type, .object.sha] | @tsv' ) done - [[ "${TAG_SOURCE_SHA:-}" == "$EXPECTED_SOUR CE_SHA" ]] \ - || { echo "::error:: $RELEASE_TAG указывает на ${TAG_SO URCE_SHA:-unknown}"; exit 1; } - - OWN ER="${GITHUB_REPOSITORY%%/*}" - REPOS ITORY_NAME="${GITHUB_REPOSITORY#*/}" - lookup_release() { - # $owner/$na me/$tagName ниже принадлежат G raphQL, а не shell. - # shellche ck disable=SC2016 - gh api graphql \ - -f query='query($owner: Strin g!, $name: String!, $tagName: String!) { - repository(owner: $owner, name: $ name) { - release(tagName: $t agName) { tagName isDraft isPrerelease } - } + [[ "${TAG_SOURCE_SHA:-}" == "$EXPECTED_SOURCE_SHA" ]] \ + || { echo "::error::$RELEASE_TAG указывает на ${TAG_SOURCE_SHA:-unknown}"; exit 1; } + + OWNER="${GITHUB_REPOSITORY%%/*}" + REPOSITORY_NAME="${GITHUB_REPOSITORY#*/}" + lookup_release() { + # $owner/$name/$tagName ниже принадлежат GraphQL, а не shell. + # shellcheck disable=SC2016 + gh api graphql \ + -f query='query($owner: String!, $name: String!, $tagName: String!) { + repository(owner: $owner, name: $name) { + release(tagName: $tagName) { tagName isDraft isPrerelease } + } }' \ - -F owner="$OWNER" \ - -F name=" $REPOSITORY_NAME" \ - -F tagName= "$RELEASE_TAG" \ - --jq '(.data.r epository.release // empty) | [.tagName, .isD raft, .isPrerelease] | @tsv' + -F owner="$OWNER" \ + -F name="$REPOSITORY_NAME" \ + -F tagName="$RELEASE_TAG" \ + --jq '(.data.repository.release // empty) | [.tagName, .isDraft, .isPrerelease] | @tsv' } - EXISTING=$(lookup_release) - i f [[ -z "$EXISTING" ]]; then - # М ежду lookup и create релиз мог � �оявиться в параллельном job. - # Ошибка create до� �устима только если повт� �рное чтение докажет ито� �. - if ! gh release create "$RELEA SE_TAG" \ - --repo "$GITHUB_REPOS ITORY" \ + EXISTING=$(lookup_release) + if [[ -z "$EXISTING" ]]; then + # Между lookup и create релиз мог появиться в параллельном job. + # Ошибка create допустима только если повторное чтение докажет итог. + if ! gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ --verify-tag \ - --generate-notes \ - --tit le "$RELEASE_TAG"; then - echo ": :warning::создание GitHub Release н� � подтверждено; проверяе� � итоговое состояние" - fi - EXISTING=$(lookup_releas e) + --generate-notes \ + --title "$RELEASE_TAG"; then + echo "::warning::создание GitHub Release не подтверждено; проверяем итоговое состояние" + fi + EXISTING=$(lookup_release) fi - IFS=$'\t' read -r A CTUAL_TAG IS_DRAFT IS_PRERELEASE <<< "$EXISTI NG" - [[ "$ACTUAL_TAG" == "$RELEASE_T AG" && "$IS_DRAFT" == "false" && "$IS_PRERELE ASE" == "false" ]] \ - || { echo ": :error::существующий GitHub Relea se имеет неверное состоян ие"; exit 1; } - echo "GitHub Relea se $RELEASE_TAG подтверждён" - \ No newline at end of file + IFS=$'\t' read -r ACTUAL_TAG IS_DRAFT IS_PRERELEASE <<< "$EXISTING" + [[ "$ACTUAL_TAG" == "$RELEASE_TAG" && "$IS_DRAFT" == "false" && "$IS_PRERELEASE" == "false" ]] \ + || { echo "::error::существующий GitHub Release имеет неверное состояние"; exit 1; } + echo "GitHub Release $RELEASE_TAG подтверждён" diff --git a/bench/compare/bench.mjs b/bench/compare/bench.mjs index 4e8a91fc..ce87c13b 100644 --- a/bench/compare/bench.mjs +++ b/bench/compare/bench.mjs @@ -75,8 +75,11 @@ import { renderBenchmarkMarkdown, renderBenchmarkEnvironment, createBenchmarkClaims, + createBenchmarkMotionConformance, + S5_MOTION_REQUIREMENTS, validateBenchmarkReportPair, } from './report-contract.mjs'; +import { S5_MOTION_CONTRACT } from './motion-conformance.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..'); @@ -172,9 +175,10 @@ const PAGE_HTML = ``; -async function startBenchmarkOrigin() { +export async function startBenchmarkOrigin() { const server = createServer((_request, response) => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', @@ -500,16 +504,16 @@ async function runSemanticStartCheck(page, scenario, calls) { // ─── сценарий S5: freeze-continuity (визуальный) ───────────────────────────── -function redLeftEdge(pngBuf) { +function colorLeftEdge(pngBuf, channel) { // Полный скан: кадры скринкаста — целый вьюпорт (возможен и letterbox), - // привязываться к конкретной строке нельзя. Ищем левейший красный пиксель. + // привязываться к конкретной строке нельзя. Ищем левейший пиксель канала. const img = PNG.sync.read(pngBuf); let left = null; for (let y = 0; y < img.height; y++) { for (let x = 0; x < img.width; x++) { if (left !== null && x >= left) break; const i = (img.width * y + x) << 2; - if (img.data[i] > 180 && img.data[i + 1] < 120 && img.data[i + 2] < 120) { + if (img.data[i + channel] > 180 && img.data[i + 1 - channel] < 120 && img.data[i + 2] < 120) { left = x; break; } @@ -518,6 +522,10 @@ function redLeftEdge(pngBuf) { return left; } +function redLeftEdge(pngBuf) { + return colorLeftEdge(pngBuf, 0); +} + async function waitForBaselineFrame(frames) { const deadline = Date.now() + 2_000; let inspected = 0; @@ -536,9 +544,10 @@ async function waitForBaselineFrame(frames) { let cdpStartSequence = 0; -async function startWithCdpClock(page, cdp, scenario) { +async function readCdpStartClock(page, cdp, startPage, config) { const token = `lab-motion-start-${++cdpStartSequence}`; let timeout; + let removeListener = () => {}; const eventPromise = new Promise((resolve, reject) => { const onConsole = (event) => { const cdpToken = event.args?.[0]?.value; @@ -548,12 +557,32 @@ async function startWithCdpClock(page, cdp, scenario) { resolve({ cdpToken, cdpRuntimeTimestampMs: event.timestamp }); }; cdp.on('Runtime.consoleAPICalled', onConsole); + removeListener = () => cdp.off('Runtime.consoleAPICalled', onConsole); timeout = setTimeout(() => { cdp.off('Runtime.consoleAPICalled', onConsole); - reject(new Error('first presented: CDP start marker timeout')); + reject(new Error('CDP start marker timeout')); }, 2_000); }); - const pageClock = await page.evaluate(({ clockToken, config }) => { + try { + const [pageClock, marker] = await Promise.all([ + page.evaluate(startPage, { clockToken: token, config }), eventPromise, + ]); + return { + token, + cdpClockDomain: 'TimeSinceEpoch', + runtimeTimestampUnit: 'milliseconds', + frameTimestampUnit: 'seconds', + ...pageClock, + ...marker, + }; + } finally { + clearTimeout(timeout); + removeListener(); + } +} + +async function startWithCdpClock(page, cdp, scenario) { + return readCdpStartClock(page, cdp, ({ clockToken, config }) => { const element = document.getElementById('probe'); const pageTimeOriginMs = performance.timeOrigin; const pageBeforeNowMs = performance.now(); @@ -565,15 +594,7 @@ async function startWithCdpClock(page, cdp, scenario) { config.durationMs, ); return { pageTimeOriginMs, pageBeforeNowMs, pageApiNowMs }; - }, { clockToken: token, config: scenario }); - return { - token, - cdpClockDomain: 'TimeSinceEpoch', - runtimeTimestampUnit: 'milliseconds', - frameTimestampUnit: 'seconds', - ...pageClock, - ...await eventPromise, - }; + }, scenario); } /** Cold S1: CDP marker перед API → первый сдвинувшийся compositor-пиксель. */ @@ -630,14 +651,16 @@ async function runFirstPresented(browser, adapterPath, scenario, pageUrl) { }; } -const FREEZE_PX = 600; -const FREEZE_DURATION_MS = 2400; +const FREEZE_PX = S5_MOTION_CONTRACT.distancePx; +const FREEZE_DURATION_MS = S5_MOTION_CONTRACT.durationMs; const BLOCK_AT_MS = 300; const BLOCK_MS = 900; -async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { +export async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { const { context, page } = await newPage(browser, adapterPath, pageUrl); const cdp = await context.newCDPSession(page); + await cdp.send('Runtime.enable'); + const timerBefore = await measurePageTimerProbe(page, 'before'); const frames = []; let screencastActive = false; const stopScreencast = async () => { @@ -646,11 +669,20 @@ async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { await cdp.send('Page.stopScreencast').catch(() => {}); }; try { - await page.evaluate(() => { + await page.evaluate(({ adapter, blocked }) => { const p = document.createElement('div'); p.id = 'probe'; document.body.appendChild(p); - }); + const witness = document.createElement('div'); + witness.id = 'capture-witness'; + document.body.appendChild(witness); + const expected = adapter.includes('lab-spring') ? 'пружина' : 'линейное движение'; + const legend = document.createElement('div'); + legend.style.cssText = 'position:absolute;left:8px;top:112px;color:#444;font:13px sans-serif;white-space:pre-line'; + legend.textContent = `Красный: проверяемый путь, ожидается ${expected}.\nЗелёный: линейный свидетель записи, не эталон красного.`; + document.body.appendChild(legend); + document.title = `S5 ${adapter} (${expected}): ${blocked ? 'блокировка 900 мс' : 'без блокировки'}`; + }, { adapter: path.basename(adapterPath), blocked }); // Кадры забираем скринкастом: компоситор пушит их сам, main-thread страницы // не участвует. Одиночный Page.captureScreenshot при мёртвом main стопорится @@ -668,12 +700,25 @@ async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { // Предстартовый пиксель даёт честную точку удержания, не синтезируя движение. await waitForBaselineFrame(frames); - // Epoch фиксируется в page realm рядом с API-вызовом: RTT Node↔page не входит. - const startedAt = await page.evaluate(({ px, duration, shouldBlock, blockAt, blockMs }) => { + // Runtime marker и кадры имеют один CDP clock domain; page realm ограничивает + // задержку marker→API. RTT Node↔page не входит в начало траектории. + const startClock = await readCdpStartClock(page, cdp, ({ clockToken, config }) => { + const { px, duration, shouldBlock, blockAt, blockMs } = config; const el = document.getElementById('probe'); const epoch = () => (performance.timeOrigin + performance.now()) / 1000; + const pageTimeOriginMs = performance.timeOrigin; + const pageBeforeNowMs = performance.now(); + console.debug(clockToken); const start = epoch(); window.__benchTiming = { startedAt: start, blockStartedAt: null, blockEndedAt: null }; + // Свидетель создаёт visual damage даже при неподвижной/завершённой цели. + // Его пиксели в том же кадре проверяют, что запись не подменяет фриз пропуском. + // Нулевая точка — API, не поздний Animation.startTime: startup расходует бюджет S5. + window.__witness = document.getElementById('capture-witness').animate( + [{ transform: 'translateX(0px)' }, { transform: `translateX(${px}px)` }], + { duration, easing: 'linear', fill: 'forwards' }, + ); + const pageApiNowMs = performance.now(); window.__c = window.__adapterModule.start([el], px, duration); if (shouldBlock) setTimeout(() => { window.__benchTiming.blockStartedAt = epoch(); @@ -681,7 +726,7 @@ async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { while (performance.now() < end) { /* реальная блокировка main-thread */ } window.__benchTiming.blockEndedAt = epoch(); }, blockAt); - return start; + return { pageTimeOriginMs, pageBeforeNowMs, pageApiNowMs }; }, { px: FREEZE_PX, duration: FREEZE_DURATION_MS, @@ -689,10 +734,10 @@ async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { blockAt: BLOCK_AT_MS, blockMs: BLOCK_MS, }); + const startedAt = startClock.cdpRuntimeTimestampMs / 1000; // Ждём: анимация + блок + запас на lagSmoothing-подобное продление (GSAP - // после лага честно доигрывает сдвинутый таймлайн, а не прыгает — это - // валидное поведение, ему нужно время). + // после лага доигрывает сдвинутый таймлайн; endpoint не заменяет S5-контракт). await new Promise((r) => setTimeout( r, FREEZE_DURATION_MS + (blocked ? BLOCK_MS : 0) + 700, @@ -705,13 +750,26 @@ async function captureTrajectory(browser, adapterPath, blocked, pageUrl) { timing: window.__benchTiming, }; }); + const clock = { + startClock, + timerEvidence: { + crossOriginIsolated: await page.evaluate(() => globalThis.crossOriginIsolated), + probes: [timerBefore, await measurePageTimerProbe(page, 'after')], + }, + }; const decoded = frames .map((f) => ({ t: f.ts - startedAt, x: redLeftEdge(Buffer.from(f.data, 'base64')) })) .filter((f) => Number.isFinite(f.t) && f.x !== null) .sort((a, b) => a.t - b.t); + const witness = frames + .map((f) => ({ t: f.ts - startedAt, x: colorLeftEdge(Buffer.from(f.data, 'base64'), 1) })) + // Пропавший пиксель — исход захваченного кадра, не повод удалить кадр. + .sort((a, b) => a.t - b.t); return { decoded, + witness, + clock, finalX: terminal.finalX, timing: terminal.timing, rawFrames: frames.length, @@ -736,6 +794,10 @@ async function runFreezePair(browser, adapterPath, blockedFirst, pageUrl) { for (let t = windowStart; t <= windowEnd; t += 0.1) grid.push(t); } const evidence = createFreezeEvidence(blocked.decoded, baseline.decoded, grid); + evidence.baselineWitness = baseline.witness; + evidence.blockedWitness = blocked.witness; + evidence.baselineClock = baseline.clock; + evidence.blockedClock = blocked.clock; const scored = scoreAgainstBaseline(evidence.blocked, evidence.baseline, evidence.grid); const blockedWindow = blocked.decoded.filter((f) => f.t >= windowStart && f.t <= windowEnd); const baselineWindow = baseline.decoded.filter((f) => f.t >= windowStart && f.t <= windowEnd); @@ -786,6 +848,7 @@ async function main() { ['bench/methodology.mjs', path.join(__dirname, 'methodology.mjs')], ['bench/provenance.mjs', path.join(__dirname, 'provenance.mjs')], ['bench/report-contract.mjs', path.join(__dirname, 'report-contract.mjs')], + ['bench/motion-conformance.mjs', path.join(__dirname, 'motion-conformance.mjs')], ], requiredEntries: ENTRY_INPUTS, }); @@ -1004,7 +1067,8 @@ async function main() { const ids = LIBS.map((l) => l.id); const stem = `${generatedAt.slice(0, 10)}-${provenance.revisionLabel}-${provenance.distRuntime.sha256.slice(0, 12)}`; const rawPayload = { - schema: 9, + schema: 10, + motionContract: S5_MOTION_CONTRACT, package: { name: rootPkg.name, version: rootPkg.version }, generatedAt, companion: { markdownFile: `${stem}.md`, markdownSha256: '' }, @@ -1044,6 +1108,7 @@ async function main() { scenarioManifest, }); rawPayload.environment = renderBenchmarkEnvironment(rawPayload); + rawPayload.motionConformance = createBenchmarkMotionConformance(rawPayload.results); console.log('=== Честный сравнительный бенчмарк ==='); rawPayload.environment.forEach((line) => console.log(line)); const md = renderBenchmarkMarkdown(rawPayload); @@ -1084,6 +1149,12 @@ async function main() { console.log(`\n${md}`); console.log(`Отчёт: ${outPath}`); console.log(`Raw: ${rawPath}`); + // Плохое измерение сохранено как evidence; успешный exit требует отдельного контракта. + validateBenchmarkReportPair({ + stem, markdown: md, payload: rawPayload, rootPackage: rootPkg, + benchmarkPackage: JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8')), + now: Date.parse(generatedAt), motionRequirements: S5_MOTION_REQUIREMENTS, + }); } const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); diff --git a/bench/compare/conformance-smoke.mjs b/bench/compare/conformance-smoke.mjs new file mode 100644 index 00000000..1a0112a9 --- /dev/null +++ b/bench/compare/conformance-smoke.mjs @@ -0,0 +1,173 @@ +/** Реальный headed Chromium/CDP smoke; один захват на режим, без повторов. */ +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; +import { chromium } from 'playwright'; +import { captureTrajectory, startBenchmarkOrigin } from './bench.mjs'; +import { PRODUCTION_ADAPTER_PROFILE } from './methodology.mjs'; +import { createBenchmarkMotionConformance, S5_MOTION_REQUIREMENTS } from './report-contract.mjs'; +import { S5_MOTION_CONTRACT } from './motion-conformance.mjs'; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const temporaryPrefix = 'lab-motion-conformance-smoke-'; + +// Нативная интерполяция удерживает 25% от t=25% до t=65%, затем скачком догоняет время. +const injectedAdapter = ` +export const name = 'injected-native-freeze-25-to-65'; +export function start(elements, px, duration) { + const animations = elements.map((element) => element.animate([ + { offset: 0, transform: 'translateX(0px)' }, + { offset: 0.25, transform: 'translateX(' + px * 0.25 + 'px)', easing: 'steps(1, end)' }, + { offset: 0.65, transform: 'translateX(' + px * 0.65 + 'px)' }, + { offset: 1, transform: 'translateX(' + px + 'px)' }, + ], { duration, easing: 'linear', fill: 'forwards' })); + return { cancel() { for (const animation of animations) animation.cancel(); } }; +} +`; + +function buildAdapters(temporaryDirectory) { + const manifest = JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8')); + if (esbuild.version !== manifest.devDependencies.esbuild) { + throw new Error('esbuild не совпадает с точной версией bench/compare/package.json'); + } + const adapters = {}; + for (const [id, entry] of [ + ['lab', 'lab.entry.mjs'], + ['waapi-ctl', 'waapi-control.entry.mjs'], + ['lab-spring', 'lab-spring.entry.mjs'], + ['injected-native-freeze', null], + ]) { + const outfile = path.join(temporaryDirectory, `${id}.iife.js`); + esbuild.buildSync({ + ...PRODUCTION_ADAPTER_PROFILE, + format: 'iife', + globalName: '__adapterModule', + outfile, + ...(entry === null + ? { stdin: { contents: injectedAdapter, sourcefile: 'injected-native-freeze.mjs', loader: 'js' } } + : { entryPoints: [path.join(directory, 'entries', entry)] }), + }); + adapters[id] = { + path: outfile, + sha256: createHash('sha256').update(readFileSync(outfile)).digest('hex'), + }; + } + return adapters; +} + +function assessCaptures(participant, captures) { + const evidence = {}; + const rawFrames = {}; + for (const [mode, capture] of Object.entries(captures)) { + evidence[mode] = capture.decoded; + evidence[`${mode}Witness`] = capture.witness; + evidence[`${mode}Clock`] = capture.clock; + rawFrames[mode] = capture.rawFrames; + } + // Пустые массивы обозначают неизмеренные пути. Это вход oracle, не fixture отчёта. + const results = Object.fromEntries(S5_MOTION_REQUIREMENTS.baseline.map((id) => [ + id, { raw: { freeze: id === participant ? [{ evidence, rawFrames }] : [] } }, + ])); + return createBenchmarkMotionConformance(results)[participant].runs[0]; +} + +async function recordScenario(browser, origin, scenario, adapter) { + const captures = {}; + for (const mode of Object.keys(scenario.expected)) { + console.log(`Запись ${scenario.label}: ${mode}; headed Chromium, ${S5_MOTION_CONTRACT.durationMs} мс.`); + captures[mode] = await captureTrajectory(browser, adapter.path, mode === 'blocked', origin.url); + } + const run = assessCaptures(scenario.participant, captures); + const failures = []; + for (const [mode, expected] of Object.entries(scenario.expected)) { + const target = run[mode]; + const witness = run.witness[mode]; + const capture = run.capture[mode]; + const accepted = capture.verdict === 'pass' && witness.verdict === 'pass' && target.verdict === expected; + console.log(JSON.stringify({ + scenario: scenario.label, + participant: scenario.participant, + adapterSha256: adapter.sha256, + mode, + expected, + accepted, + rawFrames: captures[mode].rawFrames, + capture, + witness, + target, + })); + if (!accepted) { + failures.push(new Error( + `${scenario.label}.${mode}: ожидалось ${expected}; target=${target.verdict}, ` + + `witness=${witness.verdict}, capture=${capture.verdict}`, + )); + } + } + return failures; +} + +async function main() { + const temporaryRoot = realpathSync(tmpdir()); + const temporaryDirectory = realpathSync(mkdtempSync(path.join(temporaryRoot, temporaryPrefix))); + const errors = []; + let browser; + let origin; + try { + const adapters = buildAdapters(temporaryDirectory); + origin = await startBenchmarkOrigin(); + browser = await chromium.launch({ + headless: false, + args: [ + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-features=CalculateNativeWinOcclusion', + ], + }); + console.log(JSON.stringify({ + diagnostic: 'S5 CDP conformance smoke', + contract: S5_MOTION_CONTRACT, + chromium: browser.version(), + esbuild: esbuild.version, + repeats: 1, + })); + for (const scenario of [ + { label: 'lab-js', participant: 'lab', adapter: 'lab', expected: { baseline: 'pass', blocked: 'fail' } }, + { label: 'native-control', participant: 'waapi-ctl', adapter: 'waapi-ctl', expected: { baseline: 'pass', blocked: 'pass' } }, + { label: 'lab-spring', participant: 'lab-spring', adapter: 'lab-spring', expected: { baseline: 'pass', blocked: 'pass' } }, + { label: 'injected-native-freeze', participant: 'waapi-ctl', adapter: 'injected-native-freeze', expected: { baseline: 'fail' } }, + ]) { + errors.push(...await recordScenario(browser, origin, scenario, adapters[scenario.adapter])); + } + } catch (error) { + errors.push(error); + } finally { + if (browser !== undefined) { + try { await browser.close(); } catch (error) { errors.push(error); } + } + if (origin !== undefined) { + try { await origin.close(); } catch (error) { errors.push(error); } + } + try { + // Повторная проверка точного mkdtemp-пути перед рекурсивным удалением. + if (realpathSync(temporaryDirectory) !== temporaryDirectory + || path.dirname(temporaryDirectory) !== temporaryRoot + || !path.basename(temporaryDirectory).startsWith(temporaryPrefix)) { + throw new Error('Временный каталог smoke изменился; автоматическое удаление запрещено'); + } + rmSync(temporaryDirectory, { recursive: true }); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw new AggregateError(errors, 'S5 conformance smoke не пройден'); + console.log('PASS: семь реальных записей подтвердили ожидаемые вердикты S5; один запуск, без performance claims.'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/compare/motion-conformance.mjs b/bench/compare/motion-conformance.mjs new file mode 100644 index 00000000..bb732744 --- /dev/null +++ b/bench/compare/motion-conformance.mjs @@ -0,0 +1,161 @@ +/** Конечные бюджеты сценария S5; они не задают погрешность часов или общую плавность. */ +export const S5_MOTION_CONTRACT = Object.freeze({ + id: 's5-motion-v1', + distancePx: 600, + durationMs: 2400, + positionTolerancePx: 3, + timeToleranceMs: 20, + maxObservationGapMs: 50, + spring: Object.freeze({ stiffness: 40, damping: 8, mass: 1 }), +}); + +/** @typedef {'linear' | 'spring'} MotionModel */ +/** @typedef {{ t: number, x: number }} Observation */ +/** + * @typedef {object} Conformance + * @property {'pass' | 'fail' | 'inconclusive'} verdict + * @property {string} reason + * @property {number | null} maxErrorPx Максимальное отклонение от значения в момент t. + * @property {number | null} maxGapMs Включает ненаблюдаемое начало и конец окна. + * @property {number} samples Число валидных наблюдений внутри окна; при порче записи — 0. + */ + +const durationSeconds = S5_MOTION_CONTRACT.durationMs / 1000; +const springFrequency = Math.sqrt(24); + +/** @param {number} t @param {MotionModel} model */ +function expectedPosition(t, model) { + if (t <= 0) return 0; + if (model === 'linear') { + return S5_MOTION_CONTRACT.distancePx * Math.min(1, t / durationSeconds); + } + // Независимое решение x'' + 8x' + 40(x - 600) = 0, x(0) = x'(0) = 0. + return 600 * (1 - Math.exp(-4 * t) * ( + Math.cos(springFrequency * t) + 4 / springFrequency * Math.sin(springFrequency * t) + )); +} + +/** @param {number} t @param {MotionModel} model @param {number} timeToleranceSeconds */ +function positionEnvelope(t, model, timeToleranceSeconds) { + const from = Math.max(0, t - timeToleranceSeconds); + const to = t + timeToleranceSeconds; + const values = [expectedPosition(from, model), expectedPosition(to, model)]; + if (model === 'spring') { + // На немонотонном участке границ интервала недостаточно: x' = 0 при nπ/√24. + const first = Math.ceil(from * springFrequency / Math.PI); + const last = Math.floor(to * springFrequency / Math.PI); + for (let n = first; n <= last; n++) { + values.push(expectedPosition(n * Math.PI / springFrequency, model)); + } + } + return { + min: Math.min(...values) - S5_MOTION_CONTRACT.positionTolerancePx, + max: Math.max(...values) + S5_MOTION_CONTRACT.positionTolerancePx, + }; +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** @param {string} reason @returns {Conformance} */ +function invalidCapture(reason) { + return { verdict: 'inconclusive', reason, maxErrorPx: null, maxGapMs: null, samples: 0 }; +} + +/** + * Проверяет только записанные положения в [0, 2.4] с. Между точками ничего не достраивается. + * Точки вне окна не закрывают покрытие. PASS ограничен этой сеткой наблюдений и бюджетами S5. + * @param {unknown} points + * @param {MotionModel} model + * @param {number} [timeUncertaintyMs] Измеренная неопределённость расходует, а не расширяет бюджет. + * @returns {Conformance} + */ +export function evaluateTrajectoryConformance(points, model, timeUncertaintyMs = 0) { + if (model !== 'linear' && model !== 'spring') { + throw new TypeError(`Unsupported S5 motion model: ${String(model)}`); + } + if (!Number.isFinite(timeUncertaintyMs) || timeUncertaintyMs < 0 || + timeUncertaintyMs > S5_MOTION_CONTRACT.timeToleranceMs) { + return invalidCapture('clock-uncertainty-exceeds-contract'); + } + const timeToleranceSeconds = (S5_MOTION_CONTRACT.timeToleranceMs - timeUncertaintyMs) / 1000; + if (!Array.isArray(points)) return invalidCapture('observations-not-array'); + + /** @type {Observation[]} */ + const observations = []; + let previousTime = -Infinity; + // Сначала проверяется вся запись: даже порча вне окна подрывает доверие к захвату. + for (const point of points) { + if (!isRecord(point) || typeof point.t !== 'number' || typeof point.x !== 'number' + || !Number.isFinite(point.t) || !Number.isFinite(point.x)) { + return invalidCapture('nonfinite-observation'); + } + if (point.t <= previousTime) return invalidCapture('nonincreasing-observation-time'); + previousTime = point.t; + if (point.t >= 0 && point.t <= durationSeconds) observations.push({ t: point.t, x: point.x }); + } + + if (observations.length === 0) { + return { + verdict: 'inconclusive', reason: 'no-observations-in-window', + maxErrorPx: null, maxGapMs: S5_MOTION_CONTRACT.durationMs, samples: 0, + }; + } + + let maxErrorPx = 0; + let maxGapMs = 0; + let previousObservedTime = 0; + let violation = false; + for (const point of observations) { + const expected = expectedPosition(point.t, model); + const envelope = positionEnvelope(point.t, model, timeToleranceSeconds); + maxErrorPx = Math.max(maxErrorPx, Math.abs(point.x - expected)); + maxGapMs = Math.max(maxGapMs, (point.t - previousObservedTime) * 1000); + previousObservedTime = point.t; + // Только округление IEEE-754 при вычислении границ, а не дополнительный бюджет S5. + const roundingPx = 32 * Number.EPSILON * Math.max( + S5_MOTION_CONTRACT.distancePx, Math.abs(envelope.min), Math.abs(envelope.max), + ); + if (point.x < envelope.min - roundingPx || point.x > envelope.max + roundingPx) { + violation = true; + } + } + maxGapMs = Math.max(maxGapMs, (durationSeconds - previousObservedTime) * 1000); + const evidence = { maxErrorPx, maxGapMs, samples: observations.length }; + if (violation) return { verdict: 'fail', reason: 'position-outside-contract', ...evidence }; + + const roundingMs = 32 * Number.EPSILON * S5_MOTION_CONTRACT.durationMs; + if (maxGapMs > S5_MOTION_CONTRACT.maxObservationGapMs + roundingMs) { + return { verdict: 'inconclusive', reason: 'observation-gap-exceeds-contract', ...evidence }; + } + return { verdict: 'pass', reason: 'within-s5-motion-contract', ...evidence }; +} + +/** @param {string} id @param {unknown} run @param {{baseline: number, blocked: number}} [uncertainty] */ +export function evaluateFreezeConformance(id, run, uncertainty = { baseline: 0, blocked: 0 }) { + /** @type {MotionModel} */ + let model; + switch (id) { + case 'lab-spring': + model = 'spring'; + break; + case 'lab': + case 'motion': + case 'gsap': + case 'anime': + case 'waapi-ctl': + case 'motion-mini': + case 'anime-waapi': + model = 'linear'; + break; + default: + throw new TypeError(`Unsupported S5 participant: ${String(id)}`); + } + const evidence = isRecord(run) && isRecord(run.evidence) ? run.evidence : {}; + return { + baseline: evaluateTrajectoryConformance(evidence.baseline, model, uncertainty.baseline), + blocked: evaluateTrajectoryConformance(evidence.blocked, model, uncertainty.blocked), + }; +} diff --git a/bench/compare/package.json b/bench/compare/package.json index 3232116c..51cc81e9 100644 --- a/bench/compare/package.json +++ b/bench/compare/package.json @@ -4,7 +4,8 @@ "type": "module", "packageManager": "pnpm@11.11.0", "scripts": { - "bench": "node bench.mjs" + "bench": "node bench.mjs", + "test:conformance": "node conformance-smoke.mjs" }, "devDependencies": { "animejs": "4.5.0", diff --git a/bench/compare/report-contract.mjs b/bench/compare/report-contract.mjs index 99ae4099..c21de363 100644 --- a/bench/compare/report-contract.mjs +++ b/bench/compare/report-contract.mjs @@ -2,6 +2,11 @@ import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { CANONICAL_GZIP_PACKAGE } from '../../scripts/compression-policy.mjs'; import { formatProvenanceMarkdown, isExactPackageVersion } from './provenance.mjs'; +import { + evaluateFreezeConformance, + evaluateTrajectoryConformance, + S5_MOTION_CONTRACT, +} from './motion-conformance.mjs'; import { applyHolmCorrection, assertBalancedRunBlocks, @@ -9,6 +14,7 @@ import { assertRealmClockUncertainty, assertWarmStartMeasurement, BENCHMARK_TIMER_ISOLATION_POLICY, + deriveCdpStartClock, deriveFirstPresentedElapsedMs, deriveFirstPresentedUncertaintyMs, deriveRealmClockUncertainty, @@ -72,6 +78,100 @@ const CLAIM_METRICS = Object.freeze([ }, ]); +/** Нормальное движение обязательно у всех; блокировку — у заявленных native-путей. */ +export const S5_MOTION_REQUIREMENTS = Object.freeze({ + baseline: FREEZE_IDS, + blocked: Object.freeze(['waapi-ctl', 'lab-spring', 'motion-mini', 'anime-waapi']), +}); + +function aggregateConformance(verdicts) { + if (verdicts.includes('fail')) return 'fail'; + if (verdicts.length === 0 || verdicts.includes('inconclusive')) return 'inconclusive'; + return 'pass'; +} + +function qualifyMotionCapture(run, mode) { + const evidence = run?.evidence; + const target = evidence?.[mode]; + const witness = evidence?.[`${mode}Witness`]; + if (!Array.isArray(target) || !Array.isArray(witness) || target.length !== witness.length || + witness.length !== run.rawFrames?.[mode] || + target.some((point, index) => point?.t !== witness[index]?.t)) { + return { verdict: 'inconclusive', reason: 'unpaired-frame-observations', uncertaintyMs: null }; + } + let clock; + try { + const input = evidence?.[`${mode}Clock`]; + clock = deriveCdpStartClock(`S5 ${mode}`, input?.startClock, input?.timerEvidence); + } catch { + return { verdict: 'inconclusive', reason: 'unverified-cdp-start-clock', uncertaintyMs: null }; + } + // IEEE-754 epoch timestamps добавляют собственное округление сверх page timer. + const uncertaintyMs = clock.markerToApiUpperMs + 8 * Number.EPSILON * clock.startedAtSeconds * 1000; + return { + verdict: uncertaintyMs <= S5_MOTION_CONTRACT.timeToleranceMs ? 'pass' : 'inconclusive', + reason: uncertaintyMs <= S5_MOTION_CONTRACT.timeToleranceMs ? 'paired-frames-and-clock' : 'clock-uncertainty-exceeds-contract', + uncertaintyMs, + }; +} + +/** Ожидание выводится из сценария; совпадение с другим запуском не доказывает качество. */ +export function createBenchmarkMotionConformance(results) { + return Object.fromEntries(FREEZE_IDS.map((id) => { + const input = results?.[id]?.raw?.freeze; + if (!Array.isArray(input)) fail(`${id}: нет motion conformance evidence`); + const runs = input.map((run) => { + const capture = { baseline: qualifyMotionCapture(run, 'baseline'), blocked: qualifyMotionCapture(run, 'blocked') }; + const uncertainty = { + baseline: capture.baseline.uncertaintyMs ?? NaN, + blocked: capture.blocked.uncertaintyMs ?? NaN, + }; + return { + ...evaluateFreezeConformance(id, run, uncertainty), + capture, + witness: { + baseline: evaluateTrajectoryConformance(run.evidence?.baselineWitness, 'linear', uncertainty.baseline), + blocked: evaluateTrajectoryConformance(run.evidence?.blockedWitness, 'linear', uncertainty.blocked), + }, + }; + }); + const qualified = (mode) => aggregateConformance(runs.map((run) => ( + run.capture[mode].verdict === 'pass' && run.witness[mode].verdict === 'pass' ? run[mode].verdict : 'inconclusive' + ))); + return [id, { + baseline: qualified('baseline'), + blocked: qualified('blocked'), + capture: runs.length > 0 && runs.every((run) => ( + run.capture.baseline.verdict === 'pass' && run.capture.blocked.verdict === 'pass' && + run.witness.baseline.verdict === 'pass' && run.witness.blocked.verdict === 'pass' + )) ? 'pass' : 'inconclusive', + runs, + }]; + })); +} + +function requireMotionConformance(payload, requirements) { + if (requirements === undefined) return; + if (payload.schema !== 10) fail('motion conformance требует schema 10; legacy-отчёт не доказывает качество'); + if ( + requirements === null || typeof requirements !== 'object' || + !isDeepStrictEqual(Object.keys(requirements).sort(), ['baseline', 'blocked']) || + !Array.isArray(requirements.baseline) || !Array.isArray(requirements.blocked) || + requirements.baseline.length + requirements.blocked.length === 0 + ) fail('motion conformance: требования должны называть проверяемые пути'); + for (const mode of ['baseline', 'blocked']) { + const ids = requirements[mode]; + if (new Set(ids).size !== ids.length) fail('motion conformance: повтор участника'); + for (const id of ids) { + if (!FREEZE_IDS.includes(id)) fail('motion conformance: неизвестный участник'); + const result = payload.motionConformance[id]; + if (result[mode] !== 'pass') { + fail(`motion conformance: ${id}.${mode} = ${result[mode]}; capture = ${result.capture}`); + } + } + } +} + function fail(message) { throw new Error(`benchmark report: ${message}`); } @@ -537,6 +637,30 @@ export function renderBenchmarkMarkdown(payload) { ['Финальная x, p50 px', (result) => result.summary.freeze.finalX.p50.toFixed(1)], ]; const verdictLabel = (verdict) => verdict === 'win' ? 'победа' : 'неопределённо'; + const motionLabel = (verdict) => ({ + pass: 'соответствует', fail: 'не соответствует', inconclusive: 'недостаточно данных', + })[verdict]; + const motionSection = payload.schema === 10 ? [ + '### Независимый контракт движения', + '', + `Контракт: \`${S5_MOTION_CONTRACT.id}\`; ${S5_MOTION_CONTRACT.distancePx} px / ${S5_MOTION_CONTRACT.durationMs} мс. Допуски: позиция ${S5_MOTION_CONTRACT.positionTolerancePx} px, время ±${S5_MOTION_CONTRACT.timeToleranceMs} мс, разрыв наблюдения ≤${S5_MOTION_CONTRACT.maxObservationGapMs} мс.`, + 'Это объявленные пределы данного сценария, не доказательство абсолютной плавности. Линейное движение', + 'сверяется с уравнением времени, пружина — с независимым решением ОДУ; собственный baseline не служит эталоном.', + 'Зелёный native-свидетель снимается в том же кадре и обеспечивает наблюдение неподвижной красной цели.', + 'CDP start marker связывается с часами этого page realm; измеренная неопределённость вычитается из временного допуска.', + '', + '| Путь | Запись кадров | Без блокировки | С блокировкой |', + '|---|---|---|---|', + ...ids.map((id) => { + const result = payload.motionConformance[id]; + return `| ${id} | ${motionLabel(result.capture)} | ${motionLabel(result.baseline)} | ${motionLabel(result.blocked)} |`; + }), + '', + 'Приём отчёта подтверждает достоверность его данных, не выполнение требований движения.', + 'Любой неуспешный прогон препятствует соответствующему утверждению; медиана не скрывает нарушение.', + 'Наблюдения с пробелами не дополняются вымышленными кадрами. Поведение между захваченными кадрами и input→photon не доказаны.', + '', + ] : []; const claimRows = payload.claims.performance.map((claim) => [ claim.metric, claim.competitor, @@ -599,6 +723,7 @@ export function renderBenchmarkMarkdown(payload) { '', '## S5: freeze main thread', '', + ...motionSection, ...table('Linear full API пути', startIds, freezeMetrics), ...table('Linear native пути и платформенный контроль', ['waapi-ctl', 'motion-mini', 'anime-waapi'], freezeMetrics), ...table('Lab spring→WAAPI путь', ['lab-spring'], freezeMetrics), @@ -631,7 +756,9 @@ export function renderBenchmarkMarkdown(payload) { '- API-return не равен полной стоимости: lazy-работу отражает отдельный first-presented screencast metric.', '- Нулевой/null cold API-return прерывает отчёт, а не становится «н/д» или победным нулём.', '- GSAP после лага не прыгает (lag smoothing), а доигрывает сдвинутый таймлайн — поэтому его', - ' финальная позиция снимается с запасом +700мс; это поведение, а не дефект.', + payload.schema === 10 + ? ' финальная позиция снимается с запасом +700мс. Это не освобождает путь от проверки wall-clock контракта S5.' + : ' финальная позиция снимается с запасом +700мс; это поведение, а не дефект.', '- Headed Chromium и screencast — один браузерный контур, не универсальный рейтинг браузеров.', '- S6 исключает legal comments одинаково у всех и измеряет executable payload, не лицензионные файлы npm.', '', @@ -641,6 +768,11 @@ export function renderBenchmarkMarkdown(payload) { } /** Проверяет не формат, а воспроизводимость опубликованных чисел. */ +/** Публикуемые доказательства не могут ослабить обязательные требования S5. */ +export function validateBenchmarkReportForPublication(input) { + return validateBenchmarkReportPair({ ...input, motionRequirements: S5_MOTION_REQUIREMENTS }); +} + export function validateBenchmarkReportPair({ stem, markdown, @@ -648,8 +780,17 @@ export function validateBenchmarkReportPair({ rootPackage, benchmarkPackage, now = Date.now(), + motionRequirements, }) { - if (payload?.schema !== 9) fail(`schema ${String(payload?.schema)} не поддержана`); + if (payload?.schema !== 9 && payload?.schema !== 10) fail(`schema ${String(payload?.schema)} не поддержана`); + if (payload.schema === 10) { + if (!isDeepStrictEqual(payload.motionContract, S5_MOTION_CONTRACT)) { + fail('motion conformance: контракт изменён'); + } + if (!isDeepStrictEqual(payload.motionConformance, createBenchmarkMotionConformance(payload.results))) { + fail('motion conformance не пересчитывается из траекторий'); + } + } if ( payload.package?.name !== rootPackage.name || payload.package?.version !== rootPackage.version @@ -695,6 +836,7 @@ export function validateBenchmarkReportPair({ if (!markdown.includes('p99 не публикуется')) fail('Markdown выдаёт малую выборку за p99'); const inputs = provenance.inputs; + if (payload.schema === 10) assertSha(inputs?.['bench/motion-conformance.mjs'], 'motion conformance input'); for (const name of [ 'root/package.json', 'root/pnpm-lock.yaml', @@ -853,6 +995,7 @@ export function validateBenchmarkReportPair({ scenarioManifest: warmCalibration.scenarioManifest, }); if (!isDeepStrictEqual(payload.claims, expectedClaims)) fail('claims не пересчитываются из raw-кластеров'); + requireMotionConformance(payload, motionRequirements); return payload; } diff --git a/browser/22-motion-state-contract.spec.ts b/browser/22-motion-state-contract.spec.ts new file mode 100644 index 00000000..79cb24e7 --- /dev/null +++ b/browser/22-motion-state-contract.spec.ts @@ -0,0 +1,187 @@ +import { expect, test } from './fixtures/harness'; + +// Решение x'' + 20x' + 100(x − target) = 0 не использует солвер пакета. +function criticalPosition(from: number, to: number, velocity: number, seconds: number): number { + const displacement = from - to; + return to + (displacement + (velocity + 10 * displacement) * seconds) * Math.exp(-10 * seconds); +} + +// Бюджет компиляции 1/400 прогресса, ×2 на реконструкцию и 0.1px на matrix(). +const reconstructionBudget = (amplitude: number): number => 2 * amplitude / 400 + 0.1; + +for (const firstMs of [40, 120, 260]) { + for (const tweenMs of [40, 160, 280]) { + test(`независимый контракт native→JS→native: ${firstMs}/${tweenMs} мс`, async ({ page }) => { + const result = await page.evaluate(async ({ firstMs, tweenMs }) => { + const { animate } = await import('/dist/animate/index.js'); + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;width:10px;height:10px;transform:translateX(0px)'; + document.body.appendChild(el); + const hostAnimate = el.animate.bind(el); + let effectCreations = 0; + el.animate = (...args: Parameters) => { + effectCreations++; + return hostAnimate(...args); + }; + const read = () => new DOMMatrixReadOnly(getComputedStyle(el).transform).e; + const callbacks: Array<(ts?: number) => void> = []; + const timers: Array<{ callback: () => void; cancelCalls: number }> = []; + let completed = 0; + const common = { + matchMedia: () => ({ matches: false }), + onComplete: () => { completed++; }, + }; + const nativeOptions = { + ...common, + spring: { mass: 1, stiffness: 100, damping: 20 }, + now: () => 0, + setTimer: (callback: () => void) => { + const timer = { callback, cancelCalls: 0 }; + timers.push(timer); + return () => { timer.cancelCalls++; }; + }, + }; + const first = animate(el, { x: [0, 200] }, nativeOptions); + const counts = [el.getAnimations().length]; + const initialEffect = el.getAnimations()[0]!; + initialEffect.pause(); + initialEffect.currentTime = firstMs; + const beforeMain = read(); + + const middle = animate(el, { x: 300 }, { + ...common, + duration: 400, + ease: (t: number) => t, + requestFrame: (callback) => callbacks.push(callback), + }); + const mainImmediate = read(); + middle.pause(); + middle.seek(0); + counts.push(el.getAnimations().length); + const mainStart = read(); + middle.seek(tweenMs); + const mainEnd = read(); + + const last = animate(el, { x: 450 }, nativeOptions); + const nativeImmediate = read(); + counts.push(el.getAnimations().length); + const finalEffect = el.getAnimations()[0]!; + finalEffect.pause(); + finalEffect.currentTime = 0; + const nativeStart = read(); + const samples = [0, 100, 200].map((ms) => { + finalEffect.currentTime = ms; + return { ms, x: read() }; + }); + + // Терминальные владельцы и уже поставленные callbacks не воскресают. + const beforeStale = read(); + const ownedState = () => ({ + x: read(), + inlineStyle: el.getAttribute('style'), + scheduledCallbacks: callbacks.length, + timers: timers.map(({ cancelCalls }) => cancelCalls), + effectCreations, + effects: el.getAnimations().map((effect) => ({ + sameEffect: effect === finalEffect, + currentTime: effect.currentTime, + startTime: effect.startTime, + playbackRate: effect.playbackRate, + playState: effect.playState, + pending: effect.pending, + timing: effect.effect!.getTiming(), + keyframes: (effect.effect as KeyframeEffect).getKeyframes(), + })), + }); + const ownedBeforeStale = ownedState(); + const staleStates: ReturnType[] = []; + const observeStale = () => staleStates.push(ownedState()); + for (const stale of [first, middle]) { + stale.play(); + observeStale(); + stale.seek(300); + observeStale(); + stale.cancel(); + observeStale(); + } + for (const callback of [...callbacks]) { + callback(10_000); + observeStale(); + } + // Отменённый таймер мог уже попасть в очередь браузера до отмены. + for (const timer of timers.filter(({ cancelCalls }) => cancelCalls > 0)) { + timer.callback(); + observeStale(); + } + const afterStale = read(); + const sameEffect = el.getAnimations().length === 1 && el.getAnimations()[0] === finalEffect; + last.cancel(); + counts.push(el.getAnimations().length); + const cancelled = read(); + const ownedAfterCancel = ownedState(); + const cancelledStates: ReturnType[] = []; + for (const callback of [...callbacks]) { + callback(20_000); + cancelledStates.push(ownedState()); + } + for (const timer of [...timers]) { + timer.callback(); + cancelledStates.push(ownedState()); + } + const afterCancel = read(); + await Promise.all([first.finished, middle.finished, last.finished]); + el.remove(); + return { + beforeMain, mainImmediate, mainStart, mainEnd, nativeImmediate, nativeStart, samples, + counts, completed, beforeStale, afterStale, sameEffect, cancelled, afterCancel, + staleStates, cancelledStates, effectCreations, ownedBeforeStale, ownedAfterCancel, + }; + }, { firstMs, tweenMs }); + + const handoff = criticalPosition(0, 200, 0, firstMs / 1000); + const firstBudget = reconstructionBudget(200); + const fraction = tweenMs / 400; + const tweenEnd = handoff + (300 - handoff) * fraction; + const velocity = (300 - handoff) / 0.4; + const tweenBudget = (1 - fraction) * firstBudget + 0.1; + const velocityBudget = firstBudget / 0.4; + const lastBudget = reconstructionBudget(Math.abs(450 - tweenEnd) + tweenBudget); + + expect(Math.abs(result.beforeMain - handoff)).toBeLessThanOrEqual(firstBudget); + expect(Math.abs(result.mainStart - handoff)).toBeLessThanOrEqual(firstBudget); + expect(Math.abs(result.mainImmediate - result.beforeMain)).toBeLessThanOrEqual(0.05); + expect(Math.abs(result.mainStart - result.beforeMain)).toBeLessThanOrEqual(0.05); + expect(Math.abs(result.mainEnd - tweenEnd)).toBeLessThanOrEqual(tweenBudget); + expect(Math.abs(result.nativeStart - result.mainEnd)).toBeLessThanOrEqual(0.05); + expect(Math.abs(result.nativeImmediate - result.mainEnd)).toBeLessThanOrEqual(0.05); + for (const sample of result.samples) { + const seconds = sample.ms / 1000; + const decay = Math.exp(-10 * seconds); + const budget = tweenBudget * (1 + 10 * seconds) * decay + velocityBudget * seconds * decay + lastBudget; + const expected = criticalPosition(tweenEnd, 450, velocity, seconds); + expect(Math.abs(sample.x - expected), `t=${sample.ms}, x=${sample.x}, expected=${expected}, budget=${budget}`) + .toBeLessThanOrEqual(budget); + if (sample.ms > 0) { + // Контрпример v=0 обязан отличаться сильнее допуска: тест чувствителен к потере скорости. + const resetVelocity = criticalPosition(tweenEnd, 450, 0, seconds); + expect(Math.abs(expected - resetVelocity)).toBeGreaterThan(2 * budget); + } + } + expect(result.counts).toEqual([1, 0, 1, 0]); + expect(result.sameEffect).toBe(true); + expect(result.effectCreations).toBe(2); + expect(result.ownedBeforeStale.timers).toEqual([1, 0]); + expect(result.ownedAfterCancel.timers).toEqual([1, 1]); + expect(result.staleStates.length).toBeGreaterThanOrEqual(6); + for (const state of result.staleStates) { + expect(state).toEqual(result.ownedBeforeStale); + } + for (const state of result.cancelledStates) { + expect(state).toEqual(result.ownedAfterCancel); + } + expect(result.afterStale).toBe(result.beforeStale); + expect(result.afterCancel).toBe(result.cancelled); + expect(result.completed).toBe(0); + }); + } +} diff --git a/docs/benchmark.md b/docs/benchmark.md index 3295cea2..f59dcf39 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -73,7 +73,7 @@ pnpm size - S2 — cold/warm старт ста элементов одним вызовом; - S3 — cold/warm stagger для двухсот элементов; - S4 — cold/warm старт тысячи элементов одним вызовом; -- S5 — точность к собственной unblocked-траектории и видимое продвижение во время блокировки main thread; +- S5 — независимый контракт траектории, совпадение с собственным baseline и видимое продвижение при блокировке main thread; - S6 — import-cost адаптера потребителя. Cold realm в S1–S4 — новый JS realm с уже загруженным production-адаптером: @@ -89,12 +89,24 @@ realm; справочное значение другого realm в порог ### Граница вывода freeze -Blocked-прогон сравнивается только с unblocked-прогоном той же библиотеки. +Совпадение blocked-прогона с unblocked-прогоном той же библиотеки не доказывает +правильность: обе записи могут содержать одинаковый дефект. Schema 10 отдельно +проверяет [контракт движения](motion-conformance.md) по уравнению сценария, +парным красным/зелёным наблюдениям и часам каждого прогона. Пропуск наблюдений +даёт «недостаточно данных», нарушение — «не соответствует»; оба запрещают PASS. +Без блокировки соответствие обязательно у всех участников, с блокировкой — +у заявленных native-путей. JS-путь не получает исключение из результата: +его нарушение явно видно, но не считается обещанной устойчивостью к блокировке. + Linear-native и spring→WAAPI публикуются отдельными группами. Один невалидный run или не движущийся платформенный контроль останавливает весь отчёт. Freeze- прогоны допускаются только полными seeded-блоками: каждый участник проходит каждую позицию ровно один раз за блок. Companion JSON хранит decoded baseline, -blocked и grid каждого run, достаточные для независимого пересчёта score. +blocked и grid каждого run, достаточные для пересчёта score. Schema 10 также +хранит witness, clock evidence и пересчитываемые verdict каждого запуска. +Валидность файла отчёта отделена от допуска качества: legacy schema 9 остаётся +читаемой, но не доказывает соответствие новому контракту. Стенд сохраняет +валидный диагностический отчёт перед завершением с ошибкой при недопуске качества. ### Проверка утверждений diff --git a/docs/motion-conformance.md b/docs/motion-conformance.md new file mode 100644 index 00000000..22fff72d --- /dev/null +++ b/docs/motion-conformance.md @@ -0,0 +1,79 @@ +# Контракт наблюдаемого движения + +## Сценарий S5 + +Версия `s5-motion-v1` определена в `bench/compare/motion-conformance.mjs`. +Время `t` измеряется в секундах от CDP start marker; координата — в пикселях. +Линейный путь: `x(t) = 600 · clamp(t / 2.4, 0, 1)`. +Пружина: `x'' + 8x' + 40(x − 600) = 0`, `x(0) = x'(0) = 0`. +Её независимое решение: + +```text +x(t) = 600 · [1 − exp(−4t) · (cos(√24 · t) + 4/√24 · sin(√24 · t))] +``` + +Для каждого наблюдения в `[0, 2.4]` координата должна попадать в диапазон +решения на `t ± (20 − u)` мс, расширенный на 3 px. `u` — измеренная верхняя +граница marker→API с неопределённостью часов этого page realm и округлением +epoch timestamp. Она расходует временной допуск; при `u > 20` мс допуска нет. +Для пружины диапазон включает внутренние экстремумы, а не только концы интервала. + +Это конечные бюджеты данного сценария, не универсальные пороги восприятия. +Максимальный разрыв наблюдения, включая начало и конец окна, — 50 мс. +Никакая интерполяция не заменяет отсутствующие кадры. + +## Допуск записи и результата + +Красная цель и независимый зелёный линейный WAAPI-свидетель наблюдаются +в одном скринкаст-кадре. Свидетель создаёт перерисовку при неподвижной цели. +Отсутствующая детекция не удаляет свидетельство кадра; несовпадающие сетки, +нечисловые координаты и повторные/обратные timestamps запрещают PASS. +Runtime marker связывается с page epoch исходными пробами часов до и после +запуска. Свидетель также обязан пройти независимое уравнение времени. +Startup-задержка WAAPI входит в тот же бюджет; поздний `Animation.startTime` +не становится новым нулём. Слишком поздний свидетель означает недопуск записи, +а не основание незаметно сдвинуть ожидаемое движение. + +| Результат | Значение | +|---|---| +| `pass` | Все наблюдения соответствуют контракту, запись и часы допущены | +| `fail` | Допущенная запись содержит положение вне контрактного диапазона | +| `inconclusive` | Данных недостаточно для утверждения качества | + +Один неуспешный запуск запрещает соответствующее утверждение для участника; +медиана и совпадение с собственным baseline его не отменяют. Подпись/хеш +фиксирует артефакт, но сама по себе не доказывает истинность измерения. +Качество требует `validateBenchmarkReportPair({ motionRequirements: ... })`; +без требований проверяется согласованность данных, а не качество движения. +Публикацию через CI/release допускает только `validateBenchmarkReportForPublication`: +обязательны все baseline и заявленные native blocked-пути. Legacy schema 9 +доступна для чтения, но не для новой публикации как доказательство качества. + +## Контракт перехода владельца + +`browser/22-motion-state-contract.spec.ts` проверяет собранный публичный фасад +на переходе native spring → JS linear tween → native spring в трёх браузерах. +Независимое решение критически затухающей пружины задаёт ожидаемую позицию: + +```text +x(t) = target + [(from − target) + (v + 10(from − target))t] exp(−10t) +``` + +Положение непрерывно на обеих границах. Переход к авторскому tween вправе +изменить скорость; следующий spring обязан унаследовать скорость tween. +Допуск компиляции составляет `2 · amplitude / 400 + 0.1` px и переносится +на следующую фазу через чувствительность решения к `from` и `v`. +Терминальный владелец и отложенные callbacks не могут перезаписать новую +анимацию; отменённые прогоны не вызывают `onComplete`. + +## Граница доказательства + +Оракул не импортирует runtime-солвер. Его пружинная формула проверяется +независимым численным интегрированием ОДУ; конечный перебор окон паузы +проверяет чувствительность к freeze/jump. Browser-проверка использует +управляемые часы и наблюдаемый DOM, а S5 — реальные скринкаст-пиксели. + +Эти проверки не являются доказательством всей программы в proof assistant. +Они не устанавливают поведение между захваченными кадрами, частоту физических +кадров дисплея, input→photon, универсальную плавность или мировое превосходство. +Native-свидетель, CDP и браузер остаются частью доверенной измерительной среды. diff --git a/package.json b/package.json index 075aa1de..e9764da0 100644 --- a/package.json +++ b/package.json @@ -471,6 +471,7 @@ "dist", "docs/errors.md", "docs/benchmark.md", + "docs/motion-conformance.md", "docs/recipes.md", "!dist/**/*.map" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3b81fb2..2fb07f69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,10 +6,11 @@ settings: overrides: brace-expansion@<=5.0.7: ^5.0.8 + browserslist: ^4.28.7 esbuild: ^0.28.1 - fast-uri@>=3.0.0 <=3.1.3: ^3.1.4 + fast-uri: ^3.1.6 postcss@<=8.5.17: ^8.5.18 - qs: ^6.15.2 + qs: ^6.16.0 nanoid@<3.3.18: ^3.3.18 importers: @@ -1112,8 +1113,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.40: - resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1124,8 +1125,8 @@ packages: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1150,8 +1151,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001800: - resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1241,8 +1242,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.384: - resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + electron-to-chromium@1.5.419: + resolution: {integrity: sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1313,8 +1314,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1609,8 +1610,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.50: - resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} npm-run-path@6.0.0: @@ -1720,8 +1721,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} react-dom@18.3.1: @@ -1980,11 +1981,11 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.7 vite@8.2.0: resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} @@ -2198,7 +2199,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -3035,7 +3036,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3051,7 +3052,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.20: {} bidi-js@1.0.3: dependencies: @@ -3061,13 +3062,13 @@ snapshots: dependencies: balanced-match: 4.0.4 - browserslist@4.28.4: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001800 - electron-to-chromium: 1.5.384 - node-releases: 2.0.50 - update-browserslist-db: 1.2.3(browserslist@4.28.4) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.419 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) buffer-from@1.1.2: {} @@ -3088,7 +3089,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001800: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -3159,7 +3160,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.384: {} + electron-to-chromium@1.5.419: {} emoji-regex@10.6.0: {} @@ -3245,7 +3246,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.5: {} + fast-uri@3.1.6: {} fast-wrap-ansi@0.2.2: dependencies: @@ -3509,7 +3510,7 @@ snapshots: nanoid@3.3.18: {} - node-releases@2.0.50: {} + node-releases@2.0.54: {} npm-run-path@6.0.0: dependencies: @@ -3580,7 +3581,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -3859,7 +3860,7 @@ snapshots: dependencies: des.js: 1.1.0 js-md4: 0.3.2 - qs: 6.15.3 + qs: 6.16.0 tunnel: 0.0.6 underscore: 1.13.8 @@ -3877,9 +3878,9 @@ snapshots: unicorn-magic@0.3.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.4): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.4 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 28a02111..f61b353c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,10 +3,11 @@ allowBuilds: minimumReleaseAge: 1440 overrides: brace-expansion@<=5.0.7: ^5.0.8 + browserslist: ^4.28.7 esbuild: ^0.28.1 - fast-uri@>=3.0.0 <=3.1.3: ^3.1.4 + fast-uri: ^3.1.6 postcss@<=8.5.17: ^8.5.18 - qs: ^6.15.2 + qs: ^6.16.0 nanoid@<3.3.18: ^3.3.18 pmOnFail: error peerDependencyRules: diff --git a/scripts/check-docs-facts.mjs b/scripts/check-docs-facts.mjs index 97a56823..faa7bcce 100644 --- a/scripts/check-docs-facts.mjs +++ b/scripts/check-docs-facts.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import { assertAllowedPostReportChanges, parseBenchmarkDocumentationState, - validateBenchmarkReportPair, + validateBenchmarkReportForPublication, } from '../bench/compare/report-contract.mjs'; import { revisionFingerprint, @@ -76,6 +76,9 @@ function validateRevision(payload, stem) { 'bench/methodology.mjs': 'bench/compare/methodology.mjs', 'bench/provenance.mjs': 'bench/compare/provenance.mjs', 'bench/report-contract.mjs': 'bench/compare/report-contract.mjs', + ...(payload.schema === 10 + ? { 'bench/motion-conformance.mjs': 'bench/compare/motion-conformance.mjs' } + : {}), }; for (const [label, file] of Object.entries(historicalInputs)) { const bytes = git(['show', `${revision}:${file}`], 'buffer'); @@ -144,7 +147,7 @@ try { throw new Error(`${stem}.json: невалидный JSON (${error?.message ?? String(error)})`); } const benchmarkPackage = JSON.parse(readFileSync(benchmarkPackagePath, 'utf8')); - validateBenchmarkReportPair({ stem, markdown, payload, rootPackage, benchmarkPackage }); + validateBenchmarkReportForPublication({ stem, markdown, payload, rootPackage, benchmarkPackage }); validateRevision(payload, stem); } } catch (error) { diff --git a/scripts/pack-smoke.mjs b/scripts/pack-smoke.mjs index 2af3788f..0108fa25 100644 --- a/scripts/pack-smoke.mjs +++ b/scripts/pack-smoke.mjs @@ -232,6 +232,7 @@ try { 'package.json', 'docs/errors.md', 'docs/benchmark.md', + 'docs/motion-conformance.md', 'docs/recipes.md', ]) { if (!existsSync(join(installedRoot, file))) { @@ -246,6 +247,12 @@ try { log('FAIL: docs/errors.md в артефакте расходится с каталогом исходников'); } const installedBenchmark = join(installedRoot, 'docs', 'benchmark.md'); + const installedMotionContract = join(installedRoot, 'docs', 'motion-conformance.md'); + if (existsSync(installedMotionContract) + && readFileSync(installedMotionContract, 'utf8') !== readFileSync(join(ROOT, 'docs', 'motion-conformance.md'), 'utf8')) { + failed = true; + log('FAIL: docs/motion-conformance.md в артефакте расходится с контрактом исходников'); + } if (existsSync(installedBenchmark) && readFileSync(installedBenchmark, 'utf8') !== readFileSync(join(ROOT, 'docs', 'benchmark.md'), 'utf8')) { failed = true; diff --git a/scripts/release-metadata.mjs b/scripts/release-metadata.mjs index be34c373..6bc8340a 100644 --- a/scripts/release-metadata.mjs +++ b/scripts/release-metadata.mjs @@ -40,6 +40,7 @@ const EXACT_FIELDS = Object.freeze({ 'dist', 'docs/errors.md', 'docs/benchmark.md', + 'docs/motion-conformance.md', 'docs/recipes.md', '!dist/**/*.map', ], diff --git a/test/benchmark-methodology.test.ts b/test/benchmark-methodology.test.ts index 2bee0bc4..0b9176d9 100644 --- a/test/benchmark-methodology.test.ts +++ b/test/benchmark-methodology.test.ts @@ -661,7 +661,7 @@ describe('benchmark methodology fail-closed contracts', () => { ); const screencastStarted = capture.indexOf("await cdp.send('Page.startScreencast'"); const baselinePresented = capture.indexOf('await waitForBaselineFrame(frames)'); - const animationStarted = capture.indexOf('const startedAt = await page.evaluate'); + const animationStarted = capture.indexOf('const startClock = await readCdpStartClock'); const cleanup = capture.indexOf('} finally {', baselinePresented); const screencastStopped = capture.indexOf('await stopScreencast()', cleanup); const contextClosed = capture.indexOf('await context.close()', cleanup); diff --git a/test/benchmark-report-contract.test.ts b/test/benchmark-report-contract.test.ts index 0523327d..a9af4c5e 100644 --- a/test/benchmark-report-contract.test.ts +++ b/test/benchmark-report-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -26,12 +26,16 @@ import { assertAllowedPostReportChanges, benchmarkNoReportStatement, createBenchmarkClaims, + createBenchmarkMotionConformance, parseBenchmarkDocumentationState, renderBenchmarkMarkdown, renderBenchmarkEnvironment, sha256Text, + S5_MOTION_REQUIREMENTS, validateBenchmarkReportPair, + validateBenchmarkReportForPublication, } from '../bench/compare/report-contract.mjs'; +import { S5_MOTION_CONTRACT } from '../bench/compare/motion-conformance.mjs'; const START = ['lab', 'motion', 'gsap', 'anime']; const FREEZE = [ @@ -347,7 +351,470 @@ function fixture(startRuns = 20, warmCalls: Partial; + timerEvidence: ReturnType; +}; +type MotionEvidence = { + baseline: MotionPoint[]; + blocked: MotionPoint[]; + baselineWitness?: MotionPoint[]; + blockedWitness?: MotionPoint[]; + baselineClock?: MotionClock; + blockedClock?: MotionClock; + grid: number[]; +}; +type MotionFreezeRun = { + valid: boolean; + score: number | null; + samples: number; + movement: ReturnType; + baselineMovement: ReturnType; + finalX: number; + baselineFinalX: number; + blockStart: number; + blockEnd: number; + rawFrames: { baseline: number; blocked: number }; + evidence: MotionEvidence; +}; + +const linearMotionPoints = (): MotionPoint[] => Array.from( + { length: 121 }, (_, index) => ({ t: index / 50, x: index * 5 }), +); + +// Независимая численная фикстура исходного ОДУ; production solver не импортируется. +function springMotionPoints(): MotionPoint[] { + const points = [{ t: 0, x: 0 }]; + const step = 0.0005; + const acceleration = (x: number, velocity: number) => 40 * (600 - x) - 8 * velocity; + let x = 0; + let velocity = 0; + for (let tick = 1; tick <= 4800; tick++) { + const dx1 = velocity; + const dv1 = acceleration(x, velocity); + const dx2 = velocity + dv1 * step / 2; + const dv2 = acceleration(x + dx1 * step / 2, dx2); + const dx3 = velocity + dv2 * step / 2; + const dv3 = acceleration(x + dx2 * step / 2, dx3); + const dx4 = velocity + dv3 * step; + const dv4 = acceleration(x + dx3 * step, dx4); + x += step * (dx1 + 2 * dx2 + 2 * dx3 + dx4) / 6; + velocity += step * (dv1 + 2 * dv2 + 2 * dv3 + dv4) / 6; + if (tick % 40 === 0) points.push({ t: tick / 2000, x }); + } + return points; +} + +function motionFreezeRun(id: string): MotionFreezeRun { + const baseline = id === 'lab-spring' ? springMotionPoints() : linearMotionPoints(); + const evidence: MotionEvidence = { + baseline, + blocked: structuredClone(baseline), + baselineWitness: linearMotionPoints(), + blockedWitness: linearMotionPoints(), + baselineClock: { startClock: startClock(), timerEvidence: timerEvidence() }, + blockedClock: { startClock: startClock(), timerEvidence: timerEvidence() }, + grid: baseline.filter((point) => point.t >= 0.38 && point.t <= 1.12).map((point) => point.t), + }; + const run: MotionFreezeRun = { + valid: false, score: 0, samples: 0, + movement: movementStats([]), baselineMovement: movementStats([]), + finalX: baseline[baseline.length - 1]!.x, + baselineFinalX: baseline[baseline.length - 1]!.x, + blockStart: 0.3, blockEnd: 1.2, + rawFrames: { baseline: baseline.length, blocked: baseline.length }, + evidence, + }; + refreshMotionRun(run); + return run; +} + +function refreshMotionRun(run: MotionFreezeRun) { + const scored = scoreAgainstBaseline(run.evidence.blocked, run.evidence.baseline, run.evidence.grid); + const inWindow = (point: MotionPoint) => point.t >= run.blockStart + 0.08 + && point.t <= run.blockEnd - 0.08; + run.score = scored.score; + run.samples = scored.samples; + run.movement = movementStats(run.evidence.blocked.filter(inWindow)); + run.baselineMovement = movementStats(run.evidence.baseline.filter(inWindow)); + run.rawFrames = { baseline: run.evidence.baseline.length, blocked: run.evidence.blocked.length }; + run.valid = Math.abs(run.baselineFinalX - 600) <= 2 && Math.abs(run.finalX - 600) <= 2 + && Number.isFinite(run.score) && run.samples >= 5 + && run.baselineMovement.distinctPositions >= 5 && run.baselineMovement.totalAdvancement >= 10; +} + +function renderMotionPair(report: ReturnType) { + report.markdown = renderBenchmarkMarkdown(report.payload); + report.payload.companion.markdownSha256 = sha256Text(report.markdown); +} + +function refreshMotionReport(report: ReturnType) { + for (const id of FREEZE) { + const result = report.payload.results[id]; + const runs: MotionFreezeRun[] = result.raw.freeze; + runs.forEach(refreshMotionRun); + result.summary.freeze = { + score: summarizeMedianSamples(runs.map((run) => run.score)), + frames: summarizeMedianSamples(runs.map((run) => run.movement.frames)), + distinct: summarizeMedianSamples(runs.map((run) => run.movement.distinctPositions)), + net: summarizeMedianSamples(runs.map((run) => run.movement.netAdvancement)), + total: summarizeMedianSamples(runs.map((run) => run.movement.totalAdvancement)), + finalX: summarizeMedianSamples(runs.map((run) => run.finalX)), + }; + } + report.payload.motionConformance = createBenchmarkMotionConformance(report.payload.results); + renderMotionPair(report); + return report; +} + +function motionFixture() { + const report = fixture(); + report.payload.schema = 10; + report.payload.motionContract = structuredClone(S5_MOTION_CONTRACT); + report.payload.provenance.inputs['bench/motion-conformance.mjs'] = SHA('a'); + for (const id of FREEZE) { + report.payload.results[id].raw.freeze = Array.from({ length: 8 }, () => motionFreezeRun(id)); + } + return refreshMotionReport(report); +} + +function freeze25To65(): MotionPoint[] { + return linearMotionPoints().map((point) => point.t >= 0.6 && point.t <= 1.54 + ? { ...point, x: 150 } : point); +} + +describe('schema 10 motion conformance admission', () => { + it('публикация требует качества и не позволяет ослабить требования вызывающему', () => { + const report = motionFixture(); + expect(() => validateBenchmarkReportForPublication(report)).not.toThrow(); + expect(() => validateBenchmarkReportForPublication(fixture())).toThrow(/motion conformance/); + report.payload.results['motion-mini'].raw.freeze[0].evidence.blocked = freeze25To65(); + refreshMotionReport(report); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportForPublication({ ...report, motionRequirements: undefined })) + .toThrow(/motion-mini\.blocked = fail/); + expect(() => validateBenchmarkReportForPublication({ + ...report, motionRequirements: { baseline: ['lab'], blocked: [] }, + })).toThrow(/motion-mini\.blocked = fail/); + }); + + it('CI и release reader используют обязательный допуск публикации', () => { + const reader = readFileSync('scripts/check-docs-facts.mjs', 'utf8'); + expect(reader).toContain('validateBenchmarkReportForPublication({ stem, markdown, payload, rootPackage, benchmarkPackage })'); + expect(reader).not.toContain('validateBenchmarkReportPair('); + }); + + it('принимает полный отчёт с 8 × 8 траекториями и независимой пружиной', () => { + const report = motionFixture(); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).not.toThrow(); + expect(Object.keys(report.payload.motionConformance)).toEqual(FREEZE); + for (const id of FREEZE) { + const conformance = report.payload.motionConformance[id]; + expect(conformance).toMatchObject({ baseline: 'pass', blocked: 'pass', capture: 'pass' }); + expect(conformance.runs).toHaveLength(8); + for (const run of conformance.runs) { + expect(run.baseline.samples).toBe(121); + expect(run.blocked.samples).toBe(121); + } + } + expect(report.payload.motionConformance['lab-spring'].runs[0].baseline.maxErrorPx) + .toBeLessThan(0.000001); + expect(report.markdown).toContain('Независимый контракт движения'); + expect(report.markdown).toContain('не выполнение требований движения'); + expect(report.payload.companion.markdownSha256).toBe(sha256Text(report.markdown)); + }); + + it('фиксирует scoped требования: baseline для всех и blocked для четырёх native-путей', () => { + expect(S5_MOTION_REQUIREMENTS).toEqual({ + baseline: FREEZE, blocked: ['waapi-ctl', 'lab-spring', 'motion-mini', 'anime-waapi'], + }); + expect(Object.isFrozen(S5_MOTION_REQUIREMENTS)).toBe(true); + expect(Object.isFrozen(S5_MOTION_REQUIREMENTS.baseline)).toBe(true); + expect(Object.isFrozen(S5_MOTION_REQUIREMENTS.blocked)).toBe(true); + }); + + it('сохраняет наблюдаемое JS-зависание как валидную диагностику, отклоняя blocked quality claim', () => { + const report = motionFixture(); + const run = report.payload.results.lab.raw.freeze[0]; + run.evidence.blocked = linearMotionPoints().map((point) => point.t >= 0.3 && point.t <= 1.2 + ? { ...point, x: 75 } : point); + refreshMotionReport(report); + expect(run.valid).toBe(true); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(report.payload.motionConformance.lab).toMatchObject({ + baseline: 'pass', blocked: 'fail', capture: 'pass', + }); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: { baseline: ['lab'], blocked: ['lab'] }, + })).toThrow(/motion conformance: lab\.blocked = fail/); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).not.toThrow(); + }); + + it('отклоняет одинаковое неверное движение baseline и blocked при score 100 и valid=true', () => { + const report = motionFixture(); + for (const run of report.payload.results.lab.raw.freeze) { + run.evidence.baseline = freeze25To65(); + run.evidence.blocked = freeze25To65(); + } + refreshMotionReport(report); + expect(report.payload.results.lab.summary.freeze.score.p50).toBe(100); + expect(report.payload.results.lab.raw.freeze.every((run: { valid: boolean }) => run.valid)).toBe(true); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(report.payload.motionConformance.lab).toMatchObject({ + baseline: 'fail', blocked: 'fail', capture: 'pass', + }); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion conformance: lab\.baseline = fail/); + }); + + it('один плохой native-прогон не исчезает за медианой score 100', () => { + const report = motionFixture(); + report.payload.results['motion-mini'].raw.freeze[7].evidence.blocked = freeze25To65(); + refreshMotionReport(report); + expect(report.payload.results['motion-mini'].summary.freeze.score.p50).toBe(100); + expect(report.payload.motionConformance['motion-mini'].runs.filter( + (run: { blocked: { verdict: string } }) => run.blocked.verdict === 'pass', + )).toHaveLength(7); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion conformance: motion-mini\.blocked = fail/); + }); + + it('не принимает подделанный cached PASS даже с каноническими Markdown и SHA', () => { + const report = motionFixture(); + report.payload.results['motion-mini'].raw.freeze[0].evidence.blocked = freeze25To65(); + refreshMotionReport(report); + const forged = report.payload.motionConformance['motion-mini']; + forged.blocked = 'pass'; + forged.runs[0].blocked.verdict = 'pass'; + forged.runs[0].blocked.reason = 'within-s5-motion-contract'; + renderMotionPair(report); + expect(report.payload.companion.markdownSha256).toBe(sha256Text(report.markdown)); + expect(() => validateBenchmarkReportPair(report)).toThrow(/motion conformance не пересчитывается/); + }); + + it.each([ + ['positionTolerancePx', 600], ['timeToleranceMs', 2400], ['maxObservationGapMs', 2400], + ['distancePx', 150], ['durationMs', 9600], ['id', 's5-motion-v2'], + ])('отклоняет подмену motion manifest: %s', (field, value) => { + const report = motionFixture(); + report.payload.motionContract[field] = value; + renderMotionPair(report); + expect(() => validateBenchmarkReportPair(report)).toThrow(/motion conformance: контракт изменён/); + }); + + it('отклоняет подмену вложенных параметров пружины', () => { + const report = motionFixture(); + report.payload.motionContract.spring.damping = 800; + renderMotionPair(report); + expect(() => validateBenchmarkReportPair(report)).toThrow(/motion conformance: контракт изменён/); + }); + + it('требует provenance независимого oracle, даже если вердикты пересчитываются', () => { + const report = motionFixture(); + delete report.payload.provenance.inputs['bench/motion-conformance.mjs']; + renderMotionPair(report); + expect(() => validateBenchmarkReportPair(report)).toThrow(/motion conformance input/); + }); + + it.each([ + ['empty', { baseline: [], blocked: [] }], + ['unknown participant', { baseline: ['unknown'], blocked: [] }], + ['inherited participant', { baseline: ['toString'], blocked: [] }], + ['duplicate participant', { baseline: ['lab', 'lab'], blocked: [] }], + ['missing mode', { baseline: ['lab'] }], + ['extra mode', { baseline: ['lab'], blocked: [], other: ['lab'] }], + ['scalar list', { baseline: 'lab', blocked: [] }], + ['null', null], + ])('отклоняет неоднозначные требования %s', (_label, motionRequirements) => { + const report = motionFixture(); + expect(() => validateBenchmarkReportPair({ ...report, motionRequirements })) + .toThrow(/motion conformance/); + }); + + it.each(['baseline', 'blocked'] as const)('без %s witness движение остаётся inconclusive', (mode) => { + const report = motionFixture(); + delete report.payload.results['motion-mini'].raw.freeze[0].evidence[`${mode}Witness`]; + refreshMotionReport(report); + const conformance = report.payload.motionConformance['motion-mini']; + expect(conformance.capture).toBe('inconclusive'); + expect(conformance[mode]).toBe('inconclusive'); + expect(conformance.runs[0].witness[mode].verdict).toBe('inconclusive'); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(new RegExp(`motion-mini\\.${mode} = inconclusive`)); + }); + + it('не квалифицирует неподвижный witness, хотя целевая траектория правильная', () => { + const report = motionFixture(); + report.payload.results['motion-mini'].raw.freeze[0].evidence.blockedWitness = linearMotionPoints() + .map((point) => ({ ...point, x: 0 })); + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('не нормализует общий поздний native-старт по Animation.startTime', () => { + const report = motionFixture(); + const run = report.payload.results['motion-mini'].raw.freeze[0]; + const delayed = linearMotionPoints().map((point) => ({ ...point, x: Math.max(0, point.x - 25) })); + run.evidence.blocked = delayed; + run.evidence.blockedWitness = structuredClone(delayed); + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(() => validateBenchmarkReportForPublication(report)).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('не достраивает пропущенные кадры из верных редких положений цели и witness', () => { + const report = motionFixture(); + const run = report.payload.results['motion-mini'].raw.freeze[0]; + run.evidence.blocked = linearMotionPoints().filter((_point, index) => index % 4 === 0); + run.evidence.blockedWitness = structuredClone(run.evidence.blocked); + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(report.payload.motionConformance['motion-mini'].runs[0].blocked).toMatchObject({ + verdict: 'inconclusive', reason: 'observation-gap-exceeds-contract', samples: 31, + }); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('не подставляет witness из других кадров при правильных отдельных траекториях', () => { + const report = motionFixture(); + report.payload.results['motion-mini'].raw.freeze[0].evidence.blockedWitness = linearMotionPoints() + .map((point) => ({ ...point, t: point.t + 0.001 })); + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('не удаляет одинаковые кадры из обеих сеток при сохранённом счётчике захвата', () => { + const report = motionFixture(); + const run = report.payload.results['motion-mini'].raw.freeze[0]; + run.evidence.blocked = linearMotionPoints().filter((_point, index) => index % 2 === 0); + run.evidence.blockedWitness = structuredClone(run.evidence.blocked); + refreshMotionReport(report); + run.rawFrames.blocked = 121; + report.payload.motionConformance = createBenchmarkMotionConformance(report.payload.results); + renderMotionPair(report); + expect(report.payload.motionConformance['motion-mini'].blocked).toBe('inconclusive'); + expect(() => validateBenchmarkReportPair({ ...report, motionRequirements: S5_MOTION_REQUIREMENTS })) + .toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it.each(['baseline', 'blocked'] as const)('требует отдельную привязку часов %s', (mode) => { + const report = motionFixture(); + delete report.payload.results['motion-mini'].raw.freeze[0].evidence[`${mode}Clock`]; + refreshMotionReport(report); + const conformance = report.payload.motionConformance['motion-mini']; + expect(conformance.capture).toBe('inconclusive'); + expect(conformance[mode]).toBe('inconclusive'); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(new RegExp(`motion-mini\\.${mode} = inconclusive`)); + }); + + it('28 мс между CDP marker и API не проходят как допустимый сдвиг движения', () => { + const report = motionFixture(); + const clock = report.payload.results['motion-mini'].raw.freeze[0].evidence.blockedClock.startClock; + clock.pageApiNowMs = clock.pageBeforeNowMs + 28; + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('не принимает CDP marker другого realm даже при малом численном интервале', () => { + const report = motionFixture(); + report.payload.results['motion-mini'].raw.freeze[0].evidence.blockedClock + .timerEvidence.probes[1].timeOriginMs += 1; + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'inconclusive', capture: 'inconclusive', + }); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); + + it('измеренная неопределённость расходует временной бюджет движения', () => { + const report = motionFixture(); + const run = report.payload.results['motion-mini'].raw.freeze[0]; + run.evidence.blocked = linearMotionPoints().map((point) => point.t === 1.2 + ? { ...point, x: point.x + 7 } : point); + refreshMotionReport(report); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).not.toThrow(); + run.evidence.blockedClock.startClock.pageApiNowMs = + run.evidence.blockedClock.startClock.pageBeforeNowMs + 10; + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini']).toMatchObject({ + blocked: 'fail', capture: 'pass', + }); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = fail/); + }); + + it.each(['retained null', 'dropped frames'])('потеря красной цели через кадр: %s', (representation) => { + const report = motionFixture(); + const run = report.payload.results['motion-mini'].raw.freeze[0]; + run.evidence.blocked = representation === 'retained null' + ? linearMotionPoints().map((point, index) => index % 2 === 0 ? point : { ...point, x: null }) + : linearMotionPoints().filter((_point, index) => index % 2 === 0); + refreshMotionReport(report); + expect(report.payload.motionConformance['motion-mini'].blocked).toBe('inconclusive'); + if (representation === 'dropped frames') { + expect(report.payload.motionConformance['motion-mini'].capture).toBe('inconclusive'); + } + expect(() => validateBenchmarkReportPair({ + ...report, motionRequirements: S5_MOTION_REQUIREMENTS, + })).toThrow(/motion-mini\.blocked = inconclusive/); + }); +}); + describe('paired comparative benchmark report', () => { + it('не выдаёт достоверность старого отчёта за соответствие контракту движения', () => { + const report = fixture(); + expect(() => validateBenchmarkReportPair(report)).not.toThrow(); + expect(() => validateBenchmarkReportPair({ + ...report, + motionRequirements: { baseline: ['motion-mini'], blocked: ['motion-mini'] }, + })).toThrow(/motion conformance/i); + }); + it('accepts a clean paired report whose summaries and freeze evidence recompute', () => { expect(() => validateBenchmarkReportPair(fixture())).not.toThrow(); expect(() => validateBenchmarkReportPair(fixture(40))).not.toThrow(); @@ -635,6 +1102,7 @@ describe('benchmark documentation evidence state', () => { 'bench/compare/report-contract.mjs', 'bench/compare/provenance.mjs', 'bench/compare/methodology.mjs', + 'bench/compare/motion-conformance.mjs', 'scripts/compression-oracle.mjs', 'scripts/compression-policy.mjs', ]; diff --git a/test/motion-conformance.test.ts b/test/motion-conformance.test.ts new file mode 100644 index 00000000..70ed99ba --- /dev/null +++ b/test/motion-conformance.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; +import { + evaluateFreezeConformance, + evaluateTrajectoryConformance, + S5_MOTION_CONTRACT, +} from '../bench/compare/motion-conformance.mjs'; + +type Point = { readonly t: number; readonly x: number }; + +const linear = (t: number) => 600 * Math.max(0, Math.min(1, t / 2.4)); +const sample = (curve: (t: number) => number): Point[] => Array.from( + { length: 121 }, (_, index) => ({ t: index / 50, x: curve(index / 50) }), +); + +// RK4 решает исходное ОДУ; фикстура не повторяет аналитическую формулу oracle. +function springSamples(): Point[] { + const points: Point[] = [{ t: 0, x: 0 }]; + const step = 0.0005; + const acceleration = (x: number, velocity: number) => 40 * (600 - x) - 8 * velocity; + let x = 0; + let velocity = 0; + for (let tick = 1; tick <= 4800; tick++) { + const dx1 = velocity; + const dv1 = acceleration(x, velocity); + const dx2 = velocity + dv1 * step / 2; + const dv2 = acceleration(x + dx1 * step / 2, dx2); + const dx3 = velocity + dv2 * step / 2; + const dv3 = acceleration(x + dx2 * step / 2, dx3); + const dx4 = velocity + dv3 * step; + const dv4 = acceleration(x + dx3 * step, dx4); + x += step * (dx1 + 2 * dx2 + 2 * dx3 + dx4) / 6; + velocity += step * (dv1 + 2 * dv2 + 2 * dv3 + dv4) / 6; + if (tick % 40 === 0) points.push({ t: tick / 2000, x }); + } + return points; +} + +const spring = springSamples(); +const pair = (baseline: unknown, blocked: unknown = baseline) => ({ + evidence: { baseline, blocked }, +}); + +describe('bounded independent S5 motion contract', () => { + it('не расширяет допуск за счёт неопределённости часов', () => { + const advanced = sample((t) => linear(t + 0.025)); + expect(evaluateTrajectoryConformance(advanced, 'linear').verdict).toBe('pass'); + expect(evaluateTrajectoryConformance(advanced, 'linear', 10).verdict).toBe('fail'); + expect(evaluateTrajectoryConformance(sample(linear), 'linear', 20).verdict).toBe('pass'); + }); + + it.each([-1, NaN, Infinity, 20.001])('неизвестные или чрезмерно неточные часы (%s) не дают PASS', (uncertainty) => { + expect(evaluateTrajectoryConformance(sample(linear), 'linear', uncertainty)).toMatchObject({ + verdict: 'inconclusive', reason: 'clock-uncertainty-exceeds-contract', + }); + }); + + it('freezes the explicit S5 budgets including spring parameters', () => { + expect(S5_MOTION_CONTRACT).toEqual({ + id: 's5-motion-v1', distancePx: 600, durationMs: 2400, + positionTolerancePx: 3, timeToleranceMs: 20, maxObservationGapMs: 50, + spring: { stiffness: 40, damping: 8, mass: 1 }, + }); + expect(Object.isFrozen(S5_MOTION_CONTRACT)).toBe(true); + expect(Object.isFrozen(S5_MOTION_CONTRACT.spring)).toBe(true); + }); + + it('accepts a complete linear trajectory and reports nominal error and observed coverage', () => { + const points = sample(linear); + const result = evaluateTrajectoryConformance(points, 'linear'); + expect(result).toMatchObject({ + verdict: 'pass', reason: 'within-s5-motion-contract', samples: 121, maxErrorPx: 0, + }); + expect(result.maxGapMs).toBeCloseTo(20, 10); + expect(JSON.parse(JSON.stringify(result))).toEqual(result); + expect(points).toEqual(sample(linear)); + }); + + it('accepts the independent numerical spring solution including its overshoot', () => { + expect(Math.max(...spring.map((point) => point.x))).toBeGreaterThan(640); + const result = evaluateTrajectoryConformance(spring, 'spring'); + expect(result.verdict).toBe('pass'); + expect(result.maxErrorPx).toBeLessThan(0.000001); + expect(evaluateFreezeConformance('lab-spring', pair(spring))).toMatchObject({ + baseline: { verdict: 'pass' }, blocked: { verdict: 'pass' }, + }); + }); + + it.each(['lab', 'motion', 'gsap', 'anime', 'waapi-ctl', 'motion-mini', 'anime-waapi'])( + 'maps %s to the linear contract', (id) => { + expect(evaluateFreezeConformance(id, pair(sample(linear)))).toMatchObject({ + baseline: { verdict: 'pass' }, blocked: { verdict: 'pass' }, + }); + }, + ); + + it('rejects unsupported models and participants without silently choosing a curve', () => { + for (const model of ['ease', '', null, undefined]) { + expect(() => evaluateTrajectoryConformance(sample(linear), model)).toThrow(/model/i); + } + for (const id of ['unknown', 'toString', '__proto__', '', null]) { + expect(() => evaluateFreezeConformance(id, pair(sample(linear)))).toThrow(/participant/i); + } + }); + + it.each([ + ['frozen', (_t: number) => 0], + ['quadratic', (t: number) => 600 * (t / 2.4) ** 2], + ['early', (t: number) => linear(t + 0.1)], + ['late', (t: number) => linear(t - 0.1)], + ['short distance', (t: number) => linear(t) * 0.8], + ['long distance', (t: number) => linear(t) * 1.2], + ])('fails the same wrong %s curve in both baseline and blocked capture', (_label, curve) => { + expect(evaluateFreezeConformance('lab', pair(sample(curve)))).toMatchObject({ + baseline: { verdict: 'fail' }, blocked: { verdict: 'fail' }, + }); + }); + + it('detects 25 percent held from 0.6 to 1.54 seconds before a jump to 65 percent', () => { + const blocked = sample((t) => t >= 0.6 && t <= 1.54 ? 150 : linear(t)); + expect(blocked.find((point) => point.t === 1.56)?.x).toBe(390); + expect(evaluateFreezeConformance('lab', pair(sample(linear), blocked))).toMatchObject({ + baseline: { verdict: 'pass' }, + blocked: { verdict: 'fail', reason: 'position-outside-contract' }, + }); + }); + + it('detects a spring held before its first peak even if it later jumps to wall time', () => { + const held = spring.find((point) => point.t === 0.3)!.x; + const blocked = spring.map((point) => point.t >= 0.3 && point.t <= 1.2 + ? { ...point, x: held } : point); + expect(evaluateFreezeConformance('lab-spring', pair(spring, blocked))).toMatchObject({ + baseline: { verdict: 'pass' }, blocked: { verdict: 'fail' }, + }); + }); + + it('keeps each trajectory verdict independent of run validity and its paired verdict', () => { + const run = { ...pair(sample(() => 0), sample(linear)), valid: false }; + expect(evaluateFreezeConformance('lab', run)).toMatchObject({ + baseline: { verdict: 'fail' }, blocked: { verdict: 'pass' }, + }); + }); + + it('enumerates every pause window on the finite 20 ms linear observation grid', () => { + // Все 7260 окон этой сетки; скрытое движение между точками этим не доказано. + const points = sample(linear); + let windows = 0; + for (let start = 0; start < points.length - 1; start++) { + for (let end = start + 1; end < points.length; end++) { + const held = points[start]!.x; + const blocked = points.map((point, index) => index >= start && index <= end + ? { ...point, x: held } : point); + const result = evaluateTrajectoryConformance(blocked, 'linear'); + // За 20 мс ошибка 5 px внутри бюджета 8 px; за 40 мс уже 10 px. + expect(result.verdict, `pause ${start * 20}..${end * 20} ms`) + .toBe(end - start === 1 ? 'pass' : 'fail'); + windows++; + } + } + expect(windows).toBe(7260); + }); + + it.each(['linear', 'spring'])('accepts deterministic small signed perturbations for %s', (model) => { + const points = model === 'linear' ? sample(linear) : spring; + for (let seed = 1; seed <= 32; seed++) { + let state = seed; + const perturbed = points.map((point) => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return { ...point, x: point.x + (state / 0xffffffff * 2 - 1) * 2.99 }; + }); + expect(evaluateTrajectoryConformance(perturbed, model).verdict).toBe('pass'); + } + }); + + it.each([-1, 1])('enforces the inclusive spatial and temporal envelope in direction %s', (sign) => { + const atBoundary = sample((t) => t === 1.2 ? linear(t) + sign * 8 : linear(t)); + expect(evaluateTrajectoryConformance(atBoundary, 'linear').verdict).toBe('pass'); + const outside = atBoundary.map((point) => point.t === 1.2 + ? { ...point, x: point.x + sign * 0.000001 } : point); + expect(evaluateTrajectoryConformance(outside, 'linear').verdict).toBe('fail'); + }); + + it.each([ + [1, 1], [2, -1], + ])('includes exact spring extremum %s inside a time envelope', (n, direction) => { + const extremumTime = n * Math.PI / Math.sqrt(24); + const extremumX = 600 * (1 - Math.exp(-4 * extremumTime) * (-1) ** n); + const observedTime = extremumTime + 0.01; + const index = Math.floor(observedTime * 50); + const points = spring.map((point, current) => current === index + ? { t: observedTime, x: extremumX + direction * 3 } : point); + expect(evaluateTrajectoryConformance(points, 'spring').verdict).toBe('pass'); + const outside = points.map((point, current) => current === index + ? { ...point, x: point.x + direction * 0.000001 } : point); + expect(evaluateTrajectoryConformance(outside, 'spring').verdict).toBe('fail'); + }); + + it('clamps the early time envelope and linear completion before adding pixel tolerance', () => { + for (const [time, boundary, outside] of [[0, -3, -3.001], [2.4, 603, 603.001]]) { + const points = sample((t) => t === time ? boundary! : linear(t)); + expect(evaluateTrajectoryConformance(points, 'linear').verdict).toBe('pass'); + expect(evaluateTrajectoryConformance(points.map((point) => point.t === time + ? { ...point, x: outside! } : point), 'linear').verdict).toBe('fail'); + } + }); + + it('does not infer continuous motion from sparse perfect observations', () => { + const points = [0, 0.6, 1.2, 1.8, 2.4].map((t) => ({ t, x: linear(t) })); + expect(evaluateTrajectoryConformance(points, 'linear')).toMatchObject({ + verdict: 'inconclusive', reason: 'observation-gap-exceeds-contract', samples: 5, + }); + }); + + it.each(['leading', 'trailing', 'interior'])('rejects a %s coverage hole', (location) => { + const points = sample(linear).filter((point) => location === 'leading' ? point.t >= 0.06 + : location === 'trailing' ? point.t <= 2.34 : point.t < 0.6 || point.t > 0.66); + expect(evaluateTrajectoryConformance(points, 'linear').verdict).toBe('inconclusive'); + }); + + it('includes leading and trailing gaps in the 50 ms coverage budget', () => { + const times = Array.from({ length: 47 }, (_, index) => (index + 1) / 20); + const points = times.map((t) => ({ t, x: linear(t) })); + const result = evaluateTrajectoryConformance(points, 'linear'); + expect(result.verdict).toBe('pass'); + expect(result.maxGapMs).toBeCloseTo(50, 10); + for (const index of [0, points.length - 1]) { + const moved = points.map((point, current) => current === index + ? { t: point.t + (index === 0 ? 1 : -1) * 0.000001, + x: linear(point.t + (index === 0 ? 1 : -1) * 0.000001) } : point); + expect(evaluateTrajectoryConformance(moved, 'linear').verdict).toBe('inconclusive'); + } + }); + + it('does not let observations outside the window close missing endpoints', () => { + const outsideOnly = [{ t: -0.001, x: 0 }, { t: 2.401, x: 600 }]; + expect(evaluateTrajectoryConformance(outsideOnly, 'linear')).toMatchObject({ + verdict: 'inconclusive', reason: 'no-observations-in-window', samples: 0, maxGapMs: 2400, + }); + const interior = sample(linear).filter((point) => point.t >= 0.06 && point.t <= 2.34); + expect(evaluateTrajectoryConformance([outsideOnly[0], ...interior, outsideOnly[1]], 'linear') + .verdict).toBe('inconclusive'); + expect(evaluateTrajectoryConformance([ + { t: -1, x: 999 }, ...sample(linear), { t: 3, x: -999 }, + ], 'linear')).toMatchObject({ verdict: 'pass', samples: 121, maxErrorPx: 0 }); + }); + + it('preserves a definite violation when valid observations also have coverage gaps', () => { + expect(evaluateTrajectoryConformance([{ t: 1, x: 0 }], 'linear')).toMatchObject({ + verdict: 'fail', reason: 'position-outside-contract', samples: 1, maxErrorPx: 250, + }); + }); + + it.each([ + ['missing array', undefined], ['null', null], ['object', {}], + ['empty', []], ['sparse', Array(3)], ['missing point', [undefined]], + ['missing coordinate', [{ t: 0 }]], ['null point', [null]], + ['string time', [{ t: '0', x: 0 }]], ['string coordinate', [{ t: 0, x: '0' }]], + ['NaN time', [{ t: Number.NaN, x: 0 }]], ['infinite time', [{ t: Infinity, x: 0 }]], + ['NaN coordinate', [{ t: 0, x: Number.NaN }]], + ['infinite coordinate', [{ t: 0, x: -Infinity }]], + ['duplicate time', [{ t: 0, x: 0 }, { t: 0, x: 1 }]], + ['reversed time', [{ t: 1, x: 250 }, { t: 0, x: 0 }]], + ])('returns a JSON-safe inconclusive verdict for %s', (_label, points) => { + const result = evaluateTrajectoryConformance(points, 'linear'); + expect(result.verdict).toBe('inconclusive'); + expect(typeof result.reason).toBe('string'); + expect(result.reason.length).toBeGreaterThan(0); + expect(JSON.parse(JSON.stringify(result))).toEqual(result); + }); + + it('invalidates the whole capture before considering apparent violations', () => { + for (const invalid of [ + [{ t: 1, x: 0 }, { t: 1, x: 250 }], + [{ t: 1, x: 0 }, { t: 3, x: Number.NaN }], + ]) { + expect(evaluateTrajectoryConformance(invalid, 'linear')).toMatchObject({ + verdict: 'inconclusive', maxErrorPx: null, maxGapMs: null, + }); + } + expect(evaluateFreezeConformance('lab', {})).toMatchObject({ + baseline: { verdict: 'inconclusive' }, blocked: { verdict: 'inconclusive' }, + }); + }); +}); diff --git a/test/pack-release-boundary.test.ts b/test/pack-release-boundary.test.ts index 804db71e..dba11c37 100644 --- a/test/pack-release-boundary.test.ts +++ b/test/pack-release-boundary.test.ts @@ -1,4 +1,6 @@ import { readFileSync } from 'node:fs'; +import { join, posix } from 'node:path'; +import { runInNewContext } from 'node:vm'; import { describe, expect, it } from 'vitest'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); @@ -8,8 +10,11 @@ describe('packed release boundary', () => { it('ships every referenced support document', () => { expect(pkg.files).toContain('docs/errors.md'); expect(pkg.files).toContain('docs/benchmark.md'); + expect(pkg.files).toContain('docs/motion-conformance.md'); expect(pkg.files).toContain('docs/recipes.md'); expect(smoke).toContain("'docs/benchmark.md'"); + expect(smoke).toContain("'docs/motion-conformance.md'"); + expect(smoke).toContain("readFileSync(installedMotionContract, 'utf8') !== readFileSync"); expect(smoke).toContain("'docs/recipes.md'"); expect(smoke).toContain("readFileSync(installedRecipes, 'utf8') !== readFileSync"); expect(smoke).toContain("readFileSync(installedBenchmark, 'utf8') !== readFileSync"); @@ -17,6 +22,38 @@ describe('packed release boundary', () => { expect(smoke).not.toContain('/bench/compare/results/`;'); }); + it('ссылка на контракт движения разрешается в реально поставляемый документ', () => { + const document = readFileSync(new URL('../docs/benchmark.md', import.meta.url), 'utf8'); + const links = [...document.matchAll(/\[контракт движения\]\(([^)]+)\)/g)]; + expect(links).toHaveLength(1); + const target = posix.normalize(posix.join('docs', links[0]![1]!)); + expect(target).toBe('docs/motion-conformance.md'); + expect(pkg.files).toContain(target); + expect(readFileSync(new URL(`../${target}`, import.meta.url), 'utf8').length).toBeGreaterThan(0); + }); + + it.each([['контракт-v1', false], ['повреждённый контракт', true]])( + 'исполняемый readback отклоняет повреждённый документ: %s', (packed, expectedFailure) => { + const start = smoke.indexOf(' const installedMotionContract ='); + const end = smoke.indexOf(' if (existsSync(installedBenchmark)', start); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + // Исполняется реальный readback-блок; ожидание задано независимо от его условия. + const files = new Map([ + [join('/source', 'docs', 'motion-conformance.md'), 'контракт-v1'], + [join('/archive', 'docs', 'motion-conformance.md'), packed], + ]); + const context = { + ROOT: '/source', installedRoot: '/archive', failed: false, join, + existsSync: (path: string) => files.has(path), + readFileSync: (path: string) => files.get(path), + log: () => {}, + }; + runInNewContext(smoke.slice(start, end), context); + expect(context.failed).toBe(expectedFailure); + }, + ); + it('derives the runnable Node floor and export surface from installed archive metadata', () => { expect(smoke).toContain("JSON.parse(readFileSync(join(installedRoot, 'package.json'), 'utf8'))"); expect(smoke).toContain("/^>=(\\d+)$/.exec(installedPackage.engines?.node ?? '')"); diff --git a/test/release-artifact.test.ts b/test/release-artifact.test.ts index 895f47ec..f8266198 100644 --- a/test/release-artifact.test.ts +++ b/test/release-artifact.test.ts @@ -51,6 +51,7 @@ function releaseMetadata() { 'dist', 'docs/errors.md', 'docs/benchmark.md', + 'docs/motion-conformance.md', 'docs/recipes.md', '!dist/**/*.map', ], diff --git a/test/release-metadata.test.ts b/test/release-metadata.test.ts index 4fc3e5d9..e49c5fb4 100644 --- a/test/release-metadata.test.ts +++ b/test/release-metadata.test.ts @@ -49,6 +49,7 @@ function metadata() { 'dist', 'docs/errors.md', 'docs/benchmark.md', + 'docs/motion-conformance.md', 'docs/recipes.md', '!dist/**/*.map', ],