Files
houseplan-card/test/capture-environment.test.mjs
T
Codex 3f95dac451 fix: индекс эталонов хранит платформу съёмки, а не платформу приёмки (#571)
Аудит 14.09, §4 и §9. `golden-report.json` не нёс платформу вовсе, поэтому
приёмщик вызывал `captureEnvironment()` у себя и записывал СВОЮ платформу как
платформу кадров. На `ad4000f9` это дало `"platform": "win32"` у кадров,
снятых Linux-прогоном 34853080375, а причина осознанного обхода осталась в
stdout и в индекс не попала — хотя `AGENTS.md` обещает след в обоих местах.
Сами PNG были и остаются целы: 101 свидетель совпал байт-в-байт. Врал
провенанс.

Что сделано:

- отчёт съёмки получил раздел `capture`: платформа, архитектура, сборка
  Chromium, отпечаток материала и — в CI — прогон с попыткой и SHA. Схема
  отчёта поднята до 2;
- приёмка читает среду съёмки из отчёта. Гейт чужой среды теперь судит обе
  стороны: съёмку (из отчёта) и приёмку (свою). Отказ — до единой записи;
- индекс эталонов поднят до схемы 2 и различает `capturedOn` и `acceptedOn`,
  несёт раздел `capture` и причину осознанного обхода в `foreignCapture`;
- отчёт схемы 1 платформы съёмки не несёт физически: это отдельная явная
  ветка, `capturedOn` уезжает `null`. Выдумывать платформу нельзя — ровно этим
  задача и вызвана;
- `--baselines=<dir>` у приёмки: без него проверить «отказ произошёл ДО
  записи» можно было бы только порчей рабочего дерева, то есть никак (#556).

Осознанно отменено решение #455 «не трогать run.mjs»: ради ГЕЙТА цена
фингерпринта не окупалась, ради ПРОВЕНАНСА окупилась — платформу кадров знает
только тот, кто их снял. Плата разовая: пересобран бандл, индекс скриншотов
документации переснят отдельным коммитом. Тест #455 переписан под новый
инвариант, а не удалён.

Свидетели: `test/golden-capture-provenance.test.mjs` — семь проверок, все пять
сценариев приёмки из issue, включая «отказ до записи» и «подмена PNG и неполный
артефакт по-прежнему fail-closed». Мутанты `golden-index-invents-capture-platform`
(возвращает платформу приёмщика) и `golden-report-provenance-optional`
(разрешает отчёт схемы 2 без провенанса) прогнаны лично: оба краснеют.

npm test 2762/2761/0 fail, typecheck чистый.

Issue: #571
User-Visible: no
2026-09-17 23:19:56 +03:00

130 lines
9.9 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`.
// #571 заменил бросающую обёртку на явную пару проверок: судить надо ДВЕ
// среды — съёмки (из отчёта) и приёмки (своя), а обёртка знала только свою.
// Форма остановки прежняя: `throw`, а не печать.
assert.match(source('demo/golden/accept.mjs'),
/for \(const \[platform, stageNote\] of \[[\s\S]*?\]\) \{[\s\S]*?if \(refusal\) throw new Error\(/,
'приёмка golden обязана отказывать по обеим средам, а не печатать');
assert.match(source('demo/golden/accept.mjs'), /reportCaptureProvenance\(report\)/,
'среда съёмки обязана читаться из отчёта, а не из process.platform приёмщика');
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-скрипт с проверкой среды');
// До #571 `run.mjs` не трогали принципиально: он в корпусе sourceFingerprint,
// и правка объявляет устаревшими бандл и индекс скриншотов. Ради ГЕЙТА эта
// цена не окупалась — он прекрасно живёт в policy.mjs. Ради ПРОВЕНАНСА
// окупилась: платформу кадров знает только тот, кто их снял, и записать её
// больше негде. Цена заплачена один раз, осознанно (#571).
assert.doesNotMatch(source('demo/golden/run.mjs'), /assertGoldenInvocation\([^)]*platform/,
'гейт среды остаётся в policy.mjs: в run.mjs только сбор провенанса');
assert.match(source('demo/golden/run.mjs'), /capture: captureProvenance\(\{ chromium, buildFingerprint \}\)/,
'отчёт съёмки обязан нести провенанс среды (#571)');
});
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');
});