From 8de9ea008a49ed44c00bde5567a609cda8116399 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 2 Sep 2026 21:55:44 +0300 Subject: [PATCH] docs: restore the capture determinism spec lost in the rebase (#424) User-Visible: no Issue: #424 --- docs/specs/424-capture-determinism.md | 198 ++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/specs/424-capture-determinism.md diff --git a/docs/specs/424-capture-determinism.md b/docs/specs/424-capture-determinism.md new file mode 100644 index 00000000..9eab318d --- /dev/null +++ b/docs/specs/424-capture-determinism.md @@ -0,0 +1,198 @@ +# ТЗ #424 — Съёмка документации перестаёт зависеть от прогона + +- Issue: https://github.com/Matysh/houseplan-card/issues/424 +- Приоритет: P2, bug + infra; полный трек — одна поверхность (съёмка + документации), но правка меняет содержимое всех закоммиченных кадров, а это + требует объявленного решения, а не молчаливой пересъёмки. Класс файлов — B + (`demo/**`, `test/**`) плюс C/D (`docs/images/**`) +- Ревизия: 1 (2026-09-02) + +## Сценарий + +Приёмка скриншотов построена на одном правиле: каждый необъявленный кадр обязан +совпасть с закоммиченным **байт-в-байт**. Пока съёмка недетерминирована, правило +означает случайность — «изменились три кадра» может значить и правку продукта, и +невезение на прогоне. Порог кадров-свидетелей (#408/#409) опирается на ту же +посылку: свидетель — необъявленный кадр, совпавший байт-в-байт. + +Проверить это стало возможно только после #422: кросс-прогонный гейт покраснел в +первый же прогон на `dev`. + +## Что человек увидит до и после + +Видимого поведения продукта задача не меняет. + +**До**: два прогона съёмки на одном коммите дают разные кадры (в CI разошлись +`01-view-desktop`, `03-space-create`, `06-device-editor`; локально — те же +хеши). Приёмка при этом молчит, потому что сравнивает с закоммиченным, а не +прогоны между собой. +**После**: восемь прогонов подряд дают один и тот же набор; «кадр изменился» +снова означает «изменился продукт». + +## Проблема и контракт + +### Что именно расходится + +Попиксельное сравнение двух вариантов `06-device-editor` (1180×1100): 76 +пикселей, максимум 2 уровня, четыре кластера по краям двух элементов шириной +466 и высотой 31 — скруглённые углы двух `select.areasel`, стоящих на +полупиксельной границе (`y = 433.5` и `496.5`, `border-radius: 6px`). + +Расходится один-два случайных кадра из десяти, каждый раз разных, а внутри +процесса всё стабильно: `capture.mjs --stability=3` зелёный. То есть страница +приходит в один из нескольких **устойчивых** исходов ещё до снимка — это не +«не успело устояться». + +### Чем это не оказалось (проверено исполнением) + +- **не CSS-переходы**: гашение `transition`/`animation` инъекцией во все shadow + root стабилизирует `06`, но дрейф переезжает на `06-device-display-preview`, + затем на `01-view-desktop`; +- **не шрифты**: ожидание `document.fonts.ready` после применения состояния и + ожидание пустого `getAnimations({subtree:true})` дрейф не убирают; +- **не `select`**: со скрытыми `select` `06` стабилен, но дрейф уходит на + соседний кадр; +- **не упаковщик**: расхождение сохраняется при полностью отключённом `oxipng`; +- **не кэш шрифтов**: после `rm -rf ~/.cache/fontconfig` расходится не первый + прогон, а произвольный; +- **не обрезка**: `08-room-card`, ради которого вводили целочисленный клип + (#410), стабилен во всех прогонах. + +### Причина + +Композитор Chromium. Два его свойства делают кадр зависимым от истории прогона: +**частичная растеризация** переиспользует ранее нарисованные куски тайла, а +снимок берётся **до завершения всех стадий композитора**. + +**Контракт**: съёмка документации фиксирует состояние композитора так, чтобы +кадр зависел только от коммита. Достигается двумя флагами запуска в +`DETERMINISTIC_ARGS` (`demo/docs/capture.mjs`): + +``` +'--run-all-compositor-stages-before-draw', '--disable-partial-raster' +``` + +Подбор проверен перебором, агрегированный хеш всех десяти кадров за восемь +прогонов: + +| Конфигурация | Результат | +|---|---| +| как сейчас | 3+ варианта | +| `--num-raster-threads=1` | не помогает | +| `--disable-gpu` | не помогает | +| только `--run-all-compositor-stages-before-draw` | 2 варианта | +| `run-all` + `--num-raster-threads=1` | 3 варианта | +| **`run-all` + `--disable-partial-raster`** | **один хеш ×8** | + +Оба флага обязательны: каждый по отдельности дрейф не убирает. Это и есть +причина, по которой решение закрепляется тестом, а не комментарием. + +### Почему golden не затронут + +`demo/golden/run.mjs` сравнивает с допуском (`maxChannelDelta: 10`, +`maxDiffRatio: 0.0005` для сцены), а наш дрейф — 2 уровня на 76 пикселях из +1,3 млн, то есть 0,006% при пороге 0,05%. Проверено: `npm run golden:verify` +проходит целиком (153 сценария) в обоих прогонах. Менять golden эта задача не +требует; если его когда-нибудь переведут на побайтовое сравнение, флаги +понадобятся и там — это записано здесь, а не оставлено на догадку. + +## Скоуп / не-скоуп + +**В скоупе**: флаги запуска (вынесенные в `demo/docs/browser-args.mjs` и +используемые `capture.mjs`), контрактный тест на них, мутанты, разовая +пересъёмка и приёмка десяти кадров, строка в `docs/TESTING.md`. + +**Не в скоупе**: `demo/golden/**` (сравнение с допуском, дрейф ниже порога); +кросс-прогонный гейт (#422 — мержится вместе с этой задачей); полупиксельная +позиция `select.areasel` в продукте (симптом, а не причина: дрейф переезжает на +другие кадры при её устранении). + +## UX, модель данных, i18n + +Не применимо. + +## Критерии приёмки + +- **AC1**. Восемь прогонов съёмки подряд на одном коммите дают один и тот же + набор кадров. Доказательство: агрегированный хеш `docs/images/*.png` совпал + восемь раз из восьми; отдельно — кросс-прогонный гейт `#422` зелёный. +- **AC2**. Решение закреплено тестом, а не комментарием: контрактный тест + требует обоих флагов в `DETERMINISTIC_ARGS` и объясняет, почему их два. + Доказательство: тест в `test/`. +- **AC3**. **Отрицательный прогон обязателен**: снятие любого из двух флагов + роняет контрактный тест. Доказательство: два мутанта в + `scripts/mutation-gate.mjs`, прогнанные штатным раннером. + Мутант проверяет именно тест, а не съёмку: дрейф стохастический (один-два + кадра из десяти, не каждый прогон), и мутант, зависящий от везения, был бы + флейковым — то есть худшим видом гейта. +- **AC4**. Кадры пересняты в каноническом окружении и приняты штатной + процедурой; манифест содержит новый отпечаток скрипта. Доказательство: + артефакт прогона «Скриншоты документации» и `npm run docs:accept`. +- **AC5**. Съёмка не стала медленнее более чем на **15%**: + `--disable-partial-raster` выключает оптимизацию перерисовки, и это могло бы + стоить времени. Порог, а не «заметно»: критерий без числа не умеет + провалиться. Доказательство: время полного прогона `capture.mjs` до и после, + по два замера. Предварительно измерено на этапе ТЗ — 11 711 / 11 595 мс без + флагов против 11 589 / 11 567 мс с флагами, то есть разница в пределах шума; + если на канонической машине окажется иначе, порог и есть та граница, за + которой решение пересматривается. +- **AC6**. `golden:verify` проходит без правок эталонов. Доказательство: полный + прогон 153 сценариев. + +## План автотестов + +**Как тест доберётся до констант.** `capture.mjs` — исполняемый скрипт: с +строки 247 он безусловно поднимает Chromium, поэтому импортировать его в тест +значит запустить съёмку внутри `npm test` (сегодня ни один тест браузер не +поднимает, и нарушать это нельзя). Набор флагов выносится в чистый модуль +`demo/docs/browser-args.mjs`, откуда `capture.mjs` его импортирует — тот же +приём и по той же причине, что вынос `clip.mjs` в #422. Текстовый разбор файла +через `readFileSync` (прецеденты — `test/smoke-harness-contract.test.mjs`, +`test/golden-matrix.test.mjs`) тоже подошёл бы, но он проверяет строку, а не +значение, и переживает опечатку в имени флага. + +**Юнит** (`test/capture-determinism-args.test.mjs`): + +1. `DETERMINISTIC_ARGS` из `demo/docs/browser-args.mjs` содержит оба флага + (AC2, AC3). +2. Флаги перечислены вместе с уже принятыми (`--force-color-profile=srgb`, + `--font-render-hinting=none`, `--disable-lcd-text`) — набор не разъезжается. +3. `capture.mjs` берёт набор из этого модуля, а не объявляет свой: иначе тест + стерёг бы константу, которой никто не пользуется. + +**Мутанты** (`scripts/mutation-gate.mjs`): + +- `capture-allows-partial-raster`: убрать `--disable-partial-raster` → тест + красный; +- `capture-draws-before-compositor-settles`: убрать + `--run-all-compositor-stages-before-draw` → тест красный. + +**Проверка реальностью**: кросс-прогонный гейт из #422 — он и есть измерение +AC1; в конвейере оба изменения оказываются одновременно. + +## Риски + +- **Флаги — деталь реализации Chromium и могут исчезнуть в будущей версии.** + Смягчение: версия Chromium в проекте закреплена; исчезновение флага проявится + как падение кросс-прогонного гейта, то есть громко, а не молча. +- **`--disable-partial-raster` замедляет съёмку.** Смягчение: AC5 требует + замера; десять сценариев — не самая долгая часть конвейера. +- **Разовая пересъёмка меняет все десять кадров сразу.** Это тот самый случай, + который вердикт конвейера называет «изменилось окружение съёмки»; смягчение: + приёмка идёт штатной командой с явным решением человека, а порог свидетелей + для неё обходится через `--no-witnesses --reason`, как и задумано. +- **Дрейф может вернуться из другого источника.** Смягчение: он теперь + измеряется на каждом прогоне съёмки (#422), а не обнаруживается через месяц + по странной приёмке. + +## Откат + +Снятие двух флагов возвращает прежнее поведение; кадры при этом придётся +переснять обратно. Продуктовый код не затрагивается. + +## Release-артефакты + +- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: **не требуется** + (User-Visible: no). +- `docs/TESTING.md`: строка о том, почему съёмка запускается с этими флагами. +- `docs/images/**`: разовая пересъёмка всех десяти кадров.