From e50c01245a1fae238cfc20f5f43bb25130e47e9b Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 19:12:27 +0300 Subject: [PATCH] ci: capture documentation screenshots in one place Issue: #246 User-Visible: no --- .github/workflows/docs-screenshots.yml | 67 ++++++++++++++ PROCESS.md | 8 ++ demo/docs/capture.mjs | 73 +-------------- demo/docs/screenshots.mjs | 75 ++++++++++++++++ docs/STATUS.md | 2 +- docs/images/screenshots.json | 24 ++--- package.json | 1 + scripts/check-docs.mjs | 9 +- scripts/docs-accept.mjs | 117 +++++++++++++++++++++++++ scripts/mutation-gate.mjs | 25 ++++++ scripts/source-fingerprint.mjs | 36 +++++++- test/docs-accept.test.mjs | 114 ++++++++++++++++++++++++ test/source-fingerprint.test.mjs | 22 +++++ 13 files changed, 482 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/docs-screenshots.yml create mode 100644 demo/docs/screenshots.mjs create mode 100644 scripts/docs-accept.mjs create mode 100644 test/docs-accept.test.mjs diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml new file mode 100644 index 00000000..7e1be872 --- /dev/null +++ b/.github/workflows/docs-screenshots.yml @@ -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 diff --git a/PROCESS.md b/PROCESS.md index d8676c61..f14fbde1 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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 diff --git a/demo/docs/capture.mjs b/demo/docs/capture.mjs index 414fc39c..da8762eb 100644 --- a/demo/docs/capture.mjs +++ b/demo/docs/capture.mjs @@ -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', diff --git a/demo/docs/screenshots.mjs b/demo/docs/screenshots.mjs new file mode 100644 index 00000000..8b12d817 --- /dev/null +++ b/demo/docs/screenshots.mjs @@ -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, + }, +]); diff --git a/docs/STATUS.md b/docs/STATUS.md index 46c7237b..202521cd 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json index 00a9e583..29a103ce 100644 --- a/docs/images/screenshots.json +++ b/docs/images/screenshots.json @@ -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" } } diff --git a/package.json b/package.json index 524f05ff..db52943b 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 2ca1a65e..567cca23 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -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(); diff --git a/scripts/docs-accept.mjs b/scripts/docs-accept.mjs new file mode 100644 index 00000000..abde8d47 --- /dev/null +++ b/scripts/docs-accept.mjs @@ -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); + } +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 1709a4ed..af346830 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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="не трогает отпечаток скриншотов" ' diff --git a/scripts/source-fingerprint.mjs b/scripts/source-fingerprint.mjs index 9b6e3a20..8b55be00 100644 --- a/scripts/source-fingerprint.mjs +++ b/scripts/source-fingerprint.mjs @@ -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) + )); }; diff --git a/test/docs-accept.test.mjs b/test/docs-accept.test.mjs new file mode 100644 index 00000000..6775aa39 --- /dev/null +++ b/test/docs-accept.test.mjs @@ -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 })), + /версии/); +}); diff --git a/test/source-fingerprint.test.mjs b/test/source-fingerprint.test.mjs index 5fc8f126..54163469 100644 --- a/test/source-fingerprint.test.mjs +++ b/test/source-fingerprint.test.mjs @@ -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 }); + } +});