fix(gates): съёмка кадров в чужой среде отказывается заранее

Вопрос владельца: зачем агенты снимают PNG на Windows, если кадры мы всё
равно не принимаем, тем более что WSL есть на обеих машинах. Ответ по
коду: этому ничто не мешало. Ни один из шести скриптов съёмки и приёмки
не знал, на какой он ОС — ни `process.platform`, ни win32, ни WSL не
упоминались нигде. Съёмка отрабатывала штатно, а стена появлялась на
приёмке, и текст стены говорил «сцен-свидетелей 0 из 10», то есть
подсказывал неверный вывод «надо объявить больше сцен» — от которого до
`--expect-change` на всю матрицу одна команда.

Причина запрета не политика, а физика: Windows растеризует текст через
DirectWrite, с другим субпиксельным сглаживанием и DPI, поэтому
байтового совпадения с принятым эталоном не даёт никогда, свидетелей
среды быть не может, и приёмка откажет всё равно. Флаги детерминизма из
#410 убирают разброс внутри среды, а не между ОС.

Что сделано:

- `golden:capture` отказывается до запуска браузера, в тексте отказа
  готовая команда для WSL;
- `golden:verify` остаётся законным в любой среде: он ничего не
  принимает, а как грубая проверка полезен;
- обе приёмки (golden и документации) отказываются в чужой среде;
- к отказу свидетелей приписывается фраза про расхождение среды — та
  самая, которой не хватало, чтобы отказ не читался как «объяви больше
  сцен»;
- платформа уезжает в манифесты рядом с версией Chromium: у кадров
  появился провенанс среды;
- осознанный обход есть и требует причину: HP_ALLOW_FOREIGN_CAPTURE.

Главный урок задачи — про цену правки файла, у которого записан хеш.
Первая редакция встроила проверку в `demo/docs/capture.mjs`, и гейт
документации сразу покраснел: его sha записан в индексе скриншотов, и
`scripts/check-docs.mjs` их сверяет. То есть проверка, которая ничего не
рисует, стоила бы пересъёмки всех картинок документации и визуальной
приёмки владельца. Поэтому для документации отказ живёт шагом раньше —
`npm run docs:capture` вызывает `scripts/assert-capture-env.mjs` — и
шагом позже, на приёмке. По той же причине гейт golden стоит в
`demo/golden/policy.mjs`, а не в `run.mjs`: последний входит в корпус
sourceFingerprint. Тест закрепляет обе границы: гейт обязан быть в
policy.mjs и в npm-скрипте и обязан отсутствовать в двух
фингерпринтуемых файлах.

Платформа в юнитах — параметр, а не `process.platform`: иначе тест был
бы зелёным на Linux и красным на машине владельца, то есть тестом про
хост, а не про правило.

AGENTS.md приведён к состоянию после #401 (принимается любая среда,
доказавшая себя байтовым совпадением непринятых кадров) и разводит
проверку и съёмку — прежний текст сливал их в «advisory» и утверждал
«accepted only on a complete Linux CI artefact».

Свидетели, все проверены отрицательным прогоном: снятый гейт съёмки,
гейт, отказывающий и на verify, обход без причины, отказ без команды,
убранная приписка про среду, отцепленные гейты обеих приёмок,
переставшая бросать обёртка, npm-скрипт без проверки и возврат гейта в
каждый из двух фингерпринтуемых файлов. Проверка подключения сначала
смотрела только на импорт модуля и молча проходила, когда отказ
заменяли на `void` — теперь она проверяет вызов бросающей обёртки.

Гейты: npm test 1918 tests, 1917 pass, 0 fail; typecheck зелёный;
check-docs зелёный (индекс скриншотов не задет); pytest без HA 378
passed, 3 skipped.

