feat(web-test): M8 — per-context lifecycle (closeContext + afterOpenContext/beforeCloseContext)

browser.mjs:
- + closeContext(name): logout slot + close page (tab) или context (window),
  удаление из реестра. Throw если name неактивен (рулило: nicht den aktiven
  closen, recorder always attached к active → invariant простой).
- _logoutSlot(slot, waitMs) — извлечён из disconnect, переиспользуется в
  closeContext.

run.mjs:
- ensureContext() после createContext вызывает hooks.afterOpenContext(ctx, name, spec).
- wrapCloseContextHook() оборачивает ctx.closeContext (и каждую scoped-обёртку)
  чтобы перед browser.closeContext fir'ить hooks.beforeCloseContext.
- Финальный teardown в finally: для всех живых контекстов кроме первого
  (survivor) — beforeCloseContext + closeContext; для survivor только хук,
  его закрывает disconnect().

_hooks.mjs v0.5:
- afterOpenContext инжектит persistent DOM-badge с displayName в правый
  верхний угол page — в записанном видео всегда видно, какой контекст.
- beforeCloseContext counter-only.
- _state расширен полями afterOpenContext / beforeCloseContext.

15-multi-context-handover.test.mjs:
- +2 шага: closeContext('b') после handover, попытка closeContext(active)
  ловится throw'ом с проверкой message.

00-hooks.test.mjs:
- +1 ассерт: afterOpenContext >= 1 (default уже создан), beforeCloseContext === 0
  в теле первого теста.

spec §6:
- Раздел «Контекстный уровень» (afterOpenContext / beforeCloseContext + правила closeContext).
- ASCII-диаграмма порядка хуков обновлена с per-context lifecycle.

Регресс 19/19 ✓ (9m 16.8s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-13 16:07:45 +03:00
parent 43ed9ba142
commit eb87be5c04
6 changed files with 226 additions and 30 deletions
+45 -22
View File
@@ -168,6 +168,23 @@ export async function connect(url, { extensionPath } = {}) {
return await getPageState();
}
/**
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
* Silent if page is closed or session info missing, just returns.
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
*/
async function _logoutSlot(slot, waitMs = 500) {
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
try {
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
await slot.page.evaluate(async (url) => {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
}, logoutUrl);
await slot.page.waitForTimeout(waitMs);
} catch {}
}
/**
* Gracefully terminate the 1C session and close the browser.
* Sends POST /e1cib/logout to release the license before closing.
@@ -181,15 +198,7 @@ export async function disconnect() {
try { await stopRecording(); } catch {}
}
for (const [, slot] of contexts.entries()) {
if (slot.page && !slot.page.isClosed() && slot.seanceId && slot.sessionPrefix) {
try {
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
await slot.page.evaluate(async (url) => {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
}, logoutUrl);
await slot.page.waitForTimeout(500);
} catch {}
}
await _logoutSlot(slot);
}
contexts.clear();
activeContextName = null;
@@ -203,19 +212,7 @@ export async function disconnect() {
if (browser) {
// Graceful logout — release the 1C license (single-session connect path)
if (page && !page.isClosed() && seanceId && sessionPrefix) {
try {
const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`;
await page.evaluate(async (url) => {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"root":{}}'
});
}, logoutUrl);
await page.waitForTimeout(1000);
} catch {}
}
await _logoutSlot({ page, sessionPrefix, seanceId }, 1000);
await browser.close().catch(() => {});
browser = null;
page = null;
@@ -432,6 +429,32 @@ export function hasContext(name) {
return contexts.has(name);
}
/**
* Close a named context: logout, close its page (tab mode) or BrowserContext
* (window mode), remove from registry. Cannot close the currently active
* context caller must setActiveContext to another first. This keeps the
* recorder/page invariants simple: recorder is always attached to the
* active slot, which closeContext never touches.
*
* @throws if name is not registered or equals the active context.
*/
export async function closeContext(name) {
if (!contexts.has(name)) {
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
}
if (name === activeContextName) {
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
}
const slot = contexts.get(name);
await _logoutSlot(slot);
if (activeMode === 'tab') {
try { await slot.page.close(); } catch {}
} else {
try { await slot.context.close(); } catch {}
}
contexts.delete(name);
}
/**
* Close startup modals and guide tabs.
* Strategy: Escape click default buttons close extra tabs repeat.
+64 -1
View File
@@ -511,11 +511,37 @@ async function cmdTest(rawArgs) {
if (hooks.prepare) await hooks.prepare(hookEnv);
// Lazy context creation: ensures the named browser context exists, creating it on first request.
// Fires `afterOpenContext(ctx, name, spec)` once per context — right after createContext succeeds.
// The hook receives the same `ctx` that tests use (assembled below), so it can access browser API.
async function ensureContext(name) {
if (browser.hasContext(name)) return;
const spec = contextSpecs[name];
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
if (hooks.afterOpenContext && hookCtx) {
try { await hooks.afterOpenContext(hookCtx, name, spec); }
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
}
// `hookCtx` is set after buildContext below; ensureContext is also called before ctx exists
// (for the default context), so we tolerate `hookCtx === undefined` there — the default
// context's afterOpenContext fires once ctx is built, in the explicit call below.
let hookCtx = null;
// Wrap `target.closeContext` so calling it from a test fires `beforeCloseContext(ctx, name, spec)`
// before delegating to the bare browser.closeContext. Applied to the flat ctx and each scoped
// context (ctx.a / ctx.b) so `await a.closeContext('b')` triggers the hook.
function wrapCloseContextHook(target) {
const orig = target.closeContext;
if (typeof orig !== 'function') return;
target.closeContext = async (name) => {
if (hooks.beforeCloseContext) {
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
return await orig(name);
};
}
try {
@@ -529,6 +555,14 @@ async function cmdTest(rawArgs) {
const ctx = buildContext({ noRecord: false });
ctx.assert = createAssertions();
ctx.log = (...a) => { /* per-test, overridden below */ };
wrapCloseContextHook(ctx);
hookCtx = ctx;
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
if (hooks.afterOpenContext) {
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
}
// beforeAll
if (hooks.beforeAll) await hooks.beforeAll(ctx);
@@ -630,6 +664,7 @@ async function cmdTest(rawArgs) {
if (t.contexts && t.contexts.length) {
for (const cn of t.contexts) {
ctx[cn] = buildScopedContext(cn);
wrapCloseContextHook(ctx[cn]);
scopedKeys.push(cn);
}
}
@@ -722,7 +757,35 @@ async function cmdTest(rawArgs) {
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
} finally {
// Disconnect
// Per-context teardown: fire beforeCloseContext for every remaining slot, then close.
// Mirror the `ctx.a.closeContext('b')` invariant: active is some OTHER context while
// closing `name`. We keep the first registered context (the default) as the survivor —
// it stays active, hooks fire against it, the other slots are closed one by one.
// The default itself is closed by disconnect() (no surviving context to switch to).
try {
const remaining = browser.listContexts();
if (remaining.length > 0) {
const survivor = remaining[0];
try { await browser.setActiveContext(survivor); } catch {}
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]}`); }
}
// Fire beforeCloseContext for the survivor too — disconnect() actually closes it.
if (hooks.beforeCloseContext && hookCtx) {
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
}
}
} catch (e) {
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
}
// Disconnect — closes the last remaining context + browser.
try { await browser.disconnect(); } catch {}
// Cleanup: infrastructure hooks (same signature as prepare)
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}