mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
+51
-5
@@ -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=<id,id>`. 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=<the scenarios you changed>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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']), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user