test: локальная съёмка golden допустима по доказательству, а не по доверию

Прежде эталон принимался только из артефакта CI: растеризация шрифтов на другой
машине может отличаться, а доказать обратное было нечем. Цена — два полных
прогона на каждый визуальный фикс, при версии матрицы 48 она платится часто.

Доказательство теперь эмпирическое: среда равна раннеру, если каждая сцена,
которую менять не собирались, совпала со своим эталоном. Расхождение
растеризации спрятать нельзя — оно задевает все сцены с текстом. Ревьюер
объявляет намерение через --expect-change, всё разошедшееся помимо списка
приёмку запрещает. Поэтому неверно угаданный тег образа не может испортить
эталоны: он может только не сработать.

То же правило независимо от среды запрещает «принять всё, чтобы CI позеленел» —
именно так эталон перестаёт быть эталоном, молча и одной командой.

scripts/golden-container.mjs снимает кандидатов в образе Playwright той же
версии, что залочена в package-lock. Хозяйский node_modules прячется анонимным
томом: он собран под Windows, и npm ci внутри контейнера сломал бы дерево.

Обёртка, а не правка demo/golden/accept.mjs, — намеренно. sourceFingerprint
включает ВСЕ .mjs из demo/golden, включая accept.mjs и policy.mjs, которые
исполняются после съёмки и ни одного пикселя изменить не могут. Их правка
объявляет устаревшими бандл и оба манифеста, то есть требует ровно того двойного
цикла, который эта задача убирает. Сужение корпуса отпечатка — отдельная задача:
сам source-fingerprint.mjs в корпусе, и одна пересборка бандла неизбежна.

Issue: #334
User-Visible: no
This commit is contained in:
Claude
2026-08-28 01:59:45 +03:00
parent 2c20f2dc35
commit 4800c44e51
5 changed files with 308 additions and 5 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Приёмка эталонов с объявлением намерения (#334).
*
* node scripts/golden-accept.mjs --reviewed --expect-change=<id,id>
* node scripts/golden-accept.mjs --reviewed --expect-change=<id> --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);
+59
View File
@@ -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=<id,id>';
}
return null;
};
/** Сцены, объявленные изменёнными, но совпавшие с эталоном: не ошибка, но и не молчание. */
export const goldenSilentDeclarations = (results, declared = []) => declared
.filter((id) => results.find((result) => result.id === id)?.status === 'passed')
.sort();
+84
View File
@@ -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);