mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
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:
@@ -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)');
|
||||
}
|
||||
Reference in New Issue
Block a user