feat(web-test): M6-MVP — автономный стенд через _hooks.mjs

Новый tests/web-test/_hooks.mjs v0.2 с prepare()/cleanup().
prepare() поднимает изолированный стенд:
- Hash-locks `tests/skills/.cache/webtest-stand/{config,epf}.lock`
  на sha256 от build-steps и EPF_SPEC — автоматический skip
  пересборки при отсутствии изменений.
- Слои конфиг XML / БД / EPF пересобираются независимо. Триггер
  ручной — флаги `--rebuild-config`/`--reload-data`/`--rebuild-epf`/
  `--rebuild-stand` (через `-- ...` после CLI раннера).
- Smart Apache: web-stop+web-publish выполняются только когда
  пересоздаём БД (нужно освободить блокировку). Иначе probe-first:
  жив (200) → no-op; мёртв → publish + probeReady. На warm-старте
  prepare сводится к чтению локов и одному probe (~200ms).
- web-publish на собственном AppName `webtest-runner` :9191 — не
  пересекается с интерактивной публикацией `webtest`.
- Кросс-платформенно: env WEBTEST_HOOKS_RUNTIME=python переключает
  на зеркальные py-порты скиллов (для не-Windows стендов).

cleanup() пока stub — оставляем стенд поднятым между прогонами,
для full-shutdown ручной /web-stop или `-- --rebuild-stand`.

E2E-проверено: cold-start `--rebuild-stand` поднимает стенд за
~38s; warm-старт prepare = 0.0s; полный регресс 18/18 зелёный
за 9m 7.1s (включая оба multi-context-теста, которые исторически
флапали).
This commit is contained in:
Nick Shirokov
2026-05-12 20:25:47 +03:00
parent a92bce05fb
commit 5c734202b6
+295
View File
@@ -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 ... -- <args>`):
// --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)');
}