ci: prove the screenshot environment instead of trusting the place

Правило приёмки скриншотов было про место: снимать только в CI. Обоснование
измерено — съёмка в другом окружении переписывает файлы без содержательных
изменений, в #231 два из девяти на 7–8 байт, набор с беты все девять. Но
держалось правило на комментарии, а не на механизме: кандидат проверялся на
самосогласованность и принимался целиком, ни разу не сравниваясь с тем, что
лежит в репозитории.

Цена видна на #390: правка типов, которая физически не может сдвинуть
пиксель, потребовала прогона workflow, а затем правки одиннадцати полей
манифеста руками.

Теперь правило про доказательство, и оно то же, что у golden с #334: среда
доказана, если каждый кадр, который менять не собирались, совпал с
закоммиченным байт-в-байт. Расхождение растеризации спрятать нельзя — оно
задевает все кадры с текстом сразу. Снимать можно где угодно, включая WSL;
принять получится только оттуда, где кадры воспроизводятся, и перестанет
получаться в тот день, когда обновятся шрифты.

Остальное следует из того же правила: намерение объявляется
--expect-change, необъявленное расхождение останавливает приёмку,
объявленное без расхождения — тоже (ложная декларация обесценивает список),
заменяются ровно объявленные файлы, а тотальная перерисовка требует
--no-witnesses --reason, и причина уезжает в манифест.

Частый случай закрылся сам: ничего не объявлено, все кадры совпали —
принимается один манифест, руками ничего писать не надо.

Проверено шестью сквозными прогонами на подделанном артефакте, не только
юнитами: идентичный кандидат, необъявленное расхождение, объявленное,
молчаливая декларация, тотальная перерисовка без причины и с ней.

