diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 65146710..a01fce78 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -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. diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index 5c49c621..5c94b190 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -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 {} diff --git a/docs/web-test-runner-spec.md b/docs/web-test-runner-spec.md index 3bede21c..a8ffd36e 100644 --- a/docs/web-test-runner-spec.md +++ b/docs/web-test-runner-spec.md @@ -330,23 +330,41 @@ assert.noErrors(state, msg) - `afterEach(ctx)` -- после каждого теста. Дополнительно доступен `ctx.testResult` с результатом завершившегося теста (status/duration/error/...). +**Контекстный уровень** (на каждый browser-контекст, lifecycle = создан → удалён): +- `afterOpenContext(ctx, name, spec)` -- сразу после успешного `createContext`. + `spec` -- запись из `config.contexts[name]` со всеми custom-полями (`displayName`, + `url`, `isolation`, ...). Полезно: инжект persistent overlay/badge, + preload-навигация для контекста, регистрация телеметрии. +- `beforeCloseContext(ctx, name, spec)` -- перед `closeContext` (контекст ещё + активен и работает). Полезно: финальный flush, сбор метрик, последний скриншот. + Срабатывает как при явном `ctx.closeContext(name)` из теста, так и в + финальном teardown раннера перед `disconnect`. + +`closeContext(name)` валиден только когда `name !== getActiveContext()` -- иначе +бросает. В scoped API (`ctx.a.closeContext('b')`) это естественно: scoped-обёртка +сначала `setActiveContext('a')`, потом close `'b'` -- target всегда не активен. + ### Порядок выполнения ``` prepare() // без браузера (восстановление БД, публикация) browser.launch() // запуск процесса браузера - создание BrowserContext'ов // по одному на каждый используемый контекст - beforeAll(ctx) // браузер готов, контексты созданы + createContext(default) // первый контекст создан + afterOpenContext(ctx, default) // hook: контекст готов + beforeAll(ctx) // браузер готов, default-контекст создан + [lazy ensureContext(name)] // для multi-context тестов + afterOpenContext(ctx, name) beforeEach(ctx) test.setup(ctx) // подготовка теста - test.default(ctx) // тело теста + test.default(ctx) // тело теста (может вызвать ctx.closeContext) + [при ctx.closeContext(x)]: beforeCloseContext(ctx, x) → close(x) test.teardown(ctx) // очистка теста (всегда) afterEach(ctx) // всегда - [встроенный сброс] // всегда (для каждого активного контекста) + [встроенный сброс] // всегда (для каждого живого контекста теста) ...следующий тест... afterAll(ctx) - закрытие всех BrowserContext'ов - browser.close() + [для каждого оставшегося контекста]: beforeCloseContext → closeContext + browser.close() // финальный disconnect cleanup() // без браузера (удаление публикации) ``` diff --git a/tests/web-test/00-hooks.test.mjs b/tests/web-test/00-hooks.test.mjs index 52e9e5d1..8ea90411 100644 --- a/tests/web-test/00-hooks.test.mjs +++ b/tests/web-test/00-hooks.test.mjs @@ -42,6 +42,16 @@ export default async function ({ step, assert, log, testInfo }) { assert.equal(typeof testInfo.primaryContext, 'string', 'primaryContext должен быть строкой'); }); + await step('afterOpenContext отработал хотя бы для default', () => { + // Default контекст создаётся до beforeAll → afterOpenContext должен был + // отработать как минимум один раз. beforeCloseContext в теле первого + // теста ещё не вызывался (контексты живы). + assert.ok(_state.afterOpenContext >= 1, + `afterOpenContext=${_state.afterOpenContext}, ожидался >= 1 (default-контекст создан)`); + assert.equal(_state.beforeCloseContext, 0, + `beforeCloseContext=${_state.beforeCloseContext}, ожидался 0 (контексты ещё живы)`); + }); + await step('afterEach для этого теста ещё не вызывался', () => { // В теле теста afterEach НЕ должен быть вызван ни разу для текущего теста. // Если 00-hooks запущен первым (что и ожидается), afterEach === 0. diff --git a/tests/web-test/15-multi-context-handover.test.mjs b/tests/web-test/15-multi-context-handover.test.mjs index 16d7cf59..1beec8b1 100644 --- a/tests/web-test/15-multi-context-handover.test.mjs +++ b/tests/web-test/15-multi-context-handover.test.mjs @@ -43,4 +43,32 @@ export default async function({ a, b, assert, step, log }) { await a.closeForm(); log('a deleted'); }); + + await step('a: освободить контекст b через closeContext', async () => { + // M8: handover завершён, b больше не нужен — освобождаем лицензию. + // scoped-обёртка `a.closeContext('b')` сначала setActiveContext('a'), + // потом browser.closeContext('b') → 'b' уже неактивен → success. + const before = await a.listContexts(); + assert.includes(before, 'b', 'b должен быть в списке до closeContext'); + await a.closeContext('b'); + const after = await a.listContexts(); + log(`contexts: before=[${before.join(',')}] after=[${after.join(',')}]`); + assert.ok(!after.includes('b'), `b должен исчезнуть, но contexts=[${after.join(',')}]`); + assert.includes(after, 'a', 'a должен остаться'); + }); + + await step('a: closeContext активного контекста бросает', async () => { + // M8 invariant: нельзя закрыть active. scoped a.closeContext('a') сначала + // setActiveContext('a'), потом browser.closeContext('a') — 'a' активен → throw. + let caught = null; + try { + await a.closeContext('a'); + } catch (e) { + caught = e; + } + assert.ok(caught, 'closeContext(active) должен бросить, но не бросил'); + assert.match(caught.message, /cannot close the active context/, + `ожидался текст "cannot close the active context", получено: ${caught.message}`); + log(`thrown as expected: ${caught.message.split('\n')[0]}`); + }); } diff --git a/tests/web-test/_hooks.mjs b/tests/web-test/_hooks.mjs index f625fb8c..212a73bd 100644 --- a/tests/web-test/_hooks.mjs +++ b/tests/web-test/_hooks.mjs @@ -1,4 +1,4 @@ -// _hooks.mjs v0.4 — автономный тестовый стенд для web-test + testlevel-хуки + title slides +// _hooks.mjs v0.5 — автономный стенд + testlevel-хуки + title slides + per-context badge // // `prepare()` поднимает изолированный стенд по smart-логике: // 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop @@ -310,6 +310,8 @@ export const _state = { afterAll: 0, beforeEach: 0, afterEach: 0, + afterOpenContext: 0, + beforeCloseContext: 0, events: [], lastTestResult: null, }; @@ -363,3 +365,55 @@ export async function afterEach(ctx) { } _state.events.push(`afterEach:${ctx.testInfo?.file || '?'}:${ctx.testResult?.status || '?'}`); } + +// ── Per-context hooks (M8) ──────────────────────────────────────────────────── +// +// `afterOpenContext` инжектит persistent DOM-badge с displayName в правый +// верхний угол страницы контекста — в записанном видео всегда видно, какая +// вкладка к какому пользователю относится. Badge переживает любые +// перерисовки 1С (это собственный div с z-index, не часть SPA). +// +// `beforeCloseContext` — counter-only (страница вот-вот закроется, делать +// что-либо с DOM бессмысленно). + +async function injectContextBadge(ctx, name, spec) { + const label = spec?.displayName || name; + // ctx может быть scoped (auto-setActiveContext) или flat — в любом случае + // getPage() возвращает активную страницу, которая на момент afterOpenContext + // = только что созданный контекст. + const page = ctx.getPage?.(); + if (!page) return; + await page.evaluate((text) => { + let div = document.getElementById('__web_test_ctx_badge'); + if (!div) { + div = document.createElement('div'); + div.id = '__web_test_ctx_badge'; + document.body.appendChild(div); + } + div.style.cssText = [ + 'position:fixed', 'top:8px', 'right:8px', + 'padding:4px 10px', + 'background:rgba(30,30,46,0.85)', 'color:#fff', + 'font:600 13px Segoe UI,Arial,sans-serif', + 'border-radius:4px', 'box-shadow:0 2px 6px rgba(0,0,0,0.25)', + 'z-index:999998', 'pointer-events:none', + 'letter-spacing:0.3px', + ].join(';'); + div.textContent = text; + }, label); +} + +export async function afterOpenContext(ctx, name, spec) { + _state.afterOpenContext++; + _state.events.push(`afterOpenContext:${name}`); + try { + await injectContextBadge(ctx, name, spec); + } catch { + // Не валим прогон если badge не сел — это чисто визуальный bonus. + } +} + +export async function beforeCloseContext(_ctx, name, _spec) { + _state.beforeCloseContext++; + _state.events.push(`beforeCloseContext:${name}`); +}