test(skills): фейк платформы для *nix — детектор тихих отказов проверяется и на macOS

Кейсы детектора были написаны на batch-фейке и потому гейтились osOnly: win32. На маке
py-порт единственный, то есть ровно там, где проверять важнее всего, детектор оставался
без автоматического покрытия.

Добавлен sh-фейк и восемь зеркальных кейсов (osOnly: darwin/linux, runtimeOnly: python).
Для этого потребовались два расширения DSL:

- `writeFile.executable` — на *nix навык запускает платформу через exec, и файл без бита
  исполнения не стартует вовсе; Node пишет файлы без +x. На Windows chmod — no-op.
  Реализовано во всех трёх местах разбора шага (оба в runner.mjs и в verify-snapshots.mjs):
  ключ, который понимает только один раннер, даёт тихую дыру.
- `osOnly` принимает массив, а не только строку. Иначе darwin и linux требовали бы двух
  копий одного кейса.

Оба ключа описаны в tests/skills/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-13 15:46:47 +03:00
co-authored by Claude Opus 5
parent a024b7da9c
commit 793cb8f3d6
11 changed files with 378 additions and 5 deletions
+2 -2
View File
@@ -274,7 +274,7 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») |
| `idempotent` | нет | `true` — повторный прогон с теми же аргументами должен дать байт-в-байт тот же `workDir` |
| `runtimeOnly` | нет | `"powershell"` / `"python"` — кейс имеет смысл только на одном порте, на другом `○ skipped` |
| `osOnly` | нет | `"win32"` / `"darwin"` / `"linux"` — кейс работает только на этой ОС (напр. фейк платформы написан как `.cmd`), на других `○ skipped` |
| `osOnly` | нет | `"win32"` / `"darwin"` / `"linux"` — кейс работает только на этой ОС (напр. фейк платформы написан как `.cmd`), на других `○ skipped`. Принимает и массив: `["darwin","linux"]` |
| `cwd` | нет | `"workDir"` — запустить навык из рабочего каталога (нужно, если кейс кладёт туда `.v8-project.json`) |
| `skipValidation` | нет | `true` — не запускать `postValidate` из `_skill.json` (только при `--with-validation`) |
| `skipPlatformVerify` | нет | Непустая строка с причиной — `verify-snapshots.mjs` пропускает кейс. Нужен, когда фикстура не является конфигурацией, которую можно загрузить (напр. фрагмент расширения) |
@@ -318,7 +318,7 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| Форма шага | Описание |
|---|---|
| `{ "script": "<навык>/scripts/<файл>", "input": {...}, "args": { "-Flag": "{inputFile}" } }` | Прогон другого навыка для подготовки фикстуры. Плейсхолдеры: `{inputFile}`, `{workDir}` |
| `{ "writeFile": { "path": "<путь>", "content": "<строка или объект>" } }` | Записать произвольный файл в `workDir` (объект сериализуется в JSON) |
| `{ "writeFile": { "path": "<путь>", "content": "<строка или объект>" } }` | Записать произвольный файл в `workDir` (объект сериализуется в JSON). Ключ `"executable": true` ставит бит исполнения — нужен фейку платформы на *nix, где навык запускает её через `exec`; на Windows no-op |
Дополнительно у шага-скрипта: `"cwd": "{workDir}"` — запустить шаг из рабочего каталога. Нужно,
когда относительный путь в `args` (напр. `-OutputPath: "Template.xml"`) должен лечь в фикстуру, а
@@ -0,0 +1,47 @@
{
"name": "Чистый лог на *nix: предупреждения нет",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nЗагрузка завершена. Суммарное время выполнения операции: 780 миллисекунд.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
},
{
"writeFile": {
"path": "src/Configuration.xml",
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MetaDataObject/>"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib",
"-ConfigDir",
"{workDir}/src"
],
"expect": {
"stdoutNotContains": "[warning] platform reported success, but the log contains"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,50 @@
{
"name": "Режим совместимости выше платформы на *nix: классифицируется как отказ (issue #63)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Для работы с конфигурацией необходима версия платформы не меньше, чем 8.3.27.\r\nТекущая версия 8.3.24.1691.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
},
{
"writeFile": {
"path": "src/Configuration.xml",
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MetaDataObject/>"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib",
"-ConfigDir",
"{workDir}/src"
],
"expect": {
"stdoutContains": [
"[warning] platform reported success, but the log contains 1 problem(s):",
"необходима версия платформы не меньше"
]
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,51 @@
{
"name": "Тихий отказ в логе на *nix: py-порт предупреждает (fake platform)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nНеверное свойство объекта метаданных. Свойство LineNumberLength не входит в состав объекта метаданных TabularSection. - LineNumberLength\r\nЗагрузка завершена.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
},
{
"writeFile": {
"path": "src/Configuration.xml",
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MetaDataObject/>"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib",
"-ConfigDir",
"{workDir}/src"
],
"expect": {
"stdoutContains": [
"[warning] platform reported success, but the log contains 1 problem(s):",
"Свойство LineNumberLength не входит в состав объекта метаданных"
],
"stdoutNotContains": "-StrictLog"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,49 @@
{
"name": "Тот же лог под -StrictLog на *nix: код возврата 1 (fake platform)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nНеверное свойство объекта метаданных. Свойство LineNumberLength не входит в состав объекта метаданных TabularSection. - LineNumberLength\r\nЗагрузка завершена.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
},
{
"writeFile": {
"path": "src/Configuration.xml",
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<MetaDataObject/>"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib",
"-ConfigDir",
"{workDir}/src",
"-StrictLog"
],
"expect": {
"stdoutContains": "[warning] platform reported success, but the log contains 1 problem(s):"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы",
"expectError": true
}
@@ -0,0 +1,39 @@
{
"name": "Чистый лог на *nix: предупреждения нет",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nЗагрузка завершена. Суммарное время выполнения операции: 780 миллисекунд.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib"
],
"expect": {
"stdoutNotContains": "[warning] platform reported success, but the log contains"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,42 @@
{
"name": "Режим совместимости выше платформы на *nix: классифицируется как отказ (issue #63)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Для работы с конфигурацией необходима версия платформы не меньше, чем 8.3.27.\r\nТекущая версия 8.3.24.1691.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib"
],
"expect": {
"stdoutContains": [
"[warning] platform reported success, but the log contains 1 problem(s):",
"необходима версия платформы не меньше"
]
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,43 @@
{
"name": "Тихий отказ в логе на *nix: py-порт предупреждает (fake platform)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nНеверное свойство объекта метаданных. Свойство LineNumberLength не входит в состав объекта метаданных TabularSection. - LineNumberLength\r\nЗагрузка завершена.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib"
],
"expect": {
"stdoutContains": [
"[warning] platform reported success, but the log contains 1 problem(s):",
"Свойство LineNumberLength не входит в состав объекта метаданных"
],
"stdoutNotContains": "-StrictLog"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы"
}
@@ -0,0 +1,41 @@
{
"name": "Тот же лог под -StrictLog на *nix: код возврата 1 (fake platform)",
"osOnly": [
"darwin",
"linux"
],
"runtimeOnly": "python",
"preRun": [
{
"writeFile": {
"path": "fake.sh",
"content": "#!/bin/sh\n# Фейк платформы для *nix: вычитывает путь из /Out и кладёт туда готовый лог.\nSELF=$(dirname \"$0\")\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"/Out\" ]; then OUT=\"$2\"; fi\n shift\ndone\ncp \"$SELF/log.txt\" \"$OUT\"\nexit 0\n",
"executable": true
}
},
{
"writeFile": {
"path": "log.txt",
"content": "Загрузка конфигурации из файлов.\r\nНеверное свойство объекта метаданных. Свойство LineNumberLength не входит в состав объекта метаданных TabularSection. - LineNumberLength\r\nЗагрузка завершена.\r\n"
}
},
{
"writeFile": {
"path": "ib/1Cv8.1CD",
"content": "stub"
}
}
],
"args_extra": [
"-V8Path",
"{workDir}/fake.sh",
"-InfoBasePath",
"{workDir}/ib",
"-StrictLog"
],
"expect": {
"stdoutContains": "[warning] platform reported success, but the log contains 1 problem(s):"
},
"noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор лога платформы",
"expectError": true
}
+10 -2
View File
@@ -4,7 +4,7 @@
import { execFileSync, execFile } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync,
readdirSync, statSync, cpSync, copyFileSync } from 'fs';
readdirSync, statSync, cpSync, copyFileSync, chmodSync } from 'fs';
import { createHash } from 'crypto';
import { join, resolve, dirname, relative, basename, extname } from 'path';
import { tmpdir, cpus } from 'os';
@@ -713,7 +713,9 @@ async function runCaseAsync(testCase, opts) {
// osOnly: gate a case to one OS (e.g. a fake platform written as a .cmd cannot run on
// macOS/Linux at all, whatever the port). Values are process.platform strings.
if (caseData.osOnly && caseData.osOnly !== process.platform) {
// Значение — строка или массив строк process.platform: фейк платформы бывает нужен и на
// darwin, и на linux, а дублировать кейс ради второй ОС смысла нет.
if (caseData.osOnly && ![].concat(caseData.osOnly).includes(process.platform)) {
return { id: testCase.id, skill: testCase.skillDir, name: testCase.name, passed: true, skipped: true, errors: [], elapsed: '0.0s' };
}
@@ -745,6 +747,9 @@ async function runCaseAsync(testCase, opts) {
: JSON.stringify(step.writeFile.content, null, 2);
mkdirSync(dirname(wfPath), { recursive: true });
writeFileSync(wfPath, wfContent, 'utf8');
// Бит исполнения: на *nix навык запускает платформу через exec, и фейк без +x
// не стартует вовсе. На Windows chmod — no-op.
if (step.writeFile.executable) chmodSync(wfPath, 0o755);
continue;
}
const preScript = resolveScript(step.script, opts.runtime);
@@ -1363,6 +1368,9 @@ async function runIntegrationOnce(test, opts, engine, labelEngine) {
const abs = target.includes(':') || target.startsWith('/') ? target : join(workDir, target);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, step.content ?? '', 'utf8');
// Бит исполнения: на *nix навык запускает платформу через exec, и фейк без +x
// не стартует вовсе. На Windows chmod — no-op.
if (step.executable) chmodSync(abs, 0o755);
const stepElapsed = ((performance.now() - stepT0) / 1000).toFixed(1);
stepResults.push({ name: step.name, passed: true, elapsed: `${stepElapsed}s` });
} catch (e) {
+4 -1
View File
@@ -13,7 +13,7 @@
import { execFileSync } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync,
readdirSync, statSync, cpSync, copyFileSync } from 'fs';
readdirSync, statSync, cpSync, copyFileSync, chmodSync } from 'fs';
import { join, resolve, dirname, basename } from 'path';
import { tmpdir } from 'os';
@@ -627,6 +627,9 @@ function runPreSteps(preRun, workDir, runtime, log) {
: JSON.stringify(step.writeFile.content, null, 2);
mkdirSync(dirname(wfPath), { recursive: true });
writeFileSync(wfPath, wfContent, 'utf8');
// Бит исполнения: на *nix навык запускает платформу через exec, и фейк без +x
// не стартует вовсе. На Windows chmod — no-op.
if (step.writeFile.executable) chmodSync(wfPath, 0o755);
log(`preRun: writeFile ${step.writeFile.path}`, true);
continue;
}