From 3f1168b9755c94c0204b1b6e7b31b01019b90a00 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 15 Jul 2026 14:57:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D0=BB=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2/=D0=BB=D0=B8=D1=86=D0=B5=D0=BD=D0=B7=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раннер регресса теперь держит пул 1С-сеансов сам, вместо накопления контекстов между тестами и ручного закрытия в хуках. Три необязательных поля webtest.config.mjs (без них поведение прежнее): - maxContexts — потолок одновременно живых сеансов (null = без лимита); - contextPolicy — 'reuse' (держать открытыми в пределах лимита) | 'strict' (закрывать non-pinned контексты теста сразу после него); - pinnedContexts — не вытесняются LRU (default = [defaultContext]; [] делает default вытесняемым на тесном стенде). Перед setup каждого теста LRU-вытеснение освобождает слот под нужды теста; уже открытые нужные контексты переиспользуются. Default больше не вечно-pinned. Исчерпание пула даёт внятную ошибку вместо маскирующего «Browser not connected». - new: cli/test-runner/context-pool.mjs — чистый планировщик planEviction + LRU. - cli/commands/test.mjs (v1.4): парсинг/валидация полей, вытеснение с фолбэк-парковкой на нужный контекст (нельзя закрыть единственный активный), strict-закрытие, LRU-трекинг. - Доки: regression-spec (§7/§8/глоссарий), regression-guide (рецепт), regress.md. - Регресс дай-фудит фичу: 14-multi-context-routing роутит в 3-й контекст c, 15-multi-context-handover проверяет вытеснение c на границе; конфиг maxContexts:2. Юнит-краёв планировщика — в debug/ (gitignored). Live-регресс: 25/25 зелёных. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/web-test/regress.md | 9 +- .../web-test/scripts/cli/commands/test.mjs | 91 ++++++++++++++++++- .../scripts/cli/test-runner/context-pool.mjs | 87 ++++++++++++++++++ docs/web-test-regression-guide.md | 10 +- docs/web-test-regression-spec.md | 39 +++++++- .../14-multi-context-routing.test.mjs | 14 +-- .../15-multi-context-handover.test.mjs | 13 +++ tests/web-test/README.md | 3 +- tests/web-test/webtest.config.mjs | 12 +++ 9 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/context-pool.mjs diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md index 806e14a7..5dc1b441 100644 --- a/.claude/skills/web-test/regress.md +++ b/.claude/skills/web-test/regress.md @@ -209,6 +209,11 @@ export default { // }, // defaultContext: 'clerk', + // Context-pool / 1C license management (all optional; omit = no cap, default stays open). + // maxContexts: 2, // cap on simultaneous 1C sessions; omit for unlimited + // contextPolicy: 'reuse', // 'reuse' (keep open within cap) | 'strict' (close after each test) + // pinnedContexts: [], // never evicted; defaults to [defaultContext], [] makes default evictable + timeout: 30000, retries: 0, screenshot: 'on-failure', // 'every-step' | 'off' @@ -319,7 +324,9 @@ export default async function({ clerk, manager, step, assert }) { } ``` -Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state. +Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state. On tight-license stands prefer configuring the pool (`maxContexts` + `contextPolicy` + `pinnedContexts`) over manual per-test closing — the runner then evicts and reuses sessions automatically. + +**Context pool (1C licenses).** With `maxContexts` set, the runner caps simultaneous 1C sessions: before each test it evicts least-recently-used contexts that are neither pinned nor needed, reusing already-open ones. `contextPolicy: 'reuse'` (default) keeps sessions for speed; `'strict'` closes a test's non-pinned contexts right after it. `pinnedContexts` are never evicted (default `[defaultContext]`; set `[]` to make the default context evictable on a tight stand). If the pool can't fit even after eviction, the test fails with a clear `context pool exhausted` error instead of an opaque connection failure. ### Failing-test repro diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs index 448e87de..e9214dd0 100644 --- a/.claude/skills/web-test/scripts/cli/commands/test.mjs +++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs @@ -1,4 +1,4 @@ -// web-test cli/commands/test v1.3 — regression test runner +// web-test cli/commands/test v1.4 — regression test runner // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { existsSync, writeFileSync, mkdirSync } from 'fs'; import { resolve, dirname, basename, relative } from 'path'; @@ -9,6 +9,7 @@ import { createAssertions } from '../test-runner/assertions.mjs'; import { buildSeverityIndex } from '../test-runner/severity.mjs'; import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs'; import { discoverTests, resetState } from '../test-runner/discover.mjs'; +import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs'; export async function cmdTest(rawArgs) { // Split off everything after `--` — those args belong to user-defined hooks @@ -87,6 +88,32 @@ export async function cmdTest(rawArgs) { } if (!url) url = contextSpecs[defaultContextName].url; + // Context-pool config (license management). All three optional; without them the runner keeps + // its legacy behavior: default stays open, contexts accumulate, no eviction. + // maxContexts — cap on simultaneous 1C sessions (null = unlimited). + // contextPolicy — 'reuse' (keep open within the cap) | 'strict' (close a test's non-pinned + // contexts right after it, to release licenses ASAP). + // pinnedContexts — never evicted by LRU. Defaults to [defaultContext] so today's "default is + // never closed between tests" holds; set [] to make default evictable. + let maxContexts = null; + if (config.maxContexts != null) { + if (!Number.isInteger(config.maxContexts) || config.maxContexts < 1) { + die(`Invalid maxContexts=${config.maxContexts} (expected a positive integer or omit for unlimited)`); + } + maxContexts = config.maxContexts; + } + const contextPolicy = config.contextPolicy == null ? 'reuse' : config.contextPolicy; + if (!['reuse', 'strict'].includes(contextPolicy)) { + die(`Invalid contextPolicy="${contextPolicy}" (expected 'reuse' or 'strict')`); + } + const pinnedContexts = Array.isArray(config.pinnedContexts) ? config.pinnedContexts : [defaultContextName]; + for (const n of pinnedContexts) { + if (!contextSpecs[n]) die(`pinnedContexts entry "${n}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`); + } + const pinnedSet = new Set(pinnedContexts); + // LRU usage order — oldest first, freshest last. Drives eviction under a maxContexts cap. + const lruOrder = []; + // Apply config defaults (CLI flags override) if (!tags && config.tags) tags = config.tags; opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); @@ -210,8 +237,11 @@ export async function cmdTest(rawArgs) { } try { - // Connect: create default context up front + // Connect: create default context up front (hosts beforeAll / hooks). It is NOT permanently + // pinned — under a maxContexts cap it becomes an LRU eviction candidate unless it is listed in + // pinnedContexts. Register it in the LRU order. await ensureContext(defaultContextName); + touchLru(lruOrder, defaultContextName); const ctx = buildContext({ noRecord: false }); ctx.assert = createAssertions(); @@ -244,8 +274,45 @@ export async function cmdTest(rawArgs) { const testContextNames = declaredContexts; try { + // Make room in the license pool before opening this test's contexts. Already-open needed + // contexts are reused (ensureContext no-ops); LRU-oldest non-pinned contexts are evicted. + const plan = planEviction({ + open: browser.listContexts(), + needed: testContextNames, + pinned: pinnedSet, + max: maxContexts, + lruOrder, + }); + if (plan.error) throw new Error(plan.error); + // Needed-but-not-yet-open contexts — also serve as a parking fallback when eviction would + // close the sole open context (can't closeContext the active slot with no survivor). + const toOpenQueue = testContextNames.filter(n => !browser.hasContext(n)); + for (const name of plan.toEvict) { + if (browser.getActiveContext() === name) { + let survivor = browser.listContexts().find(n => n !== name); + if (!survivor) { + // `name` is the only open context. Open a needed one first to park on — room is + // guaranteed because we free `name` right after and multi-context implies max>=2. + if (browser.listContexts().length < maxContexts && toOpenQueue.length) { + const parkName = toOpenQueue.shift(); + await ensureContext(parkName); + survivor = parkName; + } else { + throw new Error(`cannot evict "${name}": it is the only open context and maxContexts=${maxContexts} leaves no room to switch. Use maxContexts>=2 when tests alternate contexts.`); + } + } + await browser.setActiveContext(survivor); + } + if (hooks.beforeCloseContext && hookCtx) { + try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } + catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + await browser.closeContext(name); + dropLru(lruOrder, name); + } for (const cn of testContextNames) await ensureContext(cn); await browser.setActiveContext(testContextNames[0]); + touchLru(lruOrder, testContextNames); } catch (e) { W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); @@ -385,6 +452,26 @@ export async function cmdTest(rawArgs) { } } + // strict policy: release this test's non-pinned contexts right after it (all attempts done), + // instead of keeping them for reuse. Frees 1C licenses ASAP on shared/tight stands. Parks + // active on a survivor before closing; never closes the sole remaining context. + if (contextPolicy === 'strict') { + for (const name of testContextNames) { + if (pinnedSet.has(name) || !browser.hasContext(name)) continue; + if (browser.getActiveContext() === name) { + const survivor = browser.listContexts().find(n => n !== name); + if (!survivor) continue; // can't close the sole active context — leave it open + try { await browser.setActiveContext(survivor); } catch {} + } + 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 {} + dropLru(lruOrder, name); + } + } + results.push(testResult); if (testResult.status === 'passed') { diff --git a/.claude/skills/web-test/scripts/cli/test-runner/context-pool.mjs b/.claude/skills/web-test/scripts/cli/test-runner/context-pool.mjs new file mode 100644 index 00000000..4b1924ff --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/context-pool.mjs @@ -0,0 +1,87 @@ +// web-test cli/test-runner/context-pool v1.0 — pure context-pool planner (LRU eviction). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Decides which already-open contexts (each = one live 1C session = one license) to evict +// so the next test's declared contexts fit within `maxContexts` simultaneous sessions. +// Pure functions, no browser — unit-tested in context-pool.test.mjs. + +/** + * @param {object} p + * @param {string[]} p.open currently open context names (live 1C sessions) + * @param {string[]} p.needed context names the next test declares + * @param {Set|string[]} [p.pinned] never-evict context names + * @param {number|null} [p.max] simultaneous-session cap; null/undefined = unlimited + * @param {string[]} [p.lruOrder] usage order, oldest first / freshest last + * @returns {{ toEvict: string[], error: string|null }} + */ +export function planEviction({ open = [], needed = [], pinned = [], max = null, lruOrder = [] }) { + const pinnedSet = pinned instanceof Set ? pinned : new Set(pinned); + const neededSet = new Set(needed); + const openSet = new Set(open); + + // Unlimited pool → never evict (back-compat: behaves like the pre-pool runner). + if (max == null) return { toEvict: [], error: null }; + + // Lower bound that must stay live regardless of eviction: this test's needed contexts, plus + // pinned contexts that are ALREADY open (pinned = "don't evict while open", NOT "always open" — + // a pinned context that is currently closed does not count against this test's budget). + const mustStay = new Set(needed); + for (const p of pinnedSet) if (openSet.has(p)) mustStay.add(p); + if (mustStay.size > max) { + return { + toEvict: [], + error: `context pool exhausted: this test needs ${mustStay.size} simultaneous 1C sessions ` + + `(declared contexts + already-open pinned) but maxContexts=${max}. ` + + `Raise maxContexts, reduce declared contexts, or shrink pinnedContexts.`, + }; + } + + // projected = everything live once we open `needed`. If it already fits, nothing to evict. + const projected = new Set([...open, ...needed]); + if (projected.size <= max) return { toEvict: [], error: null }; + + // Evictable = open, not pinned, not needed — oldest first by lruOrder. + const evictable = []; + for (const name of lruOrder) { + if (openSet.has(name) && !pinnedSet.has(name) && !neededSet.has(name)) evictable.push(name); + } + // Any open evictable missing from lruOrder → treat as oldest (evict first). + for (const name of open) { + if (!lruOrder.includes(name) && !pinnedSet.has(name) && !neededSet.has(name)) { + evictable.unshift(name); + } + } + + const toEvict = []; + let size = projected.size; + for (const name of evictable) { + if (size <= max) break; + toEvict.push(name); + size--; + } + // Guaranteed size <= max here: after removing all evictable, projected collapses to + // (open ∩ pinned) ∪ needed == mustStay, and mustStay.size <= max passed the guard above. + return { toEvict, error: null }; +} + +/** + * Move `names` to the fresh end of the LRU order (most-recently-used last). Mutates and returns + * `lruOrder`. Idempotent per name — existing entries are relocated, not duplicated. + */ +export function touchLru(lruOrder, names) { + for (const n of (Array.isArray(names) ? names : [names])) { + const i = lruOrder.indexOf(n); + if (i >= 0) lruOrder.splice(i, 1); + lruOrder.push(n); + } + return lruOrder; +} + +/** Remove `names` from the LRU order (e.g. after a context is closed). Mutates and returns it. */ +export function dropLru(lruOrder, names) { + for (const n of (Array.isArray(names) ? names : [names])) { + const i = lruOrder.indexOf(n); + if (i >= 0) lruOrder.splice(i, 1); + } + return lruOrder; +} diff --git a/docs/web-test-regression-guide.md b/docs/web-test-regression-guide.md index 772b5470..5755425c 100644 --- a/docs/web-test-regression-guide.md +++ b/docs/web-test-regression-guide.md @@ -104,7 +104,15 @@ export default async function({ кладовщик, менеджер, step, asse } ``` -Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на «свободных лицензий не осталось». Модель освобождает сессии между тестами автоматически (закрывает контексты после процессного теста), но если стенд ограничен — закладывайте это в планирование набора: один-два многопользовательских сценария вместо десяти. +Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на нехватке лицензий. + +Движок умеет держать пул сам — три поля в `webtest.config.mjs` (подробно в спецификации, раздел «Пул контекстов и лицензии»): + +- `maxContexts` — потолок одновременно живых сеансов. При его достижении движок автоматически вытесняет наименее используемые контексты (LRU), освобождая лицензии под нужды следующего теста. Уже открытый нужный контекст переиспользуется — соседние тесты под тем же пользователем не переоткрывают сеанс. +- `contextPolicy` — `'reuse'` (по умолчанию, держать открытыми ради скорости) или `'strict'` (закрывать контексты сразу после теста ради экономии лицензий). +- `pinnedContexts` — что не вытеснять. По умолчанию защищён контекст по умолчанию; на тесном стенде поставьте `pinnedContexts: []`, чтобы и он мог освобождать слот. + +Рецепт для стенда на 2 лицензии с процессными тестами на двух пользователей: `maxContexts: 2`, `pinnedContexts: []`. Если пула не хватает даже после вытеснения, движок валит тест внятной ошибкой `context pool exhausted` с подсказкой — вручную закрывать контексты в хуках больше не нужно. ### Воспроизведение ошибки тестом diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md index 17494831..adfe5bf3 100644 --- a/docs/web-test-regression-spec.md +++ b/docs/web-test-regression-spec.md @@ -469,6 +469,12 @@ export default { // (config.contexts..isolation). См. §8. isolation: 'tab', // 'tab' | 'window' + // Управление пулом 1С-лицензий (см. §8 «Пул контекстов и лицензии»). Все три поля + // необязательны; без них поведение прежнее (лимита нет, контексты копятся, default живёт весь прогон). + maxContexts: 2, // потолок одновременно живых 1С-сеансов; опустить = без лимита + contextPolicy: 'reuse', // 'reuse' (держать открытыми в пределах лимита) | 'strict' (закрывать сразу после теста) + pinnedContexts: ['admin'], // никогда не вытесняются LRU; по умолчанию = [defaultContext], [] делает default вытесняемым + // Allure severity policy (опционально). Маппинг наоборот: уровень → [теги]. // Резолв см. §9 «Авто-эмиссия label-ов». severity: { @@ -499,6 +505,8 @@ export default { При нарушении любого правила движок выводит сообщение с указанием конфликта и завершается с ненулевым кодом до запуска тестов. +Пул контекстов валидируется при загрузке: `maxContexts` — положительное целое или опущено; `contextPolicy` — `'reuse'` либо `'strict'`; каждый элемент `pinnedContexts` должен присутствовать в `contexts`. Нарушение — явная ошибка до запуска. + Кириллица в ID контекстов работает, но смешанный регистр снижает читаемость кода (`testInfo.contexts.кладовщик.displayName` рядом с `testInfo.contexts.clerk.displayName`). Рекомендуем разделять технический ID и человекочитаемое имя. Флаги CLI всегда переопределяют значения из файла конфигурации. @@ -537,11 +545,13 @@ export default async function({ clickElement, fillFields, … }) { } Движок НЕ группирует тесты по контексту. Порядок выполнения — алфавитный по полному относительному пути файла (плюс порядок экспорта внутри файла). Для каждого теста: -1. Через `ensureContext(name)` создаются BrowserContext-ы, упомянутые в `t.context` / `t.contexts` (если ещё не созданы). -2. `setActiveContext(primaryContext)` — активный контекст = первый объявленный (для single — `t.context || defaultContext`, для multi — `t.contexts[0]`). -3. После теста встроенный сброс пробегает по всем использованным контекстам. +1. **(при заданном `maxContexts`)** Освобождение пула: если `union(открытые, нужные)` превышает лимит, движок вытесняет LRU-старейшие контексты, не входящие в `pinned` и не нужные этому тесту (см. §8 «Пул контекстов и лицензии»). +2. Через `ensureContext(name)` создаются BrowserContext-ы, упомянутые в `t.context` / `t.contexts` (уже открытые переиспользуются — повторного входа в 1С нет). +3. `setActiveContext(primaryContext)` — активный контекст = первый объявленный (для single — `t.context || defaultContext`, для multi — `t.contexts[0]`). +4. После теста встроенный сброс пробегает по всем использованным контекстам. +5. **(при `contextPolicy: 'strict'`)** Non-pinned контексты теста закрываются сразу после него. -Контексты живут между тестами: переключение через `setActiveContext` — дешёвое, повторный вход в 1С не требуется. Закрываются явно (`closeContext`) или финальной очисткой движка перед закрытием браузера. +Контексты живут между тестами (при `contextPolicy: 'reuse'` — по умолчанию): переключение через `setActiveContext` — дешёвое, повторный вход в 1С не требуется. Закрываются явно (`closeContext`), LRU-вытеснением под лимит, `strict`-политикой либо финальной очисткой движка перед закрытием браузера. ### Мульти-контекст (процессные тесты) @@ -600,6 +610,24 @@ await step('Кладовщик проверяет статус', async () => { `closeContext(name)` нельзя вызвать на активном контексте — будет исключение. В scoped API это естественно: вызывать `manager.closeContext('clerk')` (scoped-обёртка сначала переключает активный на `manager`, потом закрывает `clerk`). Если контекст лишний (роль больше не нужна в рамках теста / прогона) — закрывайте его сразу: освобождает лицензию платформы и снимает нагрузку со следующих тестов. +### Пул контекстов и лицензии + +Каждый живой контекст — отдельный 1С-сеанс, то есть отдельная лицензия (в режиме `'tab'` каждая вкладка = свой `seanceId`). На стендах с малым пулом лицензий бесконтрольное накопление контекстов между тестами рано или поздно пробивает лимит, и тест падает в setup. Три поля конфигурации переносят управление пулом в движок: + +| Поле | Значение | По умолчанию | +|------|----------|--------------| +| `maxContexts` | Потолок одновременно живых 1С-сеансов. `null`/опущено — без лимита, вытеснения нет (прежнее поведение). | `null` | +| `contextPolicy` | `'reuse'` — держать контексты открытыми в пределах лимита ради скорости (соседние тесты под тем же контекстом не переоткрывают сеанс). `'strict'` — закрывать non-pinned контексты теста сразу после него ради экономии лицензий. | `'reuse'` | +| `pinnedContexts` | Контексты, которые НЕ вытесняются LRU. По умолчанию `[defaultContext]` — сохраняет прежнее «default живёт весь прогон». `[]` делает default обычным вытесняемым контекстом. | `[defaultContext]` | + +**Вытеснение (LRU).** При заданном `maxContexts` перед setup каждого теста движок проверяет `union(открытые, нужные_тесту)`. Если он превышает лимит — закрывает контексты в порядке давности использования (старейшие первыми), пропуская `pinned` и нужные текущему тесту, пока не влезет в лимит. Уже открытый нужный контекст переиспользуется. Вытесненный при следующей надобности переоткрывается штатным `ensureContext`. + +**default вытесняем.** `defaultContext` (на нём исполняется `beforeAll` и хуки) больше не «вечный» — под лимитом он такой же кандидат на вытеснение, если не входит в `pinnedContexts`. Это осознанно: обычно default не держит состояния, нужного между тестами. Если ваши хуки требуют постоянного admin-сеанса — оставьте его в `pinnedContexts`. + +**Исчерпание пула.** Если даже после вытеснения всего допустимого `pinned ∪ нужные` не влезает в `maxContexts`, движок валит тест с внятной ошибкой `context pool exhausted: …` (а не маскирующим «Browser not connected») и подсказкой — поднять `maxContexts`, сократить объявленные контексты или уменьшить `pinnedContexts`. + +**Рецепт тесного пула.** Стенд на 2 лицензии, процессные тесты на 2 контекста: `maxContexts: 2`, `pinnedContexts: []` (чтобы default вытеснялся и освобождал слот). `contextPolicy: 'reuse'` даёт переиспользование соседних тестов; `'strict'` — максимально быстрое освобождение лицензий на шаренном стенде. + --- ## 9. Отчёты @@ -982,7 +1010,8 @@ JSON-отчёт (`tests[]`, полная структура — §9) для ка | **Context (BrowserContext)** | Изолированная сессия Playwright. Куки/состояние/страница независимы. В рамках одного теста используется один или несколько контекстов. | | **Active context** | Контекст, на котором сейчас оперируют функции browser-API. Переключается `setActiveContext`. | | **Primary context** | Контекст, активный на входе в тест. Декларация (`mod.context` или `mod.contexts[0]`). Зафиксирован в `testInfo.primaryContext`. | -| **Default context** | Контекст из `config.defaultContext` (или единственный URL в упрощённой конфигурации). Используется, если тест не указал `context` / `contexts`. | +| **Default context** | Контекст из `config.defaultContext` (или единственный URL в упрощённой конфигурации). Используется, если тест не указал `context` / `contexts`. Под лимитом `maxContexts` — вытесняем, если не входит в `pinnedContexts` (см. §8 «Пул контекстов и лицензии»). | +| **Pinned context** | Контекст из `config.pinnedContexts`, не подлежащий LRU-вытеснению. По умолчанию = `[defaultContext]`. | | **Scoped API** | Объект на `ctx.` в мульти-контекстных тестах — обёртки browser-функций, авто-переключающие контекст перед каждым вызовом. | | **Action function (ACTION_FN)** | Browser-функция, обёрнутая авто-обнаружением 1С-ошибок. Список — в §3. | | **Step** | Логический блок внутри теста, обёрнутый `step(name, fn)`. Маппится на Allure-step, попадает в `report.tests[].steps[]`. | diff --git a/tests/web-test/14-multi-context-routing.test.mjs b/tests/web-test/14-multi-context-routing.test.mjs index 546c9608..83e57189 100644 --- a/tests/web-test/14-multi-context-routing.test.mjs +++ b/tests/web-test/14-multi-context-routing.test.mjs @@ -1,22 +1,24 @@ export const name = 'Multi-context: routing single test to non-default context'; export const tags = ['multi-context', 'smoke']; -export const context = 'b'; +export const context = 'c'; 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 + await step('Active context is c', async () => { + // Sanity check — ensure we are routed into c's session (a third, non-default context). + // Leaving c open here is intentional: it becomes the LRU eviction candidate at the + // 14→15 boundary under maxContexts:2 (asserted in 15-multi-context-handover). 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(', ')); + log('Sections in c: ' + state.sections.map(s => s.name).join(', ')); }); - await step('Open Контрагенты in context b', async () => { + await step('Open Контрагенты in context c', async () => { await navigateSection('Склад'); const state = await openCommand('Контрагенты'); assert.ok(state.form != null, 'List form should open'); - log('Opened in b: ' + state.title); + log('Opened in c: ' + 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 index 1beec8b1..3fb33671 100644 --- a/tests/web-test/15-multi-context-handover.test.mjs +++ b/tests/web-test/15-multi-context-handover.test.mjs @@ -7,6 +7,19 @@ export default async function({ a, b, assert, step, log }) { const unique = 'MultiCtx-' + Date.now(); + await step('пул: c вытеснен под maxContexts=2 (a,b — ровно два сеанса)', async () => { + // 14-multi-context-routing оставил контекст `c` открытым. Этот тест объявляет [a,b]; + // раннер под maxContexts:2 вытесняет LRU-контекст `c`, чтобы открыть `b`. Без пула `c` + // копился бы (открыто было бы a,b,c). Проверяем, что лимит удержан и c закрыт. + // (При запуске 15 в одиночку `c` и не открывался — length===2 всё равно верно.) + const open = await a.listContexts(); + log(`пул после setup: [${open.join(',')}]`); + assert.ok(!open.includes('c'), `c должен быть вытеснен под лимитом, открыто: [${open.join(',')}]`); + assert.equal(open.length, 2, `в пуле ровно 2 сеанса, получено ${open.length}: [${open.join(',')}]`); + assert.includes(open, 'a', 'a должен быть открыт'); + assert.includes(open, 'b', 'b должен быть открыт'); + }); + await step('a: открыть Контрагенты, создать новую запись', async () => { await a.navigateSection('Склад'); await a.openCommand('Контрагенты'); diff --git a/tests/web-test/README.md b/tests/web-test/README.md index d0db42a9..0f20cd0f 100644 --- a/tests/web-test/README.md +++ b/tests/web-test/README.md @@ -82,9 +82,10 @@ node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-e ## Конфигурация `tests/web-test/webtest.config.mjs` задаёт: -- **`contexts.a` / `contexts.b`** — два независимых 1C-сеанса (разные cookies) на той же URL. Тесты с `multi-context` тегом используют оба. +- **`contexts.a` / `contexts.b` / `contexts.c`** — независимые 1C-сеансы (разные cookies) на той же URL. `a`,`b` — два «пользователя» мультиконтекст-тестов; `c` задействован только `14-multi-context-routing` и служит топливом для проверки вытеснения пула. - **`defaultContext: 'a'`** — большинство тестов работают в одном контексте. - **`isolation: 'tab'`** — вкладки в одном окне (default). Альтернатива `'window'` — отдельный BrowserContext (полная изоляция cookies). +- **`maxContexts: 2` / `contextPolicy: 'reuse'` / `pinnedContexts: []`** — дай-фуд управления пулом лицензий. Лимит 2 одновременных сеанса: на границе `14 (c) → 15 (a,b)` раннер автоматически вытесняет LRU-контекст `c` (проверяется первым шагом `15-multi-context-handover`). Благодаря лимиту 3 контекста никогда не живут одновременно. ## Env переменные diff --git a/tests/web-test/webtest.config.mjs b/tests/web-test/webtest.config.mjs index f5696edb..5826ecd0 100644 --- a/tests/web-test/webtest.config.mjs +++ b/tests/web-test/webtest.config.mjs @@ -12,8 +12,20 @@ export default { // Custom-поля любого типа пробрасываются как есть. a: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь A' }, b: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь B' }, + // c — третий контекст, задействован только 14-multi-context-routing. Под maxContexts:2 + // он вытесняется на границе 14→15 (см. проверку пула в 15-multi-context-handover). + c: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь C' }, }, defaultContext: 'a', + + // Пул 1С-лицензий (дай-фудим фичу на собственном регрессе). Одновременно живых сеансов — + // не больше maxContexts; LRU-вытеснение освобождает слот под нужды следующего теста. + // pinnedContexts:[] делает default `a` вытесняемым (здесь он всё равно нужен почти всем тестам, + // так что не вытесняется — но снимает жёсткий пин). Благодаря лимиту 3 контекста a/b/c + // никогда не живут одновременно: c закрывается до открытия b. + maxContexts: 2, + contextPolicy: 'reuse', + pinnedContexts: [], // isolation: 'tab' (default) — persistent context, tabs in one window, 1С extension loads. // Cookies are shared between tabs but scope by URL path, so different vrd-publications // give independent auth without extra isolation.