From 0ddb6755022bb38dcc68dc3fbae359ce7b7fba4f Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 28 Mar 2026 12:48:51 +0300 Subject: [PATCH] feat: add skill regression test runner with meta-compile pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot-based test runner (tests/skills/runner.mjs) for verifying skill script output. Zero dependencies, runs on any machine with Node.js — no 1C platform needed for daily regression. Pilot: meta-compile with 6 cases (4 positive with snapshots, 2 negative). Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + tests/skills/README.md | 148 ++++++ tests/skills/cases/meta-compile/_skill.json | 12 + .../cases/meta-compile/catalog-basic.json | 7 + .../Catalogs/Валюты.xml | 327 ++++++++++++ .../Catalogs/Валюты/Ext/ObjectModule.bsl | 0 .../catalog-basic.snapshot/Configuration.xml | 252 +++++++++ .../Languages/Русский.xml | 16 + .../cases/meta-compile/catalog-tabparts.json | 21 + .../Catalogs/Товары.xml | 468 +++++++++++++++++ .../Catalogs/Товары/Ext/ObjectModule.bsl | 0 .../Configuration.xml | 252 +++++++++ .../Languages/Русский.xml | 16 + .../cases/meta-compile/document-basic.json | 23 + .../document-basic.snapshot/Configuration.xml | 252 +++++++++ .../Documents/ПриходнаяНакладная.xml | 437 ++++++++++++++++ .../ПриходнаяНакладная/Ext/ObjectModule.bsl | 0 .../Languages/Русский.xml | 16 + tests/skills/cases/meta-compile/enum.json | 11 + .../enum.snapshot/Configuration.xml | 252 +++++++++ .../enum.snapshot/Enums/ВидыНоменклатуры.xml | 133 +++++ .../enum.snapshot/Languages/Русский.xml | 16 + .../cases/meta-compile/error-empty-name.json | 5 + .../meta-compile/error-unknown-type.json | 5 + tests/skills/runner.mjs | 494 ++++++++++++++++++ 25 files changed, 3166 insertions(+) create mode 100644 tests/skills/README.md create mode 100644 tests/skills/cases/meta-compile/_skill.json create mode 100644 tests/skills/cases/meta-compile/catalog-basic.json create mode 100644 tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты.xml create mode 100644 tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты/Ext/ObjectModule.bsl create mode 100644 tests/skills/cases/meta-compile/catalog-basic.snapshot/Configuration.xml create mode 100644 tests/skills/cases/meta-compile/catalog-basic.snapshot/Languages/Русский.xml create mode 100644 tests/skills/cases/meta-compile/catalog-tabparts.json create mode 100644 tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары.xml create mode 100644 tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары/Ext/ObjectModule.bsl create mode 100644 tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Configuration.xml create mode 100644 tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Languages/Русский.xml create mode 100644 tests/skills/cases/meta-compile/document-basic.json create mode 100644 tests/skills/cases/meta-compile/document-basic.snapshot/Configuration.xml create mode 100644 tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная.xml create mode 100644 tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная/Ext/ObjectModule.bsl create mode 100644 tests/skills/cases/meta-compile/document-basic.snapshot/Languages/Русский.xml create mode 100644 tests/skills/cases/meta-compile/enum.json create mode 100644 tests/skills/cases/meta-compile/enum.snapshot/Configuration.xml create mode 100644 tests/skills/cases/meta-compile/enum.snapshot/Enums/ВидыНоменклатуры.xml create mode 100644 tests/skills/cases/meta-compile/enum.snapshot/Languages/Русский.xml create mode 100644 tests/skills/cases/meta-compile/error-empty-name.json create mode 100644 tests/skills/cases/meta-compile/error-unknown-type.json create mode 100644 tests/skills/runner.mjs diff --git a/.gitignore b/.gitignore index 33d83063..3077e883 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ tools/ # Отладка навыков (eval, trigger-test, run_loop результаты) debug/ +# Кэш тестов навыков +tests/skills/.cache/ + # Python кэш __pycache__/ diff --git a/tests/skills/README.md b/tests/skills/README.md new file mode 100644 index 00000000..9714a68e --- /dev/null +++ b/tests/skills/README.md @@ -0,0 +1,148 @@ +# Регресс-тесты навыков + +Snapshot-тестирование скриптов навыков: навык получает вход → генерирует файлы → результат сравнивается с эталоном. + +Быстрые, файловые, без зависимости от платформы 1С. + +## Запуск + +```bash +node tests/skills/runner.mjs # все кейсы +node tests/skills/runner.mjs cases/meta-compile # один навык +node tests/skills/runner.mjs cases/meta-compile/catalog-basic # один кейс +node tests/skills/runner.mjs --update-snapshots # обновить эталоны +node tests/skills/runner.mjs --runtime python # запуск на PY-версиях +node tests/skills/runner.mjs --json report.json # JSON-отчёт +``` + +Exit code: 0 = все прошли, 1 = есть падения. + +## Как добавить навык + +1. Создать папку `tests/skills/cases/<имя-навыка>/` +2. Положить `_skill.json` — описание навыка для раннера +3. Добавить кейсы — по одному `.json` файлу на кейс + +### Формат _skill.json + +```json +{ + "script": "meta-compile/scripts/meta-compile", + "setup": "empty-config", + "args": [ + { "flag": "-JsonPath", "from": "inputFile" }, + { "flag": "-OutputDir", "from": "workDir" } + ], + "snapshot": { + "root": "workDir", + "normalizeUuids": true + } +} +``` + +| Поле | Описание | +|---|---| +| `script` | Путь от `.claude/skills/`, без расширения. Раннер добавит `.ps1` или `.py` | +| `setup` | Фикстура: `"empty-config"` (пустая конфа), `"base-config"`, `"none"`, `"fixture:"` | +| `args` | Маппинг параметров навыка (см. ниже) | +| `snapshot` | Настройки сравнения: `root` и `normalizeUuids` | + +### Значения `from` в args + +| Значение | Что подставляется | +|---|---| +| `"inputFile"` | Путь к temp-файлу с `case.input` (JSON) | +| `"workDir"` | Рабочая директория (копия фикстуры) | +| `"outputPath"` | `workDir` + `case.outputPath` | +| `"case."` | Поле из JSON кейса, напр. `case.name`, `case.objectPath` | +| `"switch"` | Флаг без значения (напр. `-Detailed`) | +| `"literal"` | Фиксированное значение из `mapping.value` | + +## Как добавить кейс + +Положить `.json` файл в папку навыка. Имя файла = имя кейса. + +### Позитивный кейс (минимальный) + +```json +{ + "name": "Простой справочник", + "input": { "type": "Catalog", "name": "Валюты" } +} +``` + +Раннер проверит: exitCode=0 + выход совпадает со snapshot (если есть). + +### С дополнительными проверками + +```json +{ + "name": "Справочник с ТЧ", + "input": { "type": "Catalog", "name": "Товары", "tabularSections": [...] }, + "expect": { + "files": ["Catalogs/Товары.xml"], + "stdoutContains": "compiled" + } +} +``` + +### Негативный кейс + +```json +{ + "name": "Ошибка: пустое имя", + "input": { "type": "Catalog", "name": "" }, + "expectError": true +} +``` + +`expectError: true` — ожидается exitCode≠0. Можно указать строку — проверит наличие в stderr. + +### Поля кейса + +| Поле | Обязательно | Описание | +|---|---|---| +| `name` | да | Название теста (отображается в отчёте) | +| `input` | нет | JSON-объект, передаётся навыку через temp-файл | +| `setup` | нет | Переопределение setup из `_skill.json` | +| `outputPath` | нет | Относительный путь для `-OutputPath` навыков | +| `expect` | нет | Дополнительные проверки: `files`, `stdoutContains` | +| `expectError` | нет | `true` или строка — ожидается ошибка | + +## Эталоны (snapshots) + +Эталон — директория `<имя-кейса>.snapshot/` рядом с `.json` файлом кейса. Содержит ожидаемый выход навыка после нормализации. + +### Создание / обновление эталонов + +```bash +node tests/skills/runner.mjs --update-snapshots # все кейсы +node tests/skills/runner.mjs cases/meta-compile --update-snapshots # один навык +``` + +### Когда обновлять + +- После изменения логики навыка (новый выход — новый эталон) +- После сертификации: загрузить результат в 1С (`db-load-xml`), убедиться что платформа приняла, затем `--update-snapshots` + +### Нормализация + +Перед сравнением (и при сохранении) применяется: +- **UUID** → `UUID-001`, `UUID-002`... (по порядку появления, ссылочная целостность сохраняется) +- **BOM** (U+FEFF) — удаляется +- **Line endings** — `\r\n` → `\n` + +## Структура + +``` +tests/skills/ + runner.mjs # тест-раннер + README.md # этот файл + .cache/ # кэш фикстур (в .gitignore) + fixtures/ # broken-фикстуры для тестов валидаторов + cases/ + <навык>/ + _skill.json # конфиг навыка + <кейс>.json # тестовый случай + <кейс>.snapshot/ # эталон +``` diff --git a/tests/skills/cases/meta-compile/_skill.json b/tests/skills/cases/meta-compile/_skill.json new file mode 100644 index 00000000..646169eb --- /dev/null +++ b/tests/skills/cases/meta-compile/_skill.json @@ -0,0 +1,12 @@ +{ + "script": "meta-compile/scripts/meta-compile", + "setup": "empty-config", + "args": [ + { "flag": "-JsonPath", "from": "inputFile" }, + { "flag": "-OutputDir", "from": "workDir" } + ], + "snapshot": { + "root": "workDir", + "normalizeUuids": true + } +} diff --git a/tests/skills/cases/meta-compile/catalog-basic.json b/tests/skills/cases/meta-compile/catalog-basic.json new file mode 100644 index 00000000..8c3e4f20 --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-basic.json @@ -0,0 +1,7 @@ +{ + "name": "Простой справочник без реквизитов", + "input": { "type": "Catalog", "name": "Валюты" }, + "expect": { + "files": ["Catalogs/Валюты.xml", "Catalogs/Валюты/Ext/ObjectModule.bsl"] + } +} diff --git a/tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты.xml b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты.xml new file mode 100644 index 00000000..e6edf1af --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты.xml @@ -0,0 +1,327 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + + Валюты + + + ru + Валюты + + + + false + HierarchyFoldersAndItems + false + 2 + true + true + + ToItems + 9 + 25 + String + Variable + WholeCatalog + false + true + AsDescription + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + Auto + InDialog + true + BothWays + + Catalog.Валюты.StandardAttribute.Description + Catalog.Валюты.StandardAttribute.Code + + Begin + DontUse + Directly + + + + + + + + + + + false + + + Automatic + Use + + + + + + DontUse + Auto + DontUse + false + false + + + + diff --git a/tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты/Ext/ObjectModule.bsl b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Catalogs/Валюты/Ext/ObjectModule.bsl new file mode 100644 index 00000000..e69de29b diff --git a/tests/skills/cases/meta-compile/catalog-basic.snapshot/Configuration.xml b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Configuration.xml new file mode 100644 index 00000000..7867e93a --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + UUID-012 + UUID-013 + + + UUID-014 + UUID-015 + + + + TestConfig + + + ru + TestConfig + + + + + Version8_3_24 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + Taxi + DontUse + Version8_3_24 + + + + Русский + Валюты + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/catalog-basic.snapshot/Languages/Русский.xml b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Languages/Русский.xml new file mode 100644 index 00000000..37c60d78 --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-basic.snapshot/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/catalog-tabparts.json b/tests/skills/cases/meta-compile/catalog-tabparts.json new file mode 100644 index 00000000..4f38ef2a --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-tabparts.json @@ -0,0 +1,21 @@ +{ + "name": "Справочник с табличной частью", + "input": { + "type": "Catalog", + "name": "Товары", + "attributes": [ + { "name": "Артикул", "type": "String", "length": 25 } + ], + "tabularSections": [ + { + "name": "Штрихкоды", + "attributes": [ + { "name": "Штрихкод", "type": "String", "length": 128 } + ] + } + ] + }, + "expect": { + "files": ["Catalogs/Товары.xml", "Catalogs/Товары/Ext/ObjectModule.bsl"] + } +} diff --git a/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары.xml b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары.xml new file mode 100644 index 00000000..09ff98e9 --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары.xml @@ -0,0 +1,468 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + + Товары + + + ru + Товары + + + + false + HierarchyFoldersAndItems + false + 2 + true + true + + ToItems + 9 + 25 + String + Variable + WholeCatalog + false + true + AsDescription + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + Auto + InDialog + true + BothWays + + Catalog.Товары.StandardAttribute.Description + Catalog.Товары.StandardAttribute.Code + + Begin + DontUse + Directly + + + + + + + + + + + false + + + Automatic + Use + + + + + + DontUse + Auto + DontUse + false + false + + + + + Артикул + + + ru + Артикул + + + + + xs:string + + 25 + Variable + + + false + + + + false + + false + false + + + false + + DontCheck + Items + + + Auto + Auto + + + Auto + ForItem + DontIndex + Use + Use + + + + + + UUID-014 + UUID-015 + + + UUID-016 + UUID-017 + + + + Штрихкоды + + + ru + Штрихкоды + + + + + DontCheck + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + ForItem + + + + + Штрихкод + + + ru + Штрихкод + + + + + xs:string + + 128 + Variable + + + false + + + + false + + false + false + + + DontCheck + Items + + + Auto + Auto + + + Auto + DontIndex + Use + Use + + + + + + + diff --git a/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары/Ext/ObjectModule.bsl b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Catalogs/Товары/Ext/ObjectModule.bsl new file mode 100644 index 00000000..e69de29b diff --git a/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Configuration.xml b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Configuration.xml new file mode 100644 index 00000000..ab2a9dc4 --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + UUID-012 + UUID-013 + + + UUID-014 + UUID-015 + + + + TestConfig + + + ru + TestConfig + + + + + Version8_3_24 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + Taxi + DontUse + Version8_3_24 + + + + Русский + Товары + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Languages/Русский.xml b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Languages/Русский.xml new file mode 100644 index 00000000..37c60d78 --- /dev/null +++ b/tests/skills/cases/meta-compile/catalog-tabparts.snapshot/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/document-basic.json b/tests/skills/cases/meta-compile/document-basic.json new file mode 100644 index 00000000..d0ae5f13 --- /dev/null +++ b/tests/skills/cases/meta-compile/document-basic.json @@ -0,0 +1,23 @@ +{ + "name": "Документ с табличной частью", + "input": { + "type": "Document", + "name": "ПриходнаяНакладная", + "attributes": [ + { "name": "Склад", "type": "String", "length": 100 } + ], + "tabularSections": [ + { + "name": "Товары", + "attributes": [ + { "name": "Номенклатура", "type": "String", "length": 150 }, + { "name": "Количество", "type": "Number", "length": 15, "precision": 3 }, + { "name": "Цена", "type": "Number", "length": 15, "precision": 2 } + ] + } + ] + }, + "expect": { + "files": ["Documents/ПриходнаяНакладная.xml"] + } +} diff --git a/tests/skills/cases/meta-compile/document-basic.snapshot/Configuration.xml b/tests/skills/cases/meta-compile/document-basic.snapshot/Configuration.xml new file mode 100644 index 00000000..e9d63a82 --- /dev/null +++ b/tests/skills/cases/meta-compile/document-basic.snapshot/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + UUID-012 + UUID-013 + + + UUID-014 + UUID-015 + + + + TestConfig + + + ru + TestConfig + + + + + Version8_3_24 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + Taxi + DontUse + Version8_3_24 + + + + Русский + ПриходнаяНакладная + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная.xml b/tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная.xml new file mode 100644 index 00000000..0a56ae77 --- /dev/null +++ b/tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная.xml @@ -0,0 +1,437 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + + ПриходнаяНакладная + + + ru + Приходная накладная + + + + true + + String + 11 + Variable + Year + true + true + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + + + Document.ПриходнаяНакладная.StandardAttribute.Number + + DontUse + Begin + DontUse + Directly + + + + + + + Allow + Deny + AutoDelete + WriteModified + AutoFill + + true + true + false + + Automatic + Use + + + + + + Auto + DontUse + false + false + + + + + Склад + + + ru + Склад + + + + + xs:string + + 100 + Variable + + + false + + + + false + + false + false + + + false + + DontCheck + Items + + + Auto + Auto + + + Auto + DontIndex + Use + Use + + + + + + UUID-014 + UUID-015 + + + UUID-016 + UUID-017 + + + + Товары + + + ru + Товары + + + + + DontCheck + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + + + + Номенклатура + + + ru + Номенклатура + + + + + xs:string + + 150 + Variable + + + false + + + + false + + false + false + + + DontCheck + Items + + + Auto + Auto + + + Auto + DontIndex + Use + Use + + + + + Количество + + + ru + Количество + + + + + xs:decimal + + 15 + 3 + Any + + + false + + + + false + + false + false + + + DontCheck + Items + + + Auto + Auto + + + Auto + DontIndex + Use + Use + + + + + Цена + + + ru + Цена + + + + + xs:decimal + + 15 + 2 + Any + + + false + + + + false + + false + false + + + DontCheck + Items + + + Auto + Auto + + + Auto + DontIndex + Use + Use + + + + + + + diff --git a/tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная/Ext/ObjectModule.bsl b/tests/skills/cases/meta-compile/document-basic.snapshot/Documents/ПриходнаяНакладная/Ext/ObjectModule.bsl new file mode 100644 index 00000000..e69de29b diff --git a/tests/skills/cases/meta-compile/document-basic.snapshot/Languages/Русский.xml b/tests/skills/cases/meta-compile/document-basic.snapshot/Languages/Русский.xml new file mode 100644 index 00000000..37c60d78 --- /dev/null +++ b/tests/skills/cases/meta-compile/document-basic.snapshot/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/enum.json b/tests/skills/cases/meta-compile/enum.json new file mode 100644 index 00000000..d8d6718f --- /dev/null +++ b/tests/skills/cases/meta-compile/enum.json @@ -0,0 +1,11 @@ +{ + "name": "Перечисление", + "input": { + "type": "Enum", + "name": "ВидыНоменклатуры", + "values": ["Товар", "Услуга", "Работа"] + }, + "expect": { + "files": ["Enums/ВидыНоменклатуры.xml"] + } +} diff --git a/tests/skills/cases/meta-compile/enum.snapshot/Configuration.xml b/tests/skills/cases/meta-compile/enum.snapshot/Configuration.xml new file mode 100644 index 00000000..6f6ab018 --- /dev/null +++ b/tests/skills/cases/meta-compile/enum.snapshot/Configuration.xml @@ -0,0 +1,252 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + UUID-008 + UUID-009 + + + UUID-010 + UUID-011 + + + UUID-012 + UUID-013 + + + UUID-014 + UUID-015 + + + + TestConfig + + + ru + TestConfig + + + + + Version8_3_24 + ManagedApplication + + PlatformApplication + + Russian + + + + + false + false + false + + + + + + + + + + + + + + + + + + + + + + Biometrics + true + + + Location + false + + + BackgroundLocation + false + + + BluetoothPrinters + false + + + WiFiPrinters + false + + + Contacts + false + + + Calendars + false + + + PushNotifications + false + + + LocalNotifications + false + + + InAppPurchases + false + + + PersonalComputerFileExchange + false + + + Ads + false + + + NumberDialing + false + + + CallProcessing + false + + + CallLog + false + + + AutoSendSMS + false + + + ReceiveSMS + false + + + SMSLog + false + + + Camera + false + + + Microphone + false + + + MusicLibrary + false + + + PictureAndVideoLibraries + false + + + AudioPlaybackAndVibration + false + + + BackgroundAudioPlaybackAndVibration + false + + + InstallPackages + false + + + OSBackup + true + + + ApplicationUsageStatistics + false + + + BarcodeScanning + false + + + BackgroundAudioRecording + false + + + AllFilesAccess + false + + + Videoconferences + false + + + NFC + false + + + DocumentScanning + false + + + SpeechToText + false + + + Geofences + false + + + IncomingShareRequests + false + + + AllIncomingShareRequestsTypesProcessing + false + + + + + + Normal + + + Language.Русский + + + + + + Managed + NotAutoFree + DontUse + DontUse + Taxi + DontUse + Version8_3_24 + + + + Русский + ВидыНоменклатуры + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/enum.snapshot/Enums/ВидыНоменклатуры.xml b/tests/skills/cases/meta-compile/enum.snapshot/Enums/ВидыНоменклатуры.xml new file mode 100644 index 00000000..37a6078f --- /dev/null +++ b/tests/skills/cases/meta-compile/enum.snapshot/Enums/ВидыНоменклатуры.xml @@ -0,0 +1,133 @@ + + + + + + UUID-002 + UUID-003 + + + UUID-004 + UUID-005 + + + UUID-006 + UUID-007 + + + + ВидыНоменклатуры + + + ru + Виды номенклатуры + + + + false + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + DontCheck + false + false + Auto + + + false + + + Auto + Auto + + false + Use + false + + + + Use + + + + + + + + true + BothWays + + + + + + + + Auto + + + + + Товар + + + ru + Товар + + + + + + + + Услуга + + + ru + Услуга + + + + + + + + Работа + + + ru + Работа + + + + + + + + diff --git a/tests/skills/cases/meta-compile/enum.snapshot/Languages/Русский.xml b/tests/skills/cases/meta-compile/enum.snapshot/Languages/Русский.xml new file mode 100644 index 00000000..37c60d78 --- /dev/null +++ b/tests/skills/cases/meta-compile/enum.snapshot/Languages/Русский.xml @@ -0,0 +1,16 @@ + + + + + Русский + + + ru + Русский + + + + ru + + + \ No newline at end of file diff --git a/tests/skills/cases/meta-compile/error-empty-name.json b/tests/skills/cases/meta-compile/error-empty-name.json new file mode 100644 index 00000000..0aa33471 --- /dev/null +++ b/tests/skills/cases/meta-compile/error-empty-name.json @@ -0,0 +1,5 @@ +{ + "name": "Ошибка: пустое имя объекта", + "input": { "type": "Catalog", "name": "" }, + "expectError": true +} diff --git a/tests/skills/cases/meta-compile/error-unknown-type.json b/tests/skills/cases/meta-compile/error-unknown-type.json new file mode 100644 index 00000000..3f26a1bf --- /dev/null +++ b/tests/skills/cases/meta-compile/error-unknown-type.json @@ -0,0 +1,5 @@ +{ + "name": "Ошибка: неизвестный тип объекта", + "input": { "type": "UnknownType", "name": "Тест" }, + "expectError": true +} diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs new file mode 100644 index 00000000..980d8135 --- /dev/null +++ b/tests/skills/runner.mjs @@ -0,0 +1,494 @@ +#!/usr/bin/env node +// skill-test-runner v0.1 — Snapshot-based regression tests for 1C skill scripts +// Usage: node tests/skills/runner.mjs [filter] [--update-snapshots] [--runtime python] [--json report.json] + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, + readdirSync, statSync, cpSync, copyFileSync } from 'fs'; +import { join, resolve, dirname, relative, basename, extname } from 'path'; +import { tmpdir } from 'os'; + +// ─── Paths ────────────────────────────────────────────────────────────────── + +const ROOT = resolve(dirname(new URL(import.meta.url).pathname).replace(/^\/([A-Z]:)/i, '$1')); +const REPO_ROOT = resolve(ROOT, '../..'); +const SKILLS = resolve(REPO_ROOT, '.claude/skills'); +const CASES = resolve(ROOT, 'cases'); +const CACHE = resolve(ROOT, '.cache'); +const FIXTURES = resolve(ROOT, 'fixtures'); + +// ─── CLI args ─────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const args = { filter: null, updateSnapshots: false, runtime: 'powershell', jsonReport: null }; + const rest = argv.slice(2); + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === '--update-snapshots') { args.updateSnapshots = true; continue; } + if (a === '--runtime' && rest[i + 1]) { args.runtime = rest[++i]; continue; } + if (a === '--json' && rest[i + 1]) { args.jsonReport = rest[++i]; continue; } + if (!a.startsWith('--') && !args.filter) { args.filter = a.replace(/\\/g, '/'); continue; } + } + return args; +} + +// ─── Case discovery ───────────────────────────────────────────────────────── + +function discoverCases(filter) { + const results = []; + if (!existsSync(CASES)) return results; + + for (const skillDir of readdirSync(CASES)) { + const skillPath = join(CASES, skillDir); + if (!statSync(skillPath).isDirectory()) continue; + + const skillJsonPath = join(skillPath, '_skill.json'); + if (!existsSync(skillJsonPath)) continue; + + const skillConfig = JSON.parse(readFileSync(skillJsonPath, 'utf8')); + + for (const file of readdirSync(skillPath)) { + if (file.startsWith('_') || !file.endsWith('.json')) continue; + const caseName = file.replace(/\.json$/, ''); + const caseId = `cases/${skillDir}/${caseName}`; + + // Apply filter + if (filter) { + const f = filter.replace(/\.json$/, ''); + if (!caseId.startsWith(f) && !caseId.includes(f)) continue; + } + + const casePath = join(skillPath, file); + const caseData = JSON.parse(readFileSync(casePath, 'utf8')); + const snapshotDir = join(skillPath, `${caseName}.snapshot`); + + results.push({ + id: caseId, + name: caseData.name || caseName, + skillDir, + skillConfig, + caseData, + casePath, + snapshotDir, + }); + } + } + + return results; +} + +// ─── Setup / Fixtures ─────────────────────────────────────────────────────── + +function ensureSetup(setupName, runtime) { + if (setupName === 'none' || !setupName) return null; + + if (setupName.startsWith('fixture:')) { + const fixturePath = join(FIXTURES, setupName.slice('fixture:'.length)); + if (!existsSync(fixturePath)) throw new Error(`Fixture not found: ${fixturePath}`); + return fixturePath; + } + + if (setupName === 'empty-config') { + const cached = join(CACHE, 'empty-config'); + if (existsSync(cached)) return cached; + + mkdirSync(cached, { recursive: true }); + const script = resolveScript('cf-init/scripts/cf-init', runtime); + try { + execSkillRaw(runtime, script, ['-Name', 'TestConfig', '-OutputDir', cached]); + } catch (e) { + rmSync(cached, { recursive: true, force: true }); + throw new Error(`Failed to create empty-config fixture: ${e.message}`); + } + return cached; + } + + if (setupName === 'base-config') { + const cached = join(CACHE, 'base-config'); + if (existsSync(cached)) return cached; + throw new Error('base-config fixture not found. Run integration tests first.'); + } + + throw new Error(`Unknown setup: ${setupName}`); +} + +// ─── Script resolution ────────────────────────────────────────────────────── + +function resolveScript(scriptRelPath, runtime) { + const ext = runtime === 'python' ? '.py' : '.ps1'; + const full = join(SKILLS, scriptRelPath + ext); + if (!existsSync(full)) throw new Error(`Script not found: ${full}`); + return full; +} + +function execSkillRaw(runtime, scriptPath, args) { + if (runtime === 'python') { + return execFileSync(process.env.PYTHON || 'python', [scriptPath, ...args], { + encoding: 'utf8', + timeout: 60_000, + stdio: ['pipe', 'pipe', 'pipe'], + cwd: REPO_ROOT, + }); + } + // PowerShell + return execFileSync('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', scriptPath, ...args + ], { + encoding: 'utf8', + timeout: 60_000, + stdio: ['pipe', 'pipe', 'pipe'], + cwd: REPO_ROOT, + }); +} + +// ─── Workspace ────────────────────────────────────────────────────────────── + +function createWorkspace(fixturePath) { + const tmp = mkdtempSync(join(tmpdir(), 'skill-test-')); + if (fixturePath) { + cpSync(fixturePath, tmp, { recursive: true }); + } + return tmp; +} + +function cleanupWorkspace(tmp) { + rmSync(tmp, { recursive: true, force: true }); +} + +// ─── Arg building ─────────────────────────────────────────────────────────── + +function buildArgs(skillConfig, caseData, workDir, inputFilePath, runtime) { + const args = []; + const scriptPath = resolveScript(skillConfig.script, runtime); + + for (const mapping of skillConfig.args) { + args.push(mapping.flag); + + switch (mapping.from) { + case 'inputFile': + args.push(inputFilePath); + break; + case 'workDir': + args.push(workDir); + break; + case 'outputPath': + args.push(join(workDir, caseData.outputPath || '')); + break; + case 'switch': + // flag already pushed, no value needed — remove the flag and re-push conditionally + args.pop(); // remove flag, will re-add if switch is active + if (caseData[mapping.flag.replace(/^-/, '')] !== false) { + args.push(mapping.flag); + } + break; + default: + if (mapping.from.startsWith('case.')) { + const field = mapping.from.slice(5); + const val = caseData[field] ?? caseData.params?.[field] ?? ''; + args.push(String(val)); + } else if (mapping.from === 'literal') { + args.push(mapping.value || ''); + } + } + } + + return { scriptPath, args }; +} + +// ─── Snapshot normalization ───────────────────────────────────────────────── + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +function normalizeContent(text, config) { + // Strip BOM + let s = text.replace(/^\uFEFF/, ''); + // Normalize line endings + s = s.replace(/\r\n/g, '\n'); + + // Normalize UUIDs + if (config?.normalizeUuids) { + const uuidMap = new Map(); + let counter = 0; + s = s.replace(UUID_RE, (match) => { + const lower = match.toLowerCase(); + if (!uuidMap.has(lower)) { + counter++; + uuidMap.set(lower, `UUID-${String(counter).padStart(3, '0')}`); + } + return uuidMap.get(lower); + }); + } + + return s; +} + +// ─── Snapshot comparison ──────────────────────────────────────────────────── + +function listFilesRecursive(dir, base = '') { + const result = []; + if (!existsSync(dir)) return result; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const rel = base ? `${base}/${entry}` : entry; + if (statSync(full).isDirectory()) { + result.push(...listFilesRecursive(full, rel)); + } else { + result.push(rel); + } + } + return result.sort(); +} + +function compareSnapshot(workDir, snapshotDir, snapshotConfig) { + if (!existsSync(snapshotDir)) return { match: true, reason: 'no snapshot (skipped)' }; + + const snapshotFiles = listFilesRecursive(snapshotDir); + if (snapshotFiles.length === 0) return { match: true, reason: 'empty snapshot (skipped)' }; + + const diffs = []; + + for (const relFile of snapshotFiles) { + const actualPath = join(workDir, relFile); + const snapshotPath = join(snapshotDir, relFile); + + if (!existsSync(actualPath)) { + diffs.push({ file: relFile, type: 'missing', detail: 'file not found in output' }); + continue; + } + + const actualRaw = readFileSync(actualPath, 'utf8'); + const snapshotRaw = readFileSync(snapshotPath, 'utf8'); + + const actual = normalizeContent(actualRaw, snapshotConfig); + const expected = normalizeContent(snapshotRaw, snapshotConfig); + + if (actual !== expected) { + // Find first differing line + const actualLines = actual.split('\n'); + const expectedLines = expected.split('\n'); + let diffLine = -1; + for (let i = 0; i < Math.max(actualLines.length, expectedLines.length); i++) { + if (actualLines[i] !== expectedLines[i]) { diffLine = i + 1; break; } + } + diffs.push({ + file: relFile, + type: 'content', + line: diffLine, + expected: expectedLines[diffLine - 1]?.substring(0, 120), + actual: actualLines[diffLine - 1]?.substring(0, 120), + }); + } + } + + if (diffs.length === 0) return { match: true }; + return { match: false, diffs }; +} + +function updateSnapshot(workDir, snapshotDir, snapshotConfig) { + // Remove old snapshot + if (existsSync(snapshotDir)) rmSync(snapshotDir, { recursive: true, force: true }); + + // Determine which files to snapshot — all files in workDir that were created by the skill + // For "workDir" root mode, we need to figure out what files the skill added. + // Strategy: snapshot all files in workDir (the fixture files + skill output). + // On comparison, only files IN the snapshot are checked, so this is safe. + const files = listFilesRecursive(workDir); + if (files.length === 0) return; + + mkdirSync(snapshotDir, { recursive: true }); + for (const relFile of files) { + const src = join(workDir, relFile); + const dst = join(snapshotDir, relFile); + mkdirSync(dirname(dst), { recursive: true }); + + const raw = readFileSync(src, 'utf8'); + const normalized = normalizeContent(raw, snapshotConfig); + writeFileSync(dst, normalized, 'utf8'); + } +} + +// ─── Run a single case ────────────────────────────────────────────────────── + +function runCase(testCase, opts) { + const { skillConfig, caseData, snapshotDir } = testCase; + const t0 = performance.now(); + const setupName = caseData.setup || skillConfig.setup || 'none'; + let workDir = null; + let inputFile = null; + + try { + // 1. Setup workspace + const fixturePath = ensureSetup(setupName, opts.runtime); + workDir = createWorkspace(fixturePath); + + // 2. Write input JSON if needed + if (caseData.input !== undefined) { + inputFile = join(workDir, '__input.json'); + writeFileSync(inputFile, JSON.stringify(caseData.input, null, 2), 'utf8'); + } + + // 3. Build CLI args and execute + const { scriptPath, args } = buildArgs(skillConfig, caseData, workDir, inputFile, opts.runtime); + let stdout = '', stderr = '', exitCode = 0; + + try { + stdout = execSkillRaw(opts.runtime, scriptPath, args); + } catch (e) { + exitCode = e.status ?? 1; + stdout = e.stdout || ''; + stderr = e.stderr || ''; + } + + // Remove temp input file from workDir before snapshot comparison + if (inputFile && existsSync(inputFile)) rmSync(inputFile); + + // 4. Assertions + const errors = []; + + if (caseData.expectError) { + // Negative case — expect failure + if (exitCode === 0) { + errors.push('Expected error (non-zero exit) but got exitCode=0'); + } + if (typeof caseData.expectError === 'string' && !stderr.includes(caseData.expectError)) { + errors.push(`Expected stderr to contain "${caseData.expectError}", got: ${stderr.substring(0, 200)}`); + } + } else { + // Positive case — expect success + if (exitCode !== 0) { + errors.push(`exitCode=${exitCode}\nstdout: ${stdout.substring(0, 300)}\nstderr: ${stderr.substring(0, 300)}`); + } + + // expect.files + if (caseData.expect?.files) { + for (const f of caseData.expect.files) { + if (!existsSync(join(workDir, f))) { + errors.push(`Expected file not found: ${f}`); + } + } + } + + // expect.stdoutContains + if (caseData.expect?.stdoutContains) { + if (!stdout.includes(caseData.expect.stdoutContains)) { + errors.push(`stdout does not contain "${caseData.expect.stdoutContains}"`); + } + } + + // Snapshot comparison + if (errors.length === 0 && !caseData.expectError) { + const snapshotConfig = skillConfig.snapshot || {}; + if (opts.updateSnapshots) { + updateSnapshot(workDir, snapshotDir, snapshotConfig); + } 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 elapsed = ((performance.now() - t0) / 1000).toFixed(1); + return { + id: testCase.id, + name: testCase.name, + passed: errors.length === 0, + errors, + elapsed: `${elapsed}s`, + snapshotUpdated: opts.updateSnapshots && !caseData.expectError, + }; + + } catch (e) { + const elapsed = ((performance.now() - t0) / 1000).toFixed(1); + return { + id: testCase.id, + name: testCase.name, + passed: false, + errors: [`Runner error: ${e.message}`], + elapsed: `${elapsed}s`, + }; + } finally { + if (workDir) cleanupWorkspace(workDir); + } +} + +// ─── Reporter ─────────────────────────────────────────────────────────────── + +function printReport(results, opts) { + const passed = results.filter(r => r.passed); + const failed = results.filter(r => !r.passed); + + console.log(''); + for (const r of results) { + const icon = r.passed ? '\u2713' : '\u2717'; + const suffix = r.snapshotUpdated ? ' [snapshot updated]' : ''; + console.log(` ${icon} ${r.name} (${r.elapsed})${suffix}`); + if (!r.passed) { + for (const err of r.errors) { + for (const line of err.split('\n')) { + console.log(` ${line}`); + } + } + } + } + + console.log(''); + console.log(` Passed: ${passed.length} | Failed: ${failed.length} | Total: ${results.length}`); + console.log(''); + + if (opts.jsonReport) { + const report = { + timestamp: new Date().toISOString(), + runtime: opts.runtime, + passed: passed.length, + failed: failed.length, + total: results.length, + results: results.map(r => ({ + id: r.id, + name: r.name, + passed: r.passed, + elapsed: r.elapsed, + errors: r.errors.length > 0 ? r.errors : undefined, + })), + }; + writeFileSync(opts.jsonReport, JSON.stringify(report, null, 2), 'utf8'); + console.log(` Report: ${opts.jsonReport}`); + } + + return failed.length === 0; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +function main() { + const opts = parseArgs(process.argv); + const cases = discoverCases(opts.filter); + + if (cases.length === 0) { + console.log('No test cases found.' + (opts.filter ? ` Filter: "${opts.filter}"` : '')); + process.exit(0); + } + + console.log(`\nRunning ${cases.length} test(s)... [runtime: ${opts.runtime}]`); + + // Ensure cache dir exists + mkdirSync(CACHE, { recursive: true }); + + const results = []; + for (const tc of cases) { + results.push(runCase(tc, opts)); + } + + const allPassed = printReport(results, opts); + process.exit(allPassed ? 0 : 1); +} + +main();