Files
houseplan-card/test/golden-policy.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

332 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertGoldenInvocation,
GOLDEN_BASELINE_MANIFEST,
goldenRunFailed,
goldenScenarioSetsMatch,
} from '../demo/golden/policy.mjs';
import {
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
} from '../scripts/golden-acceptance.mjs';
test('golden metadata cannot be mistaken for a Home Assistant integration manifest', () => {
assert.equal(GOLDEN_BASELINE_MANIFEST, 'baselines-index.json');
// The old check compared against 'manifest.json' and passed while the file
// was called 'baseline-manifest.json' — which is exactly what the HACS glob
// `*manifest.json` matches, and what turned Hassfest red on PR #9004
// (2026-08-11). Ending with the word is enough to break the submission.
assert.equal(GOLDEN_BASELINE_MANIFEST.endsWith('manifest.json'), false);
});
test('golden capture fails on runtime errors but permits missing baselines', () => {
assert.equal(goldenRunFailed('capture', false, [{ status: 'error' }]), true);
assert.equal(goldenRunFailed('capture', false, [{ status: 'missing-baseline' }]), false);
assert.equal(goldenRunFailed('verify', true, [{ status: 'different' }]), true);
assert.equal(goldenRunFailed('verify', true, [{ status: 'passed' }]), false);
});
test('golden verification cannot make a partial success claim', () => {
// #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/);
});
test('golden baseline inventory rejects orphan hashes and PNGs', () => {
assert.equal(goldenScenarioSetsMatch(['a', 'b'], ['b', 'a'], ['a', 'b']), true);
assert.equal(goldenScenarioSetsMatch(['a'], ['a', 'orphan'], ['a']), false);
assert.equal(goldenScenarioSetsMatch(['a'], ['a'], ['a', 'orphan']), false);
assert.equal(goldenScenarioSetsMatch(['a', 'b'], ['a'], ['a', 'b']), false);
});
// #334. Локальная съёмка допустима не по доверию, а по доказательству: среда
// равна раннеру, если всё, что менять не собирались, совпало с эталонами.
const results = (entries) => entries.map(([id, status]) => ({ id, status }));
test('приёмка отвергает расхождение в сцене, которую менять не собирались (#334)', () => {
const refusal = goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'different'], ['c', 'passed']]), ['a'],
);
assert.match(refusal, /^съёмка разошлась/);
assert.match(refusal, / b\./);
// Объявленная сцена в перечислении не появляется — иначе сообщение
// указывало бы на автора вместо среды.
assert.equal(/ a[,.]/.test(refusal), false);
});
test('приёмка проходит, когда разошлись ровно объявленные сцены (#334)', () => {
assert.equal(goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'passed'], ['c', 'missing-baseline']]), ['a'], ['c'],
), null);
});
test('новая сцена требует своего объявления, иначе станет контрактом молча (#350)', () => {
// Прежнее правило (#334) пропускало её без вопросов: эталона, которому
// противоречить, у неё нет. Половина обоснования верна — параллельность среды
// новая сцена доказать не может. Но правило молчало про второй вопрос: пустой
// или обрезанный кадр закрепляется так же надёжно, как испорченный старый.
// Так три эталона каталога устройств уехали в контракт без единого взгляда.
const refusal = goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), []);
assert.match(refusal, /эталона ещё нет, и они станут контрактом: new/);
assert.match(refusal, /--expect-new/);
assert.equal(goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), [], ['new']), null);
});
test('флаги не взаимозаменяемы: имя в чужом останавливает приёмку (#350)', () => {
// Путаница означает, что ревьюер думал об одной сцене, а утверждал про другую.
assert.match(
goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), ['new'], []),
/эталона ещё нет, их место в --expect-new: new/,
);
assert.match(
goldenAcceptanceRefusal(results([['old', 'different']]), [], ['old']),
/эталон уже есть, их место в --expect-change: old/,
);
});
test('новая сцена не отменяет разбора изменившихся, и наоборот (#350)', () => {
const list = results([['old', 'different'], ['new', 'missing-baseline'], ['same', 'passed']]);
assert.match(goldenAcceptanceRefusal(list, [], ['new']), /^съёмка разошлась/);
assert.match(goldenAcceptanceRefusal(list, ['old'], []), /станут контрактом/);
assert.equal(goldenAcceptanceRefusal(list, ['old'], ['new']), null);
});
test('приёмка без объявлений запрещает «принять всё, чтобы CI позеленел» (#334)', () => {
// Ровно то, чем эталон перестаёт быть эталоном: одна команда, три подмены,
// ни одного названного намерения.
const refusal = goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'different'], ['c', 'different']]), [],
);
assert.match(refusal, /a, b, c/);
});
test('приёмка отвергает объявление сцены, которой нет в отчёте (#334)', () => {
const refusal = goldenAcceptanceRefusal(results([['a', 'passed']]), ['a', 'опечатка']);
assert.match(refusal, /которых нет в отчёте: опечатка/);
assert.match(
goldenAcceptanceRefusal(results([['a', 'passed']]), [], ['опечатка']),
/которых нет в отчёте: опечатка/,
);
});
test('приёмка отвергает отчёт без результатов сцен (#334)', () => {
assert.match(goldenAcceptanceRefusal(null, []), /не содержит результатов/);
});
test('объявленная, но совпавшая сцена называется, а не проглатывается (#334)', () => {
const list = results([['a', 'passed'], ['b', 'different']]);
assert.deepEqual(goldenSilentDeclarations(list, ['a', 'b']), ['a']);
assert.deepEqual(goldenSilentDeclarations(list, ['b']), []);
});
// #351. `passed` означает «в пределах порога», а не «байт в байт». Прежняя
// приёмка копировала кандидата поверх каждого эталона, поэтому подпороговый
// дрейф уезжал в контракт молча и накапливался. Так 1e341c60 заменил 22
// картинки, объявив четыре.
test('необъявленная сцена сохраняет свой эталон и свой хеш (#351)', () => {
const plan = goldenAcceptancePlan({
scenarioIds: ['declared', 'drifted', 'fresh'],
results: [
{ id: 'declared', status: 'different', actualSha256: 'new-declared' },
// Байты кандидата другие, но расхождение подпороговое — и именно поэтому
// сцена не имеет права попасть в эталоны без объявления.
{ id: 'drifted', status: 'passed', actualSha256: 'new-drifted' },
{ id: 'fresh', status: 'missing-baseline', actualSha256: 'new-fresh' },
],
previousHashes: { declared: 'old-declared', drifted: 'old-drifted' },
declared: ['declared'],
declaredNew: ['fresh'],
});
assert.deepEqual(plan.replace.sort(), ['declared', 'fresh']);
assert.deepEqual(plan.keep, ['drifted']);
assert.deepEqual(plan.hashes, {
declared: 'new-declared',
drifted: 'old-drifted',
fresh: 'new-fresh',
});
});
test('индекс перезаписывается на полный набор сцен, а не на заменённые (#351)', () => {
// Полнота манифеста — инвариант goldenScenarioSetsMatch: сирота в индексе или
// пропавшая запись делают весь манифест недействительным.
const plan = goldenAcceptancePlan({
scenarioIds: ['a', 'b', 'c'],
results: [{ id: 'a', status: 'different', actualSha256: 'na' }],
previousHashes: { a: 'oa', b: 'ob', c: 'oc' },
declared: ['a'],
});
assert.deepEqual(Object.keys(plan.hashes).sort(), ['a', 'b', 'c']);
});
test('необъявленная сцена без прежнего эталона — ошибка, а не тихий кандидат (#351)', () => {
assert.throws(() => goldenAcceptancePlan({
scenarioIds: ['orphan'],
results: [{ id: 'orphan', status: 'missing-baseline', actualSha256: 'x' }],
previousHashes: {},
}), /без прежнего эталона: orphan/);
});
test('объявленная сцена без хеша кандидата — ошибка (#351)', () => {
assert.throws(() => goldenAcceptancePlan({
scenarioIds: ['a'],
results: [{ id: 'a', status: 'different' }],
previousHashes: { a: 'oa' },
declared: ['a'],
}), /без хеша кандидата: a/);
});
// #355. Floor свидетелей: перечислив всю матрицу в --expect-change, принять
// чужую съёмку больше нельзя — среду доказывают только необъявленные сцены,
// совпавшие с эталоном байт-в-байт.
test('приёмка без свидетелей отказывает и называет дефицит (#355)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
const all = Array.from({ length: 20 }, (_, index) => ({
id: `scene-${index}`, status: 'different', actualSha256: `new-${index}`,
}));
const { refusal, floor } = goldenWitnessRefusal({
results: all,
sceneCount: all.length,
declared: all.map((result) => result.id),
previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])),
});
assert.equal(floor, 2, '10% от 20 эталонных сцен');
assert.match(refusal, /0 из необходимых 2/);
assert.match(refusal, /--no-witnesses/);
});
test('обычная приёмка с достаточным числом свидетелей проходит (#355)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
const scenes = [
{ id: 'edited', status: 'different', actualSha256: 'changed' },
...Array.from({ length: 30 }, (_, index) => ({
id: `same-${index}`, status: 'passed', actualSha256: `sha-${index}`,
})),
];
const previousHashes = Object.fromEntries(
scenes.filter((scene) => scene.id !== 'edited')
.map((scene) => [scene.id, scene.actualSha256]),
);
const { refusal, witnesses, floor } = goldenWitnessRefusal({
results: scenes, sceneCount: scenes.length, declared: ['edited'], previousHashes,
});
assert.equal(refusal, null);
assert.equal(floor, 4, '10% от 31 эталонной сцены');
assert.equal(witnesses.length, 30);
});
test('passed в пределах порога — не свидетель: среду доказывает байт-в-байт (#355)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
const scenes = Array.from({ length: 10 }, (_, index) => ({
id: `drifted-${index}`, status: 'passed', actualSha256: `candidate-${index}`,
}));
const previousHashes = Object.fromEntries(
scenes.map((scene) => [scene.id, `baseline-${scene.id}`]),
);
const { refusal, witnesses } = goldenWitnessRefusal({
results: scenes, sceneCount: scenes.length, declared: [], previousHashes,
});
assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду');
assert.match(refusal, /0 из необходимых 1/);
});
test('--no-witnesses требует причину и с ней пропускает floor (#355)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
const bare = goldenWitnessRefusal({ results: [], skipWitnesses: true });
assert.match(bare.refusal, /--reason/);
const reasoned = goldenWitnessRefusal({
results: [], skipWitnesses: true, skipReason: 'полная перерисовка матрицы v49',
});
assert.equal(reasoned.refusal, null);
});
test('первичная съёмка требует названной причины, а не молчания (#408)', async () => {
const { goldenWitnessRefusal, goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs');
// До #408 здесь стоял отказ от требования: матрица без эталонов свидетелей
// иметь не может, значит и floor ноль. Верно по факту, неверно по выводу —
// именно этим ноль и обходился: `git rm baselines/*.png` превращал любую
// приёмку в первичную съёмку.
assert.equal(goldenWitnessFloor(0), 0, 'пустая матрица — единственный законный ноль');
const results = [{ id: 'first', status: 'missing-baseline', actualSha256: 'x' }];
const silent = goldenWitnessRefusal({ results, sceneCount: 1, declaredNew: ['first'] });
assert.match(silent.refusal, /свидетелей среды недостаточно/,
'невозможность доказать среду не отменяет требования, а требует сказать это вслух');
const named = goldenWitnessRefusal({
results, sceneCount: 1, declaredNew: ['first'],
skipWitnesses: true, skipReason: 'первичная съёмка матрицы',
});
assert.equal(named.refusal, null);
});
test('удаление всех эталонов не снижает порог (#408)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
// Воспроизведение обхода из #408 целиком: удалить эталоны, объявить все сцены
// новыми, принять чужую съёмку без единого следа причины.
const wiped = Array.from({ length: 143 }, (_, index) => ({
id: `scene-${index}`, status: 'missing-baseline', actualSha256: `foreign-${index}`,
}));
const { refusal, floor, witnesses } = goldenWitnessRefusal({
results: wiped,
sceneCount: wiped.length,
declaredNew: wiped.map((result) => result.id),
});
assert.equal(floor, 10, 'порог считается от матрицы, а не от нуля уцелевших');
assert.equal(witnesses.length, 0);
assert.match(refusal, /0 из необходимых 10/);
assert.match(refusal, /сцен в матрице 143, с эталонами 0/);
});
test('порог держится и при частичной потере эталонов (#408)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
// Три уцелевших эталона дают три свидетеля — но планку задаёт матрица.
const kept = Array.from({ length: 3 }, (_, index) => ({
id: `kept-${index}`, status: 'passed', actualSha256: `same-${index}`,
}));
const lost = Array.from({ length: 140 }, (_, index) => ({
id: `lost-${index}`, status: 'missing-baseline', actualSha256: `new-${index}`,
}));
const { refusal, floor, witnesses } = goldenWitnessRefusal({
results: [...kept, ...lost],
sceneCount: 143,
declaredNew: lost.map((result) => result.id),
previousHashes: Object.fromEntries(kept.map((result) => [result.id, result.actualSha256])),
});
assert.equal(floor, 10);
assert.equal(witnesses.length, 3, 'свидетелем может быть только сцена с эталоном');
assert.match(refusal, /3 из необходимых 10/);
});
test('размер матрицы обязателен: догадываться о нём нельзя (#408)', async () => {
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
// Вывести его из отчёта соблазнительно и неверно: у частичного прогона
// (`run.mjs --only=…`) results короче матрицы, и порог просел бы молча.
const results = [{ id: 'one', status: 'different', actualSha256: 'x' }];
for (const sceneCount of [undefined, null, -1, 1.5, '143']) {
const { refusal, floor } = goldenWitnessRefusal({ results, sceneCount, declared: ['one'] });
assert.match(refusal, /не знает размера матрицы/, `sceneCount=${sceneCount}`);
assert.equal(floor, 0);
}
});
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: '',
}), /съёмка отказана/);
});