diff --git a/tests/skills/build-webtest-db.mjs b/tests/skills/build-webtest-db.mjs index de65d2dd..addfd7d6 100644 --- a/tests/skills/build-webtest-db.mjs +++ b/tests/skills/build-webtest-db.mjs @@ -1,8 +1,12 @@ #!/usr/bin/env node -// build-webtest-db v0.1 — Собирает синтетическую web-test конфигурацию в постоянные пути +// build-webtest-db v0.2 — Собирает синтетическую web-test конфигурацию в постоянные пути // и накатывает её в зарегистрированную базу `webtest` (см. .v8-project.json). // -// Usage: +// Двойной режим: +// - CLI: node tests/skills/build-webtest-db.mjs [--runtime ...] [--skip-platform] +// - Module: import { runSteps, execSkill, getProjectInfo, ... } from './build-webtest-db.mjs' +// +// CLI: // node tests/skills/build-webtest-db.mjs # пересобрать с нуля // node tests/skills/build-webtest-db.mjs --runtime python // node tests/skills/build-webtest-db.mjs --skip-platform # только XML, без db-create/load/update @@ -12,179 +16,236 @@ import { execFile } from 'child_process'; import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; -const ROOT = resolve(dirname(new URL(import.meta.url).pathname).replace(/^\/([A-Z]:)/i, '$1')); +const __filename = fileURLToPath(import.meta.url); +const ROOT = dirname(__filename); const REPO_ROOT = resolve(ROOT, '../..'); const SKILLS = resolve(REPO_ROOT, '.claude/skills'); -// ── CLI ──────────────────────────────────────────────────────────────────────── -const argv = process.argv.slice(2); -const opts = { runtime: 'powershell', skipPlatform: false }; -for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--runtime' && argv[i + 1]) { opts.runtime = argv[++i]; continue; } - if (a === '--skip-platform') { opts.skipPlatform = true; continue; } - if (a === '-h' || a === '--help') { - console.log('Usage: build-webtest-db.mjs [--runtime powershell|python] [--skip-platform]'); - process.exit(0); - } +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Reads .v8-project.json and locates webtest registration. + * @returns {{ v8path: string, v8exe: string, webtestDb: object, configSrc: string, dbPath: string }} + */ +export function getProjectInfo() { + const projectFile = join(REPO_ROOT, '.v8-project.json'); + if (!existsSync(projectFile)) throw new Error('.v8-project.json not found'); + const proj = JSON.parse(readFileSync(projectFile, 'utf8')); + const webtestDb = proj.databases?.find(d => d.id === 'webtest'); + if (!webtestDb) throw new Error('Database "webtest" not registered in .v8-project.json'); + const v8path = proj.v8path; + const v8exe = join(v8path, '1cv8.exe'); + const dbPath = webtestDb.path; + const configSrc = resolve(REPO_ROOT, webtestDb.configSrc); + return { v8path, v8exe, webtestDb, configSrc, dbPath }; } -// ── Locate webtest DB in .v8-project.json ────────────────────────────────────── -const projectFile = join(REPO_ROOT, '.v8-project.json'); -if (!existsSync(projectFile)) { console.error('.v8-project.json not found'); process.exit(1); } -const proj = JSON.parse(readFileSync(projectFile, 'utf8')); -const webtestDb = proj.databases?.find(d => d.id === 'webtest'); -if (!webtestDb) { console.error('Database "webtest" not registered in .v8-project.json'); process.exit(1); } - -const v8path = proj.v8path; -const v8exe = join(v8path, '1cv8.exe'); -const dbPath = webtestDb.path; -const configSrc = resolve(REPO_ROOT, webtestDb.configSrc); - -if (!opts.skipPlatform && !existsSync(v8exe)) { - console.error(`1cv8.exe not found at ${v8exe}`); - process.exit(1); -} - -// ── Reset target dirs ────────────────────────────────────────────────────────── -console.log(`[build-webtest-db] configSrc: ${configSrc}`); -console.log(`[build-webtest-db] dbPath: ${dbPath}`); -console.log(`[build-webtest-db] runtime: ${opts.runtime}`); -console.log(''); - -if (existsSync(configSrc)) { - console.log(`Removing existing configSrc...`); - rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); -} -mkdirSync(configSrc, { recursive: true }); - -if (!opts.skipPlatform && existsSync(dbPath)) { - console.log(`Removing existing IB...`); - rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); -} - -// ── Import build steps ───────────────────────────────────────────────────────── -const buildModule = await import(`file://${join(ROOT, 'integration/build-webtest-config.test.mjs').replace(/\\/g, '/')}`); -const buildSteps = buildModule.steps; - -// Append platform load steps (same as old platform-webtest-config.test.mjs) -const platformSteps = opts.skipPlatform ? [] : [ - { - name: 'db-create: создание файловой ИБ', - script: 'db-create/scripts/db-create', - args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' }, - }, - { - name: 'db-load-xml: загрузка конфигурации', - script: 'db-load-xml/scripts/db-load-xml', - args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}', '-ConfigDir': '{workDir}' }, - }, - { - name: 'db-update: обновление БД', - script: 'db-update/scripts/db-update', - args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' }, - }, -]; - -const allSteps = [...buildSteps, ...platformSteps]; - -// ── Step executor (mirrors runner.mjs runIntegrationTest) ────────────────────── -function resolveScript(scriptRelPath) { - const ext = opts.runtime === 'python' ? '.py' : '.ps1'; +/** + * Resolves a skill script path to an absolute file (chooses .ps1 or .py based on runtime). + */ +export function resolveScript(scriptRelPath, runtime = 'powershell') { + const ext = runtime === 'python' ? '.py' : '.ps1'; const full = join(SKILLS, scriptRelPath + ext); if (!existsSync(full)) throw new Error(`Script not found: ${full}`); return full; } -function execSkill(scriptPath, args) { - return new Promise((resolve, reject) => { - const cmd = opts.runtime === 'python' +/** + * Executes a single skill script with provided arguments. + * @returns {Promise} stdout + */ +export function execSkill(scriptPath, args, runtime = 'powershell') { + return new Promise((res, rej) => { + const cmd = runtime === 'python' ? [process.env.PYTHON || 'python', [scriptPath, ...args]] : ['powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]]; execFile(cmd[0], cmd[1], { encoding: 'utf8', timeout: 120_000, cwd: REPO_ROOT }, (err, stdout, stderr) => { if (err) { - const e = new Error(stderr?.trim() || stdout?.trim() || err.message); - reject(e); + rej(new Error(stderr?.trim() || stdout?.trim() || err.message)); } else { - resolve(stdout); + res(stdout); } }); }); } -const replacePlaceholders = (s) => String(s) - .replace('{workDir}', configSrc) - .replace('{v8path}', v8path) - .replace('{dbPath}', dbPath); +/** + * Replaces {workDir}/{v8path}/{dbPath} placeholders in a string value. + */ +export function replacePlaceholders(s, paths) { + return String(s) + .replace('{workDir}', paths.workDir ?? '') + .replace('{v8path}', paths.v8path ?? '') + .replace('{dbPath}', paths.dbPath ?? ''); +} -const t0 = Date.now(); -let failed = false; +/** + * Executes an array of build steps. + * + * Each step: { name, script?, args?, input?, writeFile?, content? } + * - writeFile: write content to a file (relative to workDir or absolute), skip script call + * - script: relative path under .claude/skills (without extension) + * - args: { '-Flag': value | true }, value may contain {workDir}/{v8path}/{dbPath}/{inputFile} + * - input: JSON object written to __input.json (referenced by {inputFile} in args) + * + * @param {Array} steps + * @param {{ workDir: string, v8path: string, dbPath: string }} paths + * @param {string} runtime 'powershell' | 'python' + * @param {(line: string) => void} log + * @returns {Promise<{ ok: boolean, elapsed: number, failedAt?: number }>} + */ +export async function runSteps(steps, paths, runtime, log = console.log) { + const t0 = Date.now(); + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const stepT0 = Date.now(); -for (let i = 0; i < allSteps.length; i++) { - const step = allSteps[i]; - const stepT0 = Date.now(); - - // writeFile shortcut - if (step.writeFile) { - try { - const target = replacePlaceholders(step.writeFile); - const abs = target.includes(':') || target.startsWith('/') ? target : join(configSrc, target); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, step.content ?? '', 'utf8'); - const ms = Date.now() - stepT0; - console.log(` [${i + 1}/${allSteps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`); - } catch (e) { - console.error(` [${i + 1}/${allSteps.length}] FAIL ${step.name}: ${e.message}`); - failed = true; - break; + if (step.writeFile) { + try { + const target = replacePlaceholders(step.writeFile, paths); + const abs = target.includes(':') || target.startsWith('/') ? target : join(paths.workDir, target); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, step.content ?? '', 'utf8'); + const ms = Date.now() - stepT0; + log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`); + } catch (e) { + log(` [${i + 1}/${steps.length}] FAIL ${step.name}: ${e.message}`); + return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i }; + } + continue; } - continue; - } - // Input JSON - let inputFile = null; - if (step.input) { - inputFile = join(configSrc, '__input.json'); - writeFileSync(inputFile, JSON.stringify(step.input, null, 2), 'utf8'); - } + let inputFile = null; + if (step.input) { + inputFile = join(paths.workDir, '__input.json'); + writeFileSync(inputFile, JSON.stringify(step.input, null, 2), 'utf8'); + } - // Resolve args - const script = resolveScript(step.script); - const args = []; - for (const [flag, value] of Object.entries(step.args || {})) { - args.push(flag); - if (value === true) continue; - let v = String(value).replace('{inputFile}', inputFile || ''); - v = replacePlaceholders(v); - args.push(v); - } + const script = resolveScript(step.script, runtime); + const args = []; + for (const [flag, value] of Object.entries(step.args || {})) { + args.push(flag); + if (value === true) continue; + let v = String(value).replace('{inputFile}', inputFile || ''); + v = replacePlaceholders(v, paths); + args.push(v); + } - try { - await execSkill(script, args); - if (inputFile && existsSync(inputFile)) rmSync(inputFile); - const ms = Date.now() - stepT0; - console.log(` [${i + 1}/${allSteps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`); - } catch (e) { - if (inputFile && existsSync(inputFile)) rmSync(inputFile); - console.error(` [${i + 1}/${allSteps.length}] FAIL ${step.name}`); - console.error(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`); - failed = true; - break; + try { + await execSkill(script, args, runtime); + if (inputFile && existsSync(inputFile)) rmSync(inputFile); + const ms = Date.now() - stepT0; + log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`); + } catch (e) { + if (inputFile && existsSync(inputFile)) rmSync(inputFile); + log(` [${i + 1}/${steps.length}] FAIL ${step.name}`); + log(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`); + return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i }; + } } + return { ok: true, elapsed: (Date.now() - t0) / 1000 }; } -const elapsed = ((Date.now() - t0) / 1000).toFixed(1); -console.log(''); -if (failed) { - console.error(`Build FAILED after ${elapsed}s`); - process.exit(1); +/** + * Returns the standard platform load steps (db-create + db-load-xml + db-update). + */ +export function platformLoadSteps() { + return [ + { + name: 'db-create: создание файловой ИБ', + script: 'db-create/scripts/db-create', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' }, + }, + { + name: 'db-load-xml: загрузка конфигурации', + script: 'db-load-xml/scripts/db-load-xml', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}', '-ConfigDir': '{workDir}' }, + }, + { + name: 'db-update: обновление БД', + script: 'db-update/scripts/db-update', + args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' }, + }, + ]; } -console.log(`Build OK (${elapsed}s)`); -console.log(''); -console.log(` configSrc: ${configSrc}`); -if (!opts.skipPlatform) { - console.log(` IB: ${dbPath}`); + +/** + * Imports the build-webtest-config.test.mjs steps array. + */ +export async function loadBuildSteps() { + const buildModule = await import(`file://${join(ROOT, 'integration/build-webtest-config.test.mjs').replace(/\\/g, '/')}`); + return buildModule.steps; +} + +// ── CLI ──────────────────────────────────────────────────────────────────────── + +async function runCli() { + const argv = process.argv.slice(2); + const opts = { runtime: 'powershell', skipPlatform: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--runtime' && argv[i + 1]) { opts.runtime = argv[++i]; continue; } + if (a === '--skip-platform') { opts.skipPlatform = true; continue; } + if (a === '-h' || a === '--help') { + console.log('Usage: build-webtest-db.mjs [--runtime powershell|python] [--skip-platform]'); + process.exit(0); + } + } + + const { v8path, v8exe, configSrc, dbPath } = getProjectInfo(); + + if (!opts.skipPlatform && !existsSync(v8exe)) { + console.error(`1cv8.exe not found at ${v8exe}`); + process.exit(1); + } + + console.log(`[build-webtest-db] configSrc: ${configSrc}`); + console.log(`[build-webtest-db] dbPath: ${dbPath}`); + console.log(`[build-webtest-db] runtime: ${opts.runtime}`); console.log(''); - console.log(` Next: /web-publish webtest → open in browser`); + + if (existsSync(configSrc)) { + console.log(`Removing existing configSrc...`); + rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } + mkdirSync(configSrc, { recursive: true }); + + if (!opts.skipPlatform && existsSync(dbPath)) { + console.log(`Removing existing IB...`); + rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } + + const buildSteps = await loadBuildSteps(); + const platformSteps = opts.skipPlatform ? [] : platformLoadSteps(); + const allSteps = [...buildSteps, ...platformSteps]; + + const paths = { workDir: configSrc, v8path, dbPath }; + const result = await runSteps(allSteps, paths, opts.runtime, console.log); + + console.log(''); + if (!result.ok) { + console.error(`Build FAILED after ${result.elapsed.toFixed(1)}s`); + process.exit(1); + } + console.log(`Build OK (${result.elapsed.toFixed(1)}s)`); + console.log(''); + console.log(` configSrc: ${configSrc}`); + if (!opts.skipPlatform) { + console.log(` IB: ${dbPath}`); + console.log(''); + console.log(` Next: /web-publish webtest → open in browser`); + } +} + +// CLI guard: run only when invoked directly, not when imported. +const invokedDirectly = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (invokedDirectly) { + runCli().catch(e => { + console.error(e.message); + process.exit(1); + }); }