test(runner): строгий режим снэпшотов — отсутствие эталона не проходит молча

compareSnapshot при отсутствии каталога эталона возвращал {match:true,
reason:'no snapshot (skipped)'}, причём reason никуда не выводился. Кейс без
эталона был молча зелёным, а «намеренно нет» и «эталон потерялся / не создан
при добавлении кейса» — неразличимы. README закреплял это как штатное
(«совпадает со snapshot (если есть)»).

Теперь эталон обязателен везде, кроме expectError, readonly external: и явного
opt-out. Диагностика — на месте кейса, с готовой командой; сводной статистики
не добавляем (вне контекста она ничего не сообщает).

- noSnapshot: "<причина>" — легальный пропуск. Причина обязательна: отключение
  сверки должно стоить автору формулировки, а ревьюеру быть видно в diff'е;
  осмысленность причины рантайм проверить не может. true/"" → падение.
- Нет эталона и нет opt-out → падение с рецептом (команда --update-snapshots
  либо подсказка объявить noSnapshot).
- Мёртвый эталон (noSnapshot + существующий каталог) → падение: не сверяется,
  но выглядит покрытием.
- updateSnapshot пропускает кейсы с noSnapshot — иначе --update-snapshots сам
  порождал бы противоречие. Опечатка в имени поля fail-safe: opt-out не
  сработает, кейс упадёт как «эталон отсутствует».
- Диагностика вынесена в общий snapshotErrors() — обе ветки (runCase /
  runCaseAsync) больше не дублируют логику.

Размечены 3 кейса meta-validate: навык только читает и печатает, эталон
зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка.

