Files
houseplan-card/demo/golden/accept.mjs
T
Matysh cd0ecf4db2 fix: the witness floor comes from the matrix, not from surviving baselines
Порог свидетелей считался от числа сцен со статусом не missing-baseline, то
есть от эталонов, уцелевших на диске. Обход в одну команду: git rm
demo/golden/baselines/*.png — все сцены становятся missing-baseline, порог
обращается в ноль, свидетелей никто не требует, и чужая съёмка всей матрицы
принимается без единого следа причины в манифесте. Отказ
goldenAcceptanceRefusal этого не ловит: он требует объявить каждую новую сцену
в --expect-new, а объявить их все ничто не мешает.

Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей иметь не
может. Верно по факту и неверно по выводу: невозможность доказать среду не
отменяет требования, она требует сказать это вслух. Теперь и первичная съёмка
идёт через --no-witnesses --reason, а причина уезжает в манифест эталонов.

Размер матрицы стал обязательным параметром, а не выводится из отчёта: у
частичного прогона (run.mjs --only=…) results короче матрицы, и порог просел
бы молча — тот же дефект в другой одежде. Отсутствие параметра — отказ.

Формула не менялась: она общая с docsWitnessFloor и обязана такой остаться.
Менялся источник счётчика. На обычной приёмке ничего не меняется: при 143
эталонах порог был и остался 10.

Issue: #408
User-Visible: no
2026-09-01 17:25:54 +03:00

124 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
import {
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
goldenWitnessRefusal,
} from '../../scripts/golden-acceptance.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
const list = (name) => {
const found = process.argv.find((arg) => arg.startsWith(`--${name}=`));
return (found ? found.slice(name.length + 3) : '')
.split(',').map((id) => id.trim()).filter(Boolean);
};
const declared = list('expect-change');
const declaredNew = list('expect-new');
const skipWitnesses = process.argv.includes('--no-witnesses');
const reasonArg = process.argv.find((arg) => arg.startsWith('--reason='));
const skipReason = reasonArg ? reasonArg.slice('--reason='.length) : '';
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
if (refusal) throw new Error(refusal);
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
/**
* Прежний индекс: источник хешей для сцен, которые остаются как были (#351).
*
* `passed` не значит «байт в байт» — он значит «в пределах порога». Прежняя
* версия копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый
* дрейф уезжал в контракт молча, и накапливался: каждая приёмка подтягивала
* эталон к последней среде, порог не пересекался никогда, а эталон уходил.
* Так `1e341c60` заменил 22 картинки, объявив четыре. Владелец делал эту работу
* руками (`ad3f9981`: «nine unrelated baselines … were restored to their
* reviewed versions»); теперь её делает инструмент.
*/
const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
const previous = existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
: {};
// #355: floor свидетелей — необъявленные сцены, совпавшие с эталоном
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
const witnessCheck = goldenWitnessRefusal({
results: report.results,
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
// обходится удалением каталога эталонов.
sceneCount: GOLDEN_SCENARIOS.length,
declared,
declaredNew,
previousHashes: previous,
skipWitnesses,
skipReason,
});
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
// не имеет права оставить каталог эталонов половинным.
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const digest = createHash('sha256').update(readFileSync(candidate)).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
}
const plan = goldenAcceptancePlan({
scenarioIds: GOLDEN_SCENARIOS.map((scenario) => scenario.id),
results: report.results,
previousHashes: previous,
declared,
declaredNew,
});
const hashes = plan.hashes;
for (const id of plan.replace) {
copyFileSync(resolve(from, 'actual', `${id}.png`), resolve(baselineRoot, `${id}.png`));
}
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
// #355: след приёмки в артефакте, не только в истории shell.
witnesses: skipWitnesses
? { skipped: true, reason: skipReason }
: { count: witnessCheck.witnesses.length, floor: witnessCheck.floor },
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
const silent = goldenSilentDeclarations(report.results, declared);
if (silent.length) {
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
}
console.log(`Заменено эталонов: ${plan.replace.length}`
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
if (skipWitnesses) {
console.log(`Свидетели пропущены осознанно (--no-witnesses): ${skipReason}`);
} else {
console.log(`Свидетелей среды: ${witnessCheck.witnesses.length} (floor ${witnessCheck.floor}).`);
}