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
+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: '',
}), /съёмка отказана/);
});