diff --git a/AGENTS.md b/AGENTS.md index 33fa6396..e5143e13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/demo/golden/accept.mjs b/demo/golden/accept.mjs index 95b8c6b0..8ba906f5 100644 --- a/demo/golden/accept.mjs +++ b/demo/golden/accept.mjs @@ -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 } diff --git a/demo/golden/policy.mjs b/demo/golden/policy.mjs index 0085806a..1da7a5f0 100644 --- a/demo/golden/policy.mjs +++ b/demo/golden/policy.mjs @@ -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 diff --git a/package.json b/package.json index 6daa7e54..8f2057f7 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/assert-capture-env.mjs b/scripts/assert-capture-env.mjs new file mode 100644 index 00000000..48726438 --- /dev/null +++ b/scripts/assert-capture-env.mjs @@ -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 [--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); +} diff --git a/scripts/capture-environment.mjs b/scripts/capture-environment.mjs new file mode 100644 index 00000000..c4a385bb --- /dev/null +++ b/scripts/capture-environment.mjs @@ -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; +}; diff --git a/scripts/docs-accept.mjs b/scripts/docs-accept.mjs index 8cbf48d3..fe0f7eea 100644 --- a/scripts/docs-accept.mjs +++ b/scripts/docs-accept.mjs @@ -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, }); diff --git a/test/capture-environment.test.mjs b/test/capture-environment.test.mjs new file mode 100644 index 00000000..73f27415 --- /dev/null +++ b/test/capture-environment.test.mjs @@ -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'); +}); diff --git a/test/docs-accept.test.mjs b/test/docs-accept.test.mjs index 7a9b26dc..846e31f6 100644 --- a/test/docs-accept.test.mjs +++ b/test/docs-accept.test.mjs @@ -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), 'без платформы поля быть не должно'); +}); diff --git a/test/golden-policy.test.mjs b/test/golden-policy.test.mjs index 7dd5fab5..09e2a4b9 100644 --- a/test/golden-policy.test.mjs +++ b/test/golden-policy.test.mjs @@ -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: '', + }), /съёмка отказана/); +});