mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
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:
@@ -168,6 +168,23 @@ export async function connect(url, { extensionPath } = {}) {
|
|||||||
return await getPageState();
|
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.
|
* Gracefully terminate the 1C session and close the browser.
|
||||||
* Sends POST /e1cib/logout to release the license before closing.
|
* Sends POST /e1cib/logout to release the license before closing.
|
||||||
@@ -181,15 +198,7 @@ export async function disconnect() {
|
|||||||
try { await stopRecording(); } catch {}
|
try { await stopRecording(); } catch {}
|
||||||
}
|
}
|
||||||
for (const [, slot] of contexts.entries()) {
|
for (const [, slot] of contexts.entries()) {
|
||||||
if (slot.page && !slot.page.isClosed() && slot.seanceId && slot.sessionPrefix) {
|
await _logoutSlot(slot);
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
contexts.clear();
|
contexts.clear();
|
||||||
activeContextName = null;
|
activeContextName = null;
|
||||||
@@ -203,19 +212,7 @@ export async function disconnect() {
|
|||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Graceful logout — release the 1C license (single-session connect path)
|
// Graceful logout — release the 1C license (single-session connect path)
|
||||||
if (page && !page.isClosed() && seanceId && sessionPrefix) {
|
await _logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||||
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 browser.close().catch(() => {});
|
await browser.close().catch(() => {});
|
||||||
browser = null;
|
browser = null;
|
||||||
page = null;
|
page = null;
|
||||||
@@ -432,6 +429,32 @@ export function hasContext(name) {
|
|||||||
return contexts.has(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.
|
* Close startup modals and guide tabs.
|
||||||
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
||||||
|
|||||||
@@ -511,11 +511,37 @@ async function cmdTest(rawArgs) {
|
|||||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||||
|
|
||||||
// Lazy context creation: ensures the named browser context exists, creating it on first request.
|
// 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) {
|
async function ensureContext(name) {
|
||||||
if (browser.hasContext(name)) return;
|
if (browser.hasContext(name)) return;
|
||||||
const spec = contextSpecs[name];
|
const spec = contextSpecs[name];
|
||||||
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||||
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
|
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 {
|
try {
|
||||||
@@ -529,6 +555,14 @@ async function cmdTest(rawArgs) {
|
|||||||
const ctx = buildContext({ noRecord: false });
|
const ctx = buildContext({ noRecord: false });
|
||||||
ctx.assert = createAssertions();
|
ctx.assert = createAssertions();
|
||||||
ctx.log = (...a) => { /* per-test, overridden below */ };
|
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
|
// beforeAll
|
||||||
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
||||||
@@ -630,6 +664,7 @@ async function cmdTest(rawArgs) {
|
|||||||
if (t.contexts && t.contexts.length) {
|
if (t.contexts && t.contexts.length) {
|
||||||
for (const cn of t.contexts) {
|
for (const cn of t.contexts) {
|
||||||
ctx[cn] = buildScopedContext(cn);
|
ctx[cn] = buildScopedContext(cn);
|
||||||
|
wrapCloseContextHook(ctx[cn]);
|
||||||
scopedKeys.push(cn);
|
scopedKeys.push(cn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -722,7 +757,35 @@ async function cmdTest(rawArgs) {
|
|||||||
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
||||||
|
|
||||||
} finally {
|
} 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 {}
|
try { await browser.disconnect(); } catch {}
|
||||||
// Cleanup: infrastructure hooks (same signature as prepare)
|
// Cleanup: infrastructure hooks (same signature as prepare)
|
||||||
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
||||||
|
|||||||
@@ -330,23 +330,41 @@ assert.noErrors(state, msg)
|
|||||||
- `afterEach(ctx)` -- после каждого теста. Дополнительно доступен `ctx.testResult`
|
- `afterEach(ctx)` -- после каждого теста. Дополнительно доступен `ctx.testResult`
|
||||||
с результатом завершившегося теста (status/duration/error/...).
|
с результатом завершившегося теста (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() // без браузера (восстановление БД, публикация)
|
prepare() // без браузера (восстановление БД, публикация)
|
||||||
browser.launch() // запуск процесса браузера
|
browser.launch() // запуск процесса браузера
|
||||||
создание BrowserContext'ов // по одному на каждый используемый контекст
|
createContext(default) // первый контекст создан
|
||||||
beforeAll(ctx) // браузер готов, контексты созданы
|
afterOpenContext(ctx, default) // hook: контекст готов
|
||||||
|
beforeAll(ctx) // браузер готов, default-контекст создан
|
||||||
|
[lazy ensureContext(name)] // для multi-context тестов
|
||||||
|
afterOpenContext(ctx, name)
|
||||||
beforeEach(ctx)
|
beforeEach(ctx)
|
||||||
test.setup(ctx) // подготовка теста
|
test.setup(ctx) // подготовка теста
|
||||||
test.default(ctx) // тело теста
|
test.default(ctx) // тело теста (может вызвать ctx.closeContext)
|
||||||
|
[при ctx.closeContext(x)]: beforeCloseContext(ctx, x) → close(x)
|
||||||
test.teardown(ctx) // очистка теста (всегда)
|
test.teardown(ctx) // очистка теста (всегда)
|
||||||
afterEach(ctx) // всегда
|
afterEach(ctx) // всегда
|
||||||
[встроенный сброс] // всегда (для каждого активного контекста)
|
[встроенный сброс] // всегда (для каждого живого контекста теста)
|
||||||
...следующий тест...
|
...следующий тест...
|
||||||
afterAll(ctx)
|
afterAll(ctx)
|
||||||
закрытие всех BrowserContext'ов
|
[для каждого оставшегося контекста]: beforeCloseContext → closeContext
|
||||||
browser.close()
|
browser.close() // финальный disconnect
|
||||||
cleanup() // без браузера (удаление публикации)
|
cleanup() // без браузера (удаление публикации)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export default async function ({ step, assert, log, testInfo }) {
|
|||||||
assert.equal(typeof testInfo.primaryContext, 'string', 'primaryContext должен быть строкой');
|
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 для этого теста ещё не вызывался', () => {
|
await step('afterEach для этого теста ещё не вызывался', () => {
|
||||||
// В теле теста afterEach НЕ должен быть вызван ни разу для текущего теста.
|
// В теле теста afterEach НЕ должен быть вызван ни разу для текущего теста.
|
||||||
// Если 00-hooks запущен первым (что и ожидается), afterEach === 0.
|
// Если 00-hooks запущен первым (что и ожидается), afterEach === 0.
|
||||||
|
|||||||
@@ -43,4 +43,32 @@ export default async function({ a, b, assert, step, log }) {
|
|||||||
await a.closeForm();
|
await a.closeForm();
|
||||||
log('a deleted');
|
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]}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-логике:
|
// `prepare()` поднимает изолированный стенд по smart-логике:
|
||||||
// 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop
|
// 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop
|
||||||
@@ -310,6 +310,8 @@ export const _state = {
|
|||||||
afterAll: 0,
|
afterAll: 0,
|
||||||
beforeEach: 0,
|
beforeEach: 0,
|
||||||
afterEach: 0,
|
afterEach: 0,
|
||||||
|
afterOpenContext: 0,
|
||||||
|
beforeCloseContext: 0,
|
||||||
events: [],
|
events: [],
|
||||||
lastTestResult: null,
|
lastTestResult: null,
|
||||||
};
|
};
|
||||||
@@ -363,3 +365,55 @@ export async function afterEach(ctx) {
|
|||||||
}
|
}
|
||||||
_state.events.push(`afterEach:${ctx.testInfo?.file || '?'}:${ctx.testResult?.status || '?'}`);
|
_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}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user