From 09c410000a16532feaed12444f3bdf31ae128c42 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 2 Sep 2026 21:28:20 +0300 Subject: [PATCH] fix: pin the compositor so a frame depends only on the commit (#424) User-Visible: no Issue: #424 --- demo/docs/browser-args.mjs | 32 ++++++++++++++++++++++ demo/docs/capture.mjs | 14 ++++------ docs/TESTING.md | 14 ++++++++++ scripts/mutation-gate.mjs | 23 ++++++++++++++++ test/capture-determinism-args.test.mjs | 38 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 demo/docs/browser-args.mjs create mode 100644 test/capture-determinism-args.test.mjs diff --git a/demo/docs/browser-args.mjs b/demo/docs/browser-args.mjs new file mode 100644 index 00000000..e91d1d59 --- /dev/null +++ b/demo/docs/browser-args.mjs @@ -0,0 +1,32 @@ +/** + * Аргументы запуска Chromium для съёмки документации (#410, #424). + * + * Модуль отдельный и чистый намеренно: `capture.mjs` — исполняемый скрипт, + * который при импорте поднимает браузер и снимает набор. Тест, желающий + * прочитать эти константы, не должен запускать съёмку внутри `npm test` — тот + * же приём и та же причина, что у `clip.mjs`. + * + * Первые три флага фиксируют растеризацию текста и цветовой профиль: именно они + * убрали основную часть дрейфа в #410. + * + * Последние два добавлены в #424 и обязаны стоять вместе — по отдельности ни + * один дрейф не убирает (проверено перебором, восемь прогонов на конфигурацию): + * + * - `--disable-partial-raster` запрещает переиспользовать ранее нарисованные + * куски тайла. С включённой частичной растеризацией кадр зависит от того, + * что композитор рисовал ДО него, то есть от истории прогона; + * - `--run-all-compositor-stages-before-draw` заставляет пройти все стадии + * композитора до отрисовки, иначе снимок берётся на полпути и «докрутка» + * каждый раз происходит по-своему. + * + * Подпись дефекта, ради которого это сделано: один-два случайных кадра из + * десяти расходятся между прогонами на единицы пикселей и два уровня, при этом + * внутри одного процесса всё стабильно. + */ +export const DETERMINISTIC_ARGS = [ + '--force-color-profile=srgb', + '--font-render-hinting=none', + '--disable-lcd-text', + '--disable-partial-raster', + '--run-all-compositor-stages-before-draw', +]; diff --git a/demo/docs/capture.mjs b/demo/docs/capture.mjs index 8f1c0c3c..c06c2736 100644 --- a/demo/docs/capture.mjs +++ b/demo/docs/capture.mjs @@ -13,6 +13,7 @@ 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'; +import { DETERMINISTIC_ARGS } from './browser-args.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const OUTPUT = resolve(ROOT, 'docs/images'); @@ -167,14 +168,11 @@ mkdirSync(OUTPUT, { recursive: true }); // 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha // не менялась: подпись субпиксельного сглаживания, а не изменения продукта. // -// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало), -// `--font-render-hinting=none` снимает зависимость от хинтинга, -// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'` -// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации -// в CSS, второе — уже начатые переходы на момент съёмки. -const DETERMINISTIC_ARGS = [ - '--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text', -]; +// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы +// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424). +// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого +// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на +// момент съёмки. /** * Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`. * diff --git a/docs/TESTING.md b/docs/TESTING.md index d7fb7bdb..4ef3854e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3552,3 +3552,17 @@ require hands on real hardware — they remain for the human pass. attachment with Retry, Copy message, Download and manual links. Old or mismatched backend leaves About/Guide usable but exposes no fake submit [pre-beta: success/429/timeout/unknown-command smokes in light/dark]. + +## Съёмка документации запускается с флагами детерминизма (#424) + +- [ ] `demo/docs/browser-args.mjs` содержит `--disable-partial-raster` и + `--run-all-compositor-stages-before-draw` — оба, а не один. По отдельности + ни один дрейф не убирает: проверено перебором, восемь прогонов на + конфигурацию. + +Первый запрещает переиспользовать ранее нарисованные куски тайла (иначе кадр +зависит от того, что композитор рисовал до него), второй заставляет пройти все +стадии композитора до отрисовки (иначе снимок берётся на полпути). Подпись +дефекта, если он вернётся: один-два случайных кадра из десяти расходятся между +прогонами на единицы пикселей и два уровня, а `--stability=3` при этом зелёный — +внутри одного процесса всё стабильно. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 1f57a038..c71fb863 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -933,6 +933,29 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'capture-allows-partial-raster', + guard: 'node --test test/capture-determinism-args.test.mjs', + because: 'partial raster reuses whatever the compositor drew before, so the frame ' + + 'depends on the history of the run — one or two of ten frames drifted between ' + + 'runs while three shots inside one process matched byte for byte (#424)', + patches: [{ + file: 'demo/docs/browser-args.mjs', + find: " '--disable-partial-raster',\n", + replace: '', + }], + }, + { + id: 'capture-draws-before-compositor-settles', + guard: 'node --test test/capture-determinism-args.test.mjs', + because: 'without running every compositor stage first the screenshot is taken ' + + 'mid-flight, and the remaining work lands differently on every run (#424)', + patches: [{ + file: 'demo/docs/browser-args.mjs', + find: " '--run-all-compositor-stages-before-draw',\n", + replace: '', + }], + }, { id: 'danger-confirm-back-into-the-branch', guard: 'node demo/smoke_danger_confirm_branches.mjs', diff --git a/test/capture-determinism-args.test.mjs b/test/capture-determinism-args.test.mjs new file mode 100644 index 00000000..5020b7e4 --- /dev/null +++ b/test/capture-determinism-args.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { DETERMINISTIC_ARGS } from '../demo/docs/browser-args.mjs'; + +// #424. Съёмка документации давала разные кадры на одном коммите: один-два +// случайных кадра из десяти расходились между прогонами, при этом внутри +// процесса всё было стабильно. Причина — композитор: частичная растеризация +// переиспользует ранее нарисованные куски тайла, а снимок берётся до конца всех +// стадий. Два флага чинят это только вместе; по отдельности ни один не помогает +// (проверено перебором, восемь прогонов на конфигурацию). + +test('набор запрещает частичную растеризацию', () => { + assert.ok(DETERMINISTIC_ARGS.includes('--disable-partial-raster'), + 'без этого флага кадр зависит от того, что композитор рисовал до него'); +}); + +test('набор требует пройти все стадии композитора до отрисовки', () => { + assert.ok(DETERMINISTIC_ARGS.includes('--run-all-compositor-stages-before-draw'), + 'без этого флага снимок берётся на полпути, и докрутка каждый раз своя'); +}); + +test('прежние флаги детерминизма текста остались на месте', () => { + // #410 убрал ими основную часть дрейфа; потерять их вместе с правкой #424 + // означало бы разменять одну недетерминированность на другую. + for (const flag of ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text']) { + assert.ok(DETERMINISTIC_ARGS.includes(flag), flag); + } +}); + +test('съёмка берёт набор из этого модуля, а не объявляет свой', () => { + // Иначе тест стерёг бы константу, которой никто не пользуется. + const source = readFileSync(new URL('../demo/docs/capture.mjs', import.meta.url), 'utf8'); + assert.match(source, /import \{ DETERMINISTIC_ARGS \} from '\.\/browser-args\.mjs'/); + assert.doesNotMatch(source, /const DETERMINISTIC_ARGS\s*=/, + 'собственное объявление в capture.mjs разошлось бы с проверяемым набором'); +});