From 2c553fee987987d0f7b3e810b24b27e8e6d97ed7 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 10 May 2026 17:24:24 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20T4=20=E2=80=94=20=D0=BC=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B8-=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=20BrowserContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/skills/web-test/scripts/browser.mjs | 176 +++++++++++++++++- .claude/skills/web-test/scripts/run.mjs | 101 ++++++++-- .../14-multi-context-routing.test.mjs | 22 +++ .../15-multi-context-handover.test.mjs | 46 +++++ tests/web-test/webtest.config.mjs | 11 ++ 5 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 tests/web-test/14-multi-context-routing.test.mjs create mode 100644 tests/web-test/15-multi-context-handover.test.mjs create mode 100644 tests/web-test/webtest.config.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 8611c72c..c1edeca6 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -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. diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index 2ee1eeb6..ef88cf67 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -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. 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; diff --git a/tests/web-test/14-multi-context-routing.test.mjs b/tests/web-test/14-multi-context-routing.test.mjs new file mode 100644 index 00000000..546c9608 --- /dev/null +++ b/tests/web-test/14-multi-context-routing.test.mjs @@ -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(); + }); +} diff --git a/tests/web-test/15-multi-context-handover.test.mjs b/tests/web-test/15-multi-context-handover.test.mjs new file mode 100644 index 00000000..16d7cf59 --- /dev/null +++ b/tests/web-test/15-multi-context-handover.test.mjs @@ -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'); + }); +} diff --git a/tests/web-test/webtest.config.mjs b/tests/web-test/webtest.config.mjs new file mode 100644 index 00000000..3906f7b6 --- /dev/null +++ b/tests/web-test/webtest.config.mjs @@ -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, +};