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
+67
View File
@@ -0,0 +1,67 @@
# Скриншоты документации снимаются здесь и только здесь (#246).
#
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
# целиком. Одно окружение убирает этот шум насовсем.
#
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
name: Docs screenshots
on:
workflow_dispatch:
inputs:
ref:
description: Ветка или SHA, с которого снимать
required: false
default: dev
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
# системные библиотеки Chromium уже в образе раннера.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
- name: Capture
run: node demo/docs/capture.mjs
# Что именно изменилось — видно в логе прогона, до всякой приёмки: если
# изменились все девять файлов, значит съёмка велась не тем браузером, и
# принимать такой набор не надо.
- name: Что изменилось
run: |
git status --porcelain docs/images
echo "--- изменившихся PNG: $(git diff --name-only docs/images | grep -c png || true)"
node -e "const m=require('./docs/images/screenshots.json');console.log('Chromium:',m.chromium)"
- name: Upload candidate
uses: actions/upload-artifact@v7
with:
name: docs-screenshots
path: |
docs/images/*.png
docs/images/screenshots.json
if-no-files-found: error
+8
View File
@@ -555,6 +555,14 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
+4 -69
View File
@@ -7,6 +7,7 @@ import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
@@ -16,75 +17,6 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
@@ -212,6 +144,9 @@ try {
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
+75
View File
@@ -0,0 +1,75 @@
/**
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
* тот скрипт при импорте поднимает браузер и снимает картинки.
*/
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
+1 -1
View File
@@ -251,7 +251,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
expired and is gone.
3. Privacy: legacy real-house plan sources (`assets/`) and screenshots were
removed from the current tree. Public documentation images are generated
from synthetic fixtures by `npm run build && node demo/docs/capture.mjs` and indexed in
from synthetic fixtures by the `Docs screenshots` workflow, accepted with `npm run docs:accept -- --reviewed`, and indexed in
`docs/images/screenshots.json`. Old images persist in git history and release
archives; history rewrite is deliberately not done because it would break
release tags and HACS installs.
+12 -12
View File
@@ -1,8 +1,8 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"captureScriptSha256": "97224705298d164ba0b2bc52dcd3bc9cf9e620057705816106f8cdfe6e20503e",
"sourceFingerprint": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
"view-desktop": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "f8cba93960e94371a22c6c1c8451cbaac0742fd0f50ae2050e5cd99bcf95adc4"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "d5f67be890d6ad6bd810e202c26d922154476bc962fc017f0f95fa4455ef7fe2"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "f3550b27039f627db6b81eb661f9ff2d7cff128eca5fa79ecfeac23d1c0f5382",
"sourceSha256": "5228b9f74d8106c7698499d03eadd2f7ea26425c8604d2fec6ed276b61d7ddcf",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+1
View File
@@ -9,6 +9,7 @@
"watch": "rollup -c --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"docs:accept": "node scripts/docs-accept.mjs",
"smokes:select": "node scripts/smoke-select.mjs",
"inventory": "node scripts/inventory.mjs",
"audit:config": "node scripts/config-audit.mjs",
+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)
));
};
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createHash } from 'node:crypto';
import { verifyDocsCandidate } from '../scripts/docs-accept.mjs';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
// Приёмка — единственное место, где картинки попадают в репозиторий, поэтому
// проверяется не «работает ли она», а от чего именно отказывается. Половина
// принятого набора хуже непринятого: на плане окажется картинка от одного
// дерева рядом с манифестом от другого.
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const FINGERPRINT = 'f'.repeat(64);
const SCRIPT_SHA = 'a'.repeat(64);
const bytesOf = (id) => Buffer.from(`картинка ${id}`);
const candidate = (overrides = {}) => {
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
scenarios[scenario.id] = {
file: scenario.file,
viewport: scenario.viewport,
theme: scenario.theme,
language: scenario.language,
sourceSha256: FINGERPRINT,
imageSha256: sha256(bytesOf(scenario.id)),
};
}
return {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
chromium: 'Chromium 151.0.7922.34',
sourceFingerprint: FINGERPRINT,
captureScriptSha256: SCRIPT_SHA,
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
...overrides,
};
};
const idOf = (path) => {
const file = String(path).split('/').pop();
return DOC_SCREENSHOTS.find((scenario) => scenario.file === file)?.id;
};
const verify = (manifest, { missing = null } = {}) => verifyDocsCandidate({
root: '/repo',
from: '/artifact',
manifest,
expectedFingerprint: FINGERPRINT,
captureScript: SCRIPT_SHA,
exists: (path) => idOf(path) !== missing,
readBytes: (path) => bytesOf(idOf(path)),
});
test('полный корректный кандидат принимается целиком (#246)', () => {
const plan = verify(candidate());
assert.equal(plan.files.length, DOC_SCREENSHOTS.length);
for (const file of plan.files) {
assert.match(file.to, /docs[\\/]+images[\\/]+\d\d[\w-]*\.png$/);
}
});
test('кандидат с другого дерева не принимается (#246)', () => {
assert.throws(() => verify(candidate({ sourceFingerprint: 'b'.repeat(64) })),
/не с текущего дерева/);
});
test('кандидат, снятый другой версией капчура, не принимается (#246)', () => {
assert.throws(() => verify(candidate({ captureScriptSha256: 'c'.repeat(64) })),
/другой версией/);
});
test('кандидат без названного Chromium не принимается (#246)', () => {
// Именно смена браузера переписывает все картинки без содержательных
// изменений, поэтому окружение съёмки — часть доказательства.
assert.throws(() => verify(candidate({ chromium: ' ' })), /Chromium/);
const noField = candidate();
delete noField.chromium;
assert.throws(() => verify(noField), /Chromium/);
});
test('неполный набор сценариев не принимается (#246)', () => {
const partial = candidate();
delete partial.scenarios[DOC_SCREENSHOTS[0].id];
assert.throws(() => verify(partial), /набор сценариев неполный/);
});
test('отсутствующий в артефакте файл не принимается (#246)', () => {
const id = DOC_SCREENSHOTS[2].id;
assert.throws(() => verify(candidate(), { missing: id }), new RegExp(`${id}: в артефакте нет`));
});
test('подменённый после съёмки файл не принимается (#246)', () => {
const tampered = candidate();
tampered.scenarios[DOC_SCREENSHOTS[1].id].imageSha256 = 'd'.repeat(64);
assert.throws(() => verify(tampered), /изменился после съёмки/);
});
test('сценарий с чужим отпечатком не принимается (#246)', () => {
const mixed = candidate();
mixed.scenarios[DOC_SCREENSHOTS[3].id].sourceSha256 = 'e'.repeat(64);
assert.throws(() => verify(mixed), /отпечаток сценария не совпадает/);
});
test('кандидат не на синтетической фикстуре не принимается (#246)', () => {
assert.throws(() => verify(candidate({ fixture: 'live' })), /синтетическую фикстуру/);
});
test('кандидат чужой версии манифеста не принимается (#246)', () => {
assert.throws(() => verify(candidate({ version: DOC_SCREENSHOT_VERSION + 1 })),
/версии/);
});
+22
View File
@@ -113,3 +113,25 @@ test('отпечаток скриншотов не слепнет к правк
rmSync(directory, { recursive: true, force: true });
}
});
test('npm-скрипт не требует пересъёмки, а версия зависимости требует (#246)', () => {
const directory = releaseFixture();
try {
const before = { bundle: sourceFingerprint(directory), visual: visualFingerprint(directory) };
const packagePath = resolve(directory, 'package.json');
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
parsed.scripts = { 'docs:accept': 'node scripts/docs-accept.mjs' };
writeFileSync(packagePath, `${JSON.stringify(parsed)}\n`, 'utf8');
assert.notEqual(sourceFingerprint(directory), before.bundle,
'сборка читает package.json целиком — её отпечаток обязан ехать');
assert.equal(visualFingerprint(directory), before.visual,
'добавление npm-скрипта не меняет ни одного пикселя');
parsed.dependencies.lit = '3.2.0';
writeFileSync(packagePath, `${JSON.stringify(parsed)}\n`, 'utf8');
assert.notEqual(visualFingerprint(directory), before.visual,
'версия зависимости рендер меняет и пересъёмку требует');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});