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
+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[]`. |