fix(tests): verify-snapshots грузит фикстурные кейсы и показывает долю непроверенного

configDir приходил только из скилл-уровневого setup (empty-config) и из ветки external, поэтому
у навыков с setup: none кейс на фикстуре до платформы не доезжал — и всё равно получал PASS.
Таких кейсов девять: пять support-edit, три meta-validate и один skd-decompile. В наборе по
умолчанию дефект не проявлялся: там у всех навыков setup: empty-config, и загрузка шла.

Теперь фикстура-конфигурация грузится как external-выгрузка, а путь «грузить нечего и спец-маршрут
не подошёл» вместо молчаливого PASS требует объявить skipPlatformVerify с причиной. skd-decompile
отнесён к standalone-навыкам: его выход — JSON-описание, грузить нечего.

Отдельно — видимость: «прошло» и «проверено платформой» больше не одно и то же число. В консоли и
в отчёте печатается, сколько кейсов реально обратилось к платформе, а сколько прошло мимо и почему
(external, standalone, ожидаемый отказ навыка, недоступная платформа); в таблице отчёта появилась
колонка «Платформа». Падения в эту долю не попадают: у них обращение было и не удалось.

Девяти кейсам проставлен пропуск с проверенной причиной. Для support-edit причина выяснялась
опытом: фикстуры содержат рукотворный Ext/ParentConfigurations.bin, и платформа отвечает «Ошибка
формата потока», тогда как настоящий bin из типовой грузится, а выгрузка без bin — тоже. Попытка
пересобрать фикстуры полноценными объектами вылечила XML, но не bin, и была откачена.

Документация: в README добавлена таблица маршрутов платформенной проверки и правила появления
configDir — раньше это знание жило только в коде, и «зелёный» отчёт не отличался от «проверенного».
В docs/1c-support-state-spec.md записано, что по описанной грамматике bin можно читать, но нельзя
собрать файл, который примет платформа.

