feat(web-test): T4 — мульти-контекст BrowserContext

browser.mjs v1.10: createContext/setActiveContext/listContexts/getActiveContext/
hasContext. Несколько изолированных BrowserContext в одном Chromium-процессе через
chromium.launch() + newContext(). Module-level page/sessionPrefix/seanceId/recorder
зеркалят активный слот (атомарный своп через _saveActiveSlot/_activateSlot).
connect() оставлен для exec/run/start без изменений (launchPersistentContext).

run.mjs v1.8: ensureContext(name) + ленивое создание. Single-routing через
export const context = 'name'. Multi через export const contexts = ['a','b'] +
buildScopedContext(name) строит ctx.a/ctx.b — каждое действие префиксится
setActiveContext. Reset state после теста по всем активным контекстам.

Конфиг tests/web-test/webtest.config.mjs: два контекста a/b на одну webtest
публикацию (изолированные cookies через newContext).

Smoke-тесты:
- 14-multi-context-routing.test.mjs — single routing в b (2.6s)
- 15-multi-context-handover.test.mjs — ctx.a создаёт Контрагента, ctx.b в
  независимой сессии видит запись через filterList, ctx.a cleanup (14.5s, 4/4)

Live: 11/12 в полном прогоне. 04-selectvalue/direct-form флапает —
pre-existing, воспроизводится на baseline 95e4674 (03→04 sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-10 17:24:24 +03:00
parent 95e4674825
commit 2c553fee98
5 changed files with 341 additions and 15 deletions
+173 -3
View File
@@ -1,4 +1,4 @@
// web-test browser v1.9 — Playwright browser management for 1C web client
// web-test browser v1.10 — Playwright browser management for 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Playwright browser management for 1C web client.
@@ -37,6 +37,12 @@ let lastCaptions = []; // captions from the last completed recording (for addNar
let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
let highlightMode = false;
// Multi-context registry: name → { context, page, sessionPrefix, seanceId, recorder, lastCaptions, lastRecordingDuration, highlightMode }
// Populated by createContext(); module-level vars above mirror the active slot.
// connect() does NOT use this Map — it preserves legacy single-session behavior for exec/run/start.
const contexts = new Map();
let activeContextName = null;
const LOAD_TIMEOUT = 60000;
const INIT_TIMEOUT = 60000;
const ACTION_WAIT = 2000; // fallback minimum wait
@@ -163,13 +169,41 @@ export async function connect(url, { extensionPath } = {}) {
* Sends POST /e1cib/logout to release the license before closing.
*/
export async function disconnect() {
// Auto-stop recording if active (prevents orphaned ffmpeg)
// Multi-context path: stop recordings + logout each slot before closing browser
if (contexts.size > 0) {
// Save current active first so iteration is consistent
_saveActiveSlot();
for (const [name, slot] of contexts.entries()) {
// Stop recording per slot if any
if (slot.recorder) {
_activateSlot(name);
try { await stopRecording(); } catch {}
// re-save in case stopRecording mutated state
_saveActiveSlot();
}
}
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 {}
}
}
contexts.clear();
activeContextName = null;
}
// Single-session path (connect): auto-stop recording if active
if (recorder) {
try { await stopRecording(); } catch {}
}
if (browser) {
// Graceful logout — release the 1C license
// Graceful logout — release the 1C license (single-session connect path)
if (page && !page.isClosed() && seanceId && sessionPrefix) {
try {
const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`;
@@ -228,6 +262,142 @@ export function getSession() {
return { sessionPrefix, seanceId };
}
// ============================================================
// Multi-context support (used by run.mjs cmdTest only)
// ============================================================
/**
* Save current module-level state into the active slot before switching.
* No-op if no active slot.
*/
function _saveActiveSlot() {
if (!activeContextName) return;
const slot = contexts.get(activeContextName);
if (!slot) return;
slot.page = page;
slot.sessionPrefix = sessionPrefix;
slot.seanceId = seanceId;
slot.recorder = recorder;
slot.lastCaptions = lastCaptions;
slot.lastRecordingDuration = lastRecordingDuration;
slot.highlightMode = highlightMode;
}
/** Load a slot's state into module-level vars and mark it active. */
function _activateSlot(name) {
const slot = contexts.get(name);
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
page = slot.page;
sessionPrefix = slot.sessionPrefix;
seanceId = slot.seanceId;
recorder = slot.recorder;
lastCaptions = slot.lastCaptions || [];
lastRecordingDuration = slot.lastRecordingDuration;
highlightMode = slot.highlightMode || false;
activeContextName = 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(() => {}));
pg.on('request', req => {
if (slot.seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) {
slot.sessionPrefix = m[1];
slot.seanceId = m[2];
if (activeContextName === name) {
sessionPrefix = m[1];
seanceId = m[2];
}
}
});
}
/**
* Create (or navigate) a named browser context.
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
* subsequent calls can create additional isolated BrowserContexts in the same process.
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
* persistent profile.
*
* Use this from run.mjs cmdTest only exec/run/start use connect() and stay on the
* legacy persistent-context path.
*/
export async function createContext(name, url, { extensionPath } = {}) {
if (contexts.has(name)) {
await setActiveContext(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
// First context: launch browser. Subsequent: reuse existing browser.
if (!browser) {
const extPath = findExtension(extensionPath);
const launchArgs = ['--start-maximized'];
if (extPath) {
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
}
browser = await chromium.launch({ headless: false, args: launchArgs });
} else if (typeof browser.newContext !== 'function') {
throw new Error('createContext: existing browser was created via connect()/launchPersistentContext and cannot host additional isolated contexts. Call disconnect() first.');
}
// Save current active before switching
_saveActiveSlot();
const newCtx = await browser.newContext({
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
const newPage = await newCtx.newPage();
const slot = {
context: newCtx,
page: newPage,
sessionPrefix: null,
seanceId: null,
recorder: null,
lastCaptions: [],
lastRecordingDuration: null,
highlightMode: false,
};
contexts.set(name, slot);
_attachSessionListeners(newPage, slot, name);
_activateSlot(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
export async function setActiveContext(name) {
if (activeContextName === name) return;
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
_saveActiveSlot();
_activateSlot(name);
}
export function listContexts() {
return [...contexts.keys()];
}
export function getActiveContext() {
return activeContextName;
}
export function hasContext(name) {
return contexts.has(name);
}
/**
* Close startup modals and guide tabs.
* Strategy: Escape click default buttons close extra tabs repeat.
+89 -12
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// web-test run v1.7 — CLI runner for 1C web client automation
// web-test run v1.8 — CLI runner for 1C web client automation
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* CLI runner for 1C web client automation.
@@ -108,6 +108,27 @@ async function handleRequest(req, res) {
// buildContext: assemble browser API with error wrapping
// ============================================================
/**
* Build a per-context wrapper: same shape as buildContext output, but every call
* is prefixed with `setActiveContext(name)` so the test can interleave actions
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
*/
function buildScopedContext(name, { noRecord = false } = {}) {
const inner = buildContext({ noRecord });
const scoped = {};
for (const [k, v] of Object.entries(inner)) {
if (typeof v === 'function') {
scoped[k] = async (...args) => {
await browser.setActiveContext(name);
return v(...args);
};
} else {
scoped[k] = v;
}
}
return scoped;
}
function buildContext({ noRecord = false } = {}) {
const ctx = {};
for (const [k, v] of Object.entries(browser)) {
@@ -373,10 +394,25 @@ async function cmdTest(rawArgs) {
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
config = mod.default || {};
}
if (!url) {
url = config.url || config.contexts?.[config.defaultContext || Object.keys(config.contexts || {})[0]]?.url;
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
// CLI url overrides default context's url.
const contextSpecs = {}; // name → { url }
let defaultContextName = 'default';
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
for (const [n, spec] of Object.entries(config.contexts)) {
contextSpecs[n] = { url: spec.url };
}
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
if (url) contextSpecs[defaultContextName] = { url }; // CLI override of default
} else {
const fallbackUrl = url || config.url;
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
contextSpecs.default = { url: fallbackUrl };
}
if (!url) die('No URL provided and no webtest.config.mjs found');
if (!contextSpecs[defaultContextName]) {
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
}
if (!url) url = contextSpecs[defaultContextName].url;
// Apply config defaults (CLI flags override)
if (!tags && config.tags) tags = config.tags;
@@ -421,6 +457,8 @@ async function cmdTest(rawArgs) {
teardown: mod.teardown,
fn: mod.default,
param: undefined,
context: mod.context || null,
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
};
if (base.only) hasOnly = true;
if (Array.isArray(mod.params) && mod.params.length) {
@@ -461,11 +499,19 @@ async function cmdTest(rawArgs) {
// Prepare: infrastructure hooks (no browser)
if (hooks.prepare) await hooks.prepare();
try {
// Connect
await browser.connect(url);
// Lazy context creation: ensures the named browser context exists, creating it on first request.
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);
}
// Build context
try {
// Connect: create the default context up front (so beforeAll has a working browser)
await ensureContext(defaultContextName);
// Build context — flat API for single-context tests; reused across tests via setActiveContext
const ctx = buildContext({ noRecord: true });
ctx.assert = createAssertions();
ctx.log = (...a) => { /* per-test, overridden below */ };
@@ -485,6 +531,22 @@ async function cmdTest(rawArgs) {
continue;
}
// Resolve test's contexts: multi (t.contexts) or single (t.context || default).
// Lazy-create them and set active to the primary one.
const testContextNames = t.contexts && t.contexts.length
? t.contexts
: [t.context || defaultContextName];
try {
for (const cn of testContextNames) await ensureContext(cn);
await browser.setActiveContext(testContextNames[0]);
} catch (e) {
W.write(`${t.name} (context setup failed: ${e.message})\n`);
results.push({ name: t.name, file: t.file, tags: t.tags, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
failCount++;
if (opts.bail) break;
continue;
}
let lastError = null;
let testResult = null;
const maxAttempts = 1 + opts.retry;
@@ -532,6 +594,15 @@ async function cmdTest(rawArgs) {
}
};
// For multi-context tests, expose ctx.<name> per-context wrappers
const scopedKeys = [];
if (t.contexts && t.contexts.length) {
for (const cn of t.contexts) {
ctx[cn] = buildScopedContext(cn, { noRecord: true });
scopedKeys.push(cn);
}
}
try {
// beforeEach
if (hooks.beforeEach) await hooks.beforeEach(ctx);
@@ -548,8 +619,11 @@ async function cmdTest(rawArgs) {
if (t.teardown) try { await t.teardown(ctx); } catch {}
// afterEach
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
// Built-in state reset
await resetState(ctx);
// Built-in state reset across all contexts the test used
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
try { await browser.stopRecording(); } catch {}
@@ -564,8 +638,11 @@ async function cmdTest(rawArgs) {
if (t.teardown) try { await t.teardown(ctx); } catch {}
// afterEach (always)
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
// Built-in state reset
await resetState(ctx);
// Built-in state reset across all contexts the test used
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
// Screenshot on failure (skip if strategy is 'off')
let shotFile = e.onecError?.screenshot;
@@ -0,0 +1,22 @@
export const name = 'Multi-context: routing single test to non-default context';
export const tags = ['multi-context', 'smoke'];
export const context = 'b';
export const timeout = 60000;
export default async function({ getPageState, navigateSection, openCommand, closeForm, assert, step, log }) {
await step('Active context is b', async () => {
// Sanity check — ensure we are routed into b's session
const state = await getPageState();
assert.ok(Array.isArray(state.sections) && state.sections.length, 'Sections should be visible');
log('Sections in b: ' + state.sections.map(s => s.name).join(', '));
});
await step('Open Контрагенты in context b', async () => {
await navigateSection('Склад');
const state = await openCommand('Контрагенты');
assert.ok(state.form != null, 'List form should open');
log('Opened in b: ' + state.title);
await closeForm();
});
}
@@ -0,0 +1,46 @@
export const name = 'Multi-context: ctx.a creates, ctx.b sees the new record';
export const tags = ['multi-context'];
export const contexts = ['a', 'b'];
export const timeout = 120000;
export default async function({ a, b, assert, step, log }) {
const unique = 'MultiCtx-' + Date.now();
await step('a: открыть Контрагенты, создать новую запись', async () => {
await a.navigateSection('Склад');
await a.openCommand('Контрагенты');
await a.clickElement('Создать');
await a.fillField('Наименование', unique);
await a.clickElement('Записать и закрыть');
log(`a created: ${unique}`);
});
await step('b: открыть Контрагенты в независимой сессии', async () => {
await b.navigateSection('Склад');
const state = await b.openCommand('Контрагенты');
assert.ok(state.form != null, 'Список должен открыться в b');
});
await step('b: найти запись через filterList', async () => {
await b.filterList(unique);
const t = await b.readTable();
log(`b: total=${t.total} rows=${t.rows?.length}`);
assert.tableHasRow(t, r => r['Наименование'] === unique);
await b.unfilterList();
await b.closeForm();
});
await step('a: cleanup — удалить запись', async () => {
// a's list view is still open from step 1's "Записать и закрыть" returning to list
await a.filterList(unique);
await a.clickElement(unique);
const page = await a.getPage();
await page.keyboard.press('Delete');
// confirmation dialog → Yes
await a.clickElement('Да');
await a.unfilterList();
await a.closeForm();
log('a deleted');
});
}
+11
View File
@@ -0,0 +1,11 @@
// Default config for tests/web-test. CLI URL still overrides defaultContext URL.
// Two contexts pointing at the same webtest publication — represent two independent
// 1C sessions (different cookies), used by multi-context tests to simulate two users.
export default {
contexts: {
a: { url: 'http://localhost:8081/webtest/ru_RU' },
b: { url: 'http://localhost:8081/webtest/ru_RU' },
},
defaultContext: 'a',
timeout: 60000,
};