mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
ci: capture documentation screenshots in one place
Issue: #246 User-Visible: no
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user