diff --git a/demo/golden/README.md b/demo/golden/README.md index 2ee49c1a..1b26b2e2 100644 --- a/demo/golden/README.md +++ b/demo/golden/README.md @@ -28,6 +28,12 @@ radial spokes visible instead of hiding them under a translucent room fill. - `accept` requires `--reviewed`, a complete candidate report and current source fingerprint. It validates the whole set before copying anything and is the only command allowed to update baselines. +- `scripts/golden-accept.mjs` wraps `accept` and additionally requires the + reviewer to declare intent: every scenario whose capture differs from its + accepted baseline must be named in `--expect-change=`. Anything else + that differs refuses the whole acceptance, before a single file is copied. + This is what makes a local capture admissible (see below) and it + simultaneously blocks the one-command "accept everything so CI turns green". ## Workflow @@ -43,17 +49,57 @@ Review `artifacts/golden/actual/` and, when existing references are present, `artifacts/golden/diff/`. If every image is intentional: ```bash -npm run golden:accept -- --reviewed +node scripts/golden-accept.mjs --reviewed --expect-change=wall-junctions-plan-t-dark npm run golden:verify ``` Never accept images merely to make CI green. A matrix/framing change increments `GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate. -Future updates must still use the `golden-images` artifact produced by the Linux -CI job as the review set: desktop font rasterisation can differ from the CI -environment even with the same pinned Chromium. Pass its unpacked root via -`--from=...` when accepting it locally. + +## Capturing candidates without a second CI round trip (#334) + +Accepting from the `golden-images` CI artifact still works and is still the +safest route: unpack it and pass `--from=...`. It costs two full CI runs per +visual fix, though — one to produce the artifact and one to verify the accepted +baseline — and at matrix version 48 that toll is paid often. + +A local capture is admissible instead, because admissibility is now *proved* +rather than assumed. Desktop font rasterisation can differ from the runner, but +it cannot differ quietly: it would move every text-bearing scenario, not only +the ones under edit. So the rule is simply that the capture must reproduce every +accepted baseline the reviewer did not intend to change: + +```bash +node scripts/golden-container.mjs # capture in the pinned image +node scripts/golden-accept.mjs --reviewed --expect-change= +``` + +If the environment is not pixel-equivalent, unrelated scenarios come out +`different`, the wrapper names them and refuses. A wrong container tag or a +mismatched font set therefore cannot corrupt baselines — it can only fail. + +`scripts/golden-container.mjs` derives the image tag from the `playwright` +version locked in `package-lock.json`, so the container Chromium equals the +runner's; `--image=` overrides it when a distro-specific tag is needed +(`...:v1.62.0-jammy`). The host `node_modules` is shadowed by an anonymous +volume: the repository copy may be built for Windows, and `npm ci` inside the +container would otherwise replace it with Linux binaries. The run does write +`dist/` and the three bundle copies, exactly as a local `npm run bundle:sync` +would. + +Docker is not a requirement of the rule, only a convenience: any Linux +environment that satisfies the parity condition qualifies, WSL included. The +`chromium` string recorded in the manifest keeps the browser build itself +pinned, and `golden:verify` rejects a manifest captured by a different build. + +`scripts/golden-accept.mjs` deliberately wraps `demo/golden/accept.mjs` instead +of replacing its checks: every `.mjs` under `demo/golden` belongs to +`sourceFingerprint`, so editing the acceptance tool itself would declare the +committed bundle, the documentation screenshot manifest and the baseline +manifest stale — the very double round trip this change removes. Narrowing that +corpus is worthwhile but separate: `scripts/source-fingerprint.mjs` is itself a +build input, so any change to it forces one bundle rebuild. Scenarios may also declare a semantic pixel region (for example, a receiving room that must contain warm light). `golden:capture` and `golden:verify` reject diff --git a/scripts/golden-accept.mjs b/scripts/golden-accept.mjs new file mode 100644 index 00000000..a5cdbd60 --- /dev/null +++ b/scripts/golden-accept.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Приёмка эталонов с объявлением намерения (#334). + * + * node scripts/golden-accept.mjs --reviewed --expect-change= + * node scripts/golden-accept.mjs --reviewed --expect-change= --from=<распакованный артефакт> + * + * Обёртка над `demo/golden/accept.mjs`, а не правка его самого: любой `.mjs` из + * `demo/golden` входит в `sourceFingerprint`, поэтому его правка объявляет + * устаревшими бандл и оба манифеста — см. `scripts/golden-acceptance.mjs`. + * + * Проверка идёт ДО делегирования: `accept.mjs` копирует картинки целым набором, + * и запрет обязан сработать раньше, чем каталог эталонов будет тронут. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { goldenAcceptanceRefusal, goldenSilentDeclarations } from './golden-acceptance.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const argv = process.argv.slice(2); +const value = (name) => { + const found = argv.find((item) => item.startsWith(`--${name}=`)); + return found ? found.slice(name.length + 3) : ''; +}; + +if (!argv.includes('--reviewed')) { + console.error('приёмка требует явного --reviewed'); + process.exit(2); +} +const from = resolve(value('from') || resolve(ROOT, 'artifacts/golden')); +const declared = value('expect-change').split(',').map((id) => id.trim()).filter(Boolean); + +const reportPath = resolve(from, 'golden-report.json'); +if (!existsSync(reportPath)) { + console.error(`отчёт кандидатов не найден: ${reportPath}`); + process.exit(2); +} +const report = JSON.parse(readFileSync(reportPath, 'utf8')); + +const refusal = goldenAcceptanceRefusal(report.results, declared); +if (refusal) { + console.error(refusal); + process.exit(1); +} + +const silent = goldenSilentDeclarations(report.results, declared); +if (silent.length) { + console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`); +} +const changed = (report.results || []).filter((result) => result.status !== 'passed') + .map((result) => `${result.id} (${result.status})`).sort(); +console.log(changed.length + ? `Будут заменены: ${changed.join(', ')}.` + : 'Ни один эталон не изменился — будет перезаписан только манифест.'); +console.log(`Съёмка: chromium ${report.chromium || '?'}, матрица ${report.matrixVersion}.`); + +const accept = spawnSync(process.execPath, [ + resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, +], { cwd: ROOT, stdio: 'inherit' }); +process.exit(accept.status ?? 1); diff --git a/scripts/golden-acceptance.mjs b/scripts/golden-acceptance.mjs new file mode 100644 index 00000000..21516b16 --- /dev/null +++ b/scripts/golden-acceptance.mjs @@ -0,0 +1,59 @@ +/** + * Правило допустимости съёмки golden-кандидатов (#334). + * + * Прежде эталон принимался только из артефакта CI: растеризация шрифтов на + * другой машине может отличаться, а доказать обратное было нечем. Цена — + * двойной roundtrip на каждый визуальный фикс: пуш, семь минут CI, скачивание + * артефакта, приёмка, второй пуш, второй полный прогон. При версии матрицы 48 + * это платится регулярно. + * + * Доказательство есть, и оно эмпирическое: среда съёмки равна раннеру, если + * КАЖДАЯ сцена, которую менять не собирались, совпала со своим принятым + * эталоном. Расхождение растеризации нельзя спрятать — оно задевает все сцены + * с текстом, а не только правленные. Поэтому ревьюер объявляет намерение + * списком `--expect-change`, и всё, что разошлось помимо списка, приёмку + * запрещает: либо это регрессия рендера, либо среда не та. + * + * То же правило ловит другое, независимо от среды: «принять всё, чтобы CI + * позеленел». Именно так эталон перестаёт быть эталоном — молча, одной + * командой, без единого названного намерения. + * + * `missing-baseline` объявления не требует: у новой сцены нет эталона, которому + * она могла бы противоречить. + * + * Почему это лежит в `scripts/`, а не рядом с `accept.mjs`. Отпечаток + * `sourceFingerprint` включает ВСЕ `.mjs` из `demo/golden`, включая + * `accept.mjs` и `policy.mjs`, которые исполняются после того, как картинка уже + * снята, и ни одного пикселя изменить не могут. Правка любого из них объявляет + * устаревшими и закоммиченный бандл, и манифест скриншотов документации, и + * манифест эталонов — то есть требует ровно того двойного цикла, который эта + * задача убирает. Сужение корпуса отпечатка — отдельная задача: оно неизбежно + * требует пересборки бандла, потому что сам `source-fingerprint.mjs` в корпусе. + */ + +export const goldenAcceptanceRefusal = (results, declared = []) => { + if (!Array.isArray(results)) return 'отчёт кандидатов не содержит результатов сцен'; + const expected = new Set(declared.filter(Boolean)); + const known = new Set(results.map((result) => result.id)); + const unknown = [...expected].filter((id) => !known.has(id)); + if (unknown.length) { + return `в --expect-change названы сцены, которых нет в отчёте: ${unknown.sort().join(', ')}`; + } + const undeclared = results + .filter((result) => result.status === 'different' && !expected.has(result.id)) + .map((result) => result.id) + .sort(); + if (undeclared.length) { + return 'съёмка разошлась с принятыми эталонами в сценах, которые менять не собирались:' + + ` ${undeclared.join(', ')}.` + + ' Либо это регрессия рендера, либо среда съёмки не совпадает с раннером —' + + ' в обоих случаях приёмка запрещена. Намеренные сцены перечисляются в' + + ' --expect-change='; + } + return null; +}; + +/** Сцены, объявленные изменёнными, но совпавшие с эталоном: не ошибка, но и не молчание. */ +export const goldenSilentDeclarations = (results, declared = []) => declared + .filter((id) => results.find((result) => result.id === id)?.status === 'passed') + .sort(); diff --git a/scripts/golden-container.mjs b/scripts/golden-container.mjs new file mode 100644 index 00000000..987d5808 --- /dev/null +++ b/scripts/golden-container.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Съёмка golden-кандидатов в пиновом образе Playwright (#334). + * + * node scripts/golden-container.mjs # capture + * node scripts/golden-container.mjs --mode=verify + * node scripts/golden-container.mjs --image=mcr.microsoft.com/playwright:v1.62.0-jammy + * node scripts/golden-container.mjs --dry-run # только напечатать команду + * + * Зачем. Прежде эталон принимался только из артефакта CI, и каждый визуальный + * фикс стоил двух полных прогонов: пуш, ожидание, скачивание артефакта, + * приёмка, второй пуш. Здесь картинки снимаются локально в том же образе, что + * стоит на раннере, — одинаковый Chromium и одинаковые шрифты. + * + * Гарантия при этом не на слове. `accept.mjs` принимает съёмку только если + * разошлись РОВНО объявленные сцены (`--expect-change`), а всё остальное + * совпало с принятыми эталонами. Если растеризация в образе всё-таки другая, + * разойдутся посторонние сцены с текстом — и приёмка будет запрещена с их + * перечислением. Поэтому даже неверно угаданный тег образа не может испортить + * эталоны: он может только не сработать. + * + * Почему node_modules прячется анонимным томом: в репозитории владельца он + * собран под Windows, а `npm ci` внутри контейнера поставил бы туда + * linux-бинарники и сломал бы хозяйское дерево. Том перекрывает каталог, и + * установка живёт только внутри контейнера. + */ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const arg = (name, fallback = '') => { + const found = process.argv.find((item) => item.startsWith(`--${name}=`)); + return found ? found.slice(name.length + 3) : fallback; +}; + +const lock = JSON.parse(readFileSync(resolve(ROOT, 'package-lock.json'), 'utf8')); +const pinned = lock.packages?.['node_modules/playwright']?.version; +if (!pinned) throw new Error('в package-lock.json не найдена залоченная версия playwright'); + +const image = arg('image', `mcr.microsoft.com/playwright:v${pinned}`); +const mode = arg('mode', 'capture'); +if (!['capture', 'verify'].includes(mode)) throw new Error(`неизвестный режим: ${mode}`); +const dryRun = process.argv.includes('--dry-run'); + +// `CI=` пустой намеренно: отчёт должен честно говорить, что съёмка локальная. +const inner = [ + 'set -eu', + 'npm ci --no-audit --no-fund', + 'npm run bundle:sync', + `npm run golden:${mode}`, +].join(' && '); + +const args = [ + 'run', '--rm', '--ipc=host', + '-v', `${ROOT}:/work`, + // Анонимный том перекрывает хозяйский node_modules — см. комментарий выше. + '-v', '/work/node_modules', + '-w', '/work', + '-e', 'CI=', + image, 'bash', '-lc', inner, +]; + +console.log(`Залоченный playwright: ${pinned}`); +console.log(`Образ: ${image}`); +console.log(`docker ${args.slice(0, -1).join(' ')} '${inner}'`); +if (dryRun) process.exit(0); + +const docker = spawnSync('docker', ['--version'], { encoding: 'utf8' }); +if (docker.status !== 0) { + console.error('docker недоступен. Съёмка возможна и без контейнера — в любой Linux-среде,' + + ' включая WSL: правило приёмки одинаково и само отвергнет съёмку, если растеризация' + + ' разойдётся с раннером. См. demo/golden/README.md.'); + process.exit(2); +} + +const run = spawnSync('docker', args, { stdio: 'inherit' }); +if (run.status !== 0) { + console.error(`\nСъёмка в контейнере не удалась (код ${run.status}).` + + ' Если docker не смог получить образ — укажите тег дистрибутива:' + + ` --image=mcr.microsoft.com/playwright:v${pinned}-jammy`); +} +process.exit(run.status ?? 1); diff --git a/test/golden-policy.test.mjs b/test/golden-policy.test.mjs index 86a08f1d..aa66c2e3 100644 --- a/test/golden-policy.test.mjs +++ b/test/golden-policy.test.mjs @@ -6,6 +6,9 @@ import { goldenRunFailed, goldenScenarioSetsMatch, } from '../demo/golden/policy.mjs'; +import { + goldenAcceptanceRefusal, goldenSilentDeclarations, +} from '../scripts/golden-acceptance.mjs'; test('golden metadata cannot be mistaken for a Home Assistant integration manifest', () => { assert.equal(GOLDEN_BASELINE_MANIFEST, 'baselines-index.json'); @@ -36,3 +39,52 @@ test('golden baseline inventory rejects orphan hashes and PNGs', () => { assert.equal(goldenScenarioSetsMatch(['a'], ['a'], ['a', 'orphan']), false); assert.equal(goldenScenarioSetsMatch(['a', 'b'], ['a'], ['a', 'b']), false); }); + +// #334. Локальная съёмка допустима не по доверию, а по доказательству: среда +// равна раннеру, если всё, что менять не собирались, совпало с эталонами. +const results = (entries) => entries.map(([id, status]) => ({ id, status })); + +test('приёмка отвергает расхождение в сцене, которую менять не собирались (#334)', () => { + const refusal = goldenAcceptanceRefusal( + results([['a', 'different'], ['b', 'different'], ['c', 'passed']]), ['a'], + ); + assert.match(refusal, /^съёмка разошлась/); + assert.match(refusal, / b\./); + // Объявленная сцена в перечислении не появляется — иначе сообщение + // указывало бы на автора вместо среды. + assert.equal(/ a[,.]/.test(refusal), false); +}); + +test('приёмка проходит, когда разошлись ровно объявленные сцены (#334)', () => { + assert.equal(goldenAcceptanceRefusal( + results([['a', 'different'], ['b', 'passed'], ['c', 'missing-baseline']]), ['a'], + ), null); +}); + +test('новая сцена объявления не требует: эталона, которому противоречить, нет (#334)', () => { + assert.equal(goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), []), null); +}); + +test('приёмка без объявлений запрещает «принять всё, чтобы CI позеленел» (#334)', () => { + // Ровно то, чем эталон перестаёт быть эталоном: одна команда, три подмены, + // ни одного названного намерения. + const refusal = goldenAcceptanceRefusal( + results([['a', 'different'], ['b', 'different'], ['c', 'different']]), [], + ); + assert.match(refusal, /a, b, c/); +}); + +test('приёмка отвергает объявление сцены, которой нет в отчёте (#334)', () => { + const refusal = goldenAcceptanceRefusal(results([['a', 'passed']]), ['a', 'опечатка']); + assert.match(refusal, /которых нет в отчёте: опечатка/); +}); + +test('приёмка отвергает отчёт без результатов сцен (#334)', () => { + assert.match(goldenAcceptanceRefusal(null, []), /не содержит результатов/); +}); + +test('объявленная, но совпавшая сцена называется, а не проглатывается (#334)', () => { + const list = results([['a', 'passed'], ['b', 'different']]); + assert.deepEqual(goldenSilentDeclarations(list, ['a', 'b']), ['a']); + assert.deepEqual(goldenSilentDeclarations(list, ['b']), []); +});