ci: capture documentation screenshots in one place

Issue: #246
User-Visible: no
This commit is contained in:
Matysh
2026-08-22 19:12:27 +03:00
parent 4799c6be0c
commit e50c01245a
13 changed files with 482 additions and 91 deletions
+4 -5
View File
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -11,11 +12,9 @@ const PUBLIC_DOCS = [
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
];
const EXPECTED_SCREENSHOTS = [
'view-desktop', 'view-touch', 'space-create', 'room-contour-close',
'plan-context-tray', 'device-editor', 'device-display-preview', 'background-editor',
'room-card', 'device-info',
];
// Каталог один и живёт рядом с капчуром (#246): третья копия списка сценариев
// расходилась бы с ним молча.
const EXPECTED_SCREENSHOTS = DOC_SCREENSHOTS.map((scenario) => scenario.id);
const errors = [];
const warnings = [];
const externalUrls = new Set();
+117
View File
@@ -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);
}
}
+25
View File
@@ -122,6 +122,31 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'docs-accept-takes-any-chromium',
guard: 'node --test --test-name-pattern="без названного Chromium" test/docs-accept.test.mjs',
because: 'набор, снятый другим браузером, переписывает все десять картинок без единого '
+ 'содержательного изменения, поэтому окружение съёмки — часть доказательства, а не '
+ 'украшение манифеста (#246)',
patches: [{
file: 'scripts/docs-accept.mjs',
find: " if (typeof manifest.chromium !== 'string' || !manifest.chromium.trim())",
replace: ' if (false)',
}],
},
{
id: 'docs-accept-copies-before-checking',
guard: 'node --test --test-name-pattern="отсутствующий в артефакте файл" '
+ 'test/docs-accept.test.mjs',
because: 'половина принятого набора хуже непринятого: на плане окажется картинка от одного '
+ 'дерева рядом с манифестом от другого, и check-docs покажет одну ошибку вместо десяти '
+ '(#246)',
patches: [{
file: 'scripts/docs-accept.mjs',
find: " throw new Error(`${scenario.id}: в артефакте нет файла ${entry.file || '(без имени)'}`);",
replace: ' continue;',
}],
},
{
id: 'docs-fingerprint-sees-product-version',
guard: 'node --test --test-name-pattern="не трогает отпечаток скриншотов" '
+32 -4
View File
@@ -36,11 +36,12 @@ const fingerprintFiles = (root) => {
const digest = (root, files, normalize) => {
const hash = createHash('sha256');
for (const file of files) {
hash.update(relative(root, file).replaceAll('\\', '/'));
const name = relative(root, file).replaceAll('\\', '/');
hash.update(name);
hash.update('\0');
// Git-canonical text, independent of core.autocrlf. Otherwise the injected
// hash would make an otherwise identical Windows/Linux bundle differ.
hash.update(normalize(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n')));
hash.update(normalize(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n'), name));
hash.update('\0');
}
return hash.digest('hex');
@@ -50,6 +51,29 @@ const digest = (root, files, normalize) => {
export const sourceFingerprint = (root = process.cwd()) =>
digest(root, fingerprintFiles(root), (text) => text);
/**
* Поля `package.json`, способные изменить картинку. Всё остальное в этом файле —
* имя, версия, описание, npm-скрипты — на рендер не влияет ни при каких
* обстоятельствах, а требовать из-за них пересъёмки десяти PNG по 300 КБ
* нечестно ровно так же, как из-за номера версии (#245, #246).
*/
const VISUAL_PACKAGE_FIELDS = ['dependencies', 'devDependencies', 'overrides', 'browserslist'];
const visualPackageProjection = (text) => {
try {
const parsed = JSON.parse(text);
const projection = {};
for (const field of VISUAL_PACKAGE_FIELDS) {
if (parsed[field] !== undefined) projection[field] = parsed[field];
}
return JSON.stringify(projection);
} catch {
// Сломанный package.json — не повод молча считать отпечаток по проекции:
// пусть он поедет, и пересъёмка потребуется.
return text;
}
};
/** Номер версии продукта, как его знает package.json. */
const productVersion = (root) => {
const path = resolve(root, 'package.json');
@@ -81,8 +105,12 @@ const productVersion = (root) => {
*/
export const visualFingerprint = (root = process.cwd()) => {
const version = productVersion(root);
const normalize = version
const withoutVersion = version
? (text) => text.split(version).join('0.0.0-product-version')
: (text) => text;
return digest(root, fingerprintFiles(root), normalize);
return digest(root, fingerprintFiles(root), (text, name) => (
name === 'package.json'
? visualPackageProjection(withoutVersion(text))
: withoutVersion(text)
));
};