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
+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(