14 KiB
ТЗ #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: со скрытымиselect06стабилен, но дрейф уходит на соседний кадр; - не упаковщик: расхождение сохраняется при полностью отключённом
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):
DETERMINISTIC_ARGSизdemo/docs/browser-args.mjsсодержит оба флага (AC2, AC3).- Флаги перечислены вместе с уже принятыми (
--force-color-profile=srgb,--font-render-hinting=none,--disable-lcd-text) — набор не разъезжается. 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/**: разовая пересъёмка всех десяти кадров.