Issue: #401
User-Visible: no
This commit is contained in:
Matysh
2026-08-31 09:40:52 +03:00
parent 4683a493cc
commit 5c8cb58e1f
5 changed files with 334 additions and 11 deletions
+74 -11
View File
@@ -1,16 +1,24 @@
#!/usr/bin/env node
/**
* Приёмка скриншотов документации, снятых в CI (#246).
* Приёмка скриншотов документации (#246, правило среды переписано в #401).
*
* npm run docs:accept -- --reviewed --from=artifacts/docs
* npm run docs:accept -- --reviewed --from=… --expect-change=device-editor
*
* Зачем приёмка вообще. Съёмка на машине исполнителя даёт байтово разный PNG
* при одинаковом содержимом кадра: сглаживание и хинтинг зависят от окружения.
* Измерено на истории — пересъёмка в #231 изменила два файла из девяти на 7–8
* байт, а набор, приехавший с бетой, все девять целиком. Поэтому картинки
* рождаются в одном месте (`.github/workflows/docs-screenshots.yml`), а сюда
* приезжают артефактом. Та же конструкция, что у golden-эталонов, и по той же
* причине.
* Съёмка в другом окружении даёт байтово разный PNG при том же содержимом
* кадра: сглаживание и хинтинг зависят от шрифтового стека, а не только от
* браузера. Измерено — пересъёмка в #231 изменила два файла из девяти на 7–8
* байт, а набор, приехавший с бетой, все девять целиком.
*
* Раньше отсюда следовало правило про МЕСТО: снимать только в CI. Оно держалось
* на этом комментарии, а не на механизме, и стоило прогона workflow даже там,
* где пиксель измениться не мог (#390).
*
* Теперь правило про ДОКАЗАТЕЛЬСТВО, и оно то же, что у golden с #334: среда
* доказана, если каждый кадр, который менять не собирались, совпал с
* закоммиченным байт-в-байт. Разбор правила и его границ — в
* scripts/docs-acceptance.mjs. Снимать можно где угодно; принять получится
* только оттуда, где кадры воспроизводятся.
*
* Что здесь НЕ делается: коммит. Файлы заменяются, коммит делает человек —
* приёмка не должна быть способом протащить картинки мимо чужих глаз.
@@ -21,6 +29,7 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
import { docsAcceptancePlan } from './docs-acceptance.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -82,6 +91,11 @@ export function verifyDocsCandidate({
return { manifest, files };
}
const list = (argv, name) => argv
.filter((arg) => arg.startsWith(`--${name}=`))
.map((arg) => arg.slice(name.length + 3))
.filter(Boolean);
function main(argv) {
if (!argv.includes('--reviewed')) {
console.error('отказ: замена скриншотов без явного --reviewed');
@@ -96,13 +110,62 @@ function main(argv) {
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const plan = verifyDocsCandidate({ root: ROOT, from, manifest });
for (const file of plan.files) copyFileSync(file.from, file.to);
// Хеши закоммиченного считаются по файлам на диске, а не по закоммиченному
// манифесту: манифест — утверждение о файлах, а сравнивать надо сами файлы.
const ids = DOC_SCREENSHOTS.map((scenario) => scenario.id);
const committed = {};
const candidate = {};
for (const scenario of DOC_SCREENSHOTS) {
const entry = manifest.scenarios[scenario.id];
candidate[scenario.id] = entry.imageSha256;
const onDisk = resolve(ROOT, 'docs/images', entry.file);
if (existsSync(onDisk)) committed[scenario.id] = sha256(readFileSync(onDisk));
}
const declared = list(argv, 'expect-change');
const skipWitnesses = argv.includes('--no-witnesses');
const skipReason = (list(argv, 'reason')[0] || '').trim();
const decision = docsAcceptancePlan({
ids, committed, candidate, declared, skipWitnesses, skipReason,
});
if (decision.refusal) {
console.error(`отказ: ${decision.refusal}`);
return 1;
}
const byId = new Map(plan.files.map((file, index) => [ids[index], file]));
for (const id of decision.replace) {
const file = byId.get(id);
copyFileSync(file.from, file.to);
}
const accepted = {
...manifest,
acceptance: {
declared: [...decision.replace],
witnesses: decision.witnesses.length,
floor: decision.floor,
...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}),
},
};
writeFileSync(
resolve(ROOT, 'docs/images/screenshots.json'),
`${JSON.stringify(plan.manifest, null, 2)}\n`,
`${JSON.stringify(accepted, null, 2)}\n`,
'utf8',
);
console.log(`Принято ${plan.files.length} скриншотов, снятых ${plan.manifest.chromium}.`);
if (!decision.replace.length) {
console.log('Кадры не менялись: принят только манифест'
+ ` (отпечаток исходников ${manifest.sourceFingerprint.slice(0, 8)}).`);
} else {
console.log(`Принято кадров: ${decision.replace.length}`
+ ` (${decision.replace.join(', ')}), снято ${manifest.chromium}.`);
}
console.log(`Сохранено без изменений: ${decision.keep.length}.`);
if (skipWitnesses) {
console.log(`Свидетели пропущены осознанно: ${skipReason}`);
} else {
console.log(`Кадров-свидетелей среды: ${decision.witnesses.length} (порог ${decision.floor}).`);
}
console.log('Коммит — за вами: приёмка ничего не коммитит.');
return 0;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Правило допустимости съёмки скриншотов документации (issue #401).
*
* До этого правило было про МЕСТО: снимать только в CI, потому что растеризация
* шрифтов на другой машине отличается. Обоснование измерено — пересъёмка в #231
* изменила два файла из девяти на 7–8 байт, а набор с беты все девять. Но
* держалось оно на комментарии в шапке `docs-accept.mjs`, а не на механизме:
* кандидат проверялся на самосогласованность и принимался целиком, без единого
* сравнения с тем, что лежит в репозитории.
*
* Цена такого правила видна на #390. Правка типов, которая физически не может
* сдвинуть пиксель, потребовала прогона workflow, а затем правки одиннадцати
* полей манифеста руками. Гейт, заставляющий писать хеши руками, работает
* против себя.
*
* Здесь правило заменено на проверяемое, и оно ровно то же, что у golden с
* #334: среда съёмки доказана, если КАЖДЫЙ кадр, который менять не собирались,
* совпал с закоммиченным БАЙТ-В-БАЙТ. Расхождение растеризации спрятать нельзя
* — оно задевает все кадры с текстом, а не только правленные. Поэтому автор
* объявляет намерение списком `--expect-change`, и всё, что разошлось помимо
* списка, приёмку запрещает: либо это незамеченная регрессия рендера, либо
* среда не та.
*
* То же правило ловит второе, независимо от среды: «принять всё, чтобы гейт
* позеленел». Так скриншот документации перестаёт показывать продукт — молча,
* одной командой, без единого названного намерения.
*
* Отличие от golden только в строгости, и оно в пользу скриншотов: сцен десять,
* а не сто сорок три, и сравнение точное, без порога. Поэтому «совпал» здесь
* значит буквально совпал, а не «в пределах допуска».
*/
/**
* Порог свидетелей. Формула та же, что у golden (`goldenWitnessFloor`), и это
* намеренно: два набора картинок в одном репозитории не должны требовать от
* человека помнить два разных правила.
*
* Для десяти сцен порог равен одной. Этого достаточно, потому что основную
* работу делает не порог, а требование «все необъявленные совпали»: расхождение
* среды не бывает точечным. Порог закрывает единственную оставшуюся щель —
* попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся
* вовсе и подтвердить среду становится нечем.
*/
export const docsWitnessFloor = (committedCount) => (committedCount > 0
? Math.min(10, Math.ceil(committedCount * 0.1))
: 0);
/** Объявленные имена, которых нет в наборе сценариев. */
export const docsUnknownDeclarations = (ids, declared = []) => declared
.filter((id) => id && !ids.includes(id))
.sort();
/**
* Объявленные кадры, которые в действительности не изменились.
*
* Это не придирка к аккуратности. Декларация — утверждение «я знаю, почему этот
* кадр другой»; если он не другой, утверждение ложное, и вместе с ним теряет
* смысл весь список. На практике так выглядит усталость: автор перечисляет
* «всё, что покраснело», захватывая заодно то, что не краснело.
*/
export const docsSilentDeclarations = ({ committed = {}, candidate = {}, declared = [] }) =>
declared
.filter((id) => committed[id] && candidate[id] && committed[id] === candidate[id])
.sort();
/**
* План приёмки и причина отказа.
*
* @param ids все сценарии набора, в стабильном порядке
* @param committed id → sha256 закоммиченного файла (отсутствует — значит нет файла)
* @param candidate id → sha256 файла в артефакте
* @param declared что автор объявил изменённым
* @returns {{ refusal: string|null, replace: string[], keep: string[],
* witnesses: string[], floor: number }}
*/
export function docsAcceptancePlan({
ids = [], committed = {}, candidate = {}, declared = [],
skipWitnesses = false, skipReason = '',
}) {
const empty = { replace: [], keep: [...ids], witnesses: [], floor: 0 };
if (skipWitnesses && (typeof skipReason !== 'string' || !skipReason.trim())) {
return { ...empty, refusal: '--no-witnesses требует --reason="…": причина обхода обязана'
+ ' остаться в манифесте, а не только в истории shell' };
}
const unknown = docsUnknownDeclarations(ids, declared);
if (unknown.length) {
return { ...empty, refusal: `объявлены кадры, которых нет в наборе: ${unknown.join(', ')}` };
}
const silent = docsSilentDeclarations({ committed, candidate, declared });
if (silent.length) {
return { ...empty, refusal: `объявлены изменёнными, но не изменились: ${silent.join(', ')}.`
+ ' Декларация — утверждение «я знаю, почему этот кадр другой»; на неизменившемся'
+ ' кадре оно ложное и обесценивает весь список' };
}
const announced = new Set(declared.filter(Boolean));
const strayed = ids.filter((id) => !announced.has(id) && committed[id] !== candidate[id]);
if (strayed.length) {
return { ...empty, refusal: `разошлись, но не объявлены: ${strayed.join(', ')}.`
+ ' Либо это незамеченное изменение продукта — тогда объявите его'
+ ' --expect-change; либо съёмка велась в другой среде, и тогда её кадры'
+ ' принимать нельзя: растеризация задевает все кадры с текстом сразу' };
}
const withCommitted = ids.filter((id) => committed[id]);
const floor = docsWitnessFloor(withCommitted.length);
const witnesses = withCommitted.filter((id) => !announced.has(id)
&& committed[id] === candidate[id]);
if (!skipWitnesses && witnesses.length < floor) {
return { ...empty, refusal: 'кадров-свидетелей недостаточно:'
+ ` ${witnesses.length} из необходимых ${floor}.`
+ ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;'
+ ' только он доказывает, что среда съёмки та же. Если перерисовка'
+ ' действительно тотальная и осознанная — --no-witnesses --reason="…"'
+ ' оставит причину в манифесте' };
}
const replace = ids.filter((id) => announced.has(id));
return {
refusal: null,
replace,
keep: ids.filter((id) => !announced.has(id)),
witnesses,
floor,
};
}