#!/usr/bin/env node // verify-snapshots v0.2 — Platform verification of skill test snapshots // Reruns skill scripts from test-case DSL, then loads into 1C platform. // Usage: node tests/skills/verify-snapshots.mjs [--skill meta-compile] [--case catalog-basic] [--runtime powershell|python] [--keep] [--verbose] // Supports: meta-compile, form-compile, form-add, form-edit, skd-compile, skd-edit, // role-compile, subsystem-compile, subsystem-edit, mxl-compile, template-add, // help-add, cf-init, cf-edit, epf-init, meta-edit, interface-edit, // cfe-init, cfe-borrow, cfe-patch-method import { execFileSync } from 'child_process'; import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, cpSync } from 'fs'; import { join, resolve, dirname, basename } 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 REPORT_DIR = resolve(REPO_ROOT, 'debug/snapshot-verify'); // ─── CLI args ─────────────────────────────────────────────────────────────── function printHelp() { console.log(`verify-snapshots — Platform verification of skill test snapshots Reruns skill scripts from test-case DSL, then loads results into 1C platform. Usage: node tests/skills/verify-snapshots.mjs [options] Options: --skill Run only cases for the given skill (e.g. form-compile) --case Run only the case with this name --runtime Which script port to run (default: powershell) --keep Keep generated work directories on disk after run -v, --verbose Verbose output -h, --help, /? Show this help and exit `); } function parseArgs(argv) { const args = { skill: null, caseName: null, runtime: 'powershell', keep: false, verbose: false, help: false }; const rest = argv.slice(2); for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a === '-h' || a === '--help' || a === '/?' || a === '/help' || a === '?') { args.help = true; continue; } if (a === '--skill' && rest[i + 1]) { args.skill = rest[++i]; continue; } if (a === '--case' && rest[i + 1]) { args.caseName = rest[++i]; continue; } if (a === '--runtime' && rest[i + 1]) { args.runtime = rest[++i]; continue; } if (a === '--keep') { args.keep = true; continue; } if (a === '--verbose' || a === '-v') { args.verbose = true; continue; } } return args; } // ─── Platform context ─────────────────────────────────────────────────────── function loadV8Context() { const projectFile = join(REPO_ROOT, '.v8-project.json'); if (!existsSync(projectFile)) return null; try { const proj = JSON.parse(readFileSync(projectFile, 'utf8')); const v8bin = proj.v8path; const v8exe = v8bin ? (existsSync(join(v8bin, '1cv8.exe')) ? join(v8bin, '1cv8.exe') : null) : null; if (!v8exe) return null; return { v8path: v8bin, v8exe }; } catch { return null; } } // ─── Script execution ─────────────────────────────────────────────────────── function resolveScript(relPath, runtime) { const ext = runtime === 'python' ? '.py' : '.ps1'; const full = join(SKILLS, relPath + ext); if (!existsSync(full)) throw new Error(`Script not found: ${full}`); return full; } function execSkill(runtime, scriptRelPath, args, timeout = 60_000, cwd = REPO_ROOT) { const scriptPath = resolveScript(scriptRelPath, runtime); if (runtime === 'python') { return execFileSync(process.env.PYTHON || 'python', [scriptPath, ...args], { encoding: 'utf8', timeout, stdio: ['pipe', 'pipe', 'pipe'], cwd, }); } return execFileSync('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args ], { encoding: 'utf8', timeout, stdio: ['pipe', 'pipe', 'pipe'], cwd }); } // ─── Dependency resolution ────────────────────────────────────────────────── const ID = '[\\w\\u0400-\\u04FF]+'; function extractTypeRefs(input) { const refs = new Map(); const json = JSON.stringify(input); const refPattern = new RegExp(`(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|ExchangePlan)Ref\\.(${ID})`, 'g'); let m; while ((m = refPattern.exec(json)) !== null) { refs.set(`${m[1]}.${m[2]}`, { type: m[1], name: m[2] }); } const directPattern = new RegExp(`(ChartOfAccounts|ChartOfCalculationTypes|ChartOfCharacteristicTypes)\\.(${ID})`, 'g'); while ((m = directPattern.exec(json)) !== null) { refs.set(`${m[1]}.${m[2]}`, { type: m[1], name: m[2] }); } const objPattern = new RegExp(`(Document|Catalog|BusinessProcess|Task|ExchangePlan)Object\\.(${ID})`, 'g'); while ((m = objPattern.exec(json)) !== null) { refs.set(`${m[1]}.${m[2]}`, { type: m[1], name: m[2] }); } const modPattern = new RegExp(`CommonModule\\.(${ID})\\.${ID}`, 'g'); while ((m = modPattern.exec(json)) !== null) { refs.set(`CommonModule.${m[1]}`, { type: 'CommonModule', name: m[1] }); } if (input && input.type === 'ScheduledJob' && input.methodName) { const parts = input.methodName.split('.'); if (parts.length >= 2) { refs.set(`CommonModule.${parts[0]}`, { type: 'CommonModule', name: parts[0] }); } } return refs; } // ─── Structural dependencies ──────────────────────────────────────────────── function getStructuralDeps(input) { const deps = []; const hostEdits = []; // edits applied to the host (the input that triggered the dep) const inputs = Array.isArray(input) ? input : [input]; if (!inputs[0] || !inputs[0].type) return { deps, hostEdits }; for (const inp of inputs) { const regTypePrefix = { AccumulationRegister: 'AccumulationRegister', AccountingRegister: 'AccountingRegister', CalculationRegister: 'CalculationRegister', }[inp.type]; // InformationRegister needs a registrar only when subordinated to a recorder // (writeMode: 'Subordinate' / RecorderSubordinate). const isSubordinatedInfoReg = inp.type === 'InformationRegister' && (inp.writeMode === 'Subordinate' || inp.writeMode === 'RecorderSubordinate' || inp.recorderSubordinate === true); const effectivePrefix = regTypePrefix || (isSubordinatedInfoReg ? 'InformationRegister' : null); if (effectivePrefix) { deps.push({ type: 'Document', name: 'ТестовыйДокумент', dsl: { type: 'Document', name: 'ТестовыйДокумент' }, postEdit: [{ op: 'add-registerRecord', val: `${effectivePrefix}.${inp.name}` }], }); } switch (inp.type) { case 'BusinessProcess': { const taskRef = inp.task; if (taskRef) { const taskName = taskRef.split('.').pop(); deps.push({ type: 'Task', name: taskName, dsl: { type: 'Task', name: taskName, descriptionLength: 100 } }); } break; } case 'DocumentJournal': if (inp.registeredDocuments) { for (const docRef of inp.registeredDocuments) { const docName = docRef.split('.').pop(); deps.push({ type: 'Document', name: docName, dsl: { type: 'Document', name: docName } }); } } break; case 'ChartOfAccounts': { // ExtDimensionTypes (виды субконто) link is required when the chart uses // any ExtDimension at all — otherwise platform rejects forms that bind to // Объект.ExtDimensionTypes.*. const usesExtDim = (inp.maxExtDimensionCount && inp.maxExtDimensionCount > 0) || (Array.isArray(inp.extDimensionAccountingFlags) && inp.extDimensionAccountingFlags.length > 0); if (usesExtDim && !inp.extDimensionTypes) { const stubName = `ВидыСубконтоСтаб${inp.name}`; deps.push({ type: 'ChartOfCharacteristicTypes', name: stubName, dsl: { type: 'ChartOfCharacteristicTypes', name: stubName, codeLength: 9, descriptionLength: 100 }, }); hostEdits.push({ hostType: 'ChartOfAccounts', hostName: inp.name, op: 'modify-property', val: `ExtDimensionTypes=ChartOfCharacteristicTypes.${stubName}`, }); } break; } } } return { deps, hostEdits }; } // ─── Stub creation ────────────────────────────────────────────────────────── function makeStubDSL(type, name) { switch (type) { case 'Catalog': return { type: 'Catalog', name }; case 'Document': return { type: 'Document', name }; case 'Enum': return { type: 'Enum', name, values: ['Значение1'] }; case 'InformationRegister': return { type: 'InformationRegister', name, dimensions: ['Ключ: String(10)'] }; case 'AccumulationRegister': return { type: 'AccumulationRegister', name, dimensions: ['Ключ: String(10)'], resources: ['Значение: Number(15,2)'] }; case 'ChartOfAccounts': return { type: 'ChartOfAccounts', name, codeLength: 4, descriptionLength: 100, maxExtDimensionCount: 0 }; case 'ChartOfCharacteristicTypes': return { type: 'ChartOfCharacteristicTypes', name, codeLength: 9, descriptionLength: 100 }; case 'ChartOfCalculationTypes': return { type: 'ChartOfCalculationTypes', name, codeLength: 9, descriptionLength: 100 }; case 'CommonModule': return { type: 'CommonModule', name, server: true }; case 'BusinessProcess': return { type: 'BusinessProcess', name }; case 'Task': return { type: 'Task', name }; case 'ExchangePlan': return { type: 'ExchangePlan', name, codeLength: 9, descriptionLength: 100 }; case 'Role': return { type: 'Role', name: name }; case 'Subsystem': return null; // Subsystems need special handling default: return null; } } const TYPE_TO_PREFIX = { Catalog: 'Catalog', Document: 'Document', Enum: 'Enum', Constant: 'Constant', CommonModule: 'CommonModule', DataProcessor: 'DataProcessor', Report: 'Report', InformationRegister: 'InformationRegister', AccumulationRegister: 'AccumulationRegister', AccountingRegister: 'AccountingRegister', CalculationRegister: 'CalculationRegister', ChartOfAccounts: 'ChartOfAccounts', ChartOfCharacteristicTypes: 'ChartOfCharacteristicTypes', ChartOfCalculationTypes: 'ChartOfCalculationTypes', BusinessProcess: 'BusinessProcess', Task: 'Task', ExchangePlan: 'ExchangePlan', DocumentJournal: 'DocumentJournal', EventSubscription: 'EventSubscription', ScheduledJob: 'ScheduledJob', DefinedType: 'DefinedType', HTTPService: 'HTTPService', WebService: 'WebService', Subsystem: 'Subsystem', Role: 'Role', }; const TYPE_TO_DIR = { Catalog: 'Catalogs', Document: 'Documents', Enum: 'Enums', Constant: 'Constants', CommonModule: 'CommonModules', DataProcessor: 'DataProcessors', Report: 'Reports', InformationRegister: 'InformationRegisters', AccumulationRegister: 'AccumulationRegisters', AccountingRegister: 'AccountingRegisters', CalculationRegister: 'CalculationRegisters', ChartOfAccounts: 'ChartsOfAccounts', ChartOfCharacteristicTypes: 'ChartsOfCharacteristicTypes', ChartOfCalculationTypes: 'ChartsOfCalculationTypes', BusinessProcess: 'BusinessProcesses', Task: 'Tasks', ExchangePlan: 'ExchangePlans', DocumentJournal: 'DocumentJournals', EventSubscription: 'EventSubscriptions', ScheduledJob: 'ScheduledJobs', DefinedType: 'DefinedTypes', HTTPService: 'HTTPServices', WebService: 'WebServices', Subsystem: 'Subsystems', Role: 'Roles', }; // ─── Auto-detect objects in config dir for cf-edit ────────────────────────── function scanConfigObjects(configDir) { const objects = []; // DIR_TO_TYPE: reverse mapping of TYPE_TO_DIR const DIR_TO_TYPE = {}; for (const [type, dir] of Object.entries(TYPE_TO_DIR)) DIR_TO_TYPE[dir] = type; for (const dir of readdirSync(configDir)) { const type = DIR_TO_TYPE[dir]; if (!type) continue; const fullDir = join(configDir, dir); if (!statSync(fullDir).isDirectory()) continue; for (const item of readdirSync(fullDir)) { // Object = either dir or .xml file (for flat objects like DefinedTypes) if (statSync(join(fullDir, item)).isDirectory()) { objects.push({ type, name: item }); } else if (item.endsWith('.xml')) { const name = item.replace('.xml', ''); // Avoid duplicates: if dir "Foo" exists and "Foo.xml" too, skip the xml if (!existsSync(join(fullDir, name))) { objects.push({ type, name }); } } } } return objects; } // ─── Build skill args from _skill.json mapping ───────────────────────────── function buildSkillArgs(skillConfig, caseData, workDir, inputFile, 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(inputFile || ''); break; case 'workDir': args.push(workDir); break; case 'workPath': { const field = mapping.field || 'objectPath'; const val = caseData.params?.[field] ?? caseData[field]; if (val === undefined || val === null || val === '') { if (mapping.optional) { args.pop(); // remove flag pushed above break; } args.push(join(workDir, '')); } else { args.push(join(workDir, val)); } break; } case 'switch': args.pop(); if (caseData[mapping.flag.replace(/^-/, '')] !== false) args.push(mapping.flag); break; default: if (mapping.from.startsWith('case.')) { const field = mapping.from.slice(5); args.push(String(caseData.params?.[field] ?? caseData[field] ?? '')); } else if (mapping.from === 'literal') { args.push(mapping.value || ''); } } } if (caseData.args_extra) args.push(...caseData.args_extra); return { scriptPath, args }; } // ─── Execute preRun steps ─────────────────────────────────────────────────── function runPreSteps(preRun, workDir, runtime, log) { if (!preRun) return; for (const step of preRun) { const preArgs = []; for (const [flag, value] of Object.entries(step.args || {})) { preArgs.push(flag); if (value === true || value === '') continue; preArgs.push(String(value).replace('{workDir}', workDir).replace('{inputFile}', '')); } let preInputFile = null; if (step.input) { preInputFile = join(workDir, '__pre_input.json'); writeFileSync(preInputFile, JSON.stringify(step.input, null, 2), 'utf8'); for (let i = 0; i < preArgs.length; i++) { if (preArgs[i] === '') preArgs[i] = preInputFile; } } const stepName = step.script.split('/').pop(); try { execSkill(runtime, step.script, preArgs); log(`preRun: ${stepName}`, true); } catch (e) { log(`preRun: ${stepName}`, false, e.stderr || e.message); throw new Error(`preRun "${step.script}" failed: ${(e.stderr || e.message).substring(0, 500)}`); } if (preInputFile && existsSync(preInputFile)) rmSync(preInputFile); } } // ─── Skills that DON'T produce loadable configs ───────────────────────────── // These produce standalone files (SKD templates, MXL templates) that can't be // loaded into platform without wrapping in a container object. // 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', 'mxl-decompile', 'mxl-info', 'mxl-validate', ]); // Standalone skills that CAN be platform-verified by wrapping their output in // an external report (ERF) and running erf-build — the platform parses the // schema and we know if it's accepted. const SKD_PLATFORM_VERIFY = new Set(['skd-compile', 'skd-edit']); // MXL: wrap produced Template.xml as a SpreadsheetDocument template inside // an EPF source and run epf-build — platform parses the macro layout. const MXL_PLATFORM_VERIFY = new Set(['mxl-compile']); // EPF/ERF skills — verified by epf-build on the produced source. // Map skill -> output extension (.epf/.erf). const EPF_SKILLS = new Map([ ['epf-init', '.epf'], ['erf-init', '.erf'], ]); // Skills that produce either an EPF/ERF source or a full Configuration — // route is auto-detected after the main script runs. const EPF_OR_CONFIG_SKILLS = new Set(['template-add', 'help-add']); // CFE skills — two-stage load: base config → extension const CFE_SKILLS = new Set([ 'cfe-init', 'cfe-borrow', 'cfe-patch-method', ]); // cf-init produces a config dir — verify by loading the created config const CONFIG_INIT_SKILLS = new Set(['cf-init']); // ─── Main verification pipeline ──────────────────────────────────────────── async function verifyCase(skillName, caseName, skillConfig, caseData, opts) { const result = { skill: skillName, case: caseName, name: caseData.name || caseName, passed: false, steps: [], errors: [], warnings: [], workDir: null, }; const workDir = mkdtempSync(join(tmpdir(), `verify-${skillName}-${caseName}-`)); result.workDir = workDir; const log = (step, ok, detail) => { result.steps.push({ step, ok, detail: detail?.substring(0, 2000) }); if (opts.verbose) { const icon = ok ? '\u2713' : '\u2717'; console.log(` ${icon} ${step}${detail ? ': ' + detail.substring(0, 200) : ''}`); } }; // Determine config dir const setupType = skillConfig.setup || 'empty-config'; const isStandalone = STANDALONE_SKILLS.has(skillName); let epfExt = EPF_SKILLS.get(skillName); let isEpf = !!epfExt; const isCfInit = CONFIG_INIT_SKILLS.has(skillName); // For 'empty-config': workDir is the config (setup creates it) // For cf-init: workDir becomes the config after the script runs // For 'none' + non-special: no config (standalone/EPF) let configDir = (setupType === 'empty-config' || isCfInit) ? workDir : null; try { // ── Step 0: Case-level fixture/external setup (runner.mjs compatibility) ── // A case may declare: // "setup": "fixture:" — copy tests/skills/cases//fixtures/ // "setup": "external:" — copy contents of an external dump (e.g. ERP/БП) if (typeof caseData.setup === 'string' && caseData.setup.startsWith('fixture:')) { const fixtureName = caseData.setup.slice('fixture:'.length); const fixturePath = join(CASES, skillName, 'fixtures', fixtureName); if (!existsSync(fixturePath)) { result.errors.push(`Fixture not found: ${fixturePath}`); return result; } cpSync(fixturePath, workDir, { recursive: true }); log(`fixture: ${fixtureName}`, true); } else if (typeof caseData.setup === 'string' && caseData.setup.startsWith('external:')) { const extPath = resolve(REPO_ROOT, caseData.setup.slice('external:'.length)); if (!existsSync(extPath)) { result.errors.push(`External setup path not found: ${extPath}`); return result; } cpSync(extPath, workDir, { recursive: true }); log(`external: ${extPath}`, true); configDir = workDir; } // ── Step 1: Setup (cf-init for empty-config, nothing for 'none') ── // Skip cf-init if external/fixture setup already provided a complete config const caseProvidedConfig = typeof caseData.setup === 'string' && (caseData.setup.startsWith('external:') || caseData.setup.startsWith('fixture:')); // Skip setup for cf-init skill — the test itself creates the config if (configDir && setupType === 'empty-config' && !CONFIG_INIT_SKILLS.has(skillName) && !caseProvidedConfig) { try { execSkill(opts.runtime, 'cf-init/scripts/cf-init', ['-Name', 'VerifyTest', '-OutputDir', workDir]); log('cf-init', true); } catch (e) { log('cf-init', false, e.stderr || e.message); result.errors.push(`cf-init failed: ${(e.stderr || e.message).substring(0, 500)}`); return result; } } // ── Step 2: Dependency stubs ── // Collect all inputs: from caseData.input AND from preRun steps const allInputs = []; if (caseData.input && (caseData.input.type || Array.isArray(caseData.input))) { const inputs = Array.isArray(caseData.input) ? caseData.input : [caseData.input]; allInputs.push(...inputs.filter(i => i.type)); } // Also scan preRun inputs for type refs (D3 fix) if (caseData.preRun) { for (const step of caseData.preRun) { if (step.input && step.input.type) allInputs.push(step.input); if (Array.isArray(step.input)) allInputs.push(...step.input.filter(i => i && i.type)); } } if (configDir && allInputs.length > 0) { const mainNames = new Set(allInputs.map(i => `${i.type}.${i.name}`)); // Structural deps (scanned across both main input and preRun inputs) const { deps: structDeps, hostEdits: structHostEdits } = getStructuralDeps(allInputs); // Stash host edits on the result so we can apply them after preRun. result._structHostEdits = structHostEdits; const structDSLs = new Map(); const structPostEdits = new Map(); for (const dep of structDeps) { const key = `${dep.type}.${dep.name}`; if (dep.dsl) structDSLs.set(key, dep.dsl); if (dep.postEdit) structPostEdits.set(key, dep.postEdit); } // Type refs from ALL inputs (main + preRun) const allRefs = new Map(); for (const inp of allInputs) { for (const [key, ref] of extractTypeRefs(inp)) { if (!mainNames.has(key)) allRefs.set(key, ref); } } for (const dep of structDeps) { const key = `${dep.type}.${dep.name}`; if (!mainNames.has(key) && !allRefs.has(key)) allRefs.set(key, { type: dep.type, name: dep.name }); } // Create stubs for (const [key, ref] of allRefs) { const stubDSL = structDSLs.get(key) || makeStubDSL(ref.type, ref.name); if (!stubDSL) { result.warnings.push(`Cannot create stub for ${key}`); continue; } try { const stubFile = join(workDir, `__stub.json`); writeFileSync(stubFile, JSON.stringify(stubDSL, null, 2), 'utf8'); execSkill(opts.runtime, 'meta-compile/scripts/meta-compile', ['-JsonPath', stubFile, '-OutputDir', configDir]); log(`stub: ${key}`, true); } catch (e) { log(`stub: ${key}`, false, e.stderr || e.message); result.warnings.push(`Stub failed: ${key}`); } // Post-edit (e.g. add-registerRecord) const edits = structPostEdits.get(key); if (edits) { const dir = TYPE_TO_DIR[ref.type]; const objPath = dir ? join(configDir, dir, ref.name) : null; if (objPath && existsSync(objPath)) { for (const edit of edits) { try { execSkill(opts.runtime, 'meta-edit/scripts/meta-edit', ['-ObjectPath', objPath, '-Operation', edit.op, '-Value', edit.val]); log(`postEdit: ${key}`, true, `${edit.op} ${edit.val}`); } catch (e) { log(`postEdit: ${key}`, false, e.stderr || e.message); result.warnings.push(`PostEdit failed: ${key}`); } } } } } } // ── Step 3: preRun steps ── try { runPreSteps(caseData.preRun, workDir, opts.runtime, log); } catch (e) { result.errors.push(e.message); return result; } // ── Step 3.5: host-level structural edits (apply to objects created in preRun) ── if (configDir && result._structHostEdits?.length) { for (const he of result._structHostEdits) { const dir = TYPE_TO_DIR[he.hostType]; const objPath = dir ? join(configDir, dir, he.hostName) : null; if (!objPath || !existsSync(objPath)) { result.warnings.push(`HostEdit skipped (object not found): ${he.hostType}.${he.hostName}`); continue; } try { execSkill(opts.runtime, 'meta-edit/scripts/meta-edit', ['-ObjectPath', objPath, '-Operation', he.op, '-Value', he.val]); log(`hostEdit: ${he.hostType}.${he.hostName}`, true, `${he.op} ${he.val}`); } catch (e) { log(`hostEdit: ${he.hostType}.${he.hostName}`, false, e.stderr || e.message); result.warnings.push(`HostEdit failed: ${he.hostType}.${he.hostName}`); } } } // ── Step 4: Main skill script ── let inputFile = null; if (caseData.input !== undefined) { inputFile = join(workDir, '__input.json'); writeFileSync(inputFile, JSON.stringify(caseData.input, null, 2), 'utf8'); } try { const { args } = buildSkillArgs(skillConfig, caseData, workDir, inputFile, opts.runtime); const mainCwd = 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) { log(skillName, false, 'expected non-zero exit but got success'); result.errors.push(`${skillName}: expected error but got success`); return result; } log(skillName, true, lastLine); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); if (caseData.expectError) { if (typeof caseData.expectError === 'string' && !detail.includes(caseData.expectError)) { log(skillName, false, `expected "${caseData.expectError}" in stderr, got: ${detail.substring(0, 200)}`); result.errors.push(`${skillName}: stderr does not contain "${caseData.expectError}"`); return result; } log(skillName, true, `(expected error) ${detail.substring(0, 100)}`); result.passed = true; return result; } log(skillName, false, detail); result.errors.push(`${skillName} failed: ${detail.substring(0, 500)}`); return result; } if (inputFile && existsSync(inputFile)) rmSync(inputFile); // ── Step 5: Determine verification strategy ── if (SKD_PLATFORM_VERIFY.has(skillName)) { // 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.passed = true; log('platform-load', true, 'skipped (no v8 context)'); return result; } const tplName = caseData.params?.templatePath || caseData.params?.outputPath || 'Template.xml'; const tplPath = join(workDir, tplName); if (!existsSync(tplPath)) { result.errors.push(`Output not produced at ${tplPath}`); return result; } const erfDir = join(workDir, 'erf-src'); const erfOutDir = join(workDir, 'erf-build'); mkdirSync(erfOutDir, { recursive: true }); try { execSkill(opts.runtime, 'erf-init/scripts/init', ['-Name', 'TestReport', '-SrcDir', erfDir, '-WithSKD']); log('erf-init', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('erf-init', false, detail); result.errors.push(`erf-init failed: ${detail.substring(0, 500)}`); return result; } const dcsTpl = join(erfDir, 'TestReport', 'Templates', 'ОсновнаяСхемаКомпоновкиДанных', 'Ext', 'Template.xml'); cpSync(tplPath, dcsTpl, { force: true }); try { execSkill(opts.runtime, 'epf-build/scripts/epf-build', [ '-V8Path', opts.v8ctx.v8path, '-SourceFile', join(erfDir, 'TestReport.xml'), '-OutputFile', join(erfOutDir, 'TestReport.erf'), ], 120_000); log('erf-build', true, 'platform accepted schema'); result.passed = true; } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('erf-build', false, detail); result.errors.push(`erf-build rejected schema: ${detail.substring(0, 1000)}`); } return result; } if (MXL_PLATFORM_VERIFY.has(skillName)) { const tplName = caseData.params?.outputPath || 'Template.xml'; const tplPath = join(workDir, tplName); if (!existsSync(tplPath)) { result.errors.push(`Output not produced at ${tplPath}`); return result; } const epfDir = join(workDir, 'epf-src'); const epfOutDir = join(workDir, 'epf-build'); mkdirSync(epfOutDir, { recursive: true }); try { execSkill(opts.runtime, 'epf-init/scripts/init', ['-Name', 'TestProc', '-SrcDir', epfDir]); log('epf-init', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('epf-init', false, detail); result.errors.push(`epf-init failed: ${detail.substring(0, 500)}`); return result; } try { execSkill(opts.runtime, 'template-add/scripts/add-template', [ '-ObjectName', 'TestProc', '-TemplateName', 'Макет', '-TemplateType', 'SpreadsheetDocument', '-SrcDir', epfDir, ]); log('template-add', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('template-add', false, detail); result.errors.push(`template-add failed: ${detail.substring(0, 500)}`); return result; } const tplDest = join(epfDir, 'TestProc', 'Templates', 'Макет', 'Ext', 'Template.xml'); cpSync(tplPath, tplDest, { force: true }); try { execSkill(opts.runtime, 'epf-build/scripts/epf-build', [ '-V8Path', opts.v8ctx.v8path, '-SourceFile', join(epfDir, 'TestProc.xml'), '-OutputFile', join(epfOutDir, 'TestProc.epf'), ], 180_000); log('epf-build', true, 'platform accepted MXL'); result.passed = true; } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('epf-build', false, detail); result.errors.push(`epf-build rejected MXL: ${detail.substring(0, 1000)}`); } return result; } if (isStandalone) { result.passed = true; log('platform-load', true, 'skipped (standalone file, not a config)'); return result; } // Auto-detect: skills like template-add/help-add can target either an // EPF/ERF source or a full configuration. If Configuration.xml is absent // but a *.xml source for the named object is, route via epf-build. if (!isEpf && EPF_OR_CONFIG_SKILLS.has(skillName)) { const hasConfig = existsSync(join(workDir, 'Configuration.xml')); if (hasConfig) { configDir = workDir; } else { const epfName = caseData.params?.objectName || caseData.params?.name; if (epfName) { const xmlPath = join(workDir, `${epfName}.xml`); if (existsSync(xmlPath)) { const xml = readFileSync(xmlPath, 'utf8'); if (/]/.test(xml)) epfExt = '.epf'; else if (/]/.test(xml)) epfExt = '.erf'; isEpf = !!epfExt; } } } } if (isEpf) { const name = caseData.params?.name || caseData.params?.objectName; if (!name) { result.errors.push(`EPF/ERF verify requires params.name or params.objectName`); return result; } const sourceFile = join(workDir, `${name}.xml`); if (!existsSync(sourceFile)) { result.errors.push(`EPF/ERF source not found: ${sourceFile}`); return result; } const outDir = join(workDir, '__build'); mkdirSync(outDir, { recursive: true }); const outFile = join(outDir, `${name}${epfExt}`); try { execSkill(opts.runtime, 'epf-build/scripts/epf-build', [ '-V8Path', opts.v8ctx.v8path, '-SourceFile', sourceFile, '-OutputFile', outFile, ], 180_000); log('epf-build', true, `platform built ${epfExt}`); result.passed = true; } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('epf-build', false, detail); result.errors.push(`epf-build failed: ${detail.substring(0, 1000)}`); } return result; } if (CFE_SKILLS.has(skillName)) { // CFE: two-stage load — base config first, then extension const extDir = join(workDir, 'ext'); const baseConfigDir = workDir; // preRun puts base config directly in workDir const dbDir = join(workDir, 'testdb'); // Register base config objects const baseObjects = scanConfigObjects(baseConfigDir); const baseCfEditOps = baseObjects .filter(o => TYPE_TO_PREFIX[o.type]) .map(o => ({ operation: 'add-childObject', value: `${TYPE_TO_PREFIX[o.type]}.${o.name}` })); if (baseCfEditOps.length > 0) { try { const editFile = join(workDir, '__cf-edit-base.json'); writeFileSync(editFile, JSON.stringify(baseCfEditOps, null, 2), 'utf8'); execSkill(opts.runtime, 'cf-edit/scripts/cf-edit', ['-ConfigPath', baseConfigDir, '-DefinitionFile', editFile]); log('cf-edit (base)', true, `${baseCfEditOps.length} objects`); } catch (e) { log('cf-edit (base)', false, e.stderr || e.message); result.errors.push(`cf-edit base failed: ${(e.stderr || e.message).substring(0, 500)}`); return result; } } // Create DB + load base config try { execSkill(opts.runtime, 'db-create/scripts/db-create', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir]); log('db-create', true); } catch (e) { log('db-create', false, e.stderr || e.message); result.errors.push(`db-create failed: ${(e.stderr || e.message).substring(0, 500)}`); return result; } try { execSkill(opts.runtime, 'db-load-xml/scripts/db-load-xml', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir, '-ConfigDir', baseConfigDir, '-StrictLog'], 180_000); log('db-load-xml (config)', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-load-xml (config)', false, detail); result.errors.push(`LoadConfig failed: ${detail.substring(0, 1000)}`); return result; } try { execSkill(opts.runtime, 'db-update/scripts/db-update', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir], 180_000); log('db-update (config)', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-update (config)', false, detail); result.errors.push(`UpdateDBCfg config failed: ${detail.substring(0, 1000)}`); return result; } // Load extension — detect extension name from ext/Configuration.xml let extName = 'Extension'; try { const extConfigXml = readFileSync(join(extDir, 'Configuration.xml'), 'utf8'); const nameMatch = extConfigXml.match(/([^<]+)<\/Name>/); if (nameMatch) extName = nameMatch[1]; } catch {} if (existsSync(extDir)) { try { execSkill(opts.runtime, 'db-load-xml/scripts/db-load-xml', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir, '-ConfigDir', extDir, '-Extension', extName, '-StrictLog'], 180_000); log('db-load-xml (ext)', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-load-xml (ext)', false, detail); result.errors.push(`LoadExtension failed: ${detail.substring(0, 1000)}`); return result; } try { execSkill(opts.runtime, 'db-update/scripts/db-update', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir, '-Extension', extName], 180_000); log('db-update (ext)', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-update (ext)', false, detail); result.errors.push(`UpdateDBCfg ext failed: ${detail.substring(0, 1000)}`); return result; } } result.passed = true; return result; } if (CONFIG_INIT_SKILLS.has(skillName)) { // cf-init: the script already created the config in workDir, // but we called cf-init in Step 1 already. For cf-init tests, // the MAIN script IS cf-init, so workDir = the new config. // It should be loadable as-is. } if (!configDir) { // No config to load — setup was 'none' and not EPF/standalone result.passed = true; return result; } // ── Step 6: Auto-detect and register objects in ChildObjects ── // Skip when config came from external/fixture setup — it's already complete. const allObjects = caseProvidedConfig ? [] : scanConfigObjects(configDir); const cfEditOps = []; for (const obj of allObjects) { const prefix = TYPE_TO_PREFIX[obj.type]; if (prefix) cfEditOps.push({ operation: 'add-childObject', value: `${prefix}.${obj.name}` }); } if (cfEditOps.length > 0) { try { const editFile = join(workDir, '__cf-edit.json'); writeFileSync(editFile, JSON.stringify(cfEditOps, null, 2), 'utf8'); execSkill(opts.runtime, 'cf-edit/scripts/cf-edit', ['-ConfigPath', configDir, '-DefinitionFile', editFile]); log('cf-edit', true, `${cfEditOps.length} objects`); } catch (e) { log('cf-edit', false, e.stderr || e.message); result.errors.push(`cf-edit failed: ${(e.stderr || e.message).substring(0, 500)}`); return result; } } // ── Step 7: Platform load ── // Skip platform load for external dumps (e.g. real ERP/БП configs): // they're huge, version-sensitive, and the point of these test cases is // 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.passed = true; log('platform-load', true, 'skipped (external setup)'); return result; } const dbDir = join(workDir, 'testdb'); try { execSkill(opts.runtime, 'db-create/scripts/db-create', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir]); log('db-create', true); } catch (e) { log('db-create', false, e.stderr || e.message); result.errors.push(`db-create failed: ${(e.stderr || e.message).substring(0, 500)}`); return result; } try { execSkill(opts.runtime, 'db-load-xml/scripts/db-load-xml', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir, '-ConfigDir', configDir, '-StrictLog'], 180_000); log('db-load-xml', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-load-xml', false, detail); result.errors.push(`LoadConfigFromFiles failed: ${detail.substring(0, 1000)}`); return result; } try { execSkill(opts.runtime, 'db-update/scripts/db-update', ['-V8Path', opts.v8ctx.v8path, '-InfoBasePath', dbDir], 180_000); log('db-update', true); } catch (e) { const detail = (e.stderr || e.stdout || e.message).trim(); log('db-update', false, detail); result.errors.push(`UpdateDBCfg failed: ${detail.substring(0, 1000)}`); return result; } result.passed = true; } catch (e) { result.errors.push(`Unexpected error: ${e.message}`); } finally { if (!opts.keep) { try { rmSync(workDir, { recursive: true, force: true }); } catch {} result.workDir = '(cleaned)'; } } return result; } // ─── Discovery ────────────────────────────────────────────────────────────── // Default skills to verify when no --skill given const DEFAULT_SKILLS = [ 'meta-compile', 'form-compile', 'form-compile-from-object', 'form-add', 'form-edit', 'role-compile', 'subsystem-compile', 'subsystem-edit', 'cf-init', 'cf-edit', 'meta-edit', 'interface-edit', 'epf-init', 'erf-init', 'template-add', 'help-add', 'cfe-init', 'cfe-borrow', 'cfe-patch-method', 'skd-compile', 'skd-edit', 'mxl-compile', ]; function discoverCases(skillFilter, caseFilter) { const results = []; const skillDirs = skillFilter ? [skillFilter] : DEFAULT_SKILLS; for (const skillDir of skillDirs) { const skillPath = join(CASES, skillDir); if (!existsSync(skillPath)) continue; const skillJsonPath = join(skillPath, '_skill.json'); if (!existsSync(skillJsonPath)) continue; const skillConfig = JSON.parse(readFileSync(skillJsonPath, 'utf8')); // Skip skills that don't have snapshots (read-only, info, validate) if (!existsSync(join(skillPath, 'snapshots'))) continue; for (const file of readdirSync(skillPath)) { if (file.startsWith('_') || !file.endsWith('.json')) continue; const caseName = file.replace(/\.json$/, ''); if (caseFilter && caseName !== caseFilter) continue; const caseData = JSON.parse(readFileSync(join(skillPath, file), 'utf8')); // Skip error cases if (caseName.startsWith('error-')) continue; // Skip cases without input AND without preRun AND without params (truly read-only) if (caseData.input === undefined && !caseData.preRun && !caseData.params) continue; results.push({ skill: skillDir, caseName, caseData, skillConfig }); } } return results; } // ─── Report ───────────────────────────────────────────────────────────────── function writeReport(results) { mkdirSync(REPORT_DIR, { recursive: true }); const lines = [ `# Snapshot Verification Report`, ``, `Date: ${new Date().toISOString().split('T')[0]}`, `Total: ${results.length} | Passed: ${results.filter(r => r.passed).length} | Failed: ${results.filter(r => !r.passed).length}`, ``, ]; lines.push('| Skill | Case | Status | Error |'); lines.push('|-------|------|--------|-------|'); for (const r of results) { const status = 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 failures = results.filter(r => !r.passed); if (failures.length > 0) { lines.push('', '## Findings', ''); for (const r of failures) { lines.push(`### ${r.skill}/${r.case}: ${r.name}`); lines.push(''); lines.push('**Steps:**'); for (const s of r.steps) { lines.push(`- ${s.ok ? '\u2713' : '\u2717'} ${s.step}${s.detail ? ': ' + s.detail.substring(0, 300) : ''}`); } if (r.warnings.length > 0) { lines.push('', '**Warnings:**'); for (const w of r.warnings) lines.push(`- ${w}`); } lines.push('', '**Errors:**'); for (const e of r.errors) lines.push('```', e, '```'); lines.push(''); lines.push('**Classification:** '); lines.push('**Action:** '); lines.push(''); } } const withWarnings = results.filter(r => r.passed && r.warnings.length > 0); if (withWarnings.length > 0) { lines.push('', '## Warnings (passed with notes)', ''); for (const r of withWarnings) { lines.push(`### ${r.skill}/${r.case}`); for (const w of r.warnings) lines.push(`- ${w}`); lines.push(''); } } const reportPath = join(REPORT_DIR, 'REPORT.md'); writeFileSync(reportPath, lines.join('\n'), 'utf8'); console.log(`\nReport written to: ${reportPath}`); } // ─── Main ─────────────────────────────────────────────────────────────────── async function main() { const opts = parseArgs(process.argv); if (opts.help) { printHelp(); return; } const v8ctx = loadV8Context(); if (!v8ctx) { console.error('ERROR: 1C platform not found. Check .v8-project.json'); process.exit(1); } opts.v8ctx = v8ctx; console.log(`Platform: ${v8ctx.v8exe}`); const cases = discoverCases(opts.skill, opts.caseName); if (cases.length === 0) { console.error('No cases found.'); process.exit(1); } console.log(`Found ${cases.length} case(s) to verify.\n`); const results = []; for (const { skill, caseName, caseData, skillConfig } of cases) { const label = `${skill}/${caseName}`; if (opts.verbose) console.log(` ${label}: ${caseData.name || ''}`); else process.stdout.write(` ${label}...`); const t0 = performance.now(); const result = await verifyCase(skill, caseName, skillConfig, caseData, opts); const elapsed = ((performance.now() - t0) / 1000).toFixed(1); if (!opts.verbose) { const icon = result.passed ? '\u2713' : '\u2717'; console.log(` ${icon} (${elapsed}s)${result.errors.length ? ' — ' + result.errors[0].substring(0, 80) : ''}`); } else { console.log(` → ${result.passed ? 'PASS' : 'FAIL'} (${elapsed}s)\n`); } results.push(result); } const passed = results.filter(r => r.passed).length; const failed = results.filter(r => !r.passed).length; console.log(`\n${'='.repeat(60)}`); console.log(`Results: ${passed} passed, ${failed} failed out of ${results.length}`); writeReport(results); process.exit(failed > 0 ? 1 : 0); } main().catch(e => { console.error(e); process.exit(1); });