mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-14 14:55:16 +03:00
e473d6c7a9
Приоритет резолва платформы приведён к паритету с resolve_v8path навыков: --v8path (явный параметр) → .v8-project.json → авто-поиск. Авто-детект теперь зеркалит py/ps1: Windows — Program Files[ (x86)]\1cv8\*\bin\1cv8.exe, *nix — /opt/1cv8/*/1cv8, максимальная версия числовой сортировкой (versionKey отбрасывает хвост bin). --v8path .../ibcmd даёт чистый способ гонять цикл через ibcmd без правки конфига. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1435 lines
67 KiB
JavaScript
1435 lines
67 KiB
JavaScript
#!/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)
|
||
--v8path <path> 1C executable/dir override (e.g. .../ibcmd to run via ibcmd).
|
||
Precedence: --v8path > .v8-project.json > auto-detect.
|
||
--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', v8path: null, 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 === '--v8path' && rest[i + 1]) { args.v8path = rest[++i]; continue; }
|
||
if (a === '--keep') { args.keep = true; continue; }
|
||
if (a === '--verbose' || a === '-v') { args.verbose = true; continue; }
|
||
}
|
||
return args;
|
||
}
|
||
|
||
// ─── Platform context ───────────────────────────────────────────────────────
|
||
|
||
// Имя исполняемого файла платформы зависит от ОС: Windows — 1cv8.exe, *nix (macOS/Linux) — 1cv8.
|
||
const V8_EXE = process.platform === 'win32' ? '1cv8.exe' : '1cv8';
|
||
|
||
// Числовой ключ версии из пути к 1cv8 (лексикографическая сортировка врёт: "8.3.9" > "8.3.27").
|
||
// Версия-папка: <ver>/1cv8 (*nix) или <ver>/bin/1cv8.exe (win) — отбрасываем хвост bin, если он есть.
|
||
function versionKey(exePath) {
|
||
let dir = dirname(exePath);
|
||
if (basename(dir).toLowerCase() === 'bin') dir = dirname(dir);
|
||
return (basename(dir).match(/\d+/g) || []).map(Number);
|
||
}
|
||
|
||
function compareVersionKeys(a, b) {
|
||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||
const d = (a[i] || 0) - (b[i] || 0);
|
||
if (d) return d;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Резолвит исполняемый файл платформы внутри каталога v8bin.
|
||
// На *nix 1cv8 лежит прямо в <ver>/ (наш /opt/1cv8/<ver>/1cv8), на Windows — в <ver>/bin/.
|
||
function resolveV8Exe(v8bin) {
|
||
if (!v8bin) return null;
|
||
const direct = join(v8bin, V8_EXE);
|
||
if (existsSync(direct)) return direct;
|
||
const inBin = join(v8bin, 'bin', V8_EXE);
|
||
if (existsSync(inBin)) return inBin;
|
||
return null;
|
||
}
|
||
|
||
// Auto-detect платформы, когда v8path не задан — зеркалит resolve_v8path из py/ps1-портов навыков db-*:
|
||
// Windows — Program Files[ (x86)]\1cv8\*\bin\1cv8.exe; *nix — /opt/1cv8/*/1cv8; берём максимальную версию.
|
||
function autodetectV8Exe() {
|
||
const candidates = [];
|
||
const scan = (base, sub) => {
|
||
if (!existsSync(base)) return;
|
||
for (const d of readdirSync(base)) {
|
||
const exe = sub ? join(base, d, sub, V8_EXE) : join(base, d, V8_EXE);
|
||
if (existsSync(exe)) candidates.push(exe);
|
||
}
|
||
};
|
||
if (process.platform === 'win32') {
|
||
scan('C:\\Program Files\\1cv8', 'bin');
|
||
scan('C:\\Program Files (x86)\\1cv8', 'bin');
|
||
} else {
|
||
scan('/opt/1cv8', null);
|
||
}
|
||
if (!candidates.length) return null;
|
||
return candidates.sort((a, b) => compareVersionKeys(versionKey(a), versionKey(b))).pop();
|
||
}
|
||
|
||
// Приоритет резолва платформы зеркалит навыки db-* (resolve_v8path):
|
||
// явный параметр (--v8path) → .v8-project.json → авто-поиск 1cv8.
|
||
function loadV8Context(override) {
|
||
let v8bin = override || null;
|
||
if (!v8bin) {
|
||
const projectFile = join(REPO_ROOT, '.v8-project.json');
|
||
if (existsSync(projectFile)) {
|
||
try { v8bin = JSON.parse(readFileSync(projectFile, 'utf8')).v8path || null; } catch { /* ignore */ }
|
||
}
|
||
}
|
||
if (v8bin) {
|
||
// v8path указывает прямо на исполняемый файл (1cv8/ibcmd) — используем как есть, не подменяя авто-детектом.
|
||
// Так verify умеет гонять цикл через ibcmd (db-* навыки сами выбирают движок по basename).
|
||
if (existsSync(v8bin) && statSync(v8bin).isFile()) return { v8path: v8bin, v8exe: v8bin };
|
||
const v8exe = resolveV8Exe(v8bin);
|
||
// Явно заданный, но неразрешимый путь — это ошибка конфигурации, НЕ повод молча брать другую платформу.
|
||
return v8exe ? { v8path: v8bin, v8exe } : null;
|
||
}
|
||
// v8path не задан — авто-детект (Program Files на win, /opt/1cv8 на маке).
|
||
const v8exe = autodetectV8Exe();
|
||
return v8exe ? { v8path: v8exe, v8exe } : 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 directPattern2 = new RegExp(`(CommonPicture|CommonTemplate|SettingsStorage|DefinedType)\\.(${ID})`, 'g');
|
||
while ((m = directPattern2.exec(json)) !== null) {
|
||
refs.set(`${m[1]}.${m[2]}`, { type: m[1], name: m[2] });
|
||
}
|
||
|
||
// Characteristic.X (тип-характеристика в составе типа значения) → ChartOfCharacteristicTypes.X
|
||
const charPattern = new RegExp(`Characteristic\\.(${ID})`, 'g');
|
||
while ((m = charPattern.exec(json)) !== null) {
|
||
refs.set(`ChartOfCharacteristicTypes.${m[1]}`, { type: 'ChartOfCharacteristicTypes', name: m[1] });
|
||
}
|
||
|
||
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;
|
||
case 'EventSubscription': {
|
||
// Обработчик подписки ссылается на экспортный метод общего модуля — 1С требует его существования.
|
||
// Стабим CommonModule и дописываем экспортную процедуру в его модуль (postWrite).
|
||
let h = String(inp.handler || '').replace(/^CommonModule\./, '');
|
||
const hdot = h.indexOf('.');
|
||
if (hdot > 0) {
|
||
const modName = h.substring(0, hdot), methodName = h.substring(hdot + 1);
|
||
deps.push({ type: 'CommonModule', name: modName,
|
||
dsl: { type: 'CommonModule', name: modName, server: true },
|
||
postWrite: [{ relPath: `CommonModules/${modName}/Ext/Module.bsl`,
|
||
content: `Процедура ${methodName}(Источник, Отказ) Экспорт\nКонецПроцедуры\n` }] });
|
||
}
|
||
break;
|
||
}
|
||
case 'Sequence':
|
||
// Документы последовательности (documents) + реквизиты из documentMap — 1С требует существования
|
||
// самого Document.X И реквизита, на который ссылается измерение. Стабим документ с нужными реквизитами
|
||
// (тип реквизита берём из соответствующего измерения последовательности).
|
||
if (inp.documents) {
|
||
const norm = (s) => String(s).replace(/^Документ\./, 'Document.').replace(/\.Реквизит\./, '.Attribute.');
|
||
const docAttrs = {}; // имя документа -> Set("Реквизит: Тип")
|
||
for (const dim of (inp.dimensions || [])) {
|
||
const dtype = dim.type || 'CatalogRef.Организации';
|
||
for (const mp of (dim.documentMap || [])) {
|
||
const m = norm(mp).match(/^Document\.([^.]+)\.Attribute\.(.+)$/);
|
||
if (m) (docAttrs[m[1]] = docAttrs[m[1]] || new Set()).add(`${m[2]}: ${dtype}`);
|
||
}
|
||
}
|
||
for (const dref of inp.documents) {
|
||
const dn = String(dref).split('.').pop();
|
||
const attrs = [...(docAttrs[dn] || [])];
|
||
deps.push({ type: 'Document', name: dn, dsl: { type: 'Document', name: dn, ...(attrs.length ? { attributes: attrs } : {}) } });
|
||
}
|
||
}
|
||
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 'DefinedType': return { type: 'DefinedType', name, valueType: 'Number(15,2)' };
|
||
case 'CommonPicture': return { type: 'CommonPicture', name };
|
||
case 'CommonTemplate': return { type: 'CommonTemplate', name };
|
||
case 'SettingsStorage': return { type: 'SettingsStorage', 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',
|
||
CommonPicture: 'CommonPicture', CommonTemplate: 'CommonTemplate', SettingsStorage: 'SettingsStorage',
|
||
};
|
||
|
||
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',
|
||
CommonPicture: 'CommonPictures', CommonTemplate: 'CommonTemplates', SettingsStorage: 'SettingsStorages',
|
||
};
|
||
|
||
// ─── Кросс-объектные ссылки на поля → богатые стабы ─────────────────────────
|
||
// Разбор MDObjectRef-пути "Тип.Имя[.ТабличнаяЧасть.ТЧ].Реквизит|Измерение|Ресурс.Поле" (+рус.синонимы)
|
||
// в стаб объекта с нужными полями. Плюс сбор предопределённых значений перечислений / элементов справочников
|
||
// из fillValue/choiceParameters. Обобщение ветки Sequence (documentMap) на все типы, ссылающиеся на чужие поля.
|
||
|
||
const TYPE_SYN = {
|
||
'Документ': 'Document', 'Справочник': 'Catalog', 'РегистрНакопления': 'AccumulationRegister',
|
||
'РегистрСведений': 'InformationRegister', 'РегистрБухгалтерии': 'AccountingRegister',
|
||
'РегистрРасчета': 'CalculationRegister', 'Константа': 'Constant',
|
||
'ПланВидовХарактеристик': 'ChartOfCharacteristicTypes', 'ПланСчетов': 'ChartOfAccounts',
|
||
'ПланВидовРасчета': 'ChartOfCalculationTypes', 'ПланОбмена': 'ExchangePlan',
|
||
'БизнесПроцесс': 'BusinessProcess', 'Задача': 'Task',
|
||
};
|
||
const FIELD_SYN = { 'Реквизит': 'Attribute', 'Измерение': 'Dimension', 'Ресурс': 'Resource', 'ТабличнаяЧасть': 'TabularSection' };
|
||
const asArray = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
|
||
|
||
function parseFieldRef(rawPath) {
|
||
const parts = String(rawPath).split(':')[0].trim().split('.');
|
||
if (parts.length < 2) return null;
|
||
const type = TYPE_SYN[parts[0]] || parts[0];
|
||
const name = parts[1];
|
||
let ts = null, fieldKind = null, fieldName = null, i = 2;
|
||
while (i < parts.length) {
|
||
const seg = FIELD_SYN[parts[i]] || parts[i];
|
||
if (seg === 'TabularSection' && i + 1 < parts.length) { ts = parts[i + 1]; i += 2; continue; }
|
||
if ((seg === 'Attribute' || seg === 'Dimension' || seg === 'Resource') && i + 1 < parts.length) {
|
||
fieldKind = seg; fieldName = parts[i + 1]; i += 2; continue;
|
||
}
|
||
i++;
|
||
}
|
||
return { type, name, ts, fieldKind, fieldName };
|
||
}
|
||
|
||
function getFieldStubs(input) {
|
||
const inputs = Array.isArray(input) ? input : [input];
|
||
const mainKeys = new Set(inputs.filter(i => i && i.type).map(i => `${i.type}.${i.name}`));
|
||
const acc = new Map(); // key -> { type, name, attrs, dims, res, tsAttrs, enumVals, predef }
|
||
const ID2 = '[\\wА-Яа-яЁё]+';
|
||
|
||
const ent = (type, name) => {
|
||
const key = `${type}.${name}`;
|
||
if (!TYPE_TO_DIR[type] || mainKeys.has(key)) return null; // неизвестный тип или сам проверяемый объект — не трогаем
|
||
let e = acc.get(key);
|
||
if (!e) { e = { type, name, attrs: new Set(), dims: new Set(), res: new Set(), tsAttrs: new Map(), enumVals: new Set(), predef: new Set() }; acc.set(key, e); }
|
||
return e;
|
||
};
|
||
const addRef = (raw, attrType) => {
|
||
if (!raw) return;
|
||
const p = parseFieldRef(raw); if (!p) return;
|
||
const e = ent(p.type, p.name); if (!e) return;
|
||
if (p.fieldKind) {
|
||
const spec = `${p.fieldName}: ${attrType || 'String(10)'}`;
|
||
if (p.ts) { if (!e.tsAttrs.has(p.ts)) e.tsAttrs.set(p.ts, new Set()); e.tsAttrs.get(p.ts).add(spec); }
|
||
else if (p.fieldKind === 'Attribute') e.attrs.add(spec);
|
||
else if (p.fieldKind === 'Dimension') e.dims.add(spec);
|
||
else if (p.fieldKind === 'Resource') e.res.add(spec);
|
||
}
|
||
};
|
||
const addPredef = (val, ctxType) => {
|
||
if (typeof val !== 'string' || !val) return;
|
||
if (val === 'EmptyRef' || val.includes('ПустаяСсылка')) return;
|
||
let m = val.match(new RegExp(`(?:Enum|Перечисление)\\.(${ID2})\\.(?:EnumValue|ЗначениеПеречисления)\\.(${ID2})`));
|
||
if (m) { const e = ent('Enum', m[1]); if (e) e.enumVals.add(m[2]); return; }
|
||
m = val.match(new RegExp(`^(?:Catalog|Справочник)\\.(${ID2})\\.(${ID2})$`));
|
||
if (m && m[2] !== 'EmptyRef' && m[2] !== 'Predefined') { const e = ent('Catalog', m[1]); if (e) e.predef.add(m[2]); return; }
|
||
if (ctxType && !val.includes('.')) { // короткая запись — тип несёт реквизит
|
||
let em = ctxType.match(/^EnumRef\.(.+)$/); if (em) { const e = ent('Enum', em[1]); if (e) e.enumVals.add(val); return; }
|
||
let cm = ctxType.match(/^CatalogRef\.(.+)$/); if (cm) { const e = ent('Catalog', cm[1]); if (e) e.predef.add(val); return; }
|
||
}
|
||
};
|
||
const scanAttrs = (attrs) => {
|
||
for (const a of asArray(attrs)) {
|
||
if (!a || typeof a !== 'object') continue;
|
||
if (typeof a.fillValue === 'string') addPredef(a.fillValue, a.type);
|
||
for (const cp of asArray(a.choiceParameters)) {
|
||
if (cp && typeof cp === 'object') for (const v of asArray(cp.value)) addPredef(v, cp.type);
|
||
}
|
||
}
|
||
};
|
||
|
||
for (const inp of inputs) {
|
||
if (!inp || !inp.type) continue;
|
||
switch (inp.type) {
|
||
case 'FilterCriterion': for (const c of asArray(inp.content)) addRef(c, inp.valueType); break;
|
||
case 'FunctionalOption': addRef(inp.location); for (const c of asArray(inp.content)) addRef(c); break;
|
||
case 'FunctionalOptionsParameter': for (const u of asArray(inp.use)) addRef(u); break;
|
||
case 'CommonAttribute':
|
||
for (const c of asArray(inp.content)) addRef(typeof c === 'string' ? c : (c.metadata || c['Метаданные'] || c['объект'] || ''));
|
||
break;
|
||
}
|
||
for (const b of asArray(inp.basedOn)) addRef(b);
|
||
for (const o of asArray(inp.owners)) addRef(o);
|
||
scanAttrs(inp.attributes);
|
||
if (inp.tabularSections && typeof inp.tabularSections === 'object' && !Array.isArray(inp.tabularSections)) {
|
||
for (const ts of Object.values(inp.tabularSections)) scanAttrs(Array.isArray(ts) ? ts : (ts && (ts.attributes || ts.columns)));
|
||
}
|
||
if (inp.standardAttributes && typeof inp.standardAttributes === 'object') {
|
||
for (const sa of Object.values(inp.standardAttributes)) if (sa && typeof sa === 'object') scanAttrs([{ ...sa, type: '' }]);
|
||
}
|
||
if (inp.type === 'DocumentJournal') {
|
||
for (const col of asArray(inp.columns)) if (col && typeof col === 'object') for (const r of asArray(col.references)) addRef(r);
|
||
}
|
||
}
|
||
|
||
const deps = [];
|
||
for (const e of acc.values()) {
|
||
const base = makeStubDSL(e.type, e.name) || { type: e.type, name: e.name };
|
||
if (e.attrs.size) base.attributes = [...(base.attributes || []), ...e.attrs];
|
||
if (e.dims.size) base.dimensions = [...(base.dimensions || []), ...e.dims];
|
||
if (e.res.size) base.resources = [...(base.resources || []), ...e.res];
|
||
if (e.tsAttrs.size) { base.tabularSections = base.tabularSections || {}; for (const [ts, set] of e.tsAttrs) base.tabularSections[ts] = [...set]; }
|
||
if (e.enumVals.size) base.values = [...e.enumVals];
|
||
if (e.predef.size) base.predefined = [...e.predef];
|
||
deps.push({ type: e.type, name: e.name, dsl: base });
|
||
// Регистру накопления/бухгалтерии/расчёта нужен документ-регистратор, иначе 1С отвергает
|
||
// конфигурацию («ни один документ не является регистратором для регистра»).
|
||
if (e.type === 'AccumulationRegister' || e.type === 'AccountingRegister' || e.type === 'CalculationRegister') {
|
||
const rn = `Регистратор${e.name}`;
|
||
deps.push({ type: 'Document', name: rn, dsl: { type: 'Document', name: rn },
|
||
postEdit: [{ op: 'add-registerRecord', val: `${e.type}.${e.name}` }] });
|
||
}
|
||
}
|
||
return deps;
|
||
}
|
||
|
||
// ─── 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), ...getFieldStubs(allInputs)];
|
||
const structDSLs = new Map();
|
||
const structPostEdits = new Map();
|
||
const structPostWrites = 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);
|
||
if (dep.postWrite) structPostWrites.set(key, dep.postWrite);
|
||
}
|
||
|
||
// 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}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Post-write: перезаписать файл стаба (напр. тело модуля CommonModule с экспортным методом-обработчиком)
|
||
const writes = structPostWrites.get(key);
|
||
if (writes) {
|
||
for (const w of writes) {
|
||
try {
|
||
writeFileSync(join(configDir, w.relPath), '' + w.content, 'utf8');
|
||
log(`postWrite: ${key}`, true, w.relPath);
|
||
} catch (e) { log(`postWrite: ${key}`, false, e.message); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 5.5: досоздать формы/макеты, на которые ссылается вход ──
|
||
// meta-compile эмитит ссылки defaultForm/mainDataCompositionSchema/… но сами формы/макеты не создаёт
|
||
// (это территория form-add/template-add). Для загрузки в 1С досоздаём их на уже скомпилированном объекте
|
||
// (основной — из Step 4, внешние — из стабов Step 2). Решение: верификатор достраивает дочерние формы/макеты.
|
||
if (Array.isArray(allInputs) && allInputs.length > 0) {
|
||
// form-add поддерживает не все типы (напр. DocumentJournal — нет); для прочих форму не досоздать.
|
||
const FORM_ADD_TYPES = new Set(['Document', 'Catalog', 'DataProcessor', 'Report',
|
||
'InformationRegister', 'AccumulationRegister', 'ChartOfAccounts', 'ChartOfCharacteristicTypes',
|
||
'ExchangePlan', 'BusinessProcess', 'Task', 'DocumentJournal']);
|
||
const walk = function* (o, key) {
|
||
if (o == null) return;
|
||
if (typeof o === 'string') { yield { key, value: o }; return; }
|
||
if (Array.isArray(o)) { for (const v of o) yield* walk(v, key); return; }
|
||
if (typeof o === 'object') { for (const [k, v] of Object.entries(o)) yield* walk(v, k); }
|
||
};
|
||
const forms = new Map(), tpls = new Map();
|
||
for (const inp of allInputs) {
|
||
for (const { key, value } of walk(inp, null)) {
|
||
const kl = (key || '').toLowerCase();
|
||
const parts = String(value).split('.');
|
||
const objType = TYPE_SYN[parts[0]] || parts[0];
|
||
if (parts.length < 3 || !TYPE_TO_DIR[objType]) continue;
|
||
const objName = parts[1], leaf = parts[parts.length - 1];
|
||
// Форма — по ключу *Form (нотация ссылки любая: .Form./.Форма./короткая)
|
||
if (/form$/.test(kl)) {
|
||
let purpose = 'Object';
|
||
if (kl.includes('list')) purpose = 'List';
|
||
else if (kl.includes('choice')) purpose = 'Choice';
|
||
else if (kl.includes('record')) purpose = 'Record';
|
||
if (objType === 'Report' || objType === 'DataProcessor') purpose = 'Object';
|
||
else if (objType === 'DocumentJournal') purpose = 'List'; // журнал — только списочные формы
|
||
forms.set(`${objType}.${objName}.${leaf}`, { objType, objName, formName: leaf, purpose });
|
||
}
|
||
// Макет — по ключу *schema/*template
|
||
if (kl.includes('datacompositionschema') || kl.includes('template')) {
|
||
const isSKD = kl.includes('datacompositionschema');
|
||
tpls.set(`${objType}.${objName}.${leaf}`, { objName, tplName: leaf, tplType: isSKD ? 'DataCompositionSchema' : 'SpreadsheetDocument', main: isSKD });
|
||
}
|
||
}
|
||
}
|
||
for (const f of forms.values()) {
|
||
if (!FORM_ADD_TYPES.has(f.objType)) continue; // form-add не умеет этот тип (напр. DocumentJournal)
|
||
const objPath = join(configDir, TYPE_TO_DIR[f.objType], `${f.objName}.xml`);
|
||
const formDir = join(configDir, TYPE_TO_DIR[f.objType], f.objName, 'Forms', f.formName);
|
||
if (!existsSync(objPath) || existsSync(formDir)) continue;
|
||
try {
|
||
execSkill(opts.runtime, 'form-add/scripts/form-add', ['-ObjectPath', objPath, '-FormName', f.formName, '-Purpose', f.purpose]);
|
||
log(`form-add: ${f.objName}.${f.formName}`, true);
|
||
} catch (e) { log(`form-add: ${f.objName}.${f.formName}`, false, (e.stderr || e.message || '').substring(0, 200)); }
|
||
}
|
||
for (const t of tpls.values()) {
|
||
try {
|
||
execSkill(opts.runtime, 'template-add/scripts/add-template', ['-ObjectName', t.objName,
|
||
'-TemplateName', t.tplName, '-TemplateType', t.tplType, '-SrcDir', configDir, ...(t.main ? ['-SetMainSKD'] : [])]);
|
||
log(`template-add: ${t.objName}.${t.tplName}`, true);
|
||
} catch (e) { log(`template-add: ${t.objName}.${t.tplName}`, false, (e.stderr || e.message || '').substring(0, 200)); }
|
||
}
|
||
}
|
||
|
||
// ── 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(opts.v8path);
|
||
if (!v8ctx) {
|
||
console.error('ERROR: 1C platform not found. Pass --v8path, set .v8-project.json, or install to /opt/1cv8 (Program Files on Windows).');
|
||
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); });
|