diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml index a58ecda8..ec4f53b3 100644 --- a/.github/workflows/docs-screenshots.yml +++ b/.github/workflows/docs-screenshots.yml @@ -77,8 +77,17 @@ jobs: "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng" echo "$HOME/.local/bin" >> "$GITHUB_PATH" "$HOME/.local/bin/oxipng" --version - - name: Capture - run: node demo/docs/capture.mjs + # Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер + # (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри + # страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка + # плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы + # всегда. Проверка, объявленная гарантией воспроизводимости, на + # собственном инциденте промолчала бы. + # + # Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в + # артефакт: сравнивать хеши и публиковать разные файлы было бы странно. + - name: "Съёмка воспроизводима между прогонами (#410, #422)" + run: node scripts/capture-determinism.mjs # Вердикт до всякой приёмки. Само число изменившихся файлов ничего не # говорит: набор, снятый другим браузером, меняет их все, и это нормально # ровно один раз — при переходе на канонический прогон. Сравнивать надо @@ -94,8 +103,9 @@ jobs: # правки состояния между ними, обязаны совпасть побайтово. Шаг падает, # если кадр снова начнёт зависеть от времени. # - # Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт, - # снятый недетерминированной съёмкой, нет. + # Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно + # блокирует падением job'а, а снимать набор третий раз ради порядка строк + # в логе незачем. - name: "Кадр не плавает внутри одного состояния (#410)" run: node demo/docs/capture.mjs --stability=3 - name: Хеши кадров diff --git a/demo/docs/capture.mjs b/demo/docs/capture.mjs index c06c2736..e3a45df2 100644 --- a/demo/docs/capture.mjs +++ b/demo/docs/capture.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; // manifest-owned tree before launching Chromium; copying only the stable entry // leaves every content-hashed import at 404. import '../../scripts/bundle-sync.mjs'; +import { wholePixelClip } from './clip.mjs'; import { visualFingerprint } from '../../scripts/source-fingerprint.mjs'; import { assertFreshDemoBundle } from '../bundle-freshness.mjs'; import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs'; @@ -264,27 +265,12 @@ try { const rawClip = scenario.capture === 'room-card' ? await roomCardClip(page) : await goldenClip(page, scenario.capture); - // Обрезка выравнивается по целым пикселям (#410). - // - // Прямоугольник считается из живого DOM через getBoundingClientRect, то есть - // приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и - // тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от - // появившегося скроллбара — переписывает границы всех элементов на единицы - // уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня, - // alpha не тронута, всё на сглаженных границах полей. - // - // Замер показал, где искать: три снимка подряд в одном состоянии страницы - // совпадают побайтово у всех десяти сценариев, а между прогонами плавают - // два. Значит дело не в рендере, а в том, что приходит на вход съёмке. - // - // Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана - // содержать цель целиком, потерять полпикселя по краю нельзя. - const clip = rawClip && { - x: Math.floor(rawClip.x), - y: Math.floor(rawClip.y), - width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x), - height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y), - }; + // Обрезка выравнивается по целым пикселям (#410); почему именно так — + // в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в + // одном состоянии совпадают побайтово у всех десяти сценариев, а между + // прогонами плавают два. Значит дело не в рендере, а в том, что приходит на + // вход съёмке — и проверяется это между прогонами (#422). + const clip = wholePixelClip(rawClip); if (STABILITY_SHOTS && rawClip) { console.log(`${scenario.id} обрезка: сырая` + ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}` diff --git a/demo/docs/clip.mjs b/demo/docs/clip.mjs new file mode 100644 index 00000000..bd7dd0c2 --- /dev/null +++ b/demo/docs/clip.mjs @@ -0,0 +1,29 @@ +/** + * Выравнивание области съёмки по целым пикселям (#410, гейт — #422). + * + * Прямоугольник приходит из живого DOM через `getBoundingClientRect`, то есть + * дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и тогда сдвиг + * раскладки на десятую пикселя — от метрики шрифта, от появившегося + * скроллбара — переписывает границы всех элементов на единицы уровней. Ровно + * эта подпись и была в #410: 76 пикселей, максимум 2 уровня, alpha не тронута, + * всё на сглаженных границах полей. + * + * Внешняя рамка РАСШИРЯЕТСЯ, а не округляется к ближайшему: обрезка обязана + * содержать цель целиком, потерять полпикселя по краю нельзя. Именно поэтому + * здесь `floor` для начала и `ceil` для конца, а не `Math.round` — округление к + * ближайшему срезало бы край на половине входов. + * + * Функция живёт отдельным модулем, потому что она чистая, а `capture.mjs` — + * исполняемый скрипт: импортировать его в тест значит запустить съёмку. + */ +export function wholePixelClip(rect) { + if (!rect) return rect; + const x = Math.floor(rect.x); + const y = Math.floor(rect.y); + return { + x, + y, + width: Math.ceil(rect.x + rect.width) - x, + height: Math.ceil(rect.y + rect.height) - y, + }; +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 4ef3854e..d9dba0c9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3566,3 +3566,24 @@ require hands on real hardware — they remain for the human pass. дефекта, если он вернётся: один-два случайных кадра из десяти расходятся между прогонами на единицы пикселей и два уровня, а `--stability=3` при этом зелёный — внутри одного процесса всё стабильно. +## Воспроизводимость съёмки документации (#410, #422) + +Кадр в `docs/images/` обязан зависеть только от коммита. Если он зависит ещё и +от прогона, приёмка скриншотов теряет смысл: «изменилось десять кадров» +перестаёт что-либо означать, и разобрать, продукт это или среда, нечем. + +Проверяется по двум осям, и одна не заменяет другую: + +- [ ] `node scripts/capture-determinism.mjs` — снимает набор дважды в разных + процессах и сравнивает хеши. Ловит дрейф **между прогонами** — тот самый, + что был в #410 (дробная обрезка пересчитывалась от раскладки). +- [ ] `node demo/docs/capture.mjs --stability=3` — три снимка подряд в одном + процессе и одном состоянии страницы. Ловит дрейф **внутри состояния** — + анимацию, не успевшую замереть, или зависимость от времени. + +Обе команды ничего не принимают и не трогают манифест: это измерение, а не +съёмка. Первая перезаписывает `docs/images` (дважды), поэтому запускать её +удобнее до приёмки, а не после. + +Целочисленность обрезки — единственная часть съёмки, которую можно проверить без +браузера: `node --test test/capture-clip.test.mjs`. diff --git a/docs/images/03-space-create.png b/docs/images/03-space-create.png index 0659b94c..1d82774b 100644 Binary files a/docs/images/03-space-create.png and b/docs/images/03-space-create.png differ diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json index f92b44ba..34b1d96b 100644 --- a/docs/images/screenshots.json +++ b/docs/images/screenshots.json @@ -3,8 +3,8 @@ "fixture": "synthetic-only", "chromium": "151.0.7922.34", "oxipng": "oxipng 10.2.0", - "sourceFingerprint": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "captureScriptSha256": "12eb99bc095f1a8434bc85f129b078ede6d7d87ae9a6113fb6a9d54a27052b8d", + "sourceFingerprint": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "captureScriptSha256": "9ed2d7d22d41c24a2c8a6a8c73133ee3ca9a1977c77923b4a8162fabdbf7f49f", "command": "npm run build && node demo/docs/capture.mjs", "scenarios": { "view-desktop": { @@ -15,8 +15,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "e0d39cfae61d194e85147ca36499abc5a173efdf00195f2f00a7111866dd54bd" }, "view-touch": { "file": "02-view-touch.png", @@ -26,8 +26,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "946801f6475a40c0e31a70e4b67eaa34c6f70df5329a63915f445e6e4811ca6a" }, "space-create": { "file": "03-space-create.png", @@ -37,8 +37,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "a15e3e001623e293265fb8b901ffcd2efa5f316312970332be94642eb11ec8d5" }, "room-contour-close": { "file": "04-room-contour-close.png", @@ -48,8 +48,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "f81cfc6b9e4a923d36031c514f02927a33e8b8b2c75f007e8aed45dee16e491d" }, "plan-context-tray": { "file": "05-plan-context-tray.png", @@ -59,8 +59,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "6b8bfaada24ff1fadcda1b6946a5894d90d981e2c7f7096273e23bfa58ac3218" }, "device-editor": { "file": "06-device-editor.png", @@ -70,8 +70,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "a13536d93f5bf70207e7ece0d3f4617d4ba919cb556342de67eaa81961589fb2" }, "device-display-preview": { "file": "06-device-display-preview.png", @@ -81,8 +81,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", - "imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93" + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", + "imageSha256": "d93be8761de4451fa792cc4415789b4624f38638eccc3623502ea61dcf3b3a13" }, "background-editor": { "file": "07-background-editor.png", @@ -92,7 +92,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", "imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb" }, "room-card": { @@ -103,7 +103,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", "imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e" }, "device-info": { @@ -114,21 +114,8 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "ef7121c59dae6f43fc043fbfd148f4a2e53ee785807e2640cbd0bab08574fdc3", + "sourceSha256": "7a05cb2600d607b781d301f4e22426b7f14d90edbb84eabec8180fa6427bf1d9", "imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6" } - }, - "acceptance": { - "declared": [ - "view-desktop", - "view-touch", - "space-create", - "room-contour-close", - "plan-context-tray", - "device-editor", - "device-display-preview" - ], - "witnesses": 3, - "floor": 1 } } diff --git a/docs/specs/422-capture-and-anchor-gates.md b/docs/specs/422-capture-and-anchor-gates.md index b1a2d2af..797df838 100644 --- a/docs/specs/422-capture-and-anchor-gates.md +++ b/docs/specs/422-capture-and-anchor-gates.md @@ -152,7 +152,8 @@ danglingMaterialRefusal(недостижимый SHA + этот якорь) → `capture.mjs` на том же браузере. Доказательство: замер до и после, разница названа в issue числом. - **AC9**. Комментарий о порядке шагов в `docs-screenshots.yml` соответствует - фактическому порядку. + фактическому порядку. Доказательство: чтение файла — комментарий и порядок + шагов сверяются глазами в одном экране. ## План автотестов diff --git a/scripts/capture-determinism.mjs b/scripts/capture-determinism.mjs new file mode 100644 index 00000000..28357b5f --- /dev/null +++ b/scripts/capture-determinism.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Гейт воспроизводимости съёмки документации между прогонами (#422). + * + * Зачем отдельный гейт, если есть `--stability`. Тот делает N снимков в одном + * процессе и одном состоянии страницы — то есть отвечает на вопрос «плавает ли + * кадр от времени внутри страницы». Дефект #410 был по другой оси: обрезка + * плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы + * всегда. Проверка, объявленная гарантией воспроизводимости, на собственном + * инциденте промолчала бы; этот гейт закрывает вторую ось. + * + * Обе проверки остаются: одна другую не заменяет. + * + * Приём простой до скуки: снять набор дважды в разных процессах и сравнить + * хеши. Ничего не сохраняется между прогонами, кроме самих хешей, поэтому гейт + * не зависит от того, что лежало в `docs/images` до него. + */ + +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const IMAGES = resolve(ROOT, 'docs/images'); +const CAPTURE = resolve(ROOT, 'demo/docs/capture.mjs'); + +/** Хеши всех кадров набора: имя → sha256. */ +export function frameHashes(directory, read = readFileSync, list = readdirSync) { + const hashes = {}; + for (const name of list(directory).sort()) { + if (!name.endsWith('.png')) continue; + hashes[name] = createHash('sha256').update(read(resolve(directory, name))).digest('hex'); + } + return hashes; +} + +/** + * Расхождения между двумя прогонами. + * + * Пропавший и появившийся кадр — тоже расхождение: набор обязан быть одним и + * тем же, иначе сравнивать нечего и «совпало» ничего не значит. + */ +export function driftBetweenRuns(first, second) { + const names = [...new Set([...Object.keys(first), ...Object.keys(second)])].sort(); + return names + .filter((name) => first[name] !== second[name]) + .map((name) => ({ + name, + first: first[name] || '(нет кадра)', + second: second[name] || '(нет кадра)', + })); +} + +function capture(label) { + const run = spawnSync(process.execPath, [CAPTURE], { cwd: ROOT, stdio: 'inherit' }); + if (run.status !== 0) { + console.error(`::error::съёмка (${label}) завершилась с кодом ${run.status}`); + process.exit(run.status || 1); + } +} + +function main() { + capture('прогон 1'); + const first = frameHashes(IMAGES); + capture('прогон 2'); + const second = frameHashes(IMAGES); + + const drift = driftBetweenRuns(first, second); + if (!drift.length) { + console.log(`съёмка воспроизводима: ${Object.keys(first).length} кадров совпали между прогонами`); + return 0; + } + console.error('::error::съёмка недетерминирована между прогонами —' + + ` разошлись кадры: ${drift.map((item) => item.name).join(', ')}`); + for (const item of drift) { + console.error(` ${item.name}: ${item.first.slice(0, 16)} ≠ ${item.second.slice(0, 16)}`); + } + console.error('Кадр, который зависит от прогона, делает приёмку скриншотов бессмысленной:' + + ' «изменилось десять кадров» перестаёт что-либо означать.'); + return 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index c71fb863..9cd9f56f 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -954,6 +954,28 @@ const MUTANT_DEFINITIONS = [ file: 'demo/docs/browser-args.mjs', find: " '--run-all-compositor-stages-before-draw',\n", replace: '', + id: 'capture-drifts-between-runs', + guard: 'node --test test/capture-clip.test.mjs', + because: 'a crop that rounds to the nearest pixel shaves half a pixel off the ' + + 'target, and the frame then depends on sub-pixel layout — the very drift ' + + 'that made #410 undiagnosable; the in-process stability probe stays green ' + + 'on it, so the whole-pixel rule needs a check of its own (#422)', + patches: [{ + file: 'demo/docs/clip.mjs', + find: ' const x = Math.floor(rect.x);', + replace: ' const x = Math.round(rect.x);', + }], + }, + { + id: 'anchor-liveness-ignores-reachability', + guard: 'node --test test/review-doc-guard.test.mjs', + because: 'asking whether the object merely exists locally lets an anchor born on ' + + 'the author machine soften the #413 refusal exactly where the mistake was ' + + 'made — liveness has to mean reachable from origin (#422)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: " const probe = run(['log', '--remotes=origin', '--tags', '--format=%T']);", + replace: " const probe = { status: 0, stdout: object };", }], }, { diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index c650c827..67e0f08d 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -205,6 +205,53 @@ export const ANCHOR_MARKER = '