From 0c85307bf4a5362df340edebf1b7d3aa3d970031 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 6 Sep 2026 15:00:27 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=BF=D0=B5=D1=80=D1=84-=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D0=B4=D0=B8=D1=84=D1=84=D1=83=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=B7=D0=BE=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B5=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4-=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Классификация changes вынесена в scripts/classify-changes.mjs (выходы perf_iso/perf_interaction, fallback --all). performance_smoke добавляет large-house-isometric-v1 при правке src/iso-* и large-house-interaction-v1 при правке живого пути, по 3 образца против hardMaxMs полных профилей (budgets-*-smoke.json). Набор профилей входит в ключ reuse. PROCESS.md §8. Issue: #473 User-Visible: no --- .github/workflows/validate.yml | 57 ++++++--- PROCESS.md | 11 ++ demo/performance/README.md | 10 ++ .../budgets-interaction-smoke.json | 117 ++++++++++++++++++ demo/performance/budgets-isometric-smoke.json | 69 +++++++++++ scripts/classify-changes.mjs | 59 +++++++++ test/classify-changes.test.mjs | 64 ++++++++++ test/performance-budget.test.mjs | 64 ++++++++++ test/performance-workflow.test.mjs | 4 +- test/validate-workflow.test.mjs | 50 +++++++- 10 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 demo/performance/budgets-interaction-smoke.json create mode 100644 demo/performance/budgets-isometric-smoke.json create mode 100755 scripts/classify-changes.mjs create mode 100644 test/classify-changes.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d7674ab7..36ee82bc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -182,6 +182,9 @@ jobs: frontend: ${{ steps.classify.outputs.frontend }} backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} + # Диффозависимые профили перф-смока (#473 §5). + perf_iso: ${{ steps.classify.outputs.perf_iso }} + perf_interaction: ${{ steps.classify.outputs.perf_interaction }} base: ${{ steps.base.outputs.base }} # Разные вещи под разными именами намеренно: `base` — до какого коммита # классифицировать файлы ветки (#387), `range_base` — от какого коммита @@ -237,7 +240,7 @@ jobs: run: | if [ "$REF" = "refs/heads/dev" ]; then echo "dev: без фильтров, всё true" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi zero=$(printf '%040d' 0) @@ -261,7 +264,7 @@ jobs: echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем," echo "все тяжёлые job запускаются без фильтра путей (#347)." } >> "$GITHUB_STEP_SUMMARY" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi # Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387), @@ -271,17 +274,14 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi if [ -z "$base" ]; then echo "база не определена — полный прогон без классификации" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi files=$(git diff --name-only "$base" "$HEAD_SHA") printf '%s\n' "$files" | head -50 - has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; } - { - echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')" - echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')" - echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')" - } >> "$GITHUB_OUTPUT" + # Шаблоны — в scripts/classify-changes.mjs: там они проверяемы + # unit-тестом, здесь — только переписываются в выходы (#473 AC8). + printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT" # Переиспользование результата тяжёлой job (#208). Ключ = входы поведения # (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты @@ -298,6 +298,10 @@ jobs: # заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда. reuse: name: "Переиспользование: это дерево уже проверено" + # `changes` нужна ради набора профилей перф-смока (#473): ключ + # performance_smoke включает набор, иначе glow-only результат засчитался бы + # прогону, которому нужен изометрический профиль. + needs: changes runs-on: ubuntu-latest outputs: smoke: ${{ steps.probe.outputs.smoke }} @@ -307,6 +311,7 @@ jobs: smoke_key: ${{ steps.keys.outputs.smoke }} golden_key: ${{ steps.keys.outputs.golden }} performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} + performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }} backend_key: ${{ steps.keys.outputs.backend }} steps: - uses: actions/checkout@v7 @@ -314,12 +319,21 @@ jobs: with: { node-version: 22 } - name: Ключи переиспользования id: keys + env: + PERF_ISO: ${{ needs.changes.outputs.perf_iso }} + PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }} run: | for job in smoke golden performance_smoke backend; do key=$(node scripts/gate-reuse.mjs --job="$job") echo "$job=$key" >> "$GITHUB_OUTPUT" echo "$job: $key" done + # Набор профилей перф-смока — часть ключа (#473 AC5). + set=glow + [ "$PERF_ISO" = "true" ] && set="$set-iso" + [ "$PERF_INTERACTION" = "true" ] && set="$set-interaction" + echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT" + echo "performance_smoke set: $set" # lookup-only: маркер только проверяется, но не восстанавливается — # сохранять его в этой job нечего, она ничего не прогоняла. - name: Маркер smoke @@ -341,7 +355,7 @@ jobs: uses: actions/cache/restore@v6 with: path: .reuse-marker - key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }} + key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }} lookup-only: true - name: Маркер backend id: m_backend @@ -711,13 +725,14 @@ jobs: name: "Перф-смок: бюджет времени кадра" # Candidate-only catastrophic-regression guard for ordinary pushes and # prereleases. The expensive same-runner comparison lives in performance.yml. - needs: [frontend, reuse] + needs: [changes, frontend, reuse] if: needs.reuse.outputs.performance_smoke != 'true' runs-on: ubuntu-latest # 15 минут не хватало, когда установка браузера шла через apt: замер # начинался на исходе окна (#206). Запас на холодный кэш — при попадании - # job укладывается в те же минуты, что и раньше. - timeout-minutes: 20 + # job укладывается в те же минуты, что и раньше. Диффозависимые профили + # (#473) добавляют до двух замеров по 3 образца — окно расширено под них. + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -757,6 +772,20 @@ jobs: run: | npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json + # Диффозависимые профили (#473 §5): изометрия — когда дифф задел + # `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра. + # Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160 + # (9 870 мс против 3 500) ловится с одного образца, шум — нет. + - name: Изометрический профиль по диффу (#473) + if: needs.changes.outputs.perf_iso == 'true' + run: | + npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json + npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json + - name: Профиль взаимодействия по диффу (#473) + if: needs.changes.outputs.perf_interaction == 'true' + run: | + npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json + npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json # #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера), # ловит возврат квадратичных путей в оба зеркала прямо на пуше. - name: Бюджеты ограничений стыков (#330) @@ -784,7 +813,7 @@ jobs: continue-on-error: true with: path: .reuse-marker - key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }} + key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }} backend: name: "Бэкенд: pytest в Home Assistant" diff --git a/PROCESS.md b/PROCESS.md index f316e37c..c1d65472 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -661,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке при следующей задаче (#237). Пересъёмка — `npm run build && node demo/docs/capture.mjs`, коммит вместе с задачей. +**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля +гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`, +при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`, +`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против +абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`). +Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс +против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом. +Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в +ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает +зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть. + Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал, какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым пропуском. diff --git a/demo/performance/README.md b/demo/performance/README.md index 44ede7b7..4ad4f289 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -82,6 +82,16 @@ reviewed hard timing, Long Task, heap, cache and rendered-device ceilings from `budgets-glow-smoke.json`; it deliberately makes no noisy base-relative claim. This is a catastrophic-regression guard, not a performance trend detector. +Two more profiles join the smoke only when the diff touches their code path +(#473, classified by `scripts/classify-changes.mjs`): `large-house-isometric-v1` +for `src/iso-*` and `large-house-interaction-v1` for `src/live-*`, +`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Both +run `--samples=3 --warmups=1` against `budgets-isometric-smoke.json` and +`budgets-interaction-smoke.json`, whose ceilings are the `hardMaxMs` values of +the full profiles. The set of profiles is part of the `performance_smoke` +reuse key, so a glow-only success never stands in for a run that needed the +isometric profile. + The dedicated `performance.yml` workflow is the full comparison. It runs on every `main` promotion, weekly and on manual dispatch for an important beta or performance-sensitive change. It checks out the candidate and its base SHA, diff --git a/demo/performance/budgets-interaction-smoke.json b/demo/performance/budgets-interaction-smoke.json new file mode 100644 index 00000000..38c03270 --- /dev/null +++ b/demo/performance/budgets-interaction-smoke.json @@ -0,0 +1,117 @@ +{ + "schema": 1, + "profile": "large-house-interaction-v1", + "minimumSamples": 3, + "timings": { + "modelReadyMs": { + "stat": "median", + "hardMaxMs": 2500 + }, + "firstStableRenderMs": { + "stat": "median", + "hardMaxMs": 3000 + }, + "interactionSeriesMs": { + "stat": "median", + "hardMaxMs": 3000 + }, + "hoverSeriesMs": { + "stat": "median", + "hardMaxMs": 500 + }, + "panSeriesMs": { + "stat": "median", + "hardMaxMs": 500 + }, + "cameraSeriesMs": { + "stat": "median", + "hardMaxMs": 500 + }, + "editorSeriesMs": { + "stat": "median", + "hardMaxMs": 750 + }, + "irrelevantHaTicksMs": { + "stat": "median", + "hardMaxMs": 250 + }, + "relevantHaTickMs": { + "stat": "median", + "hardMaxMs": 250 + }, + "spaceSwitchMs": { + "stat": "median", + "hardMaxMs": 1500 + }, + "stateUpdateMs": { + "stat": "median", + "hardMaxMs": 1000 + }, + "resizePreviewMs": { + "stat": "median", + "hardMaxMs": 2000 + }, + "panZoomMs": { + "stat": "median", + "hardMaxMs": 500 + }, + "settingsDialogMs": { + "stat": "median", + "hardMaxMs": 1000 + }, + "switchCycleMs": { + "stat": "median", + "hardMaxMs": 7000 + } + }, + "longTasks": { + "maxSingleMs": 3000, + "maxCountP95": 30, + "maxTotalP95Ms": 12000 + }, + "longTaskWindows": { + "hoverSeries": { + "maxSingleMs": 150, + "maxCountP95": 3, + "maxTotalP95Ms": 300 + }, + "panSeries": { + "maxSingleMs": 150, + "maxCountP95": 3, + "maxTotalP95Ms": 300 + }, + "cameraSeries": { + "maxSingleMs": 150, + "maxCountP95": 3, + "maxTotalP95Ms": 300 + }, + "editorSeries": { + "maxSingleMs": 150, + "maxCountP95": 3, + "maxTotalP95Ms": 300 + } + }, + "heap": { + "required": true, + "hardMaxGrowthBytes": 67108864 + }, + "cacheEntries": { + "cleanFloor": 100, + "glowClip": 200, + "wallUnion": 1, + "openingTunnel": 1, + "openingWallIndex": 1, + "planSnapGeometry": 1, + "wallFaceGraph": 4 + }, + "cacheGrowth": { + "cleanFloor": 0, + "glowClip": 0, + "wallUnion": 0, + "openingTunnel": 0, + "openingWallIndex": 0, + "planSnapGeometry": 0, + "wallFaceGraph": 0 + }, + "renderedDevices": 200 +} diff --git a/demo/performance/budgets-isometric-smoke.json b/demo/performance/budgets-isometric-smoke.json new file mode 100644 index 00000000..bf79fb33 --- /dev/null +++ b/demo/performance/budgets-isometric-smoke.json @@ -0,0 +1,69 @@ +{ + "schema": 1, + "profile": "large-house-isometric-v1", + "minimumSamples": 3, + "timings": { + "modelReadyMs": { + "stat": "median", + "hardMaxMs": 3000 + }, + "firstStableRenderMs": { + "stat": "median", + "hardMaxMs": 3500 + }, + "viewToggleMs": { + "stat": "median", + "hardMaxMs": 1500 + }, + "spaceSwitchMs": { + "stat": "median", + "hardMaxMs": 1800 + }, + "stateUpdateMs": { + "stat": "median", + "hardMaxMs": 1000 + }, + "resizePreviewMs": { + "stat": "median", + "hardMaxMs": 2200 + }, + "panZoomMs": { + "stat": "median", + "hardMaxMs": 600 + }, + "settingsDialogMs": { + "stat": "median", + "hardMaxMs": 1200 + }, + "switchCycleMs": { + "stat": "median", + "hardMaxMs": 8000 + } + }, + "longTasks": { + "maxSingleMs": 3000, + "maxCountP95": 30, + "maxTotalP95Ms": 12000 + }, + "heap": { + "required": true, + "hardMaxGrowthBytes": 67108864 + }, + "cacheEntries": { + "cleanFloor": 100, + "glowClip": 200, + "wallUnion": 1, + "openingTunnel": 1, + "openingWallIndex": 1, + "isoGeometry": 8 + }, + "cacheGrowth": { + "cleanFloor": 0, + "glowClip": 0, + "wallUnion": 0, + "openingTunnel": 0, + "openingWallIndex": 0, + "isoGeometry": 0 + }, + "renderedDevices": 200 +} diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs new file mode 100755 index 00000000..18f47469 --- /dev/null +++ b/scripts/classify-changes.mjs @@ -0,0 +1,59 @@ +// Классификация изменённых файлов для job `changes` в validate.yml (#473 AC8). +// +// Шаблоны жили inline в shell-шаге `classify` как `has('regex')`. Пока выходов +// было три, это терпимо; с диффозависимыми профилями перф-смока (#473) их +// пять, и вопрос «запустит ли правка src/iso-x.ts изометрический профиль» +// стал вопросом к workflow, на который нельзя ответить тестом — shell в YAML +// не исполняется локально. Теперь ответ даёт функция, а shell только +// переписывает её вывод в `$GITHUB_OUTPUT`. +// +// Контракт неизменен: каждый выход — строка 'true'/'false', как её и читают +// условия `if: needs.changes.outputs.X == 'true'`. Fallback «полный прогон +// без классификации» (--all) выставляет все выходы в 'true' — в том числе те, +// что появятся позже: пропущенный ключ в fallback-е означал бы, что job +// с этим условием молча не запускается ровно в тех прогонах, где база +// недоказуема и проверять надо всё. + +import { readFileSync } from 'node:fs'; + +export const CLASSIFIERS = { + frontend: /^(src\/|demo\/|test\/|dist\/|custom_components\/houseplan\/frontend\/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)/, + backend: /^(custom_components\/.*\.py$|tests_backend\/|scripts\/support-relay\/|pytest\.ini$)/, + integration: /^(custom_components\/houseplan\/manifest\.json$|hacs\.json$|custom_components\/.*\.py$|custom_components\/.*\/translations\/)/, + // Перф-смок (#473 §5): изометрический профиль — при правке изометрии, + // профиль взаимодействия — при правке живого пути и оркестраторов кадра. + // Только `src/**`: тесты и демо кадр не замедляют. + perf_iso: /^src\/iso-[^/]+\.ts$/, + perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/, +}; + +export const OUTPUTS = Object.keys(CLASSIFIERS); + +/** Список файлов → выходы job `changes` ('true'/'false' по каждому ключу). */ +export function classifyChanges(files) { + const list = (Array.isArray(files) ? files : String(files).split('\n')) + .map((file) => file.trim()).filter(Boolean); + const result = {}; + for (const [name, pattern] of Object.entries(CLASSIFIERS)) { + result[name] = list.some((file) => pattern.test(file)) ? 'true' : 'false'; + } + return result; +} + +/** Fallback без классификации: всё прогоняется. */ +export function classifyAll() { + return Object.fromEntries(OUTPUTS.map((name) => [name, 'true'])); +} + +/** Формат `$GITHUB_OUTPUT`. */ +export function formatOutputs(outputs) { + return OUTPUTS.map((name) => `${name}=${outputs[name]}`).join('\n') + '\n'; +} + +const invokedDirectly = process.argv[1] + && import.meta.url === new URL(`file://${process.argv[1]}`).href; +if (invokedDirectly) { + const all = process.argv.includes('--all'); + const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8')); + process.stdout.write(formatOutputs(outputs)); +} diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs new file mode 100644 index 00000000..3f0e12d5 --- /dev/null +++ b/test/classify-changes.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { classifyChanges, classifyAll, formatOutputs, OUTPUTS, CLASSIFIERS } from '../scripts/classify-changes.mjs'; + +// #473 AC8: диффозависимость перф-смока доказана на самой функции +// классификации, которую исполняет job `changes`. + +test('дифф по изометрии включает perf_iso и только его из перф-выходов (#473 AC8)', () => { + const out = classifyChanges(['src/iso-x.ts']); + assert.equal(out.perf_iso, 'true'); + assert.equal(out.perf_interaction, 'false'); + assert.equal(out.frontend, 'true'); +}); + +test('дифф по живому пути и оркестраторам кадра включает perf_interaction (#473 AC8)', () => { + for (const file of ['src/live-x.ts', 'src/render-invalidation.ts', + 'src/houseplan-render-lifecycle.ts', 'src/houseplan-card.ts']) { + const out = classifyChanges([file]); + assert.equal(out.perf_interaction, 'true', file); + assert.equal(out.perf_iso, 'false', file); + } +}); + +test('дифф по документации не включает ни одного перф-профиля (#473 AC8)', () => { + const out = classifyChanges(['docs/x.md']); + assert.equal(out.perf_iso, 'false'); + assert.equal(out.perf_interaction, 'false'); + assert.equal(out.frontend, 'false'); +}); + +test('тесты и демо перф-профили не включают: кадр они не замедляют', () => { + const out = classifyChanges(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs', 'src/iso-x.test.ts.md']); + assert.equal(out.perf_iso, 'false'); + assert.equal(out.perf_interaction, 'false'); +}); + +test('прежние три выхода классифицируются как в inline-shell до выноса', () => { + assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend_registration.py']), + { frontend: 'false', backend: 'true', integration: 'true', perf_iso: 'false', perf_interaction: 'false' }); + assert.deepEqual(classifyChanges(['custom_components/houseplan/frontend/houseplan-card.js']), + { frontend: 'true', backend: 'false', integration: 'false', perf_iso: 'false', perf_interaction: 'false' }); + assert.deepEqual(classifyChanges(['hacs.json', 'tsconfig.json']), + { frontend: 'true', backend: 'false', integration: 'true', perf_iso: 'false', perf_interaction: 'false' }); + assert.equal(classifyChanges(['scripts/support-relay/x.py']).backend, 'true'); + assert.equal(classifyChanges(['']).frontend, 'false'); +}); + +test('fallback --all выставляет каждый известный выход, включая перф-профили', () => { + const all = classifyAll(); + assert.deepEqual(Object.keys(all), OUTPUTS); + assert.ok(OUTPUTS.every((name) => all[name] === 'true')); + assert.deepEqual(Object.keys(CLASSIFIERS), OUTPUTS); +}); + +test('CLI пишет формат $GITHUB_OUTPUT: stdin — список файлов, --all — всё true', () => { + const script = new URL('../scripts/classify-changes.mjs', import.meta.url).pathname; + const fromStdin = execFileSync('node', [script], { input: 'src/iso-x.ts\ndocs/x.md\n', encoding: 'utf8' }); + assert.equal(fromStdin, formatOutputs(classifyChanges(['src/iso-x.ts', 'docs/x.md']))); + assert.match(fromStdin, /^perf_iso=true$/m); + assert.match(fromStdin, /^perf_interaction=false$/m); + const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' }); + assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\n'); +}); diff --git a/test/performance-budget.test.mjs b/test/performance-budget.test.mjs index 3ea692cd..bbedf049 100644 --- a/test/performance-budget.test.mjs +++ b/test/performance-budget.test.mjs @@ -181,3 +181,67 @@ test('named interaction windows enforce their own absolute Long Task limits', () ]), ); }); + +// #473 AC4: smoke-бюджеты диффозависимых профилей повторяют абсолютные потолки +// полных профилей и пригодны для `compare --absolute-only` на трёх образцах. +import { readFileSync } from 'node:fs'; + +const readBudget = (name) => JSON.parse(readFileSync(new URL(`../demo/performance/${name}`, import.meta.url), 'utf8')); + +for (const [smokeName, fullName] of [ + ['budgets-isometric-smoke.json', 'budgets-large-house-isometric.json'], + ['budgets-interaction-smoke.json', 'budgets-large-house-interaction.json'], +]) { + test(`${smokeName} повторяет hardMaxMs полного профиля и держит 3 образца (#473 AC4)`, () => { + const smoke = readBudget(smokeName); + const full = readBudget(fullName); + assert.equal(smoke.profile, full.profile); + assert.equal(smoke.minimumSamples, 3); + assert.deepEqual(Object.keys(smoke.timings), Object.keys(full.timings), 'набор метрик тот же'); + for (const [metric, budget] of Object.entries(smoke.timings)) { + assert.equal(budget.hardMaxMs, full.timings[metric].hardMaxMs, `${metric}: потолок отличается от полного`); + assert.equal(budget.stat, full.timings[metric].stat); + // Регрессионных коэффициентов в смоке нет: сравнивать не с чем (§5). + assert.equal(budget.maxRegressionRatio, undefined, `${metric}: в смоке нет относительных лимитов`); + } + assert.equal(smoke.longTasks.maxSingleMs, full.longTasks.maxSingleMs); + assert.equal(smoke.longTasks.maxCountP95, full.longTasks.maxCountP95); + assert.equal(smoke.longTasks.maxTotalP95Ms, full.longTasks.maxTotalP95Ms); + assert.deepEqual(smoke.longTaskWindows, full.longTaskWindows); + assert.equal(smoke.heap.hardMaxGrowthBytes, full.heap.hardMaxGrowthBytes); + assert.deepEqual(smoke.cacheEntries, full.cacheEntries); + assert.deepEqual(smoke.renderedDevices, full.renderedDevices); + + // Пригодность для --absolute-only: синтетический отчёт под потолками + // проходит, первый кадр как у de215578 (9 870 мс) — красный. + const build = (firstFrame) => ({ + schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture', + runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' }, + fixture: { rooms: 60 }, + summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => { + const value = metric === 'firstStableRenderMs' ? firstFrame : 1; + return [metric, { median: value, p95: value, min: value, max: value }]; + })), + longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 }, + ...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}), + rows: [0, 1, 2].map(() => ({ + heapGrowthBytes: 1, preciseGc: true, + longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})] + .map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])), + cacheEntries: { ...smoke.cacheEntries }, + cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])), + renderedDevices: smoke.renderedDevices, + ...(smoke.profile === 'large-house-isometric-v1' + ? { + effectiveProjection: 'iso', + isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 }, + } : {}), + })), + }); + const ok = evaluatePerformanceBudget({ candidate: build(1), budgets: smoke, absoluteOnly: true }); + assert.deepEqual(ok.failures, [], 'отчёт под потолками обязан проходить'); + const regressed = evaluatePerformanceBudget({ candidate: build(9870), budgets: smoke, absoluteOnly: true }); + assert.ok(regressed.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'), + 'первый кадр 9 870 мс обязан краснеть'); + }); +} diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index bfe228ab..2ec5fd76 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -178,8 +178,10 @@ test('#347: a rewritten before forces the full run instead of guessing the range workflow.indexOf('Классификация изменённых файлов'), workflow.indexOf('reuse:'), ); + // Полный прогон — `classify-changes.mjs --all`: все выходы true (#473 вынес + // список выходов из inline-shell, ветка force-push идёт тем же путём). assert.ok( - /force-push[\s\S]*?frontend=true[\s\S]*?backend=true[\s\S]*?integration=true/.test(classify), + /force-push[\s\S]*?node scripts\/classify-changes\.mjs --all >> "\$GITHUB_OUTPUT"/.test(classify), 'мёртвый before обязан включать полный прогон, не merge-base-угадывание'); assert.ok(classify.includes('GITHUB_STEP_SUMMARY'), 'пропуск классификации обязан быть громким в summary'); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 6248721a..74f8d055 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -170,10 +170,58 @@ test('классификация опирается на завершённый // Пустая база означает «доказательства нет» и обязана вести к полному // прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось». const empty = changes.slice(changes.indexOf('if [ -z "$base" ]')); - assert.match(empty, /frontend=true\\nbackend=true\\nintegration=true/, + assert.match(empty, /node scripts\/classify-changes\.mjs --all >> "\$GITHUB_OUTPUT"/, 'без базы классификация обязана раскрываться в полный прогон'); }); +test('перф-смок добавляет профиль ровно при своём выходе changes (#473 AC3)', () => { + const workflow = read('validate.yml'); + const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n')); + assert.match(changes, /perf_iso: \$\{\{ steps\.classify\.outputs\.perf_iso \}\}/); + assert.match(changes, /perf_interaction: \$\{\{ steps\.classify\.outputs\.perf_interaction \}\}/); + // Выходы пишет скрипт, а не inline-shell: шаблоны проверяются unit-тестом (AC8). + assert.match(changes, /printf '%s\\n' "\$files" \| node scripts\/classify-changes\.mjs \| tee -a "\$GITHUB_OUTPUT"/); + // Все три fallback-а «без классификации» идут через тот же скрипт с --all: + // новый выход не может выпасть из fallback-а. + const fallbacks = changes.split('node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"').length - 1; + assert.equal(fallbacks, 3, 'fallback-и классификатора раскрываются через --all'); + assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён'); + + const start = workflow.indexOf('\n performance_smoke:\n'); + const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start)); + assert.match(job, /needs: \[changes, frontend, reuse\]/); + const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу')); + assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/); + assert.match(iso, /--profile=large-house-isometric-v1 --samples=3 --warmups=1/); + assert.match(iso, /--absolute-only --budgets=demo\/performance\/budgets-isometric-smoke\.json/); + const interaction = job.slice(job.indexOf('Профиль взаимодействия по диффу'), job.indexOf('#330 AC7')); + assert.match(interaction, /if: needs\.changes\.outputs\.perf_interaction == 'true'/); + assert.match(interaction, /--profile=large-house-interaction-v1 --samples=3 --warmups=1/); + assert.match(interaction, /--absolute-only --budgets=demo\/performance\/budgets-interaction-smoke\.json/); + // Glow-профили остаются безусловными. + const glow = job.slice(job.indexOf('Capture the heaviest Glow state'), job.indexOf('Enforce absolute smoke ceilings')); + assert.ok(!/\n\s+if:/.test(glow), 'glow-профили гоняются всегда'); +}); + +test('ключ reuse перф-смока различает наборы профилей (#473 AC5)', () => { + const workflow = read('validate.yml'); + const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); + assert.match(reuse, /needs: changes/); + assert.match(reuse, /PERF_ISO: \$\{\{ needs\.changes\.outputs\.perf_iso \}\}/); + assert.match(reuse, /PERF_INTERACTION: \$\{\{ needs\.changes\.outputs\.perf_interaction \}\}/); + assert.match(reuse, /\[ "\$PERF_ISO" = "true" \] && set="\$set-iso"/); + assert.match(reuse, /\[ "\$PERF_INTERACTION" = "true" \] && set="\$set-interaction"/); + assert.match(reuse, /performance_smoke_set: \$\{\{ steps\.keys\.outputs\.performance_smoke_set \}\}/); + // Набор входит и в маркер-lookup, и в маркер-save: расхождение ключей + // означало бы, что маркер пишется под именем, которого никто не ищет. + const lookup = 'reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}'; + const save = 'reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}'; + assert.ok(reuse.includes(lookup), 'lookup-ключ без набора профилей'); + assert.ok(workflow.includes(save), 'save-ключ без набора профилей'); + assert.ok(!workflow.includes('reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}\n'), + 'старый save-ключ без набора остался'); +}); + test('гейты диапазона судят от доказанного предка, а не от предыдущего пуша (#388)', () => { const workflow = read('validate.yml'); const preflight = workflow.slice(