ci: перф-смок по диффу — изометрия и взаимодействие в код-ревью

Классификация 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
This commit is contained in:
Codex
2026-09-06 15:00:29 +03:00
parent 90a5b3f6e6
commit 0c85307bf4
10 changed files with 489 additions and 16 deletions
+43 -14
View File
@@ -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"
+11
View File
@@ -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 как подтверждение дешёвых гейтов — смок его часть.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
+10
View File
@@ -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,
@@ -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
}
@@ -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
}
+59
View File
@@ -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));
}
+64
View File
@@ -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');
});
+64
View File
@@ -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 мс обязан краснеть');
});
}
+3 -1
View File
@@ -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');
+49 -1
View File
@@ -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(