Files
houseplan-card/test/capture-environment.test.mjs
T
Claude 28a4cb5eec 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
2026-09-04 19:26:35 +03:00

120 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* #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');
});