Полный прогон verify-snapshots до и после правки даёт одинаковый состав: 461 passed, 6 failed,
30 skipped из 497 — новых падений нет. Тесты: 871/879, интеграционные 13/13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-22 18:04:55 +03:00
co-authored by Claude Opus 5
parent 333bcad938
commit 9c66e89372
11 changed files with 104 additions and 14 deletions
+2
View File
@@ -62,6 +62,8 @@ guidA, X, guidПоставщика, "версия", "вендор", "имя", co
Парсер, разбирающий весь файл по этой грамматике, потребляет 100% токенов на образце с `K = 7` (458107 токенов, остатка нет) — грамматика подтверждена.
**Грамматика описывает чтение: собрать по ней файл, который примет платформа, нельзя.** Рукотворный `bin` (`K = 1`, UTF-8 с BOM) `/LoadConfigFromFiles` отвергает — «Ошибка формата потока», тогда как выгрузка с настоящим `bin` либо вовсе без него загружается (проверено на 8.3.24.1691). Для стендов и фикстур файл берут у платформы — выгрузкой конфигурации, стоящей на поддержке.
---
## 3. Глобальная «возможность изменения» (`G`)
+43
View File
@@ -46,6 +46,49 @@ node tests/skills/verify-snapshots.mjs --help # полный
Перепрогоняет навык из DSL кейса и грузит результат в 1С — отлавливает случаи, когда снапшоты обновили, но платформа уже не принимает выход.
#### Когда кейс доезжает до платформы
Сначала — что вообще попадает в прогон: навыки из `DEFAULT_SKILLS` (без `--skill`), у которых есть
каталог `snapshots/`, и их кейсы кроме `error-*`. Остальное молча не запускается, так что «в отчёте
нет падений» и «всё проверено» — не одно и то же.
Дальше маршрут выбирается по навыку; из кейса на него влияют только `skipPlatformVerify` и `setup`.
`OK` в отчёте ещё не значит, что 1С что-то видела:
| Условие | Что делает verify | Статус |
|---|---|---|
| кейс объявил `skipPlatformVerify` | ничего не запускает | `SKIP` с причиной из кейса |
| `mxl-compile` | макет вкладывается в обработку, `epf-build` | `OK` / `FAIL` |
| `skd-compile`, `skd-edit` | схема оборачивается во внешний отчёт, `erf-build` | `OK` / `FAIL` |
| прочие `skd-*`, `mxl-*` (`STANDALONE_SKILLS`) | ничего: выход не конфигурация | `OK`, помечен «без платформы» |
| `epf-init`, `erf-init` | `epf-build` по произведённому исходнику | `OK` / `FAIL` |
| `cfe-*` (`CFE_SKILLS`) | двухэтапно: база, затем расширение | `OK` / `FAIL` |
| `template-add`, `help-add` | EPF или конфигурация — по наличию `Configuration.xml` | `OK` / `FAIL` |
| есть каталог конфигурации | `db-create``db-load-xml``db-update` | `OK` / `FAIL` |
| ничего из перечисленного | — | `FAIL` с требованием объявить `skipPlatformVerify` |
Каталог конфигурации (`configDir`) появляется в трёх случаях: скилл-уровневый `setup`
`empty-config` (именно **скилл**-уровневый, из `_skill.json`; переопределение в кейсе на это не
влияет), навык — `cf-init`, либо кейс объявил `setup: external:` или `setup: fixture:` — для
фикстуры дополнительно нужен `Configuration.xml` в её корне.
Отдельный случай — `external:`: конфигурация есть, но грузить её бессмысленно, 1С читала бы
собственную выгрузку типовой (~3 мин на кейс, ноль информации о навыке). Кейс проходит, но
обращения к платформе не было.
Поэтому «прошло» и «проверено платформой» — разные числа, и второе печатается отдельной строкой
с разбивкой по причинам:
```
Results: <passed> passed, <failed> failed, <skipped> skipped out of <total>
из них проверено платформой: <N>; без обращения к платформе: <M>
• <k> — external: грузилась бы собственная выгрузка типовой
• <k> — standalone-навык: выход не конфигурация
• <k> — навык ожидаемо отказал: своего выхода нет, грузить нечего
```
Пока эта доля не названа, отчёт читается как «всё проверено», хотя платформу спрашивали не у всех.
## Гарды-инварианты
Проверяют не вывод навыков, а инварианты исходников — то, что снапшотами не ловится. Навыки
@@ -5,5 +5,6 @@
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы фикстуру, а не выход проверяемого навыка; проверяется stdout",
"expect": {
"stdoutNotContains": ["появился в формате", "Unusual version"]
}
},
"skipPlatformVerify": "фикстура — одиночный объект без Configuration.xml: загружать нечего"
}
@@ -5,5 +5,6 @@
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы фикстуру, а не выход проверяемого навыка; проверяется stdout",
"expect": {
"stdoutNotContains": ["Unusual version", "появился в формате"]
}
},
"skipPlatformVerify": "фикстура — одиночный объект без Configuration.xml: загружать нечего"
}
@@ -4,5 +4,6 @@
"params": { "objectPath": "Catalogs/Товары.xml" },
"expect": {
"stdoutNotContains": "20."
}
},
"skipPlatformVerify": "фикстура — одиночный объект без Configuration.xml: загружать нечего"
}
@@ -10,5 +10,6 @@
],
"expect": {
"stdoutContains": "ВЫКЛЮЧЕНА"
}
},
"skipPlatformVerify": "фикстура синтетическая: рукотворный Ext/ParentConfigurations.bin платформа не принимает"
}
@@ -10,5 +10,6 @@
],
"expect": {
"stdoutContains": "ВКЛЮЧЕНА"
}
},
"skipPlatformVerify": "фикстура синтетическая: рукотворный Ext/ParentConfigurations.bin платформа не принимает"
}
@@ -14,5 +14,6 @@
"file": "Ext/ParentConfigurations.bin",
"bom": true
}
}
},
"skipPlatformVerify": "фикстура синтетическая: рукотворный Ext/ParentConfigurations.bin платформа не принимает"
}
@@ -10,5 +10,6 @@
],
"expect": {
"stdoutContains": "снят с поддержки"
}
},
"skipPlatformVerify": "фикстура синтетическая: рукотворный Ext/ParentConfigurations.bin платформа не принимает"
}
@@ -8,5 +8,6 @@
"-Set",
"editable"
],
"expectError": "Capability on"
"expectError": "Capability on",
"skipPlatformVerify": "фикстура синтетическая: рукотворный Ext/ParentConfigurations.bin платформа не принимает"
}
+43 -6
View File
@@ -680,7 +680,7 @@ function runPreSteps(preRun, workDir, runtime, log) {
// Standalone file skills — produce files (not configs), platform load = just run script
const STANDALONE_SKILLS = new Set([
'skd-compile', 'skd-edit', 'skd-info', 'skd-validate',
'skd-compile', 'skd-edit', 'skd-info', 'skd-validate', 'skd-decompile',
'mxl-decompile', 'mxl-info', 'mxl-validate',
]);
@@ -871,6 +871,10 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
}
cpSync(fixturePath, workDir, { recursive: true });
log(`fixture: ${fixtureName}`, true);
// Фикстура-конфигурация — такой же вход для платформы, как external-выгрузка. Раньше
// configDir приходил только из скилл-уровневого setup (empty-config), поэтому у навыков
// с setup: none фикстура до платформы не доезжала, а кейс всё равно получал PASS.
if (existsSync(join(workDir, 'Configuration.xml'))) configDir = workDir;
} else if (typeof caseData.setup === 'string' && caseData.setup.startsWith('external:')) {
const extPath = resolve(REPO_ROOT, caseData.setup.slice('external:'.length));
// Недоступная внешняя выгрузка — СКИП, как в runner.mjs (`ensureSetup`, ветка
@@ -1029,6 +1033,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
return result;
}
log(skillName, true, `(expected error) ${detail.substring(0, 100)}`);
result.noPlatformReason = 'навык ожидаемо отказал — своего выхода нет, грузить нечего';
result.passed = true;
return result;
}
@@ -1057,6 +1062,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
// Wrap produced Template.xml in an external report (ERF) and try to build —
// platform either accepts the schema or rejects it with an error.
if (!opts.v8ctx) {
result.noPlatformReason = 'платформа недоступна в этом окружении';
result.passed = true;
log('platform-load', true, 'skipped (no v8 context)');
return result;
@@ -1090,6 +1096,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
'-OutputFile', join(erfOutDir, 'TestReport.erf'),
], 120_000);
log('erf-build', true, 'platform accepted schema');
result.platformChecked = true;
result.passed = true;
} catch (e) {
const detail = errDetail(e);
@@ -1142,6 +1149,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
'-OutputFile', join(epfOutDir, 'TestProc.epf'),
], 180_000);
log('epf-build', true, 'platform accepted MXL');
result.platformChecked = true;
result.passed = true;
} catch (e) {
const detail = errDetail(e);
@@ -1152,6 +1160,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
}
if (isStandalone) {
result.noPlatformReason = 'standalone-навык: выход не конфигурация';
result.passed = true;
log('platform-load', true, 'skipped (standalone file, not a config)');
return result;
@@ -1199,6 +1208,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
'-OutputFile', outFile,
], 180_000);
log('epf-build', true, `platform built ${epfExt}`);
result.platformChecked = true;
result.passed = true;
} catch (e) {
const detail = errDetail(e);
@@ -1314,6 +1324,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
}
}
result.platformChecked = true;
result.passed = true;
return result;
}
@@ -1326,8 +1337,13 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
}
if (!configDir) {
// No config to load — setup was 'none' and not EPF/standalone
result.passed = true;
// Грузить нечего, и спец-маршрута (MXL/SKD/EPF/CFE/standalone) для навыка нет. Раньше здесь
// стоял PASS — кейс выглядел проверенным, ни разу не обратившись к платформе. Пропуск
// допустим, но только объявленный: причина должна быть в кейсе и видна в отчёте.
result.errors.push(
'Платформенной проверки не было: конфигурации для загрузки нет, спец-маршрут не подошёл. '
+ 'Если для этого кейса проверка невозможна или вырождается — объявите в кейсе '
+ '"skipPlatformVerify": "<причина>"');
return result;
}
@@ -1418,6 +1434,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
// to exercise the skill script against real-world XML, not to validate
// that an entire vendor config loads into a fresh DB.
if (caseProvidedConfig && caseData.setup.startsWith('external:')) {
result.noPlatformReason = 'external: грузилась бы собственная выгрузка типовой (~3 мин, ноль информации о навыке)';
result.passed = true;
log('platform-load', true, 'skipped (external setup)');
return result;
@@ -1456,6 +1473,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
return result;
}
result.platformChecked = true;
result.passed = true;
} catch (e) {
result.errors.push(`Unexpected error: ${e.message}`);
@@ -1533,18 +1551,21 @@ function writeReport(results) {
`Total: ${results.length} | Passed: ${results.filter(r => r.passed && !r.skipped).length}`
+ ` | Failed: ${results.filter(r => !r.passed && !r.skipped).length}`
+ ` | Skipped: ${results.filter(r => r.skipped).length}`,
`Проверено платформой: ${results.filter(r => r.platformChecked).length}`
+ ` | Прошло без обращения к платформе: ${results.filter(r => r.passed && !r.skipped && !r.platformChecked).length}`,
``,
];
lines.push('| Skill | Case | Status | Error |');
lines.push('|-------|------|--------|-------|');
lines.push('| Skill | Case | Status | Платформа | Error |');
lines.push('|-------|------|--------|-----------|-------|');
for (const r of results) {
// Пропуск — не падение: в консольной сводке они уже различались, а в файле отчёта пропуск
// выглядел как FAIL и попадал в счётчик падений. Отчёт читают глазами и по нему решают,
// есть ли проблема, — расхождение с консолью здесь дороже всего.
const status = r.skipped ? 'SKIP' : (r.passed ? 'OK' : 'FAIL');
const error = r.errors.length > 0 ? r.errors[0].substring(0, 100).replace(/\|/g, '\\|').replace(/\n/g, ' ') : '';
lines.push(`| ${r.skill} | ${r.case} | ${status} | ${error} |`);
const plat = r.platformChecked ? 'да' : (r.skipped ? '—' : (r.noPlatformReason || 'нет'));
lines.push(`| ${r.skill} | ${r.case} | ${status} | ${plat.substring(0, 60)} | ${error} |`);
}
const failures = results.filter(r => !r.passed && !r.skipped);
@@ -1635,6 +1656,22 @@ async function main() {
console.log(`\n${'='.repeat(60)}`);
console.log(`Results: ${passed} passed, ${failed} failed`
+ (skipped ? `, ${skipped} skipped` : '') + ` out of ${results.length}`);
// «Прошло» и «проверено платформой» — разные вещи: часть кейсов законно идёт мимо неё
// (навык отказал, выход не конфигурация, external). Пока эта доля не названа, отчёт читается
// как «всё проверено», хотя платформу спрашивали не у всех.
const onPlatform = results.filter(r => r.platformChecked).length;
// Только успешные: у падения обращение к платформе было — оно и не удалось, мешать их
// с «мимо платформы» значит завышать долю непроверенного.
const offPlatform = results.filter(r => r.passed && !r.skipped && !r.platformChecked);
console.log(` из них проверено платформой: ${onPlatform}; без обращения к платформе: ${offPlatform.length}`);
const byReason = {};
for (const r of offPlatform) {
const key = r.noPlatformReason || 'причина не указана';
(byReason[key] = byReason[key] || []).push(`${r.skill}/${r.case}`);
}
for (const [reason, list] of Object.entries(byReason)) {
console.log(`${list.length}${reason}`);
}
for (const r of results.filter(x => x.skipped)) {
console.log(` \u25cb ${r.skill}/${r.case} \u2014 ${r.skipReason}`);
}