mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Вопрос владельца: зачем агенты снимают 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
151 lines
8.6 KiB
JavaScript
151 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from 'node:crypto';
|
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
|
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
|
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
|
|
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');
|
|
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
|
|
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
|
|
const list = (name) => {
|
|
const found = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
return (found ? found.slice(name.length + 3) : '')
|
|
.split(',').map((id) => id.trim()).filter(Boolean);
|
|
};
|
|
const declared = list('expect-change');
|
|
const declaredNew = list('expect-new');
|
|
const skipWitnesses = process.argv.includes('--no-witnesses');
|
|
const reasonArg = process.argv.find((arg) => arg.startsWith('--reason='));
|
|
const skipReason = reasonArg ? reasonArg.slice('--reason='.length) : '';
|
|
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
|
|
|
const reportPath = resolve(from, 'golden-report.json');
|
|
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
|
|
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
|
|
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
|
|
if (report.buildFingerprint !== sourceFingerprint(ROOT))
|
|
throw new Error('candidate screenshots were not captured from the current frontend source');
|
|
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);
|
|
|
|
const byId = new Map(report.results.map((result) => [result.id, result]));
|
|
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
|
mkdirSync(baselineRoot, { recursive: true });
|
|
/**
|
|
* Прежний индекс: источник хешей для сцен, которые остаются как были (#351).
|
|
*
|
|
* `passed` не значит «байт в байт» — он значит «в пределах порога». Прежняя
|
|
* версия копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый
|
|
* дрейф уезжал в контракт молча, и накапливался: каждая приёмка подтягивала
|
|
* эталон к последней среде, порог не пересекался никогда, а эталон уходил.
|
|
* Так `1e341c60` заменил 22 картинки, объявив четыре. Владелец делал эту работу
|
|
* руками (`ad3f9981`: «nine unrelated baselines … were restored to their
|
|
* reviewed versions»); теперь её делает инструмент.
|
|
*/
|
|
const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
|
const previous = existsSync(manifestPath)
|
|
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
|
|
: {};
|
|
// #355: floor свидетелей — необъявленные сцены, совпавшие с эталоном
|
|
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
|
const witnessCheck = goldenWitnessRefusal({
|
|
results: report.results,
|
|
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
|
// обходится удалением каталога эталонов.
|
|
sceneCount: GOLDEN_SCENARIOS.length,
|
|
declared,
|
|
declaredNew,
|
|
previousHashes: previous,
|
|
skipWitnesses,
|
|
skipReason,
|
|
});
|
|
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) {
|
|
const result = byId.get(scenario.id);
|
|
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
|
|
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
|
|
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
|
|
if (!result?.actualSha256 || !existsSync(candidate))
|
|
throw new Error(`review candidate missing: ${scenario.id}`);
|
|
const digest = createHash('sha256').update(readFileSync(candidate)).digest('hex');
|
|
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
|
|
}
|
|
const plan = goldenAcceptancePlan({
|
|
scenarioIds: GOLDEN_SCENARIOS.map((scenario) => scenario.id),
|
|
results: report.results,
|
|
previousHashes: previous,
|
|
declared,
|
|
declaredNew,
|
|
});
|
|
const hashes = plan.hashes;
|
|
for (const id of plan.replace) {
|
|
copyFileSync(resolve(from, 'actual', `${id}.png`), resolve(baselineRoot, `${id}.png`));
|
|
}
|
|
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
|
|
schema: 1,
|
|
matrixVersion: GOLDEN_MATRIX_VERSION,
|
|
acceptedAt: new Date().toISOString(),
|
|
sourceFingerprint: report.buildFingerprint,
|
|
chromium: report.chromium,
|
|
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
|
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
|
platform: environment.platform,
|
|
// #355: след приёмки в артефакте, не только в истории shell.
|
|
witnesses: skipWitnesses
|
|
? { skipped: true, reason: skipReason }
|
|
: { count: witnessCheck.witnesses.length, floor: witnessCheck.floor },
|
|
scenarios: hashes,
|
|
}, null, 2)}\n`, 'utf8');
|
|
const silent = goldenSilentDeclarations(report.results, declared);
|
|
if (silent.length) {
|
|
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
|
|
}
|
|
console.log(`Заменено эталонов: ${plan.replace.length}`
|
|
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
|
|
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
|
|
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
|
|
if (skipWitnesses) {
|
|
console.log(`Свидетели пропущены осознанно (--no-witnesses): ${skipReason}`);
|
|
} else {
|
|
console.log(`Свидетелей среды: ${witnessCheck.witnesses.length} (floor ${witnessCheck.floor}).`);
|
|
}
|