Files
cc-1c-skills/tests/skills/verify-snapshots.mjs
T
Nick Shirokov de2e966311 Merge branch 'dev' into meta-roundtrip
# Conflicts:
#	.claude/skills/meta-compile/scripts/meta-compile.ps1
#	.claude/skills/meta-compile/scripts/meta-compile.py
2026-07-09 12:37:18 +03:00

1120 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <name> Run only cases for the given skill (e.g. form-compile)
--case <name> Run only the case with this name
--runtime <ps|python> 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 inputs = Array.isArray(input) ? input : [input];
if (!inputs[0] || !inputs[0].type) return deps;
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 'Document':
// RegisterRecords (движения) ссылаются на регистры по MDObjectRef — 1С требует их существования. Стабим.
if (inp.registerRecords) {
const regSyn = { 'РегистрСведений': 'InformationRegister', 'РегистрНакопления': 'AccumulationRegister', 'РегистрБухгалтерии': 'AccountingRegister', 'РегистрРасчета': 'CalculationRegister' };
for (const rr of inp.registerRecords) {
const ref = String(rr).split(':')[0].trim();
const dot = ref.indexOf('.');
if (dot < 0) continue;
const t = regSyn[ref.substring(0, dot)] || ref.substring(0, dot);
const n = ref.substring(dot + 1);
const dsl = makeStubDSL(t, n);
if (dsl) deps.push({ type: t, name: n, dsl });
}
}
break;
case 'ChartOfCharacteristicTypes':
// Доп. значения характеристик (CharacteristicExtValues) — ссылка на справочник, ПОДЧИНЁННЫЙ этому ПВХ
// (Owner = ChartOfCharacteristicTypes.X). 1С требует такой справочник для целостности; плоский стаб не годится
// (тип предопределённых должен входить в тип значения ПВХ, а справочник — быть подчинён). Стабим с Owner.
if (inp.characteristicExtValues) {
const ref = String(inp.characteristicExtValues);
const dot = ref.indexOf('.');
const cn = dot >= 0 ? ref.substring(dot + 1) : ref;
deps.push({
type: 'Catalog', name: cn,
dsl: { type: 'Catalog', name: cn, owners: [`ChartOfCharacteristicTypes.${inp.name}`] },
});
}
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 'ExchangePlan':
// Состав плана обмена (Content.xml) ссылается на объекты по MDObjectRef "Type.Name" —
// 1С требует их существования при загрузке. Стабим каждый.
if (inp.content) {
const entries = Array.isArray(inp.content) ? inp.content : [inp.content];
for (const e of entries) {
let ref = typeof e === 'string' ? e : (e.metadata || e['Метаданные'] || e['объект'] || '');
ref = String(ref).split(':')[0].trim(); // отбросить ": autoRecord"/флаг
const dot = ref.indexOf('.');
if (dot < 0) continue;
const t = ref.substring(0, dot);
const n = ref.substring(dot + 1);
const dsl = makeStubDSL(t, n);
if (dsl) deps.push({ type: t, name: n, dsl });
}
}
break;
}
}
return deps;
}
// ─── 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 'Constant': return { type: 'Constant', name, valueType: 'Boolean' };
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) {
// writeFile step — записать произвольный файл в workDir перед запуском скрипта
if (step.writeFile) {
const wfPath = join(workDir, step.writeFile.path);
const wfContent = typeof step.writeFile.content === 'string'
? step.writeFile.content
: JSON.stringify(step.writeFile.content, null, 2);
writeFileSync(wfPath, wfContent, 'utf8');
log(`preRun: writeFile ${step.writeFile.path}`, true);
continue;
}
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:<name>" — copy tests/skills/cases/<skill>/fixtures/<name>
// "setup": "external:<path>" — 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).
// NB: только зависимости (стабы объектов, на которые ССЫЛАЕТСЯ вход). Верификатор НЕ правит сам
// проверяемый объект — иначе в 1С грузится не то, что выдал компилятор (маскировка дефекта DSL).
const structDeps = getStructuralDeps(allInputs);
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 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 (/<ExternalDataProcessor[\s>]/.test(xml)) epfExt = '.epf';
else if (/<ExternalReport[\s>]/.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>([^<]+)<\/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:** <!-- DSL_BUG | SCRIPT_BUG | VALIDATION_GAP | PLATFORM_QUIRK -->');
lines.push('**Action:** <!-- normalize | warn | error | skip -->');
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); });