From 37108b15c4da03764e8a3f91891d07e19ee731e8 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 12 Aug 2026 14:40:05 +0300 Subject: [PATCH] =?UTF-8?q?test(skills):=20=D1=81=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20verify-snapshots=20=D1=81=20runner=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D1=83=20=D0=BA=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Верификатор читает тот же DSL, что и функциональный раннер, но своей реализацией, и отставал на пять ключей: preRun[].cwd, inputFrom, cwd на уровне кейса, раскрытие {workDir} в args_extra и маппинг from: outputPath. Кейсы, опирающиеся на них, до платформы не доезжали вовсе — падали на подготовке фикстуры, причём шаг preRun с относительным путём писал её в корень репозитория. Незнакомый from теперь роняет кейс с внятным сообщением: молчаливый default маскировал расхождение под дефект навыка (флаг уходил без значения). Платформенная верификация: mxl-compile 28/36 -> 36/36, mxl-decompile 0/7 -> 7/7, mxl-info 2/7 -> 7/7, mxl-validate 0/4 -> 4/4, skd-decompile 0/17 -> 17/17. Оба ключа, которых не было в документации DSL, дописаны в README. Co-Authored-By: Claude Opus 5 (1M context) --- tests/skills/README.md | 11 +++++++++++ tests/skills/verify-snapshots.mjs | 26 ++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/skills/README.md b/tests/skills/README.md index d70d047b..357c9201 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -260,6 +260,7 @@ ibcmd-проход автоматически `○ skipped`, если рядом | `outputPath` | нет | Относительный путь для навыков с `-OutputPath` | | `args_extra` | нет | Массив дополнительных CLI-аргументов | | `preRun` | нет | Массив шагов подготовки (см. ниже) | +| `inputFrom` | нет | Имя файла в `workDir` — вход берётся из него, а не из `input`. Нужно, когда вход производит `preRun` (напр. декомпилятор): `input` пишется ПОСЛЕ preRun и затёр бы его | | `expect` | нет | Дополнительные проверки (см. ниже) | | `expectError` | нет | `true` или строка — ожидается ошибка | | `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») | @@ -309,6 +310,16 @@ ibcmd-проход автоматически `○ skipped`, если рядом | `{ "script": "<навык>/scripts/<файл>", "input": {...}, "args": { "-Flag": "{inputFile}" } }` | Прогон другого навыка для подготовки фикстуры. Плейсхолдеры: `{inputFile}`, `{workDir}` | | `{ "writeFile": { "path": "<путь>", "content": "<строка или объект>" } }` | Записать произвольный файл в `workDir` (объект сериализуется в JSON) | +Дополнительно у шага-скрипта: `"cwd": "{workDir}"` — запустить шаг из рабочего каталога. Нужно, +когда относительный путь в `args` (напр. `-OutputPath: "Template.xml"`) должен лечь в фикстуру, а +не в текущий каталог процесса. Записи различаются: у шага — `{workDir}` в скобках, у кейса — +`"cwd": "workDir"` без них. + +Оба раннера (`runner.mjs` и `verify-snapshots.mjs`) читают этот DSL **каждый своей реализацией**. +Добавляя ключ, добавляй его в оба и в эту таблицу: недокументированный ключ, который понимает +только один раннер, даёт тихую дыру — кейс зелёный в функциональном прогоне и не доезжает до +платформы в верификации. + ## Эталоны (snapshots) Эталон — директория `snapshots/<имя-кейса>/` внутри папки навыка. Содержит ожидаемый выход навыка после нормализации. diff --git a/tests/skills/verify-snapshots.mjs b/tests/skills/verify-snapshots.mjs index f2dd6ad3..0017b394 100644 --- a/tests/skills/verify-snapshots.mjs +++ b/tests/skills/verify-snapshots.mjs @@ -563,11 +563,17 @@ function buildSkillArgs(skillConfig, caseData, workDir, inputFile, runtime) { args.push(mapping.flag); switch (mapping.from) { case 'inputFile': - args.push(inputFile || ''); + // inputFrom: вход берётся из файла в workDir, а не из case.input. Нужно, когда вход + // производит preRun (например, декомпилятор) — case.input пишется ПОСЛЕ preRun и + // затёр бы его. Как в runner.mjs. + args.push(caseData.inputFrom ? join(workDir, caseData.inputFrom) : (inputFile || '')); break; case 'workDir': args.push(workDir); break; + case 'outputPath': + args.push(join(workDir, caseData.outputPath || '')); + break; case 'workPath': { const field = mapping.field || 'objectPath'; const val = caseData.params?.[field] ?? caseData[field]; @@ -592,10 +598,19 @@ function buildSkillArgs(skillConfig, caseData, workDir, inputFile, runtime) { args.push(String(caseData.params?.[field] ?? caseData[field] ?? '')); } else if (mapping.from === 'literal') { args.push(mapping.value || ''); + } else { + // Незнакомый from раньше молча не давал значения — флаг уходил без аргумента, и + // это выглядело как дефект навыка. DSL читают два раннера, поэтому расхождение + // должно быть громким. + throw new Error(`_skill.json: неизвестный "from": "${mapping.from}" у флага ${mapping.flag}` + + ' — verify-snapshots.mjs не знает этого маппинга (см. buildSkillArgs)'); } } } - if (caseData.args_extra) args.push(...caseData.args_extra); + // Плейсхолдер {workDir} раскрывается и в args_extra — как в runner.mjs. + if (caseData.args_extra) { + args.push(...caseData.args_extra.map(a => typeof a === 'string' ? a.replace('{workDir}', workDir) : a)); + } return { scriptPath, args }; } @@ -631,7 +646,10 @@ function runPreSteps(preRun, workDir, runtime, log) { } const stepName = step.script.split('/').pop(); try { - execSkill(runtime, step.script, preArgs); + // cwd: "{workDir}" — шаг запускается из рабочего каталога, чтобы относительные + // пути в его args (напр. -OutputPath Template.xml) легли в фикстуру, а не в репозиторий. + const preCwd = step.cwd === '{workDir}' ? workDir : REPO_ROOT; + execSkill(runtime, step.script, preArgs, 60_000, preCwd); log(`preRun: ${stepName}`, true); } catch (e) { log(`preRun: ${stepName}`, false, e.stderr || e.message); @@ -875,7 +893,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) { try { const { args } = buildSkillArgs(skillConfig, caseData, workDir, inputFile, opts.runtime); - const mainCwd = skillConfig.cwd === 'workDir' ? workDir : REPO_ROOT; + const mainCwd = (caseData.cwd || skillConfig.cwd) === 'workDir' ? workDir : REPO_ROOT; const output = execSkill(opts.runtime, skillConfig.script, args, 60_000, mainCwd); const lastLine = output.trim().split('\n').pop(); if (caseData.expectError) {