Files
houseplan-card/scripts/docs-accept.mjs
T
2026-08-22 19:12:27 +03:00

118 lines
6.4 KiB
JavaScript
Raw 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
/**
* Приёмка скриншотов документации, снятых в CI (#246).
*
* npm run docs:accept -- --reviewed --from=artifacts/docs
*
* Зачем приёмка вообще. Съёмка на машине исполнителя даёт байтово разный PNG
* при одинаковом содержимом кадра: сглаживание и хинтинг зависят от окружения.
* Измерено на истории — пересъёмка в #231 изменила два файла из девяти на 7–8
* байт, а набор, приехавший с бетой, все девять целиком. Поэтому картинки
* рождаются в одном месте (`.github/workflows/docs-screenshots.yml`), а сюда
* приезжают артефактом. Та же конструкция, что у golden-эталонов, и по той же
* причине.
*
* Что здесь НЕ делается: коммит. Файлы заменяются, коммит делает человек —
* приёмка не должна быть способом протащить картинки мимо чужих глаз.
*/
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.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 };
}
function main(argv) {
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 });
for (const file of plan.files) copyFileSync(file.from, file.to);
writeFileSync(
resolve(ROOT, 'docs/images/screenshots.json'),
`${JSON.stringify(plan.manifest, null, 2)}\n`,
'utf8',
);
console.log(`Принято ${plan.files.length} скриншотов, снятых ${plan.manifest.chromium}.`);
console.log('Коммит — за вами: приёмка ничего не коммитит.');
return 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
process.exit(main(process.argv.slice(2)));
} catch (error) {
console.error(`отказ: ${error.message}`);
process.exit(1);
}
}