diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs
index 5c94b190..129cfcc3 100644
--- a/.claude/skills/web-test/scripts/run.mjs
+++ b/.claude/skills/web-test/scripts/run.mjs
@@ -571,25 +571,29 @@ async function cmdTest(rawArgs) {
let testIdx = 0;
for (const t of filtered) {
testIdx++;
+ // Declared contexts — нужны и в skip-ветке, и в основной, чтобы все
+ // testResult-записи в отчёте всегда содержали `contexts` поле.
+ const declaredContexts = t.contexts && t.contexts.length
+ ? t.contexts
+ : [t.context || defaultContextName];
+
if (t.skip) {
const reason = typeof t.skip === 'string' ? t.skip : '';
W.write(` \u25CB ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
- results.push({ name: t.name, file: t.file, tags: t.tags, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
+ results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
skipCount++;
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];
+ const testContextNames = declaredContexts;
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 });
+ 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 });
failCount++;
if (opts.bail) break;
continue;
@@ -697,7 +701,7 @@ async function cmdTest(rawArgs) {
try { await browser.stopRecording(); } catch {}
}
const dur = elapsed(t0);
- testResult = { name: t.name, file: t.file, tags: t.tags, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
+ testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
lastError = null;
break;
@@ -731,7 +735,7 @@ async function cmdTest(rawArgs) {
}
lastError = { message: e.message, step: e.onecError?.step, screenshot: shotFile };
const dur = elapsed(t0);
- testResult = { name: t.name, file: t.file, tags: t.tags, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: lastError, screenshot: shotFile, video: videoFile };
+ testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: lastError, screenshot: shotFile, video: videoFile };
}
}
diff --git a/docs/web-test-runner-spec.md b/docs/web-test-runner-spec.md
index a8ffd36e..90faee48 100644
--- a/docs/web-test-runner-spec.md
+++ b/docs/web-test-runner-spec.md
@@ -1,7 +1,7 @@
# web-test runner: спецификация
-Версия: 0.1 (черновик)
-Дата: 2026-04-05
+Версия: 0.2
+Дата: 2026-05-13 (последний sync)
## Обзор
@@ -30,26 +30,29 @@ node run.mjs test [url]
[флаги]
| `--bail` | false | Остановиться при первом падении |
| `--retry=N` | 0 | Повторить упавшие тесты N раз |
| `--timeout=ms` | 30000 | Таймаут на тест (мс) |
-| `--report=path` | (нет) | Записать JSON-отчёт в файл |
-| `--format=fmt` | json | Формат отчёта: `json`, `allure`, `junit` |
-| `--report-dir=path` | (нет) | Каталог для результатов Allure |
+| `--report=path` | (нет) | Записать JSON-отчёт в файл (или XML для `--format=junit`) |
+| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` |
+| `--report-dir=path` | dirname(report) / testDir | Каталог для скриншотов, видео, Allure-результатов |
| `--screenshot=strategy` | on-failure | `on-failure` / `every-step` / `off` |
-| `--record` | false | Записывать видео для каждого теста |
+| `--record` | false | Записывать видео для каждого теста (mp4 в `--report-dir`) |
+| `-- ` | -- | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) |
-URL необязателен, если в каталоге тестов есть `webtest.config.mjs`.
+URL необязателен, если в каталоге тестов есть `webtest.config.mjs`. CLI URL переопределяет URL дефолтного контекста.
### Режим выполнения
In-process (не через HTTP). Раннер:
-1. Загружает конфиг (если есть)
-2. Обнаруживает файлы `*.test.mjs`
-3. Импортирует каждый модуль, извлекает метаданные
-4. Фильтрует по тегам/grep/only
-5. Группирует по контексту, сортирует по алфавиту внутри группы
-6. Запускает браузер (`chromium.launch()`)
-7. Создаёт BrowserContext + page для каждого используемого контекста (лениво)
-8. Выполняет тесты последовательно, переключая активный контекст
-9. Закрывает все контексты и браузер, выводит результаты
+1. Загружает конфиг (если есть).
+2. Обнаруживает файлы `*.test.mjs`, читает каждый, извлекает метаданные.
+3. Фильтрует по `--tags`/`--grep`/`only`. Параметризованные тесты разворачиваются.
+4. Запускает браузер и default-контекст (`chromium.launch()` или `launchPersistentContext`
+ в зависимости от `isolation`).
+5. Тесты выполняются последовательно **в алфавитном порядке имён файлов**
+ (внутри файла — в порядке экспорта).
+6. Для каждого теста: лениво создаёт нужные BrowserContext-ы (`ensureContext`),
+ переключает активный, прогоняет хуки и тело, делает встроенный reset.
+7. По завершении: финальный teardown контекстов с `beforeCloseContext`-хуками,
+ `disconnect()`, `cleanup()`.
---
@@ -104,26 +107,33 @@ export default async function({ navigateSection, openCommand, clickElement,
### Пример: мульти-контекстный процессный тест
+Рекомендация: латинский ID контекста + кириллический `displayName` в
+`webtest.config.mjs.contexts..displayName` (см. §7).
+
```js
export const name = 'Согласование приходной накладной';
-export const contexts = ['кладовщик', 'менеджер'];
+export const contexts = ['clerk', 'manager'];
export const tags = ['process'];
-export default async function({ кладовщик, менеджер, step }) {
+export default async function({ clerk, manager, step }) {
await step('Кладовщик создаёт накладную', async () => {
- await кладовщик.navigateSection('Склад');
- await кладовщик.openCommand('Приходные накладные');
- await кладовщик.clickElement('Создать');
- await кладовщик.fillFields({ 'Контрагент': 'ООО Поставщик' });
- await кладовщик.clickElement('Записать');
+ await clerk.navigateSection('Склад');
+ await clerk.openCommand('Приходные накладные');
+ await clerk.clickElement('Создать');
+ await clerk.fillFields({ 'Контрагент': 'ООО Поставщик' });
+ await clerk.clickElement('Записать');
});
await step('Менеджер утверждает', async () => {
- await менеджер.navigateSection('Согласование');
- await менеджер.openCommand('На утверждении');
- await менеджер.clickElement('ООО Поставщик', { dblclick: true });
- await менеджер.clickElement('Утвердить');
+ await manager.navigateSection('Согласование');
+ await manager.openCommand('На утверждении');
+ await manager.clickElement('ООО Поставщик', { dblclick: true });
+ await manager.clickElement('Утвердить');
+ });
+
+ await step('Освобождаем контекст clerk', async () => {
+ await manager.closeContext('clerk'); // освободить лицензию 1С
});
}
```
@@ -149,8 +159,12 @@ export default async function({ кладовщик, менеджер, step }) {
**Таблицы:** `readTable`, `readSpreadsheet`, `fillTableRow`, `deleteTableRow`
**Поля:** `fillFields`, `fillField`, `selectValue`
**Действия:** `clickElement`, `closeForm`, `filterList`, `unfilterList`
+**Ошибки:** `dismissPendingErrors`, `fetchErrorStack`
+**Контексты:** `createContext`, `setActiveContext`, `closeContext`, `listContexts`,
+`hasContext`, `getActiveContext`
**Запись:** `startRecording`, `stopRecording`, `isRecording`, `addNarration`, `getCaptions`
-**Презентация:** `showCaption`, `hideCaption`, `highlight`, `unhighlight`, `showTitleSlide`, `showImage`
+**Презентация:** `showCaption`, `hideCaption`, `showTitleSlide`, `hideTitleSlide`,
+`showImage`, `hideImage`, `highlight`, `unhighlight`, `setHighlight`, `isHighlightMode`
**Утилиты:** `screenshot`, `wait`, `getPage`, `getSession`
### Тестовые утилиты
@@ -401,12 +415,26 @@ export async function cleanup({ log }) {
execSync('powershell.exe -File scripts/unpublish.ps1');
}
-export async function beforeAll({ navigateSection }) {
- await navigateSection('Склад');
+export async function beforeAll(ctx) {
+ // По умолчанию 1С после входа уже показывает дефолтную секцию — навигация
+ // в beforeAll обычно не нужна. Хук удобен для счётчиков, телеметрии,
+ // общего setup'а который должен случиться один раз для всего прогона.
}
-export async function afterEach({ closeForm }) {
- // пользовательская очистка после теста (необязательно, встроенный сброс тоже сработает)
+export async function afterEach(ctx) {
+ // Доступен ctx.testResult — { status, duration, attempts, error, steps }.
+ // Встроенный сброс состояния выполняется ПОСЛЕ afterEach автоматически.
+}
+
+export async function afterOpenContext(ctx, name, spec) {
+ // Контекст name создан. spec — config.contexts[name]. Удобно для
+ // persistent DOM-overlay'я с displayName (видно в видео какая вкладка к
+ // какому пользователю относится).
+}
+
+export async function beforeCloseContext(ctx, name, spec) {
+ // Контекст name вот-вот закроется. Срабатывает и при ctx.closeContext
+ // из теста, и в финальном teardown раннера.
}
```
@@ -508,15 +536,20 @@ export const context = 'кладовщик'; // необязательно, и
export default async function({ clickElement, fillFields, ... }) { }
```
-### Группировка по контексту
+### Порядок выполнения и переключение контекста
-Раннер группирует тесты по значению `context`:
-1. Собрать все тесты, определить набор уникальных контекстов
-2. Создать BrowserContext + page для каждого используемого контекста
-3. Для каждой группы тестов: переключить активный context, выполнить тесты
-4. Внутри группы тесты выполняются по алфавиту
+Раннер НЕ группирует тесты по контексту. Порядок выполнения — алфавитный
+по именам файлов (плюс порядок экспорта внутри файла). Для каждого теста:
+1. Через `ensureContext(name)` создаются BrowserContext-ы, упомянутые в
+ `t.context` / `t.contexts` (если ещё не созданы).
+2. `setActiveContext(testContextNames[0])` — активный контекст = первый
+ объявленный (для single — `t.context || defaultContext`, для multi —
+ `t.contexts[0]`).
+3. После теста встроенный сброс пробегает по всем использованным контекстам.
-Контексты создаются лениво (при первом обращении) и живут до конца прогона.
+Контексты живут между тестами: переключение через `setActiveContext` —
+дешёвое, новый login не требуется. Закрываются явно (`closeContext`) или
+финальным teardown'ом перед `disconnect()`.
### Мульти-контекст (процессные тесты)
@@ -550,18 +583,34 @@ await step('Кладовщик проверяет статус', async () => {
});
```
-### Влияние на browser.mjs
+### Реализация в browser.mjs
-Текущий browser.mjs хранит `page`, `browser`, `session` как module-level переменные.
-Для мульти-контекста необходимо:
-- Уметь создавать несколько `BrowserContext` + `page` в одном `browser`
-- Хранить карту контекстов `{ name → { context, page, session } }`
-- Переключать текущий `page` при смене активного контекста
-- API-функции раб��тают с текущим активным `page`
+`browser.mjs` хранит активный слот в module-level `page`/`browser`/`sessionPrefix`/`seanceId`,
+зеркалит его из Map `contexts: Map`. Переключение между слотами:
+`_saveActiveSlot()` сохраняет module-level → slot, `_activateSlot(name)`
+загружает slot → module-level. Это держит API-функции (`clickElement`,
+`fillFields` и т.д.) plain — они работают с текущим активным `page`,
+не зная про множественность контекстов.
-Это промежуточный шаг к полному `createContext()` из Фазы 3 роадмапа,
-но значительно проще -- не требует рефакторинга всех функций browser.mjs,
-только управление текущим page.
+Публичный контекстный API:
+- `createContext(name, url, { isolation, extensionPath })` — создаёт BrowserContext
+ и navigate'ит на URL.
+- `setActiveContext(name)` — переключает активный слот, при активной записи
+ flush'ит хвост старой страницы и переподключает screencast к новой.
+- `closeContext(name)` — logout + close (page для `tab`, BrowserContext для
+ `window`), удаляет из реестра. Throw если `name === active`.
+- `listContexts()` / `hasContext(name)` / `getActiveContext()` — read-only.
+
+### Режимы изоляции
+
+`isolation` (per-context или config-level):
+
+| Режим | Реализация | Окна | Cookies | 1С-расширение |
+|-------|-----------|------|---------|---------------|
+| `'tab'` (default) | `launchPersistentContext` + `newPage()` per context | 1 окно, N вкладок | shared by path | загружается надёжно |
+| `'window'` | `chromium.launch()` + `newContext()` per context | N окон | полная изоляция | может не загружаться |
+
+Смешивать режимы в одном прогоне нельзя — `createContext` бросает явную ошибку.
---
@@ -587,7 +636,7 @@ await step('Кладовщик проверяет статус', async () => {
"name": "CRUD справочника Контрагенты",
"file": "02-catalog-crud.test.mjs",
"tags": ["smoke", "crud"],
- "context": "кладовщик",
+ "contexts": ["clerk"],
"status": "passed",
"duration": 12.3,
"attempts": 1,
@@ -608,7 +657,7 @@ await step('Кладовщик проверяет статус', async () => {
"name": "Обязательное поле",
"file": "10-validation.test.mjs",
"tags": ["validation"],
- "context": "кладовщик",
+ "contexts": ["clerk"],
"status": "failed",
"duration": 8.1,
"attempts": 2,
@@ -711,7 +760,9 @@ web-test -- http://localhost/app/ru_RU
23 passed, 1 failed, 1 skipped (2m 0.5s)
```
-Шаги показываются для упавших тестов (всегда) и для успешных (в verbose-режиме).
+Для passed-тестов выводится одна строка `✓ name (duration)`. Шаги печатаются
+только для упавших — после строки `✗`, с отступом, плюс сообщение ошибки и
+путь к скриншоту. Полная картина по шагам — в JSON-отчёте (`--report=...`).
---
@@ -760,9 +811,7 @@ async function resetState(ctx) {
---
-## 13. Параметризация (будущее)
-
-Формат зарезервирован, реализация отложена.
+## 13. Параметризация
```js
export const name = 'Заполнение поля {type}';
@@ -780,33 +829,33 @@ export default async function({ fillFields, getFormState, assert }, { type, fiel
}
```
-В отчётах каждый набор параметров отображается как отдельный тест:
-- "Заполнение поля String"
-- "Заполнение поля Number"
-- "Заполнение поля Date"
-- "Заполнение поля Boolean"
+Параметры разворачиваются в отдельные тесты на этапе discovery. Имя
+формируется подстановкой через шаблон `{key}` в `mod.name`; если шаблона
+нет — суффикс `[index]`. Тест получает `param` вторым аргументом
+(`default(ctx, param)`). В отчётах каждый набор — отдельная запись со
+своим `name` и `param` в testInfo. `ctx.testInfo.param` доступен в теле
+теста и хуках.
---
-## 14. buildContext() -- рефакторинг executeScript
+## 14. buildContext()
-Извлечь из `executeScript()` в `run.mjs` (строки 104-214):
+Общая фабрика контекста, используется и `executeScript()` (для `exec`/`run`/`start`),
+и `cmdTest()` (для `test`).
-**Что извлечь:**
-- Сбор всех экспортов `browser.*` в объект
-- Обёртка ACTION_FNS авто-обнаружением ошибок (проверка модальных/всплывающих после каждого вызова)
-- Захват скриншота до того, как `fetchErrorStack` закроет модальное окно ошибки
-- Вызов `fetchErrorStack` для модальных ошибок
-- Заглушки `noRecord` для функций записи/озвучки
+**Что делает:**
+- Собирает все экспорты `browser.*` в плоский объект.
+- Оборачивает ACTION_FNS авто-обнаружением 1С-ошибок: после каждого вызова
+ проверяет `state.errors.modal`/`balloon`, делает скриншот ДО того, как
+ `fetchErrorStack` закроет модалку, вызывает `fetchErrorStack` для modal-ошибок,
+ бросает исключение со структурированным `err.onecError = { step, args, errors, formState, stack, screenshot }`.
+- Подмешивает заглушки `noRecord` (для функций записи/озвучки в exec-режиме).
-**Сигнатура новой функции:**
-```js
-function buildContext({ noRecord = false } = {}) -> object
-```
+**Сигнатура:** `function buildContext({ noRecord = false } = {}) -> object`
-**Использование после рефакторинга:**
-- `executeScript()` вызывает `buildContext()` + `new AsyncFunction(...)` (поведение не меняется)
-- `cmdTest()` вызывает `buildContext()` + `import()` + `mod.default(ctx)` (новое поведение)
+**Scoped-вариант** (`buildScopedContext(name)`): тот же `buildContext()`,
+но каждый вызов функции префиксится `await browser.setActiveContext(name)`.
+Используется для мульти-контекстных тестов (`ctx.a`/`ctx.b`).
---
@@ -854,39 +903,53 @@ function buildContext({ noRecord = false } = {}) -> object
## 16. Каталог тест-кейсов
-Расположение: `tests/web-test/`
+Расположение: `tests/web-test/`. По состоянию на 2026-05-13: 19 файлов.
-| # | Файл | Теги | Покрытие API |
-|---|------|------|-------------|
-| 01 | navigation.test.mjs | nav, smoke | navigateSection, getPageState, getSections, getCommands |
-| 02 | catalog-crud.test.mjs | crud, catalog, smoke | openCommand, fillFields, clickElement, closeForm, readTable, getFormState |
-| 03 | field-types.test.mjs | fields | fillFields (строка, число, дата, булево, перечисление) на Номенклатуре |
-| 04 | reference-field.test.mjs | fields, select | selectValue на ПриходнаяНакладная.Контрагент |
-| 05 | table-operations.test.mjs | table, smoke | readTable, fillTableRow, deleteTableRow |
-| 06 | document-workflow.test.mjs | doc, smoke | Создание документа, заполнение шапки + ТЧ, проведение, отмена |
-| 07 | tabs.test.mjs | tabs | switchTab на форме Номенклатуры |
-| 08 | hierarchy.test.mjs | hierarchy | clickElement с expand/collapse на Номенклатуре |
-| 09 | filter-list.test.mjs | filter | filterList, unfilterList, расширенный фильтр по полю |
-| 10 | validation.test.mjs | validation | Ошибка обязательного поля, подтверждение при закрытии |
-| 11 | report.test.mjs | report | Открыть отчёт, задать параметры, сформировать, readSpreadsheet |
-| 12 | form-state.test.mjs | state | getFormState: поля, кнопки, таблицы |
-| 13 | screenshots.test.mjs | util | screenshot(), wait() |
+| # | Файл | Теги | Покрытие |
+|---|------|------|----------|
+| 00 | hooks.test.mjs | hooks, smoke | индикатор порядка beforeAll/beforeEach/afterEach + testInfo + afterOpenContext |
+| 01 | navigation.test.mjs | nav, smoke | navigateSection, getPageState, navigateLink, switchTab, errors |
+| 02 | crud.test.mjs | crud, smoke | openCommand, fillFields, clickElement, closeForm, save-confirm flow |
+| 03 | fillfields.test.mjs | fields | text/checkbox/date/dropdown/reference/radio/clear + composite + direct-edit-form |
+| 04 | selectvalue.test.mjs | fields, select | dropdown / форма выбора / auto-history / clear |
+| 05 | table.test.mjs | table, smoke | fillTableRow/deleteTableRow/tab-loop/checkbox/clear |
+| 06 | document.test.mjs | doc, smoke | создание+проведение документа |
+| 07 | tabs.test.mjs | tabs | switchTab + errors |
+| 08 | hierarchy.test.mjs | hierarchy | groups expand + tree-grid view-mode switch |
+| 09 | filter.test.mjs | filter | simple-search/advanced-column/exact/date/reference/unfilter-all/unfilter-specific |
+| 10 | validation.test.mjs | validation | сообщения + exception modal (fetchErrorStack Path 1) |
+| 11 | report.test.mjs | report | DCS form + быстрый фильтр + readSpreadsheet + drill-down |
+| 12 | formstate.test.mjs | state | fields/buttons/tables/openForms/subordinate-nav/platformDialogs |
+| 13 | misc.test.mjs | misc | openFile EPF + security confirm |
+| 14 | errors-stack.test.mjs | errors | fetchErrorStack Path 1 + dismiss-modal + dismiss-platform |
+| 14 | multi-context-routing.test.mjs | multi-context | single test → non-default context |
+| 15 | multi-context-handover.test.mjs | multi-context | ctx.a creates → ctx.b sees → closeContext(b) + edge throw |
+| 15 | recording.test.mjs | record | startRecording/stopRecording/captions/narration/overlays |
+| 16 | tree-form.test.mjs | tree, table | FormDataTree edit (ДеревоНоменклатуры) |
-~30 тест-кейсов, покрывающих все основные области API browser.mjs.
+Полный регресс — **19/19** (~9 минут на warm-стенде).
---
## 17. Дорожная карта реализации
-| # | Задача | Результат | Зависимости | Статус |
-|---|--------|-----------|-------------|--------|
-| 1 | Архитектурная спецификация | `docs/web-test-runner-spec.md` (этот файл) | -- | done 2026-04-05 |
-| 2 | Рефакторинг buildContext() | run.mjs: извлечение из executeScript | спека | done 2026-04-05 |
-| 3 | Ядро cmdTest() | run.mjs: обнаружение, импорт, выполнение, консольный вывод, JSON-отчёт | #2 | done 2026-04-05 |
-| 4 | Утверждения + обёртка step() | run.mjs: assert.*, step(name, fn) | #3 | done 2026-04-05 |
-| 5 | Хуки (prepare/cleanup + before/after) | run.mjs: поддержка _hooks.mjs | #3 | done 2026-04-05 |
-| 6 | Файл конфигурации + контексты | run.mjs: webtest.config.mjs, BrowserContext'ы, маршрутизация | #3 | config done, BrowserContext pending |
-| 7 | Форматы отчётов (Allure, JUnit) | run.mjs: --format=allure/junit | #3 | -- |
-| 8 | Синтетическая конфигурация | integration/build-webtest-config.test.mjs | спека | done 2026-04-05 |
-| 9 | Smoke-тесты (01-06) | tests/web-test/01-06*.test.mjs | #3, #8 | -- |
-| 10 | Остальные тесты (07-13) | tests/web-test/07-13*.test.mjs | #9 | -- |
+| # | Задача | Результат | Статус |
+|---|--------|-----------|--------|
+| 1 | Архитектурная спецификация | `docs/web-test-runner-spec.md` (этот файл) | done 2026-04-05 |
+| 2 | Рефакторинг buildContext() | run.mjs: извлечение из executeScript | done 2026-04-05 |
+| 3 | Ядро cmdTest() | run.mjs: обнаружение, импорт, выполнение, JSON-отчёт | done 2026-04-05 |
+| 4 | Утверждения + обёртка step() | run.mjs: assert.*, step(name, fn) | done 2026-04-05 |
+| 5 | Хуки (prepare/cleanup + before/after) | run.mjs: поддержка `_hooks.mjs` | done 2026-04-05 |
+| 6 | Файл конфигурации + BrowserContext-ы | webtest.config.mjs, мульти-контекст | done 2026-05-10 (T4 + T4.5/4.6) |
+| 7 | Форматы отчётов (Allure, JUnit) | --format=allure/junit | done 2026-05-03 (T2/T3) |
+| 8 | Синтетическая конфигурация | `build-webtest-config.test.mjs` | done 2026-04-05 + M1 расширения 2026-05-01 |
+| 9 | Smoke-тесты P0 (~18 кейсов) | `tests/web-test/01-12*.test.mjs` | done 2026-05-04 (M2) |
+| 10 | Регресс P1 (~15 кейсов) | расширение 02/03/04/05/09/12 | done 2026-05-10 (M3) |
+| 11 | M4: расширенный регресс P2 | validation/errors/recording/hierarchy/openFile | done 2026-05-11 |
+| 12 | M5-pre: расширение синтетики | tree-form, composite, textEdit, history, unfilter | done 2026-05-12 |
+| 13 | M6: автономный стенд через `_hooks.mjs` | prepare(): config-rebuild/data-reload/EPF + smart Apache | done 2026-05-12 (MVP) |
+| 14 | M7.1/M7.2: ctx.testInfo + custom-поля контекстов | спека §3 + run.mjs | done 2026-05-13 |
+| 15 | M7.3: Headless-режим | `--headless` CLI + config | deferred (1С-specific блокеры в headless) |
+| 16 | M7.4: 4 testlevel-хука + индикатор | `_hooks.mjs` v0.3 + 00-hooks.test.mjs | done 2026-05-13 |
+| 17 | M7.5: title slide bonus | `beforeEach` под isRecording() | done 2026-05-13 |
+| 18 | M8: per-context lifecycle | closeContext + afterOpenContext/beforeCloseContext | done 2026-05-13 |