mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-30 16:36:56 +03:00
feat(web-test): таймаут теста реально прерывает зависший await
Прогон вставал намертво на зависшем Playwright-действии (~29 мин, без движения),
при --format=allure отчёт терялся целиком — результаты писались только в конце.
Promise.race с таймером был и раньше, но не лечил: race не отменяет t.fn (промис
отменить нельзя), а весь путь после него шёл без единого таймаута. Паттерн
`try { await x } catch {}` ловит reject, но не «никогда не завершится» — а
page.evaluate не имеет таймаута в принципе, поэтому заблокированный JS-поток
рендерера вешал раннер навсегда.
Что сделано:
1. Прерывание. При таймауте движок опрашивает контекст (probeContext) и при
вердикте hang/browser-dead уничтожает зависшее: abortContext закрывает
страницу с runBeforeUnload:false, повисший await отваливается «Target closed»,
следующий тест поднимает контекст лениво. Зависший тест не ретраится.
2. Диагноз в отчёте. hang (браузер жив, рендерер не отвечает) против slow
(просто не уложился → поднять timeout). Разделитель — асимметрия пробников:
вызов в browser-процесс отвечает за 1 мс при мёртвом рендерере, evaluate — нет.
3. Лицензии. Штатный logout идёт fetch-ем изнутри страницы и на зависшей
странице невозможен. abortContext шлёт POST /e1cib/logout из Node: замерено —
сеанс опознаётся seanceId в URL, кук у клиента нет вообще, после запроса
клиент пишет «сеанс был завершен». Каскад: node → page → соседняя страница.
4. Дедлайны на весь путь очистки (deadline.mjs) — пробой печатается строкой,
молча зависнуть больше нельзя. Попутно: disconnect слал logout по зависшей
странице дважды (модульный `page` после multi-context ветки).
5. Инкрементальный Allure: результат теста пишется сразу по его завершении —
зависание больше не уничтожает уже собранное.
6. --global-timeout: потолок на прогон, работает и внутри зависшего теста
(неразрешённый промис не блокирует event loop). Коды выхода: 2 — потолок
сработал, 3 — зависло само сворачивание. Внешний watchdog больше не нужен.
Проверено вживую на стенде: фикстура tests/web-test/_hang (заблокированный
JS-поток) — падение за 17.6с с verdict: hang вместо вечного зависания,
следующий тест зелёный, оба результата в allure-results; --global-timeout
срабатывает посреди зависания и сохраняет отчёт. Полный регресс 25 тестов:
пробоев дедлайнов ноль.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3889c7279f
commit
037d000f20
@@ -1,4 +1,4 @@
|
||||
// web-test browser v1.18 — engine facade: re-exports the public API from engine/*
|
||||
// web-test browser v1.19 — engine facade: re-exports the public API from engine/*
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
||||
@@ -22,6 +22,9 @@ export {
|
||||
connect, disconnect, attach, detach, getSession,
|
||||
createContext, setActiveContext, listContexts, getActiveContext,
|
||||
hasContext, closeContext,
|
||||
// Unresponsive-context handling (test runner). abortContext is the sanctioned way to
|
||||
// mutate the registry from outside — the `contexts` Map itself stays private.
|
||||
abortContext, probeContext, getContextDiagnostics,
|
||||
} from './engine/core/session.mjs';
|
||||
|
||||
// ── navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// web-test cli/commands/test v1.5 — regression test runner
|
||||
// web-test cli/commands/test v1.6 — regression test runner
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname, basename, relative } from 'path';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
|
||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps, softDeadline } from '../util.mjs';
|
||||
import { buildContext, buildScopedContext, setErrorShotDir } from '../exec-context.mjs';
|
||||
import { createAssertions } from '../test-runner/assertions.mjs';
|
||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||
@@ -19,8 +19,27 @@ export async function cmdTest(rawArgs) {
|
||||
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
||||
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
||||
|
||||
// Deadline budgets for the cleanup path. Every one of these calls reaches into Playwright
|
||||
// and can hang forever against a wedged renderer (page.evaluate has no timeout of its own),
|
||||
// so none of them may be awaited bare. A breach is always logged — silent swallowing is what
|
||||
// turned the original incident into a 29-minute mystery.
|
||||
const D = {
|
||||
screenshot: 10000,
|
||||
teardown: 15000,
|
||||
afterEach: 15000,
|
||||
setActive: 5000,
|
||||
resetState: 20000,
|
||||
startRecording: 15000,
|
||||
stopRecording: 40000, // ffmpeg has its own 30s inside
|
||||
closeContext: 20000,
|
||||
disconnect: 30000,
|
||||
hooks: 120000, // prepare/cleanup/beforeAll/afterAll do real work (db rebuilds)
|
||||
abortAll: 30000, // whole abort+cleanup sequence for one hung test
|
||||
probe: 2000,
|
||||
};
|
||||
|
||||
// Parse flags
|
||||
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||
const opts = { bail: false, retry: 0, timeout: 30000, globalTimeout: 0, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||
let tags = null, grep = null, urlFlag = null;
|
||||
const positional = [];
|
||||
for (const a of ownArgs) {
|
||||
@@ -30,6 +49,7 @@ export async function cmdTest(rawArgs) {
|
||||
else if (a === '--bail') opts.bail = true;
|
||||
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
||||
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
||||
else if (a.startsWith('--global-timeout=')) opts.globalTimeout = parseInt(a.slice(17)) || 0;
|
||||
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
||||
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
||||
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
||||
@@ -118,6 +138,7 @@ export async function cmdTest(rawArgs) {
|
||||
if (!tags && config.tags) tags = config.tags;
|
||||
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
||||
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
||||
opts.globalTimeout = ownArgs.some(a => a.startsWith('--global-timeout=')) ? opts.globalTimeout : (config.globalTimeout || opts.globalTimeout);
|
||||
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
||||
browser.setPreserveClipboard(false);
|
||||
}
|
||||
@@ -210,9 +231,102 @@ export async function cmdTest(rawArgs) {
|
||||
const results = [];
|
||||
let passCount = 0, failCount = 0, skipCount = 0;
|
||||
|
||||
/**
|
||||
* Bounded best-effort await: the replacement for `try { await x } catch {}`.
|
||||
* Same tolerance for failure, but a call that never settles can no longer stall the run,
|
||||
* and every breach leaves a visible line instead of a silent 29-minute stall.
|
||||
*/
|
||||
async function bounded(promise, ms, label) {
|
||||
const r = await softDeadline(promise, ms, label);
|
||||
if (!r.ok) W.write(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Bumped for every attempt. A timed-out test's body keeps running — a promise cannot be
|
||||
// cancelled, so when its pending call finally rejects, its own `finally` would go on to
|
||||
// drive the UI of whichever test is running by then. Silent cross-test corruption.
|
||||
//
|
||||
// The run shares one `ctx` object (hooks hold it too), so an epoch stamped on ctx could not
|
||||
// tell the zombie from the live caller — both are the same object. Each attempt therefore
|
||||
// gets its own Proxy view bound to its epoch; calls through a stale view throw.
|
||||
let abortEpoch = 0;
|
||||
function makeTestCtx(base, epoch) {
|
||||
return new Proxy(base, {
|
||||
get(target, prop, recv) {
|
||||
const v = Reflect.get(target, prop, recv);
|
||||
if (typeof v !== 'function') return v;
|
||||
return (...args) => {
|
||||
if (epoch !== abortEpoch) {
|
||||
throw new Error(`test abandoned (timeout) — blocked a late ${String(prop)}() call from its body; it would have hit the next test`);
|
||||
}
|
||||
return v.apply(target, args);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildReport(state) {
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
return {
|
||||
runner: 'web-test', url, startedAt, finishedAt: new Date().toISOString(),
|
||||
state,
|
||||
duration: totalDuration,
|
||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||
tests: results,
|
||||
};
|
||||
}
|
||||
|
||||
let allureWritten = false;
|
||||
/**
|
||||
* Record a finished test AND persist it immediately. The report used to be written only
|
||||
* after the loop, so a single hang destroyed every result collected so far.
|
||||
* writeAllure([tr]) is byte-identical to the batch call: it mints its own uuid per test and
|
||||
* severityIndex is read-only.
|
||||
*/
|
||||
function recordResult(tr) {
|
||||
results.push(tr);
|
||||
if (opts.format === 'allure') {
|
||||
try { writeAllure([tr], reportDir, severityIndex); allureWritten = true; } catch (e) { W.write(` ! allure write: ${e.message}\n`); }
|
||||
} else if (opts.format === 'json' && opts.report && !reportToStdout) {
|
||||
try { writeFileSync(resolve(opts.report), JSON.stringify(buildReport('partial'), null, 2)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
||||
const hookEnv = { hookArgs, log: hookLog, config };
|
||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||
if (hooks.prepare) await bounded(hooks.prepare(hookEnv), D.hooks, 'hooks.prepare');
|
||||
|
||||
/** Force-release every open context (frees 1C licenses), then drop the browser. */
|
||||
async function shutdownAll() {
|
||||
for (const name of browser.listContexts()) {
|
||||
await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||
}
|
||||
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock ceiling for the whole run. This works even while a test is wedged: a pending
|
||||
* Playwright await does not block the event loop, it is merely an unsettled promise — which
|
||||
* is precisely why the original incident stalled quietly instead of crashing.
|
||||
* Report first (that's what the user needs), hygiene second, exit unconditionally.
|
||||
*/
|
||||
let globalTimer = null;
|
||||
let hardStopping = false;
|
||||
async function hardStop(reason) {
|
||||
if (hardStopping) return;
|
||||
hardStopping = true;
|
||||
W.write(`\n!! ${reason}: run exceeded --global-timeout=${opts.globalTimeout}ms — forcing shutdown\n`);
|
||||
abortEpoch++;
|
||||
// Last-resort exit if the shutdown itself wedges. Referenced on purpose: it must survive.
|
||||
const bailout = setTimeout(() => process.exit(3), 20000);
|
||||
try { writeFinalReport('aborted'); } catch (e) { W.write(` ! report: ${e.message}\n`); }
|
||||
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||
clearTimeout(bailout);
|
||||
process.exit(2);
|
||||
}
|
||||
if (opts.globalTimeout > 0) {
|
||||
globalTimer = setTimeout(() => { void hardStop('global-timeout'); }, opts.globalTimeout);
|
||||
}
|
||||
|
||||
// Lazy context creation
|
||||
async function ensureContext(name) {
|
||||
@@ -271,7 +385,7 @@ export async function cmdTest(rawArgs) {
|
||||
if (t.skip) {
|
||||
const reason = typeof t.skip === 'string' ? t.skip : '';
|
||||
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -319,7 +433,7 @@ export async function cmdTest(rawArgs) {
|
||||
touchLru(lruOrder, testContextNames);
|
||||
} catch (e) {
|
||||
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||
failCount++;
|
||||
if (opts.bail) break;
|
||||
continue;
|
||||
@@ -353,7 +467,8 @@ export async function cmdTest(rawArgs) {
|
||||
let videoFile = null;
|
||||
if (opts.record) {
|
||||
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
||||
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
|
||||
const rec = await bounded(browser.startRecording(videoFile, { force: true }), D.startRecording, 'startRecording');
|
||||
if (!rec.ok) videoFile = null;
|
||||
}
|
||||
|
||||
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
||||
@@ -394,6 +509,9 @@ export async function cmdTest(rawArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const myEpoch = ++abortEpoch;
|
||||
let timedOut = false;
|
||||
|
||||
try {
|
||||
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
||||
if (t.setup) await t.setup(ctx);
|
||||
@@ -401,8 +519,8 @@ export async function cmdTest(rawArgs) {
|
||||
let timeoutTimer;
|
||||
try {
|
||||
await Promise.race([
|
||||
t.fn(ctx, t.param),
|
||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
|
||||
t.fn(makeTestCtx(ctx, myEpoch), t.param),
|
||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => { timedOut = true; reject(new Error(`Timeout (${t.timeout}ms)`)); }, t.timeout); }),
|
||||
]);
|
||||
} finally {
|
||||
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
||||
@@ -411,16 +529,21 @@ export async function cmdTest(rawArgs) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
|
||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||
// Bounded even on the green path: a test can pass and still leave the UI in a state
|
||||
// where resetState wedges — that would stall the run just as dead as a failure would.
|
||||
if (t.teardown) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||
for (const cn of testContextNames) {
|
||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||
if (!browser.hasContext(cn)) continue;
|
||||
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
|
||||
if (!sw.ok) break;
|
||||
await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||
}
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||
@@ -428,14 +551,58 @@ export async function cmdTest(rawArgs) {
|
||||
break;
|
||||
|
||||
} catch (e) {
|
||||
// ── Timeout: diagnose, then destroy what hung. Everything below this point that
|
||||
// goes through the renderer (screenshot, teardown, resetState) is pointless on a
|
||||
// wedged page and would itself hang — so on `hang` we skip straight to the abort.
|
||||
let diagnosis = null;
|
||||
if (timedOut) {
|
||||
const active = browser.getActiveContext();
|
||||
const probe = active ? await browser.probeContext(active, { ms: D.probe }) : null;
|
||||
const diag = active ? browser.getContextDiagnostics(active) : null;
|
||||
const verdict = !probe ? 'no-context'
|
||||
: !probe.browserAlive ? 'browser-dead'
|
||||
: !probe.rendererAlive ? 'hang'
|
||||
: diag?.net.inFlight > 0 ? 'slow-network'
|
||||
: 'slow';
|
||||
|
||||
const lines = [
|
||||
`verdict: ${verdict}` + (verdict === 'hang' ? ' (renderer unresponsive, browser alive)' : ''),
|
||||
` context "${active}" [${diag?.isolation}] · renderer probe: ${probe?.rendererAlive ? `ok in ${probe.rendererMs}ms` : `timed out at ${D.probe}ms`}` +
|
||||
` · browser probe: ${probe?.browserAlive ? `ok in ${probe.browserMs}ms` : `timed out at ${D.probe}ms`}`,
|
||||
` network: ${diag?.net.inFlight} in flight, last event ${diag?.msSinceLastNetEvent != null ? (diag.msSinceLastNetEvent / 1000).toFixed(1) + 's ago' : 'never'}` +
|
||||
` (${diag?.net.requests} req / ${diag?.net.responses} resp)`,
|
||||
];
|
||||
// Same failure, different remedy — say which, or the next person guesses.
|
||||
if (verdict === 'slow' || verdict === 'slow-network') {
|
||||
lines.push(' no hang detected — the test is simply slower than its declared timeout; raise `export const timeout`');
|
||||
}
|
||||
|
||||
if (verdict === 'hang' || verdict === 'browser-dead') {
|
||||
const ab = await bounded(browser.abortContext(active), D.abortAll, 'abortContext');
|
||||
const r = ab.ok ? ab.value : null;
|
||||
lines.push(` recovery: ${r ? `context aborted (logout: ${r.logout}, closed: ${r.closed}${r.escalated ? ', escalated to browser kill' : ''})` : 'abort failed'} — next test recreates it`);
|
||||
if (r?.notes?.length) lines.push(` notes: ${r.notes.join('; ')}`);
|
||||
if (active) dropLru(lruOrder, active);
|
||||
}
|
||||
diagnosis = { verdict, probe, net: diag?.net };
|
||||
e.message = `${e.message} — ${lines[0]}`;
|
||||
output.push(...lines);
|
||||
W.write(lines.map(l => ` ${l}\n`).join(''));
|
||||
}
|
||||
|
||||
const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead');
|
||||
|
||||
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
||||
// Skipped on a dead page: it goes through the renderer, so it can only hang.
|
||||
let shotFile = e.onecError?.screenshot;
|
||||
if (!shotFile && opts.screenshot !== 'off') {
|
||||
try {
|
||||
const png = await browser.screenshot();
|
||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||
writeFileSync(shotFile, png);
|
||||
} catch {}
|
||||
if (!shotFile && opts.screenshot !== 'off' && !dead) {
|
||||
const shot = await bounded(browser.screenshot(), D.screenshot, 'screenshot');
|
||||
if (shot.ok) {
|
||||
try {
|
||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||
writeFileSync(shotFile, shot.value);
|
||||
} catch { shotFile = undefined; }
|
||||
}
|
||||
} else if (shotFile && dirname(resolve(shotFile)) !== reportDir) {
|
||||
// Shot came from a context built before setErrorShotDir (e.g. a server
|
||||
// session started earlier): reporters attach by basename, so anything
|
||||
@@ -453,21 +620,32 @@ export async function cmdTest(rawArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
|
||||
if (t.teardown && !dead) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError, diagnosis };
|
||||
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||
for (const cn of testContextNames) {
|
||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||
// resetState drives the UI (up to 10 × getFormState + closeForm, all page.evaluate).
|
||||
// On a dead page it cannot succeed — the slot is already gone anyway.
|
||||
if (!dead) {
|
||||
for (const cn of testContextNames) {
|
||||
if (!browser.hasContext(cn)) continue;
|
||||
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
|
||||
if (!sw.ok) break;
|
||||
await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
|
||||
}
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||
}
|
||||
lastError = errInfo;
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
||||
|
||||
// A wedged renderer is not flakiness — retrying just buys another full timeout
|
||||
// plus another abort. Stop after the first hang.
|
||||
if (dead) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +669,7 @@ export async function cmdTest(rawArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
results.push(testResult);
|
||||
recordResult(testResult);
|
||||
|
||||
if (testResult.status === 'passed') {
|
||||
passCount++;
|
||||
@@ -507,23 +685,26 @@ export async function cmdTest(rawArgs) {
|
||||
if (opts.bail && testResult.status === 'failed') break;
|
||||
}
|
||||
|
||||
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
||||
if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll');
|
||||
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
// Per-context teardown
|
||||
try {
|
||||
const remaining = browser.listContexts();
|
||||
if (remaining.length > 0) {
|
||||
const survivor = remaining[0];
|
||||
try { await browser.setActiveContext(survivor); } catch {}
|
||||
await bounded(browser.setActiveContext(survivor), D.setActive, `setActiveContext(${survivor})`);
|
||||
for (let i = remaining.length - 1; i >= 1; i--) {
|
||||
const name = remaining[i];
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
try { await browser.closeContext(name); }
|
||||
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
|
||||
// closeContext goes through the page (logout + close). If it breaches, fall back to
|
||||
// abortContext: it logs out from Node, which is the path that survives a dead page.
|
||||
const cc = await bounded(browser.closeContext(name), D.closeContext, `closeContext(${name})`);
|
||||
if (!cc.ok) await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||
}
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
||||
@@ -533,32 +714,35 @@ export async function cmdTest(rawArgs) {
|
||||
} catch (e) {
|
||||
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
try { await browser.disconnect(); } catch {}
|
||||
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
||||
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||
if (hooks.cleanup) await bounded(hooks.cleanup(hookEnv), D.hooks, 'hooks.cleanup');
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
|
||||
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
||||
|
||||
const report = {
|
||||
runner: 'web-test', url, startedAt, finishedAt,
|
||||
duration: totalDuration,
|
||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||
tests: results,
|
||||
};
|
||||
if (opts.format === 'allure') {
|
||||
writeAllure(results, reportDir, severityIndex);
|
||||
syncAllureExtras(testDir, reportDir);
|
||||
} else if (opts.format === 'junit') {
|
||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||
} else if (reportToStdout) {
|
||||
out(report);
|
||||
} else if (opts.report) {
|
||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||
}
|
||||
writeFinalReport('complete');
|
||||
|
||||
if (failCount > 0) process.exit(1);
|
||||
|
||||
/**
|
||||
* Allure results are already on disk (recordResult writes each test as it finishes), so this
|
||||
* only completes the formats that need whole-run totals. Also called from hardStop, where
|
||||
* `state` is 'aborted' and `results` holds whatever finished before the ceiling hit.
|
||||
*/
|
||||
function writeFinalReport(state) {
|
||||
const report = buildReport(state);
|
||||
if (opts.format === 'allure') {
|
||||
// Guard against a result-producing path that skipped recordResult; normally a no-op.
|
||||
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
|
||||
syncAllureExtras(testDir, reportDir);
|
||||
} else if (opts.format === 'junit') {
|
||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||
} else if (reportToStdout) {
|
||||
out(report);
|
||||
} else if (opts.report) {
|
||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// web-test cli/util v1.3 — generic helpers for CLI commands
|
||||
// web-test cli/util v1.4 — generic helpers for CLI commands
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
// Wall-clock bounds live in the engine (session.mjs needs them too, and the engine must not
|
||||
// depend on cli/). Re-exported here so CLI callers have one import site.
|
||||
export { withDeadline, softDeadline, DeadlineError } from '../engine/core/deadline.mjs';
|
||||
|
||||
export function out(obj) {
|
||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// web-test engine/core/deadline v1.0 — wall-clock bounds for calls that can hang
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Why this exists: `try { await x } catch {}` guards against a REJECTION, not against
|
||||
// a promise that never settles. A Playwright call against a wedged renderer (page.evaluate
|
||||
// has no timeout at all) does exactly that — it neither resolves nor rejects, and the runner
|
||||
// waits forever. Every such call must be bounded by a wall-clock timer instead.
|
||||
//
|
||||
// Neither helper CANCELS the underlying work — that is impossible for a promise. They only
|
||||
// stop *waiting* on it. Whoever breaks a deadline must also destroy the thing that hung
|
||||
// (see session.abortContext) or the pending call keeps holding its resources.
|
||||
|
||||
export class DeadlineError extends Error {
|
||||
constructor(label, ms) {
|
||||
super(`${label} timed out after ${ms}ms`);
|
||||
this.name = 'DeadlineError';
|
||||
this.label = label;
|
||||
this.ms = ms;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await `promise`, but give up after `ms`.
|
||||
* @throws {DeadlineError} when the deadline is reached first.
|
||||
*/
|
||||
export function withDeadline(promise, ms, label = 'operation') {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms); }),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort variant: never throws, reports what happened instead.
|
||||
* Use it where the old code said `try { await x } catch {}` — the point is that a breach
|
||||
* becomes VISIBLE (callers are expected to log `err`) rather than silently swallowed.
|
||||
* @returns {Promise<{ok: boolean, value?: any, err?: Error, timedOut: boolean, ms: number}>}
|
||||
*/
|
||||
export async function softDeadline(promise, ms, label = 'operation') {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const value = await withDeadline(promise, ms, label);
|
||||
return { ok: true, value, timedOut: false, ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return { ok: false, err, timedOut: err instanceof DeadlineError, ms: Date.now() - t0 };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// web-test core/session v1.18 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { softDeadline } from './deadline.mjs';
|
||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -138,15 +139,21 @@ async function logoutSlot(slot, waitMs = 500) {
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
const wasMultiContext = contexts.size > 0;
|
||||
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (contexts.size > 0) {
|
||||
if (wasMultiContext) {
|
||||
saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
await logoutSlot(slot);
|
||||
// Deadline-bounded: an unresponsive slot must not hold up the shutdown of the others.
|
||||
// nodeLogout is the fallback that needs no renderer — it is what keeps the license
|
||||
// from leaking when the page is the thing that died.
|
||||
const own = await softDeadline(logoutSlot(slot), 3000, 'logoutSlot');
|
||||
if (!own.ok) await nodeLogout(slot, 3000);
|
||||
}
|
||||
contexts.clear();
|
||||
setActiveContextName(null);
|
||||
@@ -155,13 +162,20 @@ export async function disconnect() {
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license (single-session connect path)
|
||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||
await browser.close().catch(() => {});
|
||||
// Graceful logout — release the 1C license (single-session connect path).
|
||||
// Skipped after the multi-context path: `page` still mirrors the last active slot, which
|
||||
// was just logged out above — re-sending it would pay a hung page's cost a second time.
|
||||
if (!wasMultiContext) {
|
||||
await softDeadline(logoutSlot({ page, sessionPrefix, seanceId }, 1000), 4000, 'logoutSlot');
|
||||
}
|
||||
await softDeadline(browser.close(), 10000, 'browser.close');
|
||||
// Floor: if Chromium ignored close(), take the process out — never leave an orphan.
|
||||
try { browser.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
try { browser.process?.()?.kill('SIGKILL'); } catch {}
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
@@ -241,7 +255,22 @@ function activateSlot(name) {
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Network counters feed the hang/slow diagnosis (see probeContext). These events are
|
||||
// emitted by the BROWSER process, so they keep flowing even when the page's JS thread is
|
||||
// wedged and page.evaluate() can no longer answer. Supporting colour only, not a verdict:
|
||||
// a wedged renderer cannot issue requests, so "quiet" looks the same as "idle waiting".
|
||||
// Counters only — never buffer URLs, this runs for the whole suite.
|
||||
slot.net = { lastEventAt: Date.now(), inFlight: 0, requests: 0, responses: 0 };
|
||||
const settled = () => { slot.net.inFlight = Math.max(0, slot.net.inFlight - 1); slot.net.lastEventAt = Date.now(); };
|
||||
pg.on('requestfinished', settled);
|
||||
pg.on('requestfailed', settled);
|
||||
pg.on('response', () => { slot.net.responses++; slot.net.lastEventAt = Date.now(); });
|
||||
|
||||
pg.on('request', req => {
|
||||
slot.net.requests++;
|
||||
slot.net.inFlight++;
|
||||
slot.net.lastEventAt = Date.now();
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
@@ -402,3 +431,211 @@ export async function closeContext(name) {
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a 1C seance straight from Node — no renderer, no CDP, no browser involved.
|
||||
*
|
||||
* Measured on the webtest stand: the seance is identified by `seanceId` in the URL and the
|
||||
* client holds NO cookies at all (context.cookies() → []), so this request is equivalent to
|
||||
* the one logoutSlot makes from inside the page. Verified end-to-end: after this call the
|
||||
* web client reports "сеанс был завершен" on its next action.
|
||||
*
|
||||
* This is the only logout that still works when the renderer is wedged — which is exactly
|
||||
* when a license would otherwise leak until the server-side seance timeout.
|
||||
*
|
||||
* @returns {Promise<boolean>} true only on a 2xx answer (a 401/404 must not pass for success).
|
||||
*/
|
||||
async function nodeLogout(slot, ms = 3000) {
|
||||
if (!slot?.sessionPrefix || !slot?.seanceId) return false;
|
||||
try {
|
||||
const res = await fetch(`${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"root":{}}',
|
||||
signal: AbortSignal.timeout(ms),
|
||||
});
|
||||
return res.status >= 200 && res.status < 300;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this context's browser alive, and is its renderer still answering?
|
||||
*
|
||||
* The two probes separate the failure modes that look identical from the outside:
|
||||
* browserAlive && !rendererAlive → the page's JS thread is wedged: a hang. Nothing that
|
||||
* goes through the renderer (evaluate, screenshot, clicks) can ever come back.
|
||||
* both alive → nothing is broken; the test simply outran its timeout.
|
||||
*
|
||||
* cookies() is served by the browser process (measured: 1ms against a wedged renderer),
|
||||
* page.evaluate() is not — that asymmetry is the whole trick.
|
||||
*
|
||||
* @returns {Promise<{browserAlive: boolean, rendererAlive: boolean, browserMs: number, rendererMs: number, pageClosed: boolean}>}
|
||||
*/
|
||||
export async function probeContext(name, { ms = 2000 } = {}) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot?.page) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||
if (slot.page.isClosed()) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||
|
||||
const [b, r] = await Promise.all([
|
||||
softDeadline(slot.page.context().cookies(), ms, 'browser probe'),
|
||||
softDeadline(slot.page.evaluate(() => 1), ms, 'renderer probe'),
|
||||
]);
|
||||
return {
|
||||
browserAlive: b.ok,
|
||||
rendererAlive: r.ok,
|
||||
browserMs: b.ms,
|
||||
rendererMs: r.ms,
|
||||
pageClosed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read-only view of a slot's network activity. Never hands out the slot itself. */
|
||||
export function getContextDiagnostics(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) return null;
|
||||
const net = slot.net || { lastEventAt: 0, inFlight: 0, requests: 0, responses: 0 };
|
||||
return {
|
||||
name,
|
||||
isolation: activeMode,
|
||||
net: { ...net },
|
||||
msSinceLastNetEvent: net.lastEventAt ? Date.now() - net.lastEventAt : null,
|
||||
pageClosed: slot.page ? slot.page.isClosed() : true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-release an unresponsive context — including the ACTIVE one, which closeContext
|
||||
* refuses to touch. Every step is wall-clock bounded, so this path is never at the mercy
|
||||
* of the thing that hung: it is bounded by timers, never by browser cooperation.
|
||||
*
|
||||
* Ordering matters: logout FIRST (a dead page can't release its own license), close second.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.logoutMs=3000] budget per logout attempt
|
||||
* @param {number} [opts.closeMs=5000] budget for page.close()/context.close()
|
||||
* @param {string} [opts.parkOn] context to activate afterwards (default: any survivor)
|
||||
* @returns {Promise<{name, logout: 'node'|'page'|'sibling'|'failed'|'skipped', closed: 'page'|'context'|'browser-killed'|'failed', escalated: boolean, notes: string[]}>}
|
||||
*/
|
||||
export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, parkOn } = {}) {
|
||||
const out = { name, logout: 'skipped', closed: 'failed', escalated: false, notes: [] };
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) { out.notes.push('not registered'); return out; }
|
||||
|
||||
// The recorder follows the active page; if we are about to close that page, stop it first
|
||||
// or the CDP screencast is dead for the rest of the run.
|
||||
if (recorder && activeContextName === name) {
|
||||
const r = await softDeadline(stopRecording(), 10000, 'stopRecording');
|
||||
if (!r.ok) out.notes.push(`stopRecording: ${r.err.message.split('\n')[0]}`);
|
||||
}
|
||||
|
||||
// ── Logout cascade: first success wins. node first — it needs neither renderer nor CDP.
|
||||
if (slot.seanceId && slot.sessionPrefix) {
|
||||
if (await nodeLogout(slot, logoutMs)) {
|
||||
out.logout = 'node';
|
||||
} else {
|
||||
const own = await softDeadline(logoutSlot(slot, 0), logoutMs, 'logoutSlot');
|
||||
if (own.ok) {
|
||||
out.logout = 'page';
|
||||
} else {
|
||||
// Same origin ⇒ same seance namespace; a live sibling can post the logout for us.
|
||||
const sibling = [...contexts.entries()].find(([n, s]) =>
|
||||
n !== name && s.page && !s.page.isClosed() && s.sessionPrefix === slot.sessionPrefix);
|
||||
if (sibling) {
|
||||
const url = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
const sib = await softDeadline(
|
||||
sibling[1].page.evaluate(async (u) => {
|
||||
const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
return r.status;
|
||||
}, url), logoutMs, 'sibling logout');
|
||||
if (sib.ok && sib.value >= 200 && sib.value < 300) out.logout = 'sibling';
|
||||
else out.logout = 'failed';
|
||||
} else {
|
||||
out.logout = 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.logout === 'failed') out.notes.push('license may leak until the 1C seance times out');
|
||||
}
|
||||
|
||||
// In tab mode `browser` is a persistent BrowserContext (see createContext): closing its LAST
|
||||
// page leaves Chromium with no windows and it exits, so the next createContext() would fail
|
||||
// with "Failed to open a new tab". When this is the only slot, tear the browser down on
|
||||
// purpose and reset state — the next createContext() then relaunches cleanly.
|
||||
const lastPageInTabMode = activeMode === 'tab' && contexts.size === 1;
|
||||
|
||||
// ── Close. runBeforeUnload:false is what survives a wedged renderer: the browser process
|
||||
// tears the target down instead of asking the page's JS to agree.
|
||||
if (activeMode === 'tab') {
|
||||
// tab mode: slot.context IS the shared browser — closing it would kill every context.
|
||||
const c = await softDeadline(slot.page.close({ runBeforeUnload: false }), closeMs, 'page.close');
|
||||
if (c.ok) out.closed = 'page';
|
||||
} else {
|
||||
const c = await softDeadline(slot.context.close(), closeMs, 'context.close');
|
||||
if (c.ok) out.closed = 'context';
|
||||
}
|
||||
|
||||
// ── Escalate: if even the close hung, the browser itself is suspect. Kill it and reset
|
||||
// state, otherwise the next createContext() would call newPage() on a dead object and
|
||||
// every remaining test would fail.
|
||||
if (out.closed === 'failed') {
|
||||
out.escalated = true;
|
||||
out.notes.push('close breached its deadline — killing the browser');
|
||||
const b = browser;
|
||||
await softDeadline(Promise.resolve(b?.close?.()), 5000, 'browser.close');
|
||||
try { b?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
try { b?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
out.closed = 'browser-killed';
|
||||
contexts.clear();
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
contexts.delete(name);
|
||||
|
||||
if (lastPageInTabMode) {
|
||||
out.notes.push('last tab closed — browser torn down, next createContext relaunches it');
|
||||
await softDeadline(Promise.resolve(browser?.close?.()), 5000, 'browser.close');
|
||||
try { browser?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Park the active pointer on a survivor (or nothing). Deliberately NOT via
|
||||
// setActiveContext()/saveActiveSlot() — those would write the dying page back into a slot.
|
||||
if (activeContextName === name) {
|
||||
const survivor = (parkOn && contexts.has(parkOn)) ? parkOn : [...contexts.keys()][0];
|
||||
if (survivor) {
|
||||
activateSlot(survivor);
|
||||
if (recorder) { try { await recorder._attachPage(page); } catch { /* recording is best-effort */ } }
|
||||
} else {
|
||||
// No slots left: isConnected() goes false and engine calls fail fast until the next
|
||||
// ensureContext() recreates one. That is the intended contract, not a leak.
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user