import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs, mutantsRequested, screenshotsGateMode, } from '../scripts/classify-changes.mjs'; import { manifest } from '../scripts/check-inputs.mjs'; import { fileURLToPath } from 'node:url'; // Классификация идёт из единого manifest входов (#492 §5.2) на РЕАЛЬНОМ // дереве репозитория: тест доказывает решения job `changes` для настоящих // файлов, а не для выдуманных путей. Manifest считается один раз. // // Пути, которые НЕ должны быть входами, собираются из кусков: строковый // литерал в этом файле сам сделал бы их входом frontend (тесты читают то, что // называют), и проверка стала бы самосбывающейся. const MANIFEST = manifest(process.cwd()); const classify = (files) => classifyChanges(files, { manifest: MANIFEST }); const p = (...parts) => parts.join('/'); test('дифф по изометрии включает perf_iso и только его из перф-выходов (#473 AC8)', () => { const out = classify(['src/iso-overlays.ts']); assert.equal(out.perf_iso, 'true'); assert.equal(out.perf_interaction, 'false'); assert.equal(out.frontend, 'true'); assert.deepEqual(out.unknown, []); }); test('дифф по живому пути и оркестраторам кадра включает perf_interaction (#473 AC8)', () => { for (const file of ['src/live-hover.ts', 'src/render-invalidation.ts', 'src/houseplan-render-lifecycle.ts', 'src/houseplan-card.ts']) { const out = classify([file]); assert.equal(out.perf_interaction, 'true', file); assert.equal(out.perf_iso, 'false', file); } }); test('дифф по документации не включает ни одного перф-профиля и ни одной job (#473 AC8)', () => { const out = classify([p('docs', 'SUN.md')]); assert.equal(out.perf_iso, 'false'); assert.equal(out.perf_interaction, 'false'); assert.equal(out.frontend, 'false'); assert.equal(out.backend, 'false'); }); test('тесты и демо перф-профили не включают: кадр они не замедляют', () => { const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs']); assert.equal(out.perf_iso, 'false'); assert.equal(out.perf_interaction, 'false'); }); test('правка реестра мутантов даёт mutants=true; юниты реестра — тоже вход frontend (#475 r1, #492)', () => { for (const file of ['scripts/mutation-gate.mjs', 'scripts/mutation-registry.mjs', 'scripts/mutation-selection.mjs', 'scripts/mutation-evidence.mjs', 'scripts/mutation-execution.mjs']) { const out = classify([file]); assert.equal(out.mutants, 'true', file); assert.equal(out.frontend, 'true', `${file}: test/mutation-gate.test.mjs читает tooling`); assert.equal(out.backend, 'false', file); } assert.equal(classify(['src/color.ts']).mutants, 'true', 'патчи мутантов лежат в src/**'); }); test('#492 §1.2: relay, converter, schema и pyproject запускают backend', () => { for (const file of ['scripts/support-relay/relay.py', 'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/sh3d-convert/golden/two-levels.space-1.json', 'scripts/config-schema.json', 'scripts/dump-config-schema.py', 'pyproject.toml', 'custom_components/houseplan/frontend_registration.py']) { const out = classify([file]); assert.equal(out.backend, 'true', file); assert.deepEqual(out.unknown, [], file); } }); test('#548: parity выбирается по обоим зеркалам, fixture и toolchain, но не по чужому UI', () => { for (const file of ['src/junction-limits.ts', 'src/space-geometry.ts', 'custom_components/houseplan/junction_limits.py', 'custom_components/houseplan/wall_segment_model.py', 'tests_backend/junction_parity.py', 'test/fixtures/junction-limits-parity.json', 'tsconfig.junction-parity.json', 'package-lock.json', '.nvmrc', '.python-version']) { const out = classify([file]); assert.equal(out.geometry_parity, 'true', file); assert.deepEqual(out.unknown, [], file); } assert.equal(classify(['src/houseplan-card.ts']).geometry_parity, 'false'); assert.equal(classify(['custom_components/houseplan/websocket_api.py']).geometry_parity, 'false'); }); test('#492 AC6: правка UI не классифицируется как backend', () => { const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']); assert.equal(out.backend, 'false'); assert.equal(out.frontend, 'true'); }); test('интеграция: манифест, hacs.json, переводы (как в inline-shell до выноса)', () => { assert.equal(classify(['custom_components/houseplan/manifest.json']).integration, 'true'); assert.equal(classify(['hacs.json']).integration, 'true'); assert.equal(classify(['custom_components/houseplan/translations/ru.json']).integration, 'true'); assert.equal(classify(['src/color.ts']).integration, 'false'); assert.equal(classify(['']).frontend, 'false'); }); test('копия бандла — не вход ни одной проверки (класс D)', () => { const out = classify(['custom_components/houseplan/frontend/houseplan-card.js', 'dist/houseplan-card.js']); for (const name of Object.keys(CHECK_OF_OUTPUT)) assert.equal(out[name], 'false', name); assert.deepEqual(out.unknown, []); }); test('#492 §5.2: неизвестный исполняемый вход расширяет прогон до полного набора и называется', () => { const out = classify(['scripts/brand-new-gate.mjs']); for (const name of OUTPUTS) assert.equal(out[name], 'true', name); assert.deepEqual(out.unknown, ['scripts/brand-new-gate.mjs']); // Объявленный не-вход и документация не расширяют. const quiet = classify([p('demo', 'stand', 'README.md'), p('demo', 'shot_sun.mjs'), p('docs', 'new-page.md')]); assert.deepEqual(quiet.unknown, []); for (const name of OUTPUTS) assert.equal(quiet[name], 'false', name); }); test('fallback --all выставляет каждый известный выход, включая перф-профили', () => { const all = classifyAll(); assert.deepEqual(Object.keys(all).filter((k) => k !== 'unknown'), OUTPUTS); assert.ok(OUTPUTS.every((name) => all[name] === 'true')); assert.deepEqual(Object.keys(CLASSIFIERS), OUTPUTS); assert.deepEqual(Object.keys(PERF_PROFILES), ['perf_iso', 'perf_interaction']); }); test('CLI пишет формат $GITHUB_OUTPUT: stdin — список файлов, --all — всё true', () => { // fileURLToPath, не URL.pathname (#496): на Windows pathname даёт «/C:/…», Node ищет C:\C:\… . const script = fileURLToPath(new URL('../scripts/classify-changes.mjs', import.meta.url)); const doc = p('docs', 'SUN.md'); const fromStdin = execFileSync('node', [script], { input: `src/iso-overlays.ts\n${doc}\n`, encoding: 'utf8' }); assert.equal(fromStdin, formatOutputs(classify(['src/iso-overlays.ts', doc]))); assert.match(fromStdin, /^perf_iso=true$/m); assert.match(fromStdin, /^perf_interaction=false$/m); assert.match(fromStdin, /^unknown_inputs=$/m); const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' }); assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\nunknown_inputs=\n'); }); // #479: тяжёлые job идут на кандидате беты, по кнопке, на PR и по расписанию — // и НЕ идут на обычном пуше. Обе стороны доказаны на самой функции, которую // исполняет шаг `heavy` job `changes`. import { heavyGatesRequested, hasReleaseTrailer } from '../scripts/classify-changes.mjs'; test('обычный push в dev не запрашивает тяжёлые job (#479)', () => { assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'fix: speed up wall-chain commits\n\nIssue: #461\nUser-Visible: yes\n', }), false); assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: '' }), false); assert.equal(heavyGatesRequested({}), false); }); test('кандидат беты и релиза — трейлер Release: — запрашивает тяжёлые job (#479)', () => { assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'build: prepare v1.73.0-beta.1 candidate\n\nIssue: #160\nUser-Visible: yes\nRelease: v1.73.0-beta.1\n', }), true); assert.equal(hasReleaseTrailer('Release v1.72.0\n\nRelease: v1.72.0'), true); // Слово в теле — не трейлер: строка должна начинаться с `Release:`. assert.equal(hasReleaseTrailer('docs: mention the Release: process in AGENTS'), false); assert.equal(hasReleaseTrailer('Release: soon'), false); }); test('workflow_dispatch запрашивает тяжёлые job только с full=true (#479)', () => { assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: true }), true); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'false' }), false); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: '' }), false); }); test('pull_request и schedule всегда запрашивают тяжёлые job (#479)', () => { assert.equal(heavyGatesRequested({ eventName: 'pull_request', headMessage: 'x' }), true); assert.equal(heavyGatesRequested({ eventName: 'schedule' }), true); }); test('CLI --heavy читает событие и сообщение из окружения (#479)', () => { const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], { encoding: 'utf8', env: { ...process.env, ...env }, }).trim(); assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), 'heavy=false\nmutants_requested=false'); // #601: кандидат беты и полный набор берут тяжёлые job, но не мутантов assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'x\n\nRelease: v1.2.3' }), 'heavy=true\nmutants_requested=false'); assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=true\nmutants_requested=false'); // #510: мутанты по кнопке без полного набора — вызов конвейера ревью и слияния assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'false', MUTANTS_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=false\nmutants_requested=true'); // обе кнопки разом — полный набор И мутанты; так делает только человек, конвейер шлёт full=false assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'true', MUTANTS_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=true\nmutants_requested=true'); }); test('#586: режим гейта скриншотов приходит одним значением и на кандидате строгий', () => { assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), 'warn'); assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), 'strict', 'кандидат беты'); assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: 'x\n\nRelease: v1.76.0' }), 'strict', 'кандидат стабильного'); assert.equal(screenshotsGateMode({ eventName: 'workflow_dispatch', fullInput: 'true' }), 'strict'); assert.equal(screenshotsGateMode({ eventName: 'workflow_dispatch', fullInput: 'false' }), 'warn'); assert.equal(screenshotsGateMode({ eventName: 'pull_request' }), 'strict'); assert.equal(screenshotsGateMode({ eventName: 'schedule' }), 'strict'); // Регрессия, ради которой заведён #586: у `--heavy` вывод ДВУХСТРОЧНЫЙ, и // сравнение всего вывода со строкой `heavy=true` не совпадает никогда. const run = (args, env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', ...args], { encoding: 'utf8', env: { ...process.env, ...env }, }).trim(); const candidate = { EVENT_NAME: 'push', HEAD_MESSAGE: 'x\n\nRelease: v1.2.3' }; assert.notEqual(run(['--heavy'], candidate), 'heavy=true', 'вывод --heavy многострочный — сравнивать его целиком нельзя'); assert.equal(run(['--screenshots-mode'], candidate), 'strict'); assert.equal(run(['--screenshots-mode'], { EVENT_NAME: 'push', HEAD_MESSAGE: 'fix: x' }), 'warn'); }); test('#586: preflight спрашивает режим одним значением, а не разбирает вывод --heavy', () => { const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); assert.match(workflow, /classify-changes\.mjs --screenshots-mode/); assert.ok(!/=\s*"heavy=true"/.test(workflow), 'сравнение со строкой «heavy=true» вернулось — строгий режим снова не включится'); assert.match(workflow, /check-docs\.mjs "\$external" --screenshots=\$mode/); }); test('#510 AC1 / #601 AC1: мутанты по диффу запрашиваются только кнопкой mutants=true и PR — не пушем, не кандидатом беты, не full', () => { const t = (env) => mutantsRequested(env); assert.equal(t({ eventName: 'push', headMessage: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), false, 'обычный push'); assert.equal(t({ eventName: 'pull_request' }), true, 'на PR Validate — единственный сигнал'); assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'false', mutantsInput: 'true' }), true, 'конвейер ревью и слияния'); assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'false', mutantsInput: 'false' }), false, 'кнопка без запроса'); // #601: мутанты проверяют тесты, а не продукт (#513); к бете задача прогнана // ими дважды, а ночь покрыта полным реестром — эти входы их не включают. assert.equal(t({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), false, 'кандидат беты — тяжёлые гейты без мутантов'); assert.equal(t({ eventName: 'push', headMessage: 'x\n\nRelease: v1.76.0' }), false, 'кандидат стабильного — тоже'); assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'true' }), false, 'полный набор по кнопке — без мутантов'); assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'true', mutantsInput: 'false' }), false); assert.equal(t({ eventName: 'schedule' }), false, 'расписание Validate — не место мутантов, реестр идёт в mutation-gate.yml'); assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'true', mutantsInput: 'true' }), true, 'явный запрос действует и рядом с full'); // #601 AC2: тяжёлые гейты на тех же входах не изменились assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), true); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true); }); test('#697: на ветке задачи трейлер Release: тяжёлый набор не включает, на dev — как прежде', () => { const acceptance = 'test: accept golden\n\nIssue: #687\nUser-Visible: no\nRelease: v1.78.0-beta.9\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1'; assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), false); assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'refs/heads/issue/687-x' }), false); assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'dev' }), true, 'кандидат на dev'); assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance }), true, 'без ветки — прежнее правило'); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true', refName: 'issue/687-x' }), true, 'ci:full/ci:golden — dispatch full=true на ветке задачи'); assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), 'warn'); const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], { encoding: 'utf8', env: { ...process.env, ...env }, }).trim(); assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: acceptance, REF_NAME: 'issue/687-x' }), 'heavy=false\nmutants_requested=false'); const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); assert.equal((workflow.match(/REF_NAME: \$\{\{ github\.ref_name \}\}/g) || []).length >= 2, true, 'оба вызова знают ветку'); });