diff --git a/tests/web-test/_hooks.mjs b/tests/web-test/_hooks.mjs new file mode 100644 index 00000000..fd0601d6 --- /dev/null +++ b/tests/web-test/_hooks.mjs @@ -0,0 +1,295 @@ +// _hooks.mjs v0.2 — автономный тестовый стенд для web-test +// +// `prepare()` поднимает изолированный стенд по smart-логике: +// 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop +// (Apache держит блокировку БД). +// 2) [config-hash изменился или --rebuild-config] → пересобрать XML. +// 3) [нужна пересборка БД] → drop+create+load+update. +// 4) [epf-hash изменился или --rebuild-epf] → пересобрать EPF. +// 5) Apache: +// - если БД пересоздавалась → web-publish + probe ready. +// - иначе probe-first: жив → ничего не делаем; мёртв → publish + probe. +// +// Идемпотентность — через sha256-локи в `tests/skills/.cache/webtest-stand/`. +// На warm-старте (ничего не менялось, Apache жив) prepare() сводится к ~200ms: +// чтение локов + probe. +// +// Поддерживаемые hookArgs (`node run.mjs test ... -- `): +// --rebuild-config принудительно пересобрать XML + БД +// --reload-data принудительно пересоздать БД из существующего XML +// --rebuild-epf принудительно пересобрать EPF +// --rebuild-stand эквивалент всех трёх флагов сразу +// +// Cross-platform: на не-Windows можно задать env WEBTEST_HOOKS_RUNTIME=python, +// тогда зеркальные py-порты скиллов будут вызваны вместо ps1. + +import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; +import { + getProjectInfo, + loadBuildSteps, + platformLoadSteps, + runSteps, + execSkill, + resolveScript, +} from '../skills/build-webtest-db.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = resolve(dirname(__filename), '../..'); +const LOCK_DIR = join(REPO_ROOT, 'tests/skills/.cache/webtest-stand'); + +// ── Configurable knobs ───────────────────────────────────────────────────────── + +const APACHE_APPNAME = 'webtest-runner'; +const APACHE_PORT = 9191; +const READY_URL = `http://localhost:${APACHE_PORT}/${APACHE_APPNAME}/ru_RU/`; +const READY_TIMEOUT = 30_000; +const RUNTIME = process.env.WEBTEST_HOOKS_RUNTIME || 'powershell'; + +// EPF spec: версионируется через epf.lock (sha256 от JSON.stringify(this)). +// Любое изменение → автоматический rebuild. +const EPF_SPEC = { + v8path: 'C:\\Program Files\\1cv8\\8.3.24.1691\\bin', + srcDir: 'test-tmp/13-openfile/src', + buildDir: 'test-tmp/13-openfile/build', + name: 'ТестОткрытия', + synonym: 'Тест открытия из файла', + formName: 'Форма', + form: { + title: 'Тест открытия', + elements: [ + { label: 'Заголовок', title: 'Это тестовая обработка для проверки openFile' }, + ], + }, +}; + +// ── Args parsing ────────────────────────────────────────────────────────────── + +function parseHookArgs(hookArgs) { + const out = { rebuildConfig: false, reloadData: false, rebuildEpf: false, rebuildStand: false }; + for (const a of hookArgs || []) { + if (a === '--rebuild-config') out.rebuildConfig = true; + else if (a === '--reload-data') out.reloadData = true; + else if (a === '--rebuild-epf') out.rebuildEpf = true; + else if (a === '--rebuild-stand') out.rebuildStand = true; + } + if (out.rebuildStand) { + out.rebuildConfig = true; + out.reloadData = true; + out.rebuildEpf = true; + } + return out; +} + +// ── Hash-lock helpers ───────────────────────────────────────────────────────── + +function sha256(s) { + return createHash('sha256').update(s, 'utf8').digest('hex'); +} + +function readLock(name) { + const f = join(LOCK_DIR, `${name}.lock`); + return existsSync(f) ? readFileSync(f, 'utf8').trim() : null; +} + +function writeLock(name, hash) { + mkdirSync(LOCK_DIR, { recursive: true }); + writeFileSync(join(LOCK_DIR, `${name}.lock`), hash + '\n', 'utf8'); +} + +// ── Apache helpers ──────────────────────────────────────────────────────────── + +async function webStop(log) { + try { + const script = resolveScript('web-stop/scripts/web-stop', RUNTIME); + await execSkill(script, [], RUNTIME); + log('apache stopped'); + } catch (e) { + log(`apache stop: ${e.message.split('\n')[0]}`); + } +} + +async function webPublish(dbPath, v8path, log) { + const script = resolveScript('web-publish/scripts/web-publish', RUNTIME); + await execSkill(script, [ + '-InfoBasePath', dbPath, + '-V8Path', v8path, + '-Port', String(APACHE_PORT), + '-AppName', APACHE_APPNAME, + ], RUNTIME); + log(`apache published: ${READY_URL}`); +} + +async function probeReady(url, timeoutMs, log) { + const t0 = Date.now(); + let attempt = 0; + while (Date.now() - t0 < timeoutMs) { + attempt++; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (res.status >= 200 && res.status < 500) { + log(`ready after ${((Date.now() - t0) / 1000).toFixed(1)}s (status=${res.status}, attempts=${attempt})`); + return; + } + } catch { /* retry */ } + await new Promise(r => setTimeout(r, 500)); + } + throw new Error(`Apache not ready at ${url} within ${timeoutMs}ms`); +} + +// Лёгкий probe для одной попытки — для проверки «жив ли Apache уже сейчас». +// Возвращает true если в течение `timeoutMs` пришёл ответ 200-499 (т.е. сервер +// откликается). Не бросает — fail-quiet. +async function probeAlive(url, timeoutMs = 1500) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + return res.status >= 200 && res.status < 500; + } catch { + return false; + } +} + +// ── EPF build ───────────────────────────────────────────────────────────────── + +async function buildEpf(spec, log) { + const srcDir = resolve(REPO_ROOT, spec.srcDir); + const buildDir = resolve(REPO_ROOT, spec.buildDir); + const srcXml = join(srcDir, `${spec.name}.xml`); + const epfPath = join(buildDir, `${spec.name}.epf`); + const formDir = join(srcDir, `${spec.name}/Forms/${spec.formName}`); + const formXml = join(formDir, 'Ext/Form.xml'); + + // Полный rebuild: чистим и собираем заново. + if (existsSync(srcDir)) rmSync(srcDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + mkdirSync(srcDir, { recursive: true }); + mkdirSync(buildDir, { recursive: true }); + + // 1. epf-init + await execSkill( + resolveScript('epf-init/scripts/init', RUNTIME), + ['-Name', spec.name, '-Synonym', spec.synonym, '-SrcDir', srcDir], + RUNTIME, + ); + log('epf-init OK'); + + // 2. form-add + await execSkill( + resolveScript('form-add/scripts/form-add', RUNTIME), + ['-ObjectPath', srcXml, '-FormName', spec.formName], + RUNTIME, + ); + log('form-add OK'); + + // 3. form-compile + const formJsonPath = join(buildDir, '__form.json'); + writeFileSync(formJsonPath, JSON.stringify(spec.form, null, 2), 'utf8'); + await execSkill( + resolveScript('form-compile/scripts/form-compile', RUNTIME), + ['-JsonPath', formJsonPath, '-OutputPath', formXml], + RUNTIME, + ); + rmSync(formJsonPath); + log('form-compile OK'); + + // 4. epf-build + await execSkill( + resolveScript('epf-build/scripts/epf-build', RUNTIME), + ['-SourceFile', srcXml, '-OutputFile', epfPath, '-V8Path', spec.v8path], + RUNTIME, + ); + if (!existsSync(epfPath)) throw new Error(`epf-build did not produce ${epfPath}`); + log(`epf-build OK (${statSync(epfPath).size} bytes)`); + return epfPath; +} + +function epfArtifactExists(spec) { + const epfPath = resolve(REPO_ROOT, spec.buildDir, `${spec.name}.epf`); + return existsSync(epfPath); +} + +// ── prepare / cleanup ───────────────────────────────────────────────────────── + +export async function prepare({ hookArgs, log, config }) { + const flags = parseHookArgs(hookArgs); + const t0 = Date.now(); + log(`stand prepare: flags=${JSON.stringify(flags)} runtime=${RUNTIME}`); + + // Project info (paths, db registration) + const { v8path, v8exe, configSrc, dbPath } = getProjectInfo(); + if (!existsSync(v8exe)) throw new Error(`1cv8.exe not found at ${v8exe} (check .v8-project.json v8path)`); + + // Hashes + const buildSteps = await loadBuildSteps(); + const configHash = sha256(JSON.stringify(buildSteps)); + const epfHash = sha256(JSON.stringify(EPF_SPEC)); + const prevConfig = readLock('config'); + const prevEpf = readLock('epf'); + + const needConfig = flags.rebuildConfig || prevConfig !== configHash; + const needData = needConfig || flags.reloadData; + const needEpf = flags.rebuildEpf || prevEpf !== epfHash || !epfArtifactExists(EPF_SPEC); + + log(`config-hash=${configHash.slice(0, 12)}... prev=${prevConfig?.slice(0, 12) || 'none'}... ${needConfig ? 'REBUILD' : 'skip'}`); + log(`epf-hash=${epfHash.slice(0, 12)}... prev=${prevEpf?.slice(0, 12) || 'none'}... ${needEpf ? 'REBUILD' : 'skip'}`); + log(`data-${needData ? 'RELOAD' : 'skip'}`); + + // 1. Apache stop — только если будем пересоздавать БД (Apache держит lock-файл). + // На warm-старте (никаких rebuild) — НЕ трогаем Apache, иначе впустую отдадим + // 5-8 секунд на restart при каждом прогоне. + if (needData) { + await webStop(log); + } + + // 2. Config rebuild + if (needConfig) { + log(`rebuild config XML → ${configSrc}`); + if (existsSync(configSrc)) rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + mkdirSync(configSrc, { recursive: true }); + const paths = { workDir: configSrc, v8path, dbPath }; + const r = await runSteps(buildSteps, paths, RUNTIME, log); + if (!r.ok) throw new Error(`config rebuild failed at step #${r.failedAt + 1}`); + writeLock('config', configHash); + } + + // 3. DB reload + if (needData) { + log(`reload DB → ${dbPath}`); + if (existsSync(dbPath)) rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + const paths = { workDir: configSrc, v8path, dbPath }; + const r = await runSteps(platformLoadSteps(), paths, RUNTIME, log); + if (!r.ok) throw new Error(`DB reload failed at step #${r.failedAt + 1}`); + } + + // 4. EPF rebuild + if (needEpf) { + log('rebuild EPF'); + await buildEpf(EPF_SPEC, log); + writeLock('epf', epfHash); + } + + // 5. Apache: publish + probe (smart logic) + // - needData=true → Apache был остановлен в #1, нужно публиковать заново + // - needData=false → probe сначала: если жив, ничего не делаем (warm-старт); + // если мёртв (упал/не поднимали) → publish + if (needData) { + await webPublish(dbPath, v8path, log); + await probeReady(READY_URL, READY_TIMEOUT, log); + } else if (await probeAlive(READY_URL)) { + log(`apache already live at ${READY_URL} (warm start)`); + } else { + log(`apache not responding — publishing`); + await webPublish(dbPath, v8path, log); + await probeReady(READY_URL, READY_TIMEOUT, log); + } + + log(`stand ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`); +} + +export async function cleanup({ log }) { + // MVP: оставляем стенд поднятым для отладки. Для full-shutdown — ручной /web-stop + // или следующий запуск с --rebuild-stand. + log('cleanup: stand left running (use /web-stop or run with `-- --rebuild-stand` to reset)'); +}