diff --git a/tests/skills/README.md b/tests/skills/README.md index f01106cf..dc58b1f4 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -31,6 +31,41 @@ node tests/skills/verify-snapshots.mjs --help # полный Перепрогоняет навык из DSL кейса и грузит результат в 1С — отлавливает случаи, когда снапшоты обновили, но платформа уже не принимает выход. +## Интеграционные тесты + +Помимо snapshot-кейсов есть многошаговые сценарии в `integration/<имя>.test.mjs` — цепочка навыков (init → compile → build → validate…), проверяющая что навыки работают вместе. Запуск: + +```bash +node tests/skills/runner.mjs integration # все интеграционные +node tests/skills/runner.mjs integration/platform-partial # один сценарий +``` + +Тест-модуль экспортирует `name`, `setup`, `steps` и опционально: + +| Экспорт | Описание | +|---|---| +| `requiresPlatform` | `true` — нужен 1С (резолвится из `.v8-project.json`). Без платформы тест `○ skipped` | +| `engines` | Массив движков для **матрицы**: по умолчанию `['1cv8']`. `['1cv8','ibcmd']` — те же шаги прогоняются на обоих движках | + +### Движковая матрица (1cv8 / ibcmd) + +Навыки `db-*`/`epf-*` выбирают движок по имени exe в `-V8Path` (опт-ин: `ibcmd.exe` → ibcmd, иначе DESIGNER). Тест с `engines: ['1cv8','ibcmd']` прогоняется по разу на каждый движок: на ibcmd-проходе плейсхолдер `{v8path}` подставляется в `ibcmd.exe`, на 1cv8 — в каталог `bin` (авто-резолв `1cv8.exe`). Результаты помечаются суффиксом id: `… [1cv8]` / `… [ibcmd]`. + +ibcmd-проход автоматически `○ skipped`, если рядом с `1cv8.exe` нет `ibcmd.exe`. Шаги тестов при этом **не меняются** — добавляется одна строка `export const engines`. Так контракт «операция держится на обоих движках» кодируется без дублирования сценария. + +### Типы шагов + +Шаг — это запуск навыка (`script` + `args` + опц. `input`/`validate`) либо один из вспомогательных: + +| Поле шага | Действие | +|---|---| +| `script` + `args` | Запустить навык. `args` поддерживают плейсхолдеры `{workDir}`, `{inputFile}`, `{v8path}` и др. | +| `input` | JSON, передаётся навыку через temp-файл (`{inputFile}`) | +| `writeFile` + `content` | Записать файл (путь — плейсхолдеры) | +| `editFile` + `replace` + `with` | Подстановочная замена в файле (напр. вставить маркер). Падает, если паттерн не найден | +| `assertContains` + `expect` | Упасть, если файл не содержит подстроку (проверка round-trip) | +| `validate` | Доп. валидация навыком после шага (только с `--with-validation`) | + ## Что делать при падении 1. Смотри **case id** в выводе — это путь к файлу кейса (можно перезапустить: `node runner.mjs `) @@ -221,10 +256,11 @@ node tests/skills/runner.mjs cases/meta-compile/enum --update-snapshots # од ``` tests/skills/ - runner.mjs # тест-раннер (snapshot-сравнение) + runner.mjs # тест-раннер (snapshot-сравнение + интеграционные) verify-snapshots.mjs # платформенная верификация снапшотов README.md # этот файл .cache/ # кэш фикстур (в .gitignore) + integration/ # многошаговые сценарии (*.test.mjs), в т.ч. движковая матрица 1cv8/ibcmd cases/ <навык>/ _skill.json # конфиг навыка diff --git a/tests/skills/integration/platform-config.test.mjs b/tests/skills/integration/platform-config.test.mjs index 599d3525..71e717d2 100644 --- a/tests/skills/integration/platform-config.test.mjs +++ b/tests/skills/integration/platform-config.test.mjs @@ -5,6 +5,9 @@ export const name = 'Загрузка конфигурации в платформу 1С'; export const setup = 'none'; export const requiresPlatform = true; +// Engine matrix: same load path must hold on DESIGNER (1cv8) and ibcmd. +// The ibcmd pass is skipped automatically when ibcmd.exe is not present. +export const engines = ['1cv8', 'ibcmd']; export const steps = [ // ── 1. Build minimal config ── diff --git a/tests/skills/integration/platform-epf.test.mjs b/tests/skills/integration/platform-epf.test.mjs index 626f9afc..c2ffc4b9 100644 --- a/tests/skills/integration/platform-epf.test.mjs +++ b/tests/skills/integration/platform-epf.test.mjs @@ -5,6 +5,9 @@ export const name = 'Сборка и разборка внешней обработки (roundtrip)'; export const setup = 'none'; export const requiresPlatform = true; +// Engine matrix: same roundtrip must hold on DESIGNER (1cv8) and ibcmd. +// The ibcmd pass is skipped automatically when ibcmd.exe is not present. +export const engines = ['1cv8', 'ibcmd']; export const steps = [ // ── 1. Create EPF ── diff --git a/tests/skills/integration/platform-partial.test.mjs b/tests/skills/integration/platform-partial.test.mjs new file mode 100644 index 00000000..d8a850db --- /dev/null +++ b/tests/skills/integration/platform-partial.test.mjs @@ -0,0 +1,86 @@ +// platform-partial.test.mjs — partial dump/load round-trip with marker survival +// Requires: 1C platform (1cv8.exe) via .v8-project.json +// Exercises partial config import (Mode Partial -Files) and partial export +// (Mode Partial -Objects) on BOTH engines: DESIGNER (1cv8) and ibcmd. Proves a +// partially-loaded change actually propagates by round-tripping a marker +// (ibtestMARK). Mirrors the proven debug/ibtest/lifecycle.sh partial flow. + +export const name = 'Частичная выгрузка/загрузка объекта (round-trip маркера)'; +export const setup = 'none'; +export const requiresPlatform = true; +// Engine matrix: partial round-trip must hold on DESIGNER (1cv8) and ibcmd. +export const engines = ['1cv8', 'ibcmd']; + +export const steps = [ + // ── 1. Build minimal config ── + { + name: 'cf-init: пустая конфигурация', + script: 'cf-init/scripts/cf-init', + args: { '-Name': 'ИбcmdТест', '-OutputDir': '{workDir}/config' }, + }, + { + name: 'meta-compile: Справочник Товары', + script: 'meta-compile/scripts/meta-compile', + input: { type: 'Catalog', name: 'Товары', codeLength: 9, descriptionLength: 100 }, + args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}/config' }, + }, + { + name: 'cf-edit: регистрация справочника', + script: 'cf-edit/scripts/cf-edit', + input: [{ operation: 'add-childObject', value: 'Catalog.Товары' }], + args: { '-ConfigPath': '{workDir}/config', '-DefinitionFile': '{inputFile}' }, + }, + + // ── 2. Create file IB and load baseline (full, unmarked) via ibcmd ── + { + name: 'db-create: файловая ИБ', + script: 'db-create/scripts/db-create', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb' }, + }, + { + name: 'db-load-xml: загрузка конфигурации (Full)', + script: 'db-load-xml/scripts/db-load-xml', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb', '-ConfigDir': '{workDir}/config' }, + }, + { + name: 'db-update: обновление БД', + script: 'db-update/scripts/db-update', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb' }, + }, + + // ── 3. Mark the source object, then partial-LOAD just that object ── + { + name: 'editFile: маркер в Comment справочника', + editFile: '{workDir}/config/Catalogs/Товары.xml', + replace: '', + with: 'ibtestMARK', + }, + { + name: 'db-load-xml: частичная загрузка Товары (Partial)', + script: 'db-load-xml/scripts/db-load-xml', + args: { + '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb', + '-ConfigDir': '{workDir}/config', '-Mode': 'Partial', '-Files': 'Catalogs/Товары.xml', + }, + }, + { + name: 'db-update: обновление БД (после partial load)', + script: 'db-update/scripts/db-update', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb' }, + }, + + // ── 4. Partial-DUMP the object back and verify the marker survived ── + { + name: 'db-dump-xml: частичная выгрузка Товары (Partial)', + script: 'db-dump-xml/scripts/db-dump-xml', + args: { + '-V8Path': '{v8path}', '-InfoBasePath': '{workDir}/testdb', + '-ConfigDir': '{workDir}/pv', '-Mode': 'Partial', '-Objects': 'Справочник.Товары', + }, + }, + { + name: 'assert: маркер ibtestMARK пережил round-trip', + assertContains: '{workDir}/pv/Catalogs/Товары.xml', + expect: 'ibtestMARK', + }, +]; diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs index bfaddc80..e47560b0 100644 --- a/tests/skills/runner.mjs +++ b/tests/skills/runner.mjs @@ -972,10 +972,12 @@ function loadV8Context() { const v8bin = proj.v8path; const v8exe = v8bin ? (existsSync(join(v8bin, '1cv8.exe')) ? join(v8bin, '1cv8.exe') : null) : null; if (!v8exe) return null; + const ibcmdExe = v8bin && existsSync(join(v8bin, 'ibcmd.exe')) ? join(v8bin, 'ibcmd.exe') : null; const defaultDb = proj.databases?.find(d => d.id === proj.default) || proj.databases?.[0]; return { v8path: v8bin, v8exe, + ibcmdExe, dbPath: defaultDb?.path || '', dbUser: defaultDb?.user || '', dbPassword: defaultDb?.password || '', @@ -994,20 +996,40 @@ async function discoverIntegration(filter) { const id = `integration/${testName}`; if (filter && !id.startsWith(filter) && !id.includes(filter)) continue; const mod = await import(`file://${join(INTEGRATION, file).replace(/\\/g, '/')}`); - results.push({ id, name: mod.name || testName, steps: mod.steps || [], file, cache: mod.cache, setup: mod.setup || 'empty-config', requiresPlatform: !!mod.requiresPlatform }); + const engines = Array.isArray(mod.engines) && mod.engines.length ? mod.engines : ['1cv8']; + results.push({ id, name: mod.name || testName, steps: mod.steps || [], file, cache: mod.cache, setup: mod.setup || 'empty-config', requiresPlatform: !!mod.requiresPlatform, engines }); } return results; } +// Run a test once per declared engine (engine matrix). The ibcmd pass swaps +// {v8path} → ibcmd.exe so the same steps exercise the ibcmd opt-in branch. async function runIntegrationTest(test, opts) { + const engines = test.engines && test.engines.length ? test.engines : ['1cv8']; + // No platform at all → single skipped result (don't multiply across engines) + if (test.requiresPlatform && !opts.v8ctx) { + return [{ id: test.id, name: test.name, passed: true, skipped: true, skipReason: 'no platform', steps: [], elapsed: '0.0s', errors: [] }]; + } + const out = []; + const labelEngine = engines.length > 1; + for (const engine of engines) { + out.push(await runIntegrationOnce(test, opts, engine, labelEngine)); + } + return out; +} + +async function runIntegrationOnce(test, opts, engine, labelEngine) { const t0 = performance.now(); const stepResults = []; let workspace = null; + const idSuffix = labelEngine ? ` [${engine}]` : ''; + const id = test.id + idSuffix; + const name = test.name + idSuffix; - // Skip platform-dependent tests if platform unavailable - if (test.requiresPlatform && !opts.v8ctx) { + // ibcmd pass requires ibcmd.exe alongside 1cv8.exe + if (engine === 'ibcmd' && !opts.v8ctx?.ibcmdExe) { const elapsed = ((performance.now() - t0) / 1000).toFixed(1); - return { id: test.id, name: test.name, passed: true, skipped: true, steps: [], elapsed: `${elapsed}s`, errors: [] }; + return { id, name, passed: true, skipped: true, skipReason: 'no ibcmd.exe', steps: [], elapsed: `${elapsed}s`, errors: [] }; } try { @@ -1015,17 +1037,19 @@ async function runIntegrationTest(test, opts) { const fixturePath = test.setup === 'none' ? null : ensureSetup(test.setup, opts.runtime, CASES); if (fixturePath === SKIP) { const elapsed = ((performance.now() - t0) / 1000).toFixed(1); - return { id: test.id, name: test.name, passed: true, skipped: true, steps: [], elapsed: `${elapsed}s`, errors: [] }; + return { id, name, passed: true, skipped: true, skipReason: 'fixture unavailable', steps: [], elapsed: `${elapsed}s`, errors: [] }; } workspace = createWorkspace(fixturePath, false); const workDir = workspace.path; - // Platform placeholders + // Platform placeholders. {v8path} resolves to ibcmd.exe on the ibcmd pass + // (engine detected by exe name) and to the bin dir otherwise (auto-resolves 1cv8.exe). const v8 = opts.v8ctx || {}; + const v8pathForEngine = engine === 'ibcmd' ? (v8.ibcmdExe || '') : (v8.v8path || ''); const replacePlaceholders = (s) => s .replace('{workDir}', workDir) .replace('{inputFile}', '') - .replace('{v8path}', v8.v8path || '') + .replace('{v8path}', v8pathForEngine) .replace('{v8exe}', v8.v8exe || '') .replace('{dbPath}', v8.dbPath || '') .replace('{dbUser}', v8.dbUser || '') @@ -1052,6 +1076,41 @@ async function runIntegrationTest(test, opts) { continue; } + // editFile step: substring replace in an existing file (e.g. inject a marker) + if (step.editFile) { + try { + const target = replacePlaceholders(step.editFile); + const abs = target.includes(':') || target.startsWith('/') ? target : join(workDir, target); + let txt = readFileSync(abs, 'utf8'); + if (!txt.includes(step.replace)) throw new Error(`pattern not found: ${step.replace}`); + txt = txt.replace(step.replace, replacePlaceholders(step.with ?? '')); + writeFileSync(abs, txt, 'utf8'); + const stepElapsed = ((performance.now() - stepT0) / 1000).toFixed(1); + stepResults.push({ name: step.name, passed: true, elapsed: `${stepElapsed}s` }); + } catch (e) { + stepResults.push({ name: step.name, passed: false, error: `editFile failed: ${e.message}` }); + break; + } + continue; + } + + // assertContains step: fail unless target file contains the expected substring + if (step.assertContains) { + try { + const target = replacePlaceholders(step.assertContains); + const abs = target.includes(':') || target.startsWith('/') ? target : join(workDir, target); + const txt = existsSync(abs) ? readFileSync(abs, 'utf8') : ''; + const needle = replacePlaceholders(step.expect ?? ''); + if (!txt.includes(needle)) throw new Error(`"${needle}" not found in ${target}`); + const stepElapsed = ((performance.now() - stepT0) / 1000).toFixed(1); + stepResults.push({ name: step.name, passed: true, elapsed: `${stepElapsed}s` }); + } catch (e) { + stepResults.push({ name: step.name, passed: false, error: `assert failed: ${e.message}` }); + break; + } + continue; + } + // Write input if provided let inputFile = null; if (step.input) { @@ -1112,10 +1171,10 @@ async function runIntegrationTest(test, opts) { const allPassed = stepResults.every(s => s.passed); const elapsed = ((performance.now() - t0) / 1000).toFixed(1); - return { id: test.id, name: test.name, passed: allPassed, steps: stepResults, elapsed: `${elapsed}s`, errors: allPassed ? [] : stepResults.filter(s => !s.passed).map(s => s.error) }; + return { id, name, passed: allPassed, steps: stepResults, elapsed: `${elapsed}s`, errors: allPassed ? [] : stepResults.filter(s => !s.passed).map(s => s.error) }; } catch (e) { const elapsed = ((performance.now() - t0) / 1000).toFixed(1); - return { id: test.id, name: test.name, passed: false, steps: stepResults, elapsed: `${elapsed}s`, errors: [`Runner error: ${e.message}`] }; + return { id, name, passed: false, steps: stepResults, elapsed: `${elapsed}s`, errors: [`Runner error: ${e.message}`] }; } finally { if (workspace) cleanupWorkspace(workspace); } @@ -1125,7 +1184,7 @@ function printIntegrationReport(results, opts) { console.log(''); for (const r of results) { const icon = r.skipped ? '\u25CB' : r.passed ? '\u2713' : '\u2717'; - const suffix = r.skipped ? ' [skipped — no platform]' : ''; + const suffix = r.skipped ? ` [skipped — ${r.skipReason || 'no platform'}]` : ''; console.log(` ${icon} ${r.name} (${r.elapsed}) ${r.id}${suffix}`); for (const step of r.steps) { const sIcon = step.passed ? '\u2713' : '\u2717'; @@ -1166,7 +1225,7 @@ async function main() { console.log(`\nRunning ${integrationTests.length} integration test(s)... [runtime: ${opts.runtime}${valStr}]`); const integrationResults = []; for (const test of integrationTests) { - integrationResults.push(await runIntegrationTest(test, opts)); + integrationResults.push(...await runIntegrationTest(test, opts)); } integrationOk = printIntegrationReport(integrationResults, opts); }