mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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:
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
|
|||||||
|
|
||||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
the beta gate is CI at the exact SHA.
|
||||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
|
||||||
|
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||||
|
advisory and legal anywhere, Windows included: it reports differences and
|
||||||
|
accepts nothing. **Capturing** frames is refused outside Linux
|
||||||
|
before the browser even starts — `golden:capture` through
|
||||||
|
`demo/golden/policy.mjs`, documentation screenshots through
|
||||||
|
`npm run docs:capture` (use that script, not a bare `node
|
||||||
|
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||||
|
capture script invalidates the committed screenshot index). The refusal prints
|
||||||
|
the WSL command. The reason is not policy but physics: Windows
|
||||||
|
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||||
|
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||||
|
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||||
|
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||||
|
simply the only one we have). The deliberate override is
|
||||||
|
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||||
|
manifest. Baselines are still accepted only via
|
||||||
|
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||||
|
index records the platform next to the Chromium build.
|
||||||
|
|
||||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||||
|
|||||||
+28
-1
@@ -10,6 +10,9 @@ import {
|
|||||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||||
goldenWitnessRefusal,
|
goldenWitnessRefusal,
|
||||||
} from '../../scripts/golden-acceptance.mjs';
|
} from '../../scripts/golden-acceptance.mjs';
|
||||||
|
import {
|
||||||
|
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||||
|
} from '../../scripts/capture-environment.mjs';
|
||||||
|
|
||||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const reviewed = process.argv.includes('--reviewed');
|
const reviewed = process.argv.includes('--reviewed');
|
||||||
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
|||||||
throw new Error('candidate report does not identify its Chromium build');
|
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');
|
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);
|
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||||
if (refusal) throw new Error(refusal);
|
if (refusal) throw new Error(refusal);
|
||||||
|
|
||||||
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
|
|||||||
skipWitnesses,
|
skipWitnesses,
|
||||||
skipReason,
|
skipReason,
|
||||||
});
|
});
|
||||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
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) {
|
for (const scenario of GOLDEN_SCENARIOS) {
|
||||||
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
|||||||
acceptedAt: new Date().toISOString(),
|
acceptedAt: new Date().toISOString(),
|
||||||
sourceFingerprint: report.buildFingerprint,
|
sourceFingerprint: report.buildFingerprint,
|
||||||
chromium: report.chromium,
|
chromium: report.chromium,
|
||||||
|
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||||
|
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||||
|
platform: environment.platform,
|
||||||
// #355: след приёмки в артефакте, не только в истории shell.
|
// #355: след приёмки в артефакте, не только в истории shell.
|
||||||
witnesses: skipWitnesses
|
witnesses: skipWitnesses
|
||||||
? { skipped: true, reason: skipReason }
|
? { skipped: true, reason: skipReason }
|
||||||
|
|||||||
+28
-1
@@ -1,12 +1,39 @@
|
|||||||
|
import {
|
||||||
|
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
|
||||||
|
} from '../../scripts/capture-environment.mjs';
|
||||||
|
|
||||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||||
|
|
||||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
/**
|
||||||
|
* Проверка вызова — и заодно проверка среды съёмки (#455).
|
||||||
|
*
|
||||||
|
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
|
||||||
|
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
|
||||||
|
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
|
||||||
|
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
|
||||||
|
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
|
||||||
|
* фингерпринтуемого файла.
|
||||||
|
*
|
||||||
|
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
|
||||||
|
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
|
||||||
|
* не про правило.
|
||||||
|
*/
|
||||||
|
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
|
||||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||||
if (mode === 'verify' && scenarioFilter)
|
if (mode === 'verify' && scenarioFilter)
|
||||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||||
|
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
|
||||||
|
if (mode !== 'capture') return;
|
||||||
|
const platform = options.platform ?? captureEnvironment().platform;
|
||||||
|
const allowance = options.allowance ?? foreignCaptureAllowance();
|
||||||
|
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||||
|
platform, kind: 'golden', stage: 'capture', allowance,
|
||||||
|
});
|
||||||
|
if (refusal) throw new Error(refusal);
|
||||||
|
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
|
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
|
||||||
"bundle:budget": "node scripts/bundle-budget.mjs",
|
"bundle:budget": "node scripts/bundle-budget.mjs",
|
||||||
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
|
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
|
||||||
|
"docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs",
|
||||||
"docs:accept": "node scripts/docs-accept.mjs",
|
"docs:accept": "node scripts/docs-accept.mjs",
|
||||||
"smokes:select": "node scripts/smoke-select.mjs",
|
"smokes:select": "node scripts/smoke-select.mjs",
|
||||||
"inventory": "node scripts/inventory.mjs",
|
"inventory": "node scripts/inventory.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Отказ до съёмки — для тех точек, где гейт нельзя встроить в сам скрипт (#455).
|
||||||
|
*
|
||||||
|
* `demo/docs/capture.mjs` править нельзя дёшево: его sha записан в индексе
|
||||||
|
* скриншотов документации, и `scripts/check-docs.mjs` сверяет их. Любая правка
|
||||||
|
* объявляет закоммиченный индекс устаревшим — то есть стоит пересъёмки всех
|
||||||
|
* картинок и визуальной приёмки владельца за проверку, которая ничего не
|
||||||
|
* рисует. Поэтому проверка живёт шагом раньше, в npm-скрипте:
|
||||||
|
*
|
||||||
|
* "docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs"
|
||||||
|
*
|
||||||
|
* У golden такой проблемы нет: там гейт стоит в `demo/golden/policy.mjs`,
|
||||||
|
* который исключён из корпуса отпечатка и уже вызывается из `run.mjs`.
|
||||||
|
*
|
||||||
|
* node scripts/assert-capture-env.mjs <golden|docs> [--stage=capture|accept]
|
||||||
|
*/
|
||||||
|
import { assertCaptureEnvironment } from './capture-environment.mjs';
|
||||||
|
|
||||||
|
const [kindArg] = process.argv.slice(2).filter((arg) => !arg.startsWith('--'));
|
||||||
|
const stageArg = process.argv.find((arg) => arg.startsWith('--stage='));
|
||||||
|
const kind = kindArg === 'docs' ? 'docs' : 'golden';
|
||||||
|
const stage = stageArg?.slice('--stage='.length) === 'accept' ? 'accept' : 'capture';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allowance = assertCaptureEnvironment({ kind, stage });
|
||||||
|
if (allowance) console.log(`Чужая среда разрешена осознанно: ${allowance}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Среда съёмки как проверяемое условие, а не как знание в голове (#455).
|
||||||
|
*
|
||||||
|
* Правило после #401: принимается любая среда, доказавшая себя байтовым
|
||||||
|
* совпадением непринятых кадров. Windows такого совпадения не даёт и не может:
|
||||||
|
* другая растеризация шрифтов (DirectWrite против FreeType), субпиксельное
|
||||||
|
* сглаживание, DPI и другая сборка Chromium. Флаги детерминизма из #410
|
||||||
|
* (`--force-color-profile=srgb`, `--font-render-hinting=none`,
|
||||||
|
* `--disable-lcd-text`, целочисленный клип) убирают разброс ВНУТРИ среды, а не
|
||||||
|
* между операционными системами.
|
||||||
|
*
|
||||||
|
* До этой задачи знание «на Windows снимать бесполезно» жило только в головах и
|
||||||
|
* в документах ревью. Съёмка отрабатывала штатно, а стена появлялась на
|
||||||
|
* приёмке — и текст той стены говорил про число сцен-свидетелей, то есть
|
||||||
|
* подсказывал неверный вывод «надо объявить больше сцен». Отсюда две вещи:
|
||||||
|
* отказ переносится на самое начало, а текст называет причину.
|
||||||
|
*
|
||||||
|
* Проверка съёмки — не то же, что проверка приёмки: `golden:verify` на Windows
|
||||||
|
* законен и полезен как грубая проверка «не сломал ли рендер вообще». Запрещена
|
||||||
|
* только съёмка с расчётом на приёмку.
|
||||||
|
*
|
||||||
|
* Где гейт НЕ стоит и почему. В `demo/docs/capture.mjs` — ни строки: его sha
|
||||||
|
* записан в манифест скриншотов документации (`captureScriptSha256`), и
|
||||||
|
* `scripts/check-docs.mjs` сверяет его с файлом. Любая правка объявляет
|
||||||
|
* закоммиченный индекс устаревшим, то есть стоит пересъёмки десяти картинок и
|
||||||
|
* визуальной приёмки владельца — за проверку, которая ничего не рисует.
|
||||||
|
* Проверено на себе: первая редакция этой задачи правку сделала, и гейт
|
||||||
|
* документации сразу покраснел. Поэтому для документации отказ живёт на шаг
|
||||||
|
* раньше (`scripts/assert-capture-env.mjs` в npm-скрипте `docs:capture`) и на
|
||||||
|
* шаг позже (приёмка). То же и для golden: гейт в `policy.mjs`, а не в
|
||||||
|
* `run.mjs`, который входит в корпус `sourceFingerprint`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Канон среды съёмки: Linux CI и WSL. */
|
||||||
|
export const CAPTURE_CANON_PLATFORM = 'linux';
|
||||||
|
|
||||||
|
/** Переменная осознанного обхода. Пустая причина обходом не считается. */
|
||||||
|
export const ALLOW_FOREIGN_ENV = 'HP_ALLOW_FOREIGN_CAPTURE';
|
||||||
|
|
||||||
|
const WSL_COMMAND = {
|
||||||
|
golden: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && npm run golden:capture',
|
||||||
|
docs: 'wsl -d Ubuntu → cd ~/houseplan-card && npm run build && node demo/docs/capture.mjs',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Провенанс среды: то, что уезжает в манифест рядом с версией Chromium. */
|
||||||
|
export const captureEnvironment = (source = process) => ({
|
||||||
|
platform: String(source.platform || ''),
|
||||||
|
arch: String(source.arch || ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разрешён ли осознанный обход. Возвращает причину или `null`.
|
||||||
|
* Пустая строка — не причина: обход без записанной причины неотличим от
|
||||||
|
* забытой переменной в окружении.
|
||||||
|
*/
|
||||||
|
export const foreignCaptureAllowance = (env = process.env) => {
|
||||||
|
const reason = String(env?.[ALLOW_FOREIGN_ENV] ?? '').trim();
|
||||||
|
return reason || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отказ съёмки или приёмки в чужой среде.
|
||||||
|
*
|
||||||
|
* `kind`: `golden` | `docs` — от него зависит только команда в подсказке.
|
||||||
|
* `stage`: `capture` | `accept` — от него зависит формулировка.
|
||||||
|
* Возвращает `{ refusal, allowance }`: `refusal` — текст или `null`.
|
||||||
|
*/
|
||||||
|
export const foreignCaptureRefusal = ({
|
||||||
|
platform,
|
||||||
|
kind = 'golden',
|
||||||
|
stage = 'capture',
|
||||||
|
canon = CAPTURE_CANON_PLATFORM,
|
||||||
|
allowance = null,
|
||||||
|
} = {}) => {
|
||||||
|
if (platform === canon) return { refusal: null, allowance: null };
|
||||||
|
if (allowance) return { refusal: null, allowance };
|
||||||
|
const what = kind === 'docs' ? 'скриншоты документации' : 'эталоны golden';
|
||||||
|
const action = stage === 'accept'
|
||||||
|
? `приёмка отказана: ${what} сняты на платформе «${platform}»`
|
||||||
|
: `съёмка отказана: ${what} на платформе «${platform}» принять будет нечем`;
|
||||||
|
return {
|
||||||
|
refusal: `${action}. Байтового совпадения с принятыми кадрами Windows не даёт`
|
||||||
|
+ ' (растеризация шрифтов, субпиксельное сглаживание, DPI, другая сборка Chromium),'
|
||||||
|
+ ` поэтому сцен-свидетелей среды будет ноль и приёмка откажет. Снимайте в WSL:\n`
|
||||||
|
+ ` ${WSL_COMMAND[kind] || WSL_COMMAND.golden}\n`
|
||||||
|
+ `Диагностика на Windows законна: golden:verify показывает расхождения и ничего не принимает.`
|
||||||
|
+ ` Если чужая среда осознанна — ${ALLOW_FOREIGN_ENV}="причина" оставит её в выводе и в манифесте.`,
|
||||||
|
allowance: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приписка к отказу приёмки, когда среда кадров и эталонов разошлась.
|
||||||
|
*
|
||||||
|
* Именно этой фразы не хватало: без неё отказ «свидетелей 0 из 10» читается
|
||||||
|
* как «объяви больше сцен», и обход в одну команду выглядит решением.
|
||||||
|
*/
|
||||||
|
export const environmentNote = ({ capturedOn, acceptedOn } = {}) => {
|
||||||
|
if (!capturedOn || !acceptedOn || capturedOn === acceptedOn) return null;
|
||||||
|
return `среда съёмки (${capturedOn}) не совпадает со средой принятых эталонов`
|
||||||
|
+ ` (${acceptedOn}): свидетелей и не могло быть — дело не в числе объявленных сцен`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бросающая обёртка для точек, где отказ обязан остановить работу.
|
||||||
|
*
|
||||||
|
* Возвращает разрешённую причину обхода (или `null`) — вызывающий печатает её
|
||||||
|
* сам, чтобы след остался в выводе прогона.
|
||||||
|
*/
|
||||||
|
export const assertCaptureEnvironment = ({
|
||||||
|
kind = 'golden', stage = 'capture', platform, allowance,
|
||||||
|
} = {}) => {
|
||||||
|
const resolvedPlatform = platform ?? captureEnvironment().platform;
|
||||||
|
const resolvedAllowance = allowance ?? foreignCaptureAllowance();
|
||||||
|
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||||
|
platform: resolvedPlatform, kind, stage, allowance: resolvedAllowance,
|
||||||
|
});
|
||||||
|
if (refusal) throw new Error(refusal);
|
||||||
|
return accepted;
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { assertCaptureEnvironment, captureEnvironment } from './capture-environment.mjs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -94,9 +95,13 @@ export function verifyDocsCandidate({
|
|||||||
/** Build the manifest written by the CLI without erasing an earlier review trace. */
|
/** Build the manifest written by the CLI without erasing an earlier review trace. */
|
||||||
export function acceptedDocsManifest({
|
export function acceptedDocsManifest({
|
||||||
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
|
manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '',
|
||||||
|
platform = null,
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
...manifest,
|
...manifest,
|
||||||
|
// Провенанс среды приёмки (#455): рядом с версией браузера, чтобы у
|
||||||
|
// следующего разбора «почему кадры разошлись» была не только догадка.
|
||||||
|
...(platform ? { acceptedOn: platform } : {}),
|
||||||
acceptance: decision.replace.length
|
acceptance: decision.replace.length
|
||||||
? {
|
? {
|
||||||
declared: [...decision.replace],
|
declared: [...decision.replace],
|
||||||
@@ -114,6 +119,13 @@ const list = (argv, name) => argv
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
function main(argv) {
|
function main(argv) {
|
||||||
|
// Среда приёмки (#455). Платформу съёмки манифест не несёт и не может:
|
||||||
|
// добавить поле — значит править `demo/docs/capture.mjs`, чей sha записан в
|
||||||
|
// индексе скриншотов, то есть платить пересъёмкой десяти картинок за
|
||||||
|
// проверку. Приёмка идёт там, где лежат артефакты, поэтому её платформа —
|
||||||
|
// достаточный признак среды кадров.
|
||||||
|
const allowed = assertCaptureEnvironment({ kind: 'docs', stage: 'accept' });
|
||||||
|
if (allowed) console.log(`Чужая среда приёмки разрешена осознанно: ${allowed}`);
|
||||||
if (!argv.includes('--reviewed')) {
|
if (!argv.includes('--reviewed')) {
|
||||||
console.error('отказ: замена скриншотов без явного --reviewed');
|
console.error('отказ: замена скриншотов без явного --reviewed');
|
||||||
return 2;
|
return 2;
|
||||||
@@ -166,6 +178,7 @@ function main(argv) {
|
|||||||
manifest,
|
manifest,
|
||||||
previousAcceptance: previous,
|
previousAcceptance: previous,
|
||||||
decision,
|
decision,
|
||||||
|
platform: captureEnvironment().platform,
|
||||||
skipWitnesses,
|
skipWitnesses,
|
||||||
skipReason,
|
skipReason,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -192,3 +192,18 @@ test('#421 fingerprint-only refresh preserves the complete previous acceptance t
|
|||||||
});
|
});
|
||||||
assert.deepEqual(firstRefresh.acceptance, { lastWriteWasFingerprintOnly: true });
|
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), 'без платформы поля быть не должно');
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ test('golden capture fails on runtime errors but permits missing baselines', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('golden verification cannot make a partial success claim', () => {
|
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.doesNotThrow(() => assertGoldenInvocation('verify', ''));
|
||||||
assert.throws(() => assertGoldenInvocation('verify', 'one-scenario'), /complete matrix/);
|
assert.throws(() => assertGoldenInvocation('verify', 'one-scenario'), /complete matrix/);
|
||||||
assert.throws(() => assertGoldenInvocation('unknown', ''), /unknown golden mode/);
|
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: '',
|
||||||
|
}), /съёмка отказана/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user