fix: pin the compositor so a frame depends only on the commit (#424)

User-Visible: no
Issue: #424
This commit is contained in:
Codex
2026-09-02 21:59:43 +03:00
parent cfa5c7790f
commit 09c410000a
5 changed files with 113 additions and 8 deletions
+32
View File
@@ -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',
];
+6 -8
View File
@@ -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`.
*
+14
View File
@@ -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` при этом зелёный —
внутри одного процесса всё стабильно.
+23
View File
@@ -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',
+38
View File
@@ -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 разошлось бы с проверяемым набором');
});