mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
336 lines
18 KiB
JavaScript
336 lines
18 KiB
JavaScript
#!/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);
|
||
});
|
||
}
|