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
co-authored by Claude Opus 4.7
parent 43ed9ba142
commit eb87be5c04
6 changed files with 226 additions and 30 deletions
+10
View File
@@ -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.
@@ -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]}`);
});
}
+55 -1
View File
@@ -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}`);
}