Files
houseplan-card/docs/specs/424-capture-determinism.md
2026-09-02 21:59:43 +03:00

14 KiB
Raw Permalink Blame History

ТЗ #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/**: разовая пересъёмка всех десяти кадров.