Files
houseplan-card/scripts/docs-accept.mjs
T
Codex b7d8b9e6ee test: displayed version through a seam; docs:accept --identical for pixel-identical screenshots
Golden frames carried the card version text (about, version banner,
support/export previews), so every beta bump re-accepted up to 20
baselines that had not visually changed. The version now reaches the DOM
and stand requests through displayVersion() (src/card-version.ts); the
golden harness pins window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'
before the card is created. CARD_VERSION literals stay where the release
contract reads them; cache-busting and the console banner keep the literal.

Docs screenshots: `npm run docs:accept -- --identical` re-captures
locally, compares decoded RGBA pixels with the committed frames inside
Chromium (scripts/png-identical.mjs) and, only when every frame is
identical, refreshes the manifest fingerprints and captureScriptSha256;
bytes stay, any difference refuses with a per-frame count. First run on
this tree: 11/11 identical, manifest refreshed.

Mutants: version-seam-ignores-override, docs-identical-accepts-any-frame.

Issue: #512
User-Visible: no
2026-09-09 18:53:38 +03:00

336 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Приёмка скриншотов документации (#246, правило среды переписано в #401).
*
* npm run docs:accept -- --reviewed --from=artifacts/docs
* npm run docs:accept -- --reviewed --from=… --expect-change=device-editor
* npm run docs:accept -- --identical # #512: кадры те же попиксельно → только отпечаток
*
* Съёмка в другом окружении даёт байтово разный PNG при том же содержимом
* кадра: сглаживание и хинтинг зависят от шрифтового стека, а не только от
* браузера. Измерено — пересъёмка в #231 изменила два файла из девяти на 7–8
* байт, а набор, приехавший с бетой, все девять целиком.
*
* Раньше отсюда следовало правило про МЕСТО: снимать только в CI. Оно держалось
* на этом комментарии, а не на механизме, и стоило прогона workflow даже там,
* где пиксель измениться не мог (#390).
*
* Теперь правило про ДОКАЗАТЕЛЬСТВО, и оно то же, что у golden с #334: среда
* доказана, если каждый кадр, который менять не собирались, совпал с
* закоммиченным байт-в-байт. Разбор правила и его границ — в
* scripts/docs-acceptance.mjs. Снимать можно где угодно; принять получится
* только оттуда, где кадры воспроизводятся.
*
* Что здесь НЕ делается: коммит. Файлы заменяются, коммит делает человек —
* приёмка не должна быть способом протащить картинки мимо чужих глаз.
*/
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { compareFramePairs, compareInPage } from './png-identical.mjs';
import { portableCommand } from './spawn-portable.mjs';
import { assertCaptureEnvironment, captureEnvironment } from './capture-environment.mjs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
import { docsAcceptancePlan } from './docs-acceptance.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
/**
* Проверить кандидата целиком и вернуть план замены. Ни одного побочного
* эффекта: половина принятого набора хуже непринятого — на плане останется
* картинка от одного дерева рядом с манифестом от другого, и `check-docs`
* покажет ровно одну ошибку вместо девяти.
*
* @returns {{ manifest: object, files: Array<{ from: string, to: string }> }}
*/
export function verifyDocsCandidate({
root = ROOT,
from,
manifest,
readBytes = (path) => readFileSync(path),
exists = (path) => existsSync(path),
expectedFingerprint,
captureScript,
scenarios = DOC_SCREENSHOTS,
version = DOC_SCREENSHOT_VERSION,
} = {}) {
if (!manifest || typeof manifest !== 'object') throw new Error('кандидат без манифеста');
if (manifest.version !== version)
throw new Error(`манифест кандидата версии ${manifest.version}, ожидалась ${version}`);
if (manifest.fixture !== 'synthetic-only')
throw new Error('кандидат не объявляет синтетическую фикстуру: на скриншоты документации '
+ 'не должны попадать чужие данные');
const fingerprint = expectedFingerprint ?? visualFingerprint(root);
if (manifest.sourceFingerprint !== fingerprint)
throw new Error('кандидат снят не с текущего дерева: отпечаток не совпадает');
const scriptSha = captureScript ?? sha256(readBytes(resolve(root, 'demo/docs/capture.mjs')));
if (manifest.captureScriptSha256 !== scriptSha)
throw new Error('кандидат снят другой версией demo/docs/capture.mjs');
// Кто снимал — часть доказательства, а не украшение: именно смена браузера
// и переписывает все девять файлов без содержательных изменений.
if (typeof manifest.chromium !== 'string' || !manifest.chromium.trim())
throw new Error('кандидат не называет свой Chromium');
const ids = Object.keys(manifest.scenarios || {}).sort();
const expected = scenarios.map((scenario) => scenario.id).sort();
if (JSON.stringify(ids) !== JSON.stringify(expected))
throw new Error(`набор сценариев неполный: ${ids.length} против ${expected.length}`);
const files = [];
for (const scenario of scenarios) {
const entry = manifest.scenarios[scenario.id];
if (entry.sourceSha256 !== manifest.sourceFingerprint)
throw new Error(`${scenario.id}: отпечаток сценария не совпадает с манифестом`);
const candidate = resolve(from, entry.file || '');
if (!entry.file || !exists(candidate))
throw new Error(`${scenario.id}: в артефакте нет файла ${entry.file || '(без имени)'}`);
if (sha256(readBytes(candidate)) !== entry.imageSha256)
throw new Error(`${scenario.id}: файл изменился после съёмки`);
files.push({ from: candidate, to: resolve(root, 'docs/images', entry.file) });
}
return { manifest, files };
}
/** Build the manifest written by the CLI without erasing an earlier review trace. */
export function acceptedDocsManifest({
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
platform = null,
}) {
return {
...manifest,
// Провенанс среды приёмки (#455): рядом с версией браузера, чтобы у
// следующего разбора «почему кадры разошлись» была не только догадка.
...(platform ? { acceptedOn: platform } : {}),
acceptance: decision.replace.length
? {
declared: [...decision.replace],
witnesses: decision.witnesses.length,
floor: decision.floor,
...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}),
}
: { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true },
};
}
const list = (argv, name) => argv
.filter((arg) => arg.startsWith(`--${name}=`))
.map((arg) => arg.slice(name.length + 3))
.filter(Boolean);
/**
* Манифест после приёмки «кадры те же попиксельно» (#512): от кандидата —
* только отпечаток исходников (и его копии в сценариях), всё остальное — от
* закоммиченного: байты кадров, их sha, браузер и упаковщик съёмки, след
* приёмки. Это тот же fingerprint-only путь, что у `--reviewed` с
* совпавшими байтами, только доказательство — декодированные пиксели.
*/
export function identicalDocsManifest({ committed, candidate }) {
const scenarios = Object.fromEntries(Object.entries(committed.scenarios).map(([id, entry]) => [
id, { ...entry, sourceSha256: candidate.sourceFingerprint },
]));
return {
...committed,
sourceFingerprint: candidate.sourceFingerprint,
// Сторож capture.mjs (check-docs) тоже берётся из кандидата: правка
// съёмочного скрипта без визуальных изменений принимается здесь же, а не
// красит docs до пересъёмки через артефакт (ревью ТЗ r1, H1).
captureScriptSha256: candidate.captureScriptSha256,
scenarios,
acceptance: { ...(committed.acceptance || {}), lastWriteWasFingerprintOnly: true, identicalPixels: true },
};
}
/**
* Решение по режиму `--identical`: все кадры попиксельно равны → принять
* только отпечаток; иначе — отказ с перечнем.
* @param {Array<{id:string, identical:boolean, differing:number, sizeMismatch:boolean}>} results
*/
export function identicalDecision(results) {
const differing = results.filter((result) => !result.identical);
if (!differing.length) return { accept: true, refusal: '' };
const lines = differing.map((result) => `${result.id}: ${result.sizeMismatch ? 'другой размер' : `${result.differing} px`}`);
return { accept: false, refusal: `кадры отличаются — приёмка только через CI-артефакт и --reviewed:\n${lines.join('\n')}` };
}
/**
* `--identical`: снять кадры локально во временный каталог (сама съёмка пишет
* в docs/images — закоммиченные кадры сохраняются заранее и возвращаются на
* место в любом исходе), декодировать оба набора в Chromium и сравнить
* пиксели. Съёмка и браузер инъектируются ради тестов.
*/
export async function acceptIdentical({
root = ROOT,
capture = () => {
const npm = portableCommand('npm');
const build = spawnSync(npm.cmd, ['run', '-s', 'build'], { cwd: root, encoding: 'utf8', stdio: 'inherit', shell: npm.shell });
if (build.status !== 0) throw new Error('сборка бандла перед съёмкой не удалась');
const shot = spawnSync(process.execPath, ['demo/docs/capture.mjs'], { cwd: root, encoding: 'utf8', stdio: 'inherit' });
if (shot.status !== 0) throw new Error('локальная съёмка не удалась');
},
compare = async (pairs) => {
const { chromium } = await import('playwright');
const browser = await chromium.launch({ args: ['--no-sandbox'] });
try {
const page = await browser.newPage();
return await compareFramePairs(pairs, (committed, candidate) => compareInPage(page, committed, candidate));
} finally {
await browser.close();
}
},
log = (line) => console.log(line),
} = {}) {
const images = resolve(root, 'docs/images');
const manifestPath = resolve(images, 'screenshots.json');
const committedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const backup = mkdtempSync(resolve(tmpdir(), 'hp-docs-identical-'));
const committedBytes = new Map();
for (const scenario of DOC_SCREENSHOTS) {
const file = committedManifest.scenarios[scenario.id]?.file;
if (!file) throw new Error(`${scenario.id}: в закоммиченном манифесте нет файла`);
committedBytes.set(scenario.id, readFileSync(resolve(images, file)));
copyFileSync(resolve(images, file), resolve(backup, file));
}
copyFileSync(manifestPath, resolve(backup, 'screenshots.json'));
const restoreFrames = () => {
for (const scenario of DOC_SCREENSHOTS) {
const file = committedManifest.scenarios[scenario.id].file;
copyFileSync(resolve(backup, file), resolve(images, file));
}
};
try {
capture();
const candidateManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const pairs = DOC_SCREENSHOTS.map((scenario) => ({
id: scenario.id,
committed: committedBytes.get(scenario.id),
candidate: readFileSync(resolve(images, candidateManifest.scenarios[scenario.id].file)),
}));
const results = await compare(pairs);
const decision = identicalDecision(results);
// Кадры на диске — всегда закоммиченные: приёмка касается только манифеста.
restoreFrames();
if (!decision.accept) {
copyFileSync(resolve(backup, 'screenshots.json'), manifestPath);
log(`отказ: ${decision.refusal}`);
return 1;
}
const accepted = identicalDocsManifest({ committed: committedManifest, candidate: candidateManifest });
writeFileSync(manifestPath, `${JSON.stringify(accepted, null, 2)}\n`, 'utf8');
log(`Все ${results.length} кадров попиксельно совпали с закоммиченными: принят только отпечаток исходников`
+ ` (${candidateManifest.sourceFingerprint.slice(0, 8)}). Коммит — за вами.`);
return 0;
} catch (error) {
restoreFrames();
copyFileSync(resolve(backup, 'screenshots.json'), manifestPath);
throw error;
} finally {
rmSync(backup, { recursive: true, force: true });
}
}
async function main(argv) {
if (argv.includes('--identical')) {
if (argv.some((arg) => arg.startsWith('--from=') || arg === '--reviewed')) {
console.error('отказ: --identical не сочетается с --from/--reviewed — это локальная проверка, а не приёмка артефакта');
return 2;
}
return acceptIdentical();
}
// Среда приёмки (#455). Платформу съёмки манифест не несёт и не может:
// добавить поле — значит править `demo/docs/capture.mjs`, чей sha записан в
// индексе скриншотов, то есть платить пересъёмкой десяти картинок за
// проверку. Приёмка идёт там, где лежат артефакты, поэтому её платформа —
// достаточный признак среды кадров.
const allowed = assertCaptureEnvironment({ kind: 'docs', stage: 'accept' });
if (allowed) console.log(`Чужая среда приёмки разрешена осознанно: ${allowed}`);
if (!argv.includes('--reviewed')) {
console.error('отказ: замена скриншотов без явного --reviewed');
return 2;
}
const fromArg = argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/docs'));
const manifestPath = resolve(from, 'screenshots.json');
if (!existsSync(manifestPath)) {
console.error(`манифест кандидата не найден: ${manifestPath}`);
return 2;
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const plan = verifyDocsCandidate({ root: ROOT, from, manifest });
// Хеши закоммиченного считаются по файлам на диске, а не по закоммиченному
// манифесту: манифест — утверждение о файлах, а сравнивать надо сами файлы.
const ids = DOC_SCREENSHOTS.map((scenario) => scenario.id);
const committed = {};
const candidate = {};
for (const scenario of DOC_SCREENSHOTS) {
const entry = manifest.scenarios[scenario.id];
candidate[scenario.id] = entry.imageSha256;
const onDisk = resolve(ROOT, 'docs/images', entry.file);
if (existsSync(onDisk)) committed[scenario.id] = sha256(readFileSync(onDisk));
}
const declared = list(argv, 'expect-change');
const skipWitnesses = argv.includes('--no-witnesses');
const skipReason = (list(argv, 'reason')[0] || '').trim();
const decision = docsAcceptancePlan({
ids, committed, candidate, declared, skipWitnesses, skipReason,
});
if (decision.refusal) {
console.error(`отказ: ${decision.refusal}`);
return 1;
}
const byId = new Map(plan.files.map((file, index) => [ids[index], file]));
for (const id of decision.replace) {
const file = byId.get(id);
copyFileSync(file.from, file.to);
}
// След приёмки отвечает на вопрос «когда эти пиксели приняли и что тогда
// объявляли». Обновление отпечатка пикселей не меняет — значит и стирать
// ответ не должно (#409, Low из #405): прежняя редакция затирала `declared`
// пустым списком, и история терялась при первом же refresh.
const previous = JSON.parse(readFileSync(resolve(ROOT, 'docs/images/screenshots.json'), 'utf8'))
.acceptance;
const accepted = acceptedDocsManifest({
manifest,
previousAcceptance: previous,
decision,
platform: captureEnvironment().platform,
skipWitnesses,
skipReason,
});
writeFileSync(
resolve(ROOT, 'docs/images/screenshots.json'),
`${JSON.stringify(accepted, null, 2)}\n`,
'utf8',
);
if (!decision.replace.length) {
console.log('Кадры не менялись: принят только манифест'
+ ` (отпечаток исходников ${manifest.sourceFingerprint.slice(0, 8)}).`);
} else {
console.log(`Принято кадров: ${decision.replace.length}`
+ ` (${decision.replace.join(', ')}), снято ${manifest.chromium}.`);
}
console.log(`Сохранено без изменений: ${decision.keep.length}.`);
if (skipWitnesses) {
console.log(`Свидетели пропущены осознанно: ${skipReason}`);
} else {
console.log(`Кадров-свидетелей среды: ${decision.witnesses.length} (порог ${decision.floor}).`);
}
console.log('Коммит — за вами: приёмка ничего не коммитит.');
return 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
console.error(`отказ: ${error.message}`);
process.exit(1);
});
}