feat(web-test): управление пулом контекстов/лицензий в раннере

Раннер регресса теперь держит пул 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) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-15 14:57:56 +03:00
parent 9e1341b80b
commit 3f1168b975
9 changed files with 262 additions and 16 deletions
+8 -1
View File
@@ -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
@@ -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') {
@@ -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>|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;
}
+9 -1
View File
@@ -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` с подсказкой — вручную закрывать контексты в хуках больше не нужно.
### Воспроизведение ошибки тестом
+34 -5
View File
@@ -469,6 +469,12 @@ export default {
// (config.contexts.<name>.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.<name>` в мульти-контекстных тестах — обёртки browser-функций, авто-переключающие контекст перед каждым вызовом. |
| **Action function (ACTION_FN)** | Browser-функция, обёрнутая авто-обнаружением 1С-ошибок. Список — в §3. |
| **Step** | Логический блок внутри теста, обёрнутый `step(name, fn)`. Маппится на Allure-step, попадает в `report.tests[].steps[]`. |
@@ -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();
});
}
@@ -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('Контрагенты');
+2 -1
View File
@@ -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 переменные
+12
View File
@@ -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.