mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
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:
@@ -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');
|
||||
});
|
||||
@@ -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 мс обязан краснеть');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user