Проверка: до разметки сюита падала ровно на этих 3 кейсах (независимое
подтверждение аудита). Негативные сценарии проверены все пять: потерянный
эталон, мёртвый эталон, noSnapshot без причины, update на opt-out кейсе
(не создаёт), update на обычном (создаёт байт-в-байт прежний).
Полная сюита 566/566 ps1; python 563 passed + 3 skipped — идентично HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-25 15:25:20 +03:00
co-authored by Claude Opus 5
parent b194834f2b
commit 3b5444e69d
5 changed files with 102 additions and 26 deletions
+48 -3
View File
@@ -129,7 +129,11 @@ ibcmd-проход автоматически `○ skipped`, если рядом
}
```
Раннер проверит: exitCode=0 + выход совпадает со snapshot (если есть).
Раннер проверит: exitCode=0 + выход совпадает с эталоном.
Эталон **обязателен**: если его нет и кейс не объявил `noSnapshot`, тест падает. Иначе потерянный
(или не созданный при добавлении кейса) эталон неотличим от намеренного отсутствия — тест зелёный,
хотя выход не проверяется.
### С параметрами навыка
@@ -226,19 +230,60 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| `preRun` | нет | Массив шагов подготовки (создание объектов и т.п.) |
| `expect` | нет | Дополнительные проверки: `files`, `stdoutContains` (строка/массив), `stdoutNotContains` (строка/массив) |
| `expectError` | нет | `true` или строка — ожидается ошибка |
| `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») |
## Эталоны (snapshots)
Эталон — директория `snapshots/<имя-кейса>/` внутри папки навыка. Содержит ожидаемый выход навыка после нормализации.
### Когда эталон обязателен
Всегда, кроме трёх случаев:
- `expectError` — проверяется факт ошибки, выхода нет;
- `setup: "external:<path>"` — рабочая директория read-only, эталон физически не создать;
- кейс объявил `noSnapshot` (см. ниже).
Во всех остальных случаях отсутствующий (или пустой) эталон — **падение** с подсказкой, что делать.
### `noSnapshot` — когда эталон не нужен
```json
{
"name": "Валидатор: ссылочный тип разрешается",
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun, а не проверяемого навыка; проверяется stdout",
"expect": { "stdoutContains": "16. Reference types:" }
}
```
Типичный случай — навык ничего не пишет в рабочую директорию (info/validate): эталон зафиксировал бы
выход `preRun`, а не проверяемого навыка, и дублировал бы эталоны того навыка.
**Причина обязательна** — непустая строка; `true` не принимается и валит кейс. Смысл в том, что
отключение сверки должно стоить автору формулировки, а ревьюеру быть видно в diff'е: проверить
осмысленность причины рантайм не может.
Если у кейса стоит `noSnapshot`, но каталог эталона существует — тоже падение: такой эталон
не сверяется и создаёт ложное впечатление покрытия. Удалите каталог либо снимите `noSnapshot`.
### Создание / обновление эталонов
```bash
node tests/skills/runner.mjs --update-snapshots # все кейсы
node tests/skills/runner.mjs cases/meta-compile/enum --update-snapshots # один кейс — предпочтительно
node tests/skills/runner.mjs cases/meta-compile --update-snapshots # один навык
node tests/skills/runner.mjs cases/meta-compile/enum --update-snapshots # один кейс
node tests/skills/runner.mjs --update-snapshots # все кейсы
```
> Прогон по навыку/сюите **перезаписывает эталоны всех** кейсов сразу: если побочно поехал вывод
> соседнего кейса, его эталон обновится вместе с целевым и непреднамеренная регрессия замаскируется.
> Поэтому по умолчанию — точечно по кейсу, а после массового пересъёма обязательно проверяйте
> `git diff` по `snapshots/`: каждая ± строка должна быть ожидаемой.
>
> Массовый пересъём легитимен, когда изменение вывода и правда затрагивает многих — например, правка
> `meta-compile` меняет фикстуры ~20 навыков, чьи кейсы строятся его `preRun`-прогоном.
>
> Кейсы с `noSnapshot` пропускаются — эталон им не создаётся.
### Когда обновлять
- После **намеренного** изменения логики навыка (новый выход — новый эталон)
@@ -10,6 +10,7 @@
],
"params": { "objectPath": "Catalogs/ТестСсылка" },
"args_extra": ["-Detailed"],
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
"expect": {
"stdoutContains": "16. Reference types:",
"stdoutNotContains": "16. Ссылочный тип"
@@ -10,6 +10,7 @@
],
"params": { "objectPath": "SessionParameters/ТекущийПользователь" },
"args_extra": ["-Detailed"],
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
"expect": {
"stdoutContains": "базовая структурная проверка"
}
@@ -9,6 +9,7 @@
}
],
"params": { "objectPath": "Catalogs/ТестСсылкаБитая" },
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
"expect": {
"stdoutContains": "16. Ссылочный тип 'CatalogRef.НетТакогоСправочника'"
}
+51 -23
View File
@@ -418,11 +418,24 @@ function listFilesRecursive(dir, base = '') {
return result.sort();
}
function compareSnapshot(workDir, snapshotDir, snapshotConfig) {
if (!existsSync(snapshotDir)) return { match: true, reason: 'no snapshot (skipped)' };
// Строгий режим: отсутствие эталона НЕ проходит молча. Кейс либо сверяется со снэпшотом,
// либо явно объявляет `"noSnapshot": "<причина>"`. Иначе потерянный (или не созданный при
// добавлении кейса) эталон неотличим от намеренного отсутствия — тест зелёный, а не проверяет ничего.
function compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData) {
const optOut = caseData?.noSnapshot;
const hasSnapshotDir = existsSync(snapshotDir) && listFilesRecursive(snapshotDir).length > 0;
if (optOut !== undefined && optOut !== false) {
// Причина обязательна: opt-out должен стоить автору формулировки, а ревьюеру — быть виден в diff'е.
if (typeof optOut !== 'string' || !optOut.trim()) return { match: false, badOptOut: true };
// Мёртвый эталон: помечен как ненужный, но лежит в репозитории — выглядит покрытием, не сверяется.
if (hasSnapshotDir) return { match: false, deadSnapshot: true };
return { match: true, reason: `no snapshot (opt-out: ${optOut})` };
}
if (!hasSnapshotDir) return { match: false, missingSnapshot: true };
const snapshotFiles = listFilesRecursive(snapshotDir);
if (snapshotFiles.length === 0) return { match: true, reason: 'empty snapshot (skipped)' };
const diffs = [];
@@ -463,7 +476,35 @@ function compareSnapshot(workDir, snapshotDir, snapshotConfig) {
return { match: false, diffs };
}
function updateSnapshot(workDir, snapshotDir, snapshotConfig) {
// Диагностика снэпшот-сверки — общая для обеих веток запуска (runCase / runCaseAsync),
// чтобы сообщения и условия не разъехались.
function snapshotErrors(cmp, caseId) {
if (cmp.match) return [];
if (cmp.badOptOut) {
return [`Snapshot: "noSnapshot" должен быть непустой строкой с причиной, почему эталон не нужен`];
}
if (cmp.deadSnapshot) {
return [`Snapshot: кейс объявил "noSnapshot", но эталон существует — он не сверяется и вводит в заблуждение.\n`
+ ` Удалите каталог snapshots/<кейс>/ либо снимите "noSnapshot"`];
}
if (cmp.missingSnapshot) {
return [`Snapshot: эталон отсутствует. Создайте:\n`
+ ` node tests/skills/runner.mjs ${caseId} --update-snapshots\n`
+ `либо объявите в кейсе: "noSnapshot": "<почему эталон не нужен>"`];
}
const errs = [];
for (const d of cmp.diffs || []) {
if (d.type === 'missing') errs.push(`Snapshot: file missing — ${d.file}`);
else errs.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
}
return errs;
}
function updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData) {
// Кейс объявил, что эталон не нужен — не создаём. Иначе --update-snapshots по навыку
// дорисовал бы эталон и сам породил противоречие с opt-out.
if (caseData?.noSnapshot) return;
// Remove old snapshot
if (existsSync(snapshotDir)) rmSync(snapshotDir, { recursive: true, force: true });
@@ -651,15 +692,10 @@ async function runCaseAsync(testCase, opts) {
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
if (opts.updateSnapshots) {
updateSnapshot(workDir, snapshotDir, snapshotConfig);
updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
} else {
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig);
if (!cmp.match && cmp.diffs) {
for (const d of cmp.diffs) {
if (d.type === 'missing') errors.push(`Snapshot: file missing — ${d.file}`);
else errors.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
}
}
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
errors.push(...snapshotErrors(cmp, testCase.id));
}
}
@@ -835,18 +871,10 @@ function runCase(testCase, opts) {
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
if (opts.updateSnapshots) {
updateSnapshot(workDir, snapshotDir, snapshotConfig);
updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
} else {
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig);
if (!cmp.match && cmp.diffs) {
for (const d of cmp.diffs) {
if (d.type === 'missing') {
errors.push(`Snapshot: file missing — ${d.file}`);
} else {
errors.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
}
}
}
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
errors.push(...snapshotErrors(cmp, testCase.id));
}
}