Issue: #455
User-Visible: no
This commit is contained in:
Claude
2026-09-04 19:26:35 +03:00
parent 9e56842bc1
commit 28a4cb5eec
10 changed files with 397 additions and 6 deletions
+21 -3
View File
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
the beta gate is CI at the exact SHA.
**Verifying and capturing are different things (#455).** `golden:verify` is
advisory and legal anywhere, Windows included: it reports differences and
accepts nothing. **Capturing** frames is refused outside Linux
before the browser even starts — `golden:capture` through
`demo/golden/policy.mjs`, documentation screenshots through
`npm run docs:capture` (use that script, not a bare `node
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
capture script invalidates the committed screenshot index). The refusal prints
the WSL command. The reason is not policy but physics: Windows
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
so no frame ever matches an accepted baseline byte for byte, no environment
witness can exist, and acceptance would refuse anyway (#401 accepts any
environment that proves itself with byte-identical undeclared frames — Linux is
simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
+28 -1
View File
@@ -10,6 +10,9 @@ import {
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
goldenWitnessRefusal,
} from '../../scripts/golden-acceptance.mjs';
import {
assertCaptureEnvironment, captureEnvironment, environmentNote,
} from '../../scripts/capture-environment.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
const environment = captureEnvironment();
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
if (foreignAllowed) {
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
}
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
if (refusal) throw new Error(refusal);
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
skipWitnesses,
skipReason,
});
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
if (witnessCheck.refusal) {
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
// решением (#455).
const note = environmentNote({
capturedOn: environment.platform,
acceptedOn: existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
: null,
});
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
}
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
// не имеет права оставить каталог эталонов половинным.
for (const scenario of GOLDEN_SCENARIOS) {
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
// приёмка отличит «объявили не то» от «сняли не там» (#455).
platform: environment.platform,
// #355: след приёмки в артефакте, не только в истории shell.
witnesses: skipWitnesses
? { skipped: true, reason: skipReason }
+28 -1
View File
@@ -1,12 +1,39 @@
import {
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
} from '../../scripts/capture-environment.mjs';
// The name must NOT end with `manifest.json`: the HACS submission check globs
// `*manifest.json` over the whole clone of the default branch and refuses a
// repository with more than one match (test/repo-hygiene.test.mjs).
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
/**
* Проверка вызова — и заодно проверка среды съёмки (#455).
*
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
* фингерпринтуемого файла.
*
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
* не про правило.
*/
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
if (mode === 'verify' && scenarioFilter)
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
if (mode !== 'capture') return;
const platform = options.platform ?? captureEnvironment().platform;
const allowance = options.allowance ?? foreignCaptureAllowance();
const { refusal, allowance: accepted } = foreignCaptureRefusal({
platform, kind: 'golden', stage: 'capture', allowance,
});
if (refusal) throw new Error(refusal);
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
};
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
+1
View File
@@ -13,6 +13,7 @@
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
"bundle:budget": "node scripts/bundle-budget.mjs",
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
"docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs",
"docs:accept": "node scripts/docs-accept.mjs",
"smokes:select": "node scripts/smoke-select.mjs",
"inventory": "node scripts/inventory.mjs",
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Отказ до съёмки — для тех точек, где гейт нельзя встроить в сам скрипт (#455).
*
* `demo/docs/capture.mjs` править нельзя дёшево: его sha записан в индексе
* скриншотов документации, и `scripts/check-docs.mjs` сверяет их. Любая правка
* объявляет закоммиченный индекс устаревшим — то есть стоит пересъёмки всех
* картинок и визуальной приёмки владельца за проверку, которая ничего не
* рисует. Поэтому проверка живёт шагом раньше, в npm-скрипте:
*
* "docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs"
*
* У golden такой проблемы нет: там гейт стоит в `demo/golden/policy.mjs`,
* который исключён из корпуса отпечатка и уже вызывается из `run.mjs`.
*
* node scripts/assert-capture-env.mjs <golden|docs> [--stage=capture|accept]
*/
import { assertCaptureEnvironment } from './capture-environment.mjs';
const [kindArg] = process.argv.slice(2).filter((arg) => !arg.startsWith('--'));
const stageArg = process.argv.find((arg) => arg.startsWith('--stage='));
const kind = kindArg === 'docs' ? 'docs' : 'golden';
const stage = stageArg?.slice('--stage='.length) === 'accept' ? 'accept' : 'capture';
try {
const allowance = assertCaptureEnvironment({ kind, stage });
if (allowance) console.log(`Чужая среда разрешена осознанно: ${allowance}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Среда съёмки как проверяемое условие, а не как знание в голове (#455).
*
* Правило после #401: принимается любая среда, доказавшая себя байтовым
* совпадением непринятых кадров. Windows такого совпадения не даёт и не может:
* другая растеризация шрифтов (DirectWrite против FreeType), субпиксельное
* сглаживание, DPI и другая сборка Chromium. Флаги детерминизма из #410
* (`--force-color-profile=srgb`, `--font-render-hinting=none`,
* `--disable-lcd-text`, целочисленный клип) убирают разброс ВНУТРИ среды, а не
* между операционными системами.
*
* До этой задачи знание «на Windows снимать бесполезно» жило только в головах и
* в документах ревью. Съёмка отрабатывала штатно, а стена появлялась на
* приёмке — и текст той стены говорил про число сцен-свидетелей, то есть
* подсказывал неверный вывод «надо объявить больше сцен». Отсюда две вещи:
* отказ переносится на самое начало, а текст называет причину.
*
* Проверка съёмки — не то же, что проверка приёмки: `golden:verify` на Windows
* законен и полезен как грубая проверка «не сломал ли рендер вообще». Запрещена
* только съёмка с расчётом на приёмку.
*
* Где гейт НЕ стоит и почему. В `demo/docs/capture.mjs` — ни строки: его sha
* записан в манифест скриншотов документации (`captureScriptSha256`), и
* `scripts/check-docs.mjs` сверяет его с файлом. Любая правка объявляет
* закоммиченный индекс устаревшим, то есть стоит пересъёмки десяти картинок и
* визуальной приёмки владельца — за проверку, которая ничего не рисует.
* Проверено на себе: первая редакция этой задачи правку сделала, и гейт
* документации сразу покраснел. Поэтому для документации отказ живёт на шаг
* раньше (`scripts/assert-capture-env.mjs` в npm-скрипте `docs:capture`) и на
* шаг позже (приёмка). То же и для golden: гейт в `policy.mjs`, а не в
* `run.mjs`, который входит в корпус `sourceFingerprint`.
*/
/** Канон среды съёмки: Linux CI и WSL. */
export const CAPTURE_CANON_PLATFORM = 'linux';
/** Переменная осознанного обхода. Пустая причина обходом не считается. */
export const ALLOW_FOREIGN_ENV = 'HP_ALLOW_FOREIGN_CAPTURE';
const WSL_COMMAND = {
golden: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && npm run golden:capture',
docs: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && node demo/docs/capture.mjs',
};
/** Провенанс среды: то, что уезжает в манифест рядом с версией Chromium. */
export const captureEnvironment = (source = process) => ({
platform: String(source.platform || ''),
arch: String(source.arch || ''),
});
/**
* Разрешён ли осознанный обход. Возвращает причину или `null`.
* Пустая строка — не причина: обход без записанной причины неотличим от
* забытой переменной в окружении.
*/
export const foreignCaptureAllowance = (env = process.env) => {
const reason = String(env?.[ALLOW_FOREIGN_ENV] ?? '').trim();
return reason || null;
};
/**
* Отказ съёмки или приёмки в чужой среде.
*
* `kind`: `golden` | `docs` — от него зависит только команда в подсказке.
* `stage`: `capture` | `accept` — от него зависит формулировка.
* Возвращает `{ refusal, allowance }`: `refusal` — текст или `null`.
*/
export const foreignCaptureRefusal = ({
platform,
kind = 'golden',
stage = 'capture',
canon = CAPTURE_CANON_PLATFORM,
allowance = null,
} = {}) => {
if (platform === canon) return { refusal: null, allowance: null };
if (allowance) return { refusal: null, allowance };
const what = kind === 'docs' ? 'скриншоты документации' : 'эталоны golden';
const action = stage === 'accept'
? `приёмка отказана: ${what} сняты на платформе «${platform}»`
: `съёмка отказана: ${what} на платформе «${platform}» принять будет нечем`;
return {
refusal: `${action}. Байтового совпадения с принятыми кадрами Windows не даёт`
+ ' (растеризация шрифтов, субпиксельное сглаживание, DPI, другая сборка Chromium),'
+ ` поэтому сцен-свидетелей среды будет ноль и приёмка откажет. Снимайте в WSL:\n`
+ ` ${WSL_COMMAND[kind] || WSL_COMMAND.golden}\n`
+ `Диагностика на Windows законна: golden:verify показывает расхождения и ничего не принимает.`
+ ` Если чужая среда осознанна — ${ALLOW_FOREIGN_ENV}="причина" оставит её в выводе и в манифесте.`,
allowance: null,
};
};
/**
* Приписка к отказу приёмки, когда среда кадров и эталонов разошлась.
*
* Именно этой фразы не хватало: без неё отказ «свидетелей 0 из 10» читается
* как «объяви больше сцен», и обход в одну команду выглядит решением.
*/
export const environmentNote = ({ capturedOn, acceptedOn } = {}) => {
if (!capturedOn || !acceptedOn || capturedOn === acceptedOn) return null;
return `среда съёмки (${capturedOn}) не совпадает со средой принятых эталонов`
+ ` (${acceptedOn}): свидетелей и не могло быть — дело не в числе объявленных сцен`;
};
/**
* Бросающая обёртка для точек, где отказ обязан остановить работу.
*
* Возвращает разрешённую причину обхода (или `null`) — вызывающий печатает её
* сам, чтобы след остался в выводе прогона.
*/
export const assertCaptureEnvironment = ({
kind = 'golden', stage = 'capture', platform, allowance,
} = {}) => {
const resolvedPlatform = platform ?? captureEnvironment().platform;
const resolvedAllowance = allowance ?? foreignCaptureAllowance();
const { refusal, allowance: accepted } = foreignCaptureRefusal({
platform: resolvedPlatform, kind, stage, allowance: resolvedAllowance,
});
if (refusal) throw new Error(refusal);
return accepted;
};
+13
View File
@@ -25,6 +25,7 @@
*/
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { assertCaptureEnvironment, captureEnvironment } from './capture-environment.mjs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -94,9 +95,13 @@ export function verifyDocsCandidate({
/** Build the manifest written by the CLI without erasing an earlier review trace. */
export function acceptedDocsManifest({
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
platform = null,
}) {
return {
...manifest,
// Провенанс среды приёмки (#455): рядом с версией браузера, чтобы у
// следующего разбора «почему кадры разошлись» была не только догадка.
...(platform ? { acceptedOn: platform } : {}),
acceptance: decision.replace.length
? {
declared: [...decision.replace],
@@ -114,6 +119,13 @@ const list = (argv, name) => argv
.filter(Boolean);
function main(argv) {
// Среда приёмки (#455). Платформу съёмки манифест не несёт и не может:
// добавить поле — значит править `demo/docs/capture.mjs`, чей sha записан в
// индексе скриншотов, то есть платить пересъёмкой десяти картинок за
// проверку. Приёмка идёт там, где лежат артефакты, поэтому её платформа —
// достаточный признак среды кадров.
const allowed = assertCaptureEnvironment({ kind: 'docs', stage: 'accept' });
if (allowed) console.log(`Чужая среда приёмки разрешена осознанно: ${allowed}`);
if (!argv.includes('--reviewed')) {
console.error('отказ: замена скриншотов без явного --reviewed');
return 2;
@@ -166,6 +178,7 @@ function main(argv) {
manifest,
previousAcceptance: previous,
decision,
platform: captureEnvironment().platform,
skipWitnesses,
skipReason,
});
+119
View File
@@ -0,0 +1,119 @@
/**
* #455: среда съёмки как проверяемое условие.
*
* Вопрос владельца был «зачем агенты снимают PNG на Windows, если мы их не
* принимаем». Ответ оказался «потому что этому ничто не мешало»: ни один из
* шести скриптов съёмки и приёмки не знал, на какой он ОС, а отказ приёмки
* говорил про число сцен-свидетелей — то есть подсказывал неверный вывод
* «надо объявить больше сцен».
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
ALLOW_FOREIGN_ENV, CAPTURE_CANON_PLATFORM, assertCaptureEnvironment, captureEnvironment,
environmentNote, foreignCaptureAllowance, foreignCaptureRefusal,
} from '../scripts/capture-environment.mjs';
test('#455 канон среды — linux, и чужая платформа получает отказ с командой', () => {
assert.equal(CAPTURE_CANON_PLATFORM, 'linux');
assert.equal(foreignCaptureRefusal({ platform: 'linux' }).refusal, null);
for (const platform of ['win32', 'darwin', '']) {
const { refusal } = foreignCaptureRefusal({ platform });
assert.ok(refusal, `${platform} обязан получить отказ`);
assert.match(refusal, /wsl -d Ubuntu/, 'отказ обязан называть команду, а не только запрет');
assert.match(refusal, /golden:verify/, 'диагностика законна — это должно быть сказано');
assert.match(refusal, new RegExp(ALLOW_FOREIGN_ENV), 'осознанный обход должен быть назван');
}
});
test('#455 текст отказа различает съёмку и приёмку и называет предмет', () => {
assert.match(foreignCaptureRefusal({ platform: 'win32', stage: 'capture' }).refusal,
/съёмка отказана: эталоны golden/);
assert.match(foreignCaptureRefusal({ platform: 'win32', stage: 'accept' }).refusal,
/приёмка отказана: эталоны golden/);
assert.match(foreignCaptureRefusal({ platform: 'win32', kind: 'docs' }).refusal,
/скриншоты документации/);
assert.match(foreignCaptureRefusal({ platform: 'win32', kind: 'docs' }).refusal,
/demo\/docs\/capture\.mjs/, 'у документации своя команда');
});
test('#455 обход требует причину, пустая строка причиной не считается', () => {
assert.equal(foreignCaptureAllowance({}), null);
assert.equal(foreignCaptureAllowance({ [ALLOW_FOREIGN_ENV]: ' ' }), null);
assert.equal(foreignCaptureAllowance({ [ALLOW_FOREIGN_ENV]: ' нет WSL ' }), 'нет WSL');
const allowed = foreignCaptureRefusal({ platform: 'win32', allowance: 'нет WSL' });
assert.equal(allowed.refusal, null);
assert.equal(allowed.allowance, 'нет WSL', 'причина обязана доехать до вывода');
assert.ok(foreignCaptureRefusal({ platform: 'win32', allowance: null }).refusal);
});
test('#455 приписка про среду появляется только при расхождении', () => {
// Именно этой фразы не хватало отказу «свидетелей 0 из 10».
assert.equal(environmentNote({ capturedOn: 'linux', acceptedOn: 'linux' }), null);
assert.equal(environmentNote({ capturedOn: 'win32' }), null, 'без второй стороны сравнивать нечего');
const note = environmentNote({ capturedOn: 'win32', acceptedOn: 'linux' });
assert.match(note, /win32/);
assert.match(note, /не в числе объявленных сцен/,
'приписка обязана снимать именно неверный вывод про число сцен');
});
test('#455 провенанс среды снимается с процесса, а не выдумывается', () => {
const environment = captureEnvironment({ platform: 'win32', arch: 'x64' });
assert.deepEqual(environment, { platform: 'win32', arch: 'x64' });
assert.equal(typeof captureEnvironment().platform, 'string');
});
test('#455 гейт стоит во всех точках, где кадры появляются и принимаются', () => {
// Проверка вида «файл упоминает модуль» слабая, но здесь она про ПОДКЛЮЧЕНИЕ,
// а поведение каждой точки закреплено отдельно: golden-policy.test.mjs
// (съёмка golden), docs-accept.test.mjs (приёмка документации) и прогон CI
// (сами скрипты). Без этой проверки точку можно тихо отцепить.
const source = (relative) =>
readFileSync(fileURLToPath(new URL(`../${relative}`, import.meta.url)), 'utf8');
for (const file of [
'demo/golden/policy.mjs', 'demo/golden/accept.mjs',
'scripts/docs-accept.mjs', 'scripts/assert-capture-env.mjs',
]) {
assert.match(source(file), /capture-environment\.mjs/, `${file} не подключает гейт среды`);
}
// Импорта мало: первая редакция этого теста проходила, когда отказ в
// accept.mjs заменили на `void foreign` — модуль остался подключён, а гейт
// перестал существовать. Поэтому проверяется форма ОСТАНОВКИ, а не наличие
// строки: поведение точек, которые можно запустить на Linux, закреплено в
// golden-policy.test.mjs и docs-accept.test.mjs, а эти две проверяются на
// Windows и в CI прогоном самих скриптов.
// Вызывается именно бросающая обёртка: она останавливает прогон по
// построению, поэтому «понизить отказ до печати» без правки этой строки
// нельзя. Первая редакция проверки смотрела лишь на импорт модуля — и
// молча проходила, когда отказ заменили на `void foreign`.
assert.match(source('demo/golden/accept.mjs'),
/assertCaptureEnvironment\(\{ kind: 'golden', stage: 'accept' \}\)/,
'приёмка golden обязана проверять среду бросающей обёрткой');
assert.match(source('scripts/docs-accept.mjs'),
/assertCaptureEnvironment\(\{ kind: 'docs', stage: 'accept' \}\)/,
'приёмка документации обязана проверять среду');
// Съёмка документации гейтится шагом раньше, в npm-скрипте: править
// demo/docs/capture.mjs дорого — его sha записан в индексе скриншотов, и
// любая правка требует пересъёмки всех картинок (проверено, гейт
// документации покраснел сразу). Отсюда требование к самому скрипту:
assert.doesNotMatch(source('demo/docs/capture.mjs'), /capture-environment\.mjs/,
'demo/docs/capture.mjs трогать нельзя: его sha в индексе скриншотов');
const packageJson = JSON.parse(source('package.json'));
assert.match(packageJson.scripts['docs:capture'] || '',
/assert-capture-env\.mjs docs/,
'у съёмки документации обязан быть npm-скрипт с проверкой среды');
// `run.mjs` трогать нельзя: он в корпусе sourceFingerprint, и его правка
// объявила бы устаревшими бандл, скриншоты документации и индекс эталонов.
assert.doesNotMatch(source('demo/golden/run.mjs'), /capture-environment\.mjs/,
'гейт обязан стоять в policy.mjs, а не в фингерпринтуемом run.mjs');
});
test('#455 бросающая обёртка бросает и возвращает разрешённую причину', () => {
assert.throws(() => assertCaptureEnvironment({ platform: 'win32', allowance: null }),
/съёмка отказана/);
assert.equal(assertCaptureEnvironment({ platform: 'linux', allowance: null }), null);
assert.equal(assertCaptureEnvironment({ platform: 'win32', allowance: 'нет WSL' }), 'нет WSL');
});
+15
View File
@@ -192,3 +192,18 @@ test('#421 fingerprint-only refresh preserves the complete previous acceptance t
});
assert.deepEqual(firstRefresh.acceptance, { lastWriteWasFingerprintOnly: true });
});
test('#455 принятый манифест несёт среду приёмки', () => {
// Платформу СЪЁМКИ манифест кандидата не несёт и не будет: добавить поле —
// значит править demo/docs/capture.mjs, чей sha записан в индексе
// скриншотов, то есть заплатить пересъёмкой всех картинок за проверку,
// которая ничего не рисует (проверено: гейт документации сразу покраснел).
// Поэтому среда фиксируется там, где её знают без правок, — на приёмке.
const decision = { replace: ['01-view-desktop'], witnesses: 3, floor: 1 };
const accepted = acceptedDocsManifest({
manifest: candidate(), decision, platform: 'linux',
});
assert.equal(accepted.acceptedOn, 'linux');
const withoutPlatform = acceptedDocsManifest({ manifest: candidate(), decision });
assert.ok(!('acceptedOn' in withoutPlatform), 'без платформы поля быть не должно');
});
+21 -1
View File
@@ -27,7 +27,9 @@ test('golden capture fails on runtime errors but permits missing baselines', ()
});
test('golden verification cannot make a partial success claim', () => {
assert.doesNotThrow(() => assertGoldenInvocation('capture', 'one-scenario'));
// #455: платформа передаётся явно — иначе юнит был бы зелёным на Linux и
// красным на машине владельца, то есть тестом про хост, а не про правило.
assert.doesNotThrow(() => assertGoldenInvocation('capture', 'one-scenario', { platform: 'linux' }));
assert.doesNotThrow(() => assertGoldenInvocation('verify', ''));
assert.throws(() => assertGoldenInvocation('verify', 'one-scenario'), /complete matrix/);
assert.throws(() => assertGoldenInvocation('unknown', ''), /unknown golden mode/);
@@ -309,3 +311,21 @@ test('размер матрицы обязателен: догадываться
}
});
test('#455 съёмка golden в чужой среде отказывается заранее, verify — нет', () => {
// До этой задачи `golden:capture` на Windows отрабатывал штатно и писал PNG,
// а стена появлялась на приёмке. Отказ переехал на первую строку прогона.
assert.throws(() => assertGoldenInvocation('capture', '', { platform: 'win32' }),
/съёмка отказана/);
assert.throws(() => assertGoldenInvocation('capture', '', { platform: 'win32' }),
/wsl -d Ubuntu/);
// Диагностика законна и остаётся доступной в любой среде.
assert.doesNotThrow(() => assertGoldenInvocation('verify', '', { platform: 'win32' }));
// Осознанный обход существует и требует причину.
assert.doesNotThrow(() => assertGoldenInvocation('capture', '', {
platform: 'win32', allowance: 'на этой машине нет WSL',
}));
assert.throws(() => assertGoldenInvocation('capture', '', {
platform: 'win32', allowance: '',
}), /съёмка отказана/);
});