mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-12 00:44:57 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user