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
+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;