diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md
index 4924db26..6a1c52b2 100644
--- a/.claude/skills/web-test/SKILL.md
+++ b/.claude/skills/web-test/SKILL.md
@@ -529,3 +529,7 @@ On error (auto-screenshot taken):
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
+
+## Regression suites
+
+When the user asks to cover a 1C solution with automated regression — multi-file test suites with assertions, hooks, tags, retries, Allure/JUnit reports, multi-user process tests — switch to the `test` mode. See [regress.md](regress.md) for authoring discipline, recon flow (metadata + live walkthrough via `exec`), per-application folder layout, ready-to-paste templates, and failure triage. Default to ad-hoc `run`/`exec` for single-script automation — `test` is the specialised mode for project-wide coverage.
diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md
new file mode 100644
index 00000000..086316df
--- /dev/null
+++ b/.claude/skills/web-test/regress.md
@@ -0,0 +1,433 @@
+# Regression suite authoring
+
+Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
+
+The runner is the same `run.mjs`. The mode is `test`:
+
+```bash
+node $RUN test [url]
[flags]
+```
+
+Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
+
+## When to choose `test` over `exec`
+
+| Goal | Mode |
+|------|------|
+| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
+| **Walk through a scenario live before committing it as a test** | `exec` first, then `test` |
+| Reproduce a bug as a failing test before fixing it | `test` |
+| Cover a feature so future changes are checked automatically | `test` |
+| Run the project's regression on a new build | `test` |
+| Generate a screencast walkthrough | `exec` with `startRecording` |
+
+Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
+
+## Before writing tests — recon
+
+Two layers, in order. Don't skip either.
+
+### 1. Static recon — metadata
+
+Never invent identifiers. For every metadata object the user mentions (or that you decide to cover), run the matching info skill first:
+
+| Object type | Skill |
+|-------------|-------|
+| Catalog/document/register attributes, tabular sections | `/meta-info` |
+| Form layout — fields, buttons, tabs, tables | `/form-info` |
+| DCS report — fields, parameters, filters | `/skd-info` |
+| Spreadsheet template areas/parameters | `/mxl-info` |
+| Role rights / restrictions | `/role-info` |
+| Subsystem composition / command interface | `/subsystem-info` |
+
+This gives the real Russian field labels, command names, column headers, table-section names. Without it, fuzzy matching will silently land on the wrong element, or fail with no useful diagnostic.
+
+If the user names objects you cannot find: stop and ask. Do not guess.
+
+### 2. Live recon — interactive walkthrough
+
+For any non-trivial scenario, walk the path live in `exec` mode before writing it down. Metadata tells you what exists; the live walkthrough tells you what actually happens — which button posts the document, which dialog 1C raises, how the form looks after `clickElement('Создать')`, what fields are required, where `wait()` is genuinely needed.
+
+```bash
+# Start a session (background).
+node $RUN start http://localhost:9191/myapp/ru_RU
+
+# Step the scenario interactively. After each step, inspect.
+cat <<'EOF' | node $RUN exec -
+await navigateSection('Склад');
+const cmds = await getCommands();
+console.log(cmds);
+EOF
+
+cat <<'EOF' | node $RUN exec -
+await openCommand('Приходная накладная');
+await clickElement('Создать');
+const s = await getFormState();
+console.log(JSON.stringify(s.fields.map(f => ({ name: f.name, label: f.label, required: f.required })), null, 2));
+console.log('buttons:', s.buttons.map(b => b.name));
+console.log('tables:', s.tables.map(t => ({ name: t.name, label: t.label, columns: t.columns })));
+EOF
+
+# Try the actions you plan to encode. If a step fails, fix and re-try
+# before transcribing it.
+cat <<'EOF' | node $RUN exec -
+await fillFields({ 'Контрагент': 'ООО Север' });
+await fillTableRow({ 'Номенклатура': 'Товар 01', 'Количество': '5' },
+ { table: 'Товары', add: true });
+await clickElement('Провести и закрыть');
+console.log(JSON.stringify(await getFormState()));
+EOF
+
+# When done, stop the session (or leave it for the next test you write).
+node $RUN stop
+```
+
+What to record from the walkthrough into the test:
+- Exact button names (`'Провести и закрыть'`, not `'Сохранить'`).
+- Field labels as 1C renders them (with possible non-breaking spaces — `fillFields` normalises, but be exact).
+- Table section names from `getFormState().tables[].name`/`label` for multi-grid forms.
+- Required `wait()` durations — only where a real async event happens (report generation, server-side calculation). Default actions await internally.
+- The shape of `getFormState()` after each action — gives you the right `assert.equal(...)` paths.
+
+After this, transcribe the working sequence into `*.test.mjs`, wrap each chunk in `step('...', async () => { ... })`, add assertions for the invariants you saw. Run the file once with `node $RUN test path/to/file.test.mjs` to confirm.
+
+When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: anything with confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, or user-customised forms you've never seen.
+
+## Suite layout
+
+**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
+
+```
+tests/
+ web-test/ # engine self-tests (reserved if our repo layout)
+ / # application regression — one per solution
+ _hooks.mjs
+ webtest.config.mjs
+ 01-login/
+ 02-counterparties/
+ ...
+ / # second solution, fully isolated
+ _hooks.mjs
+ ...
+```
+
+`` is the project/extension slug (`acc-payroll`, `erp-customisation`, etc.). Pick something stable and pass it on the CLI:
+
+```bash
+node $RUN test tests//
+```
+
+Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order (discovery is alphabetic by full path).
+
+```
+tests//
+ _hooks.mjs # stand prep + cross-cutting hooks (optional)
+ webtest.config.mjs # url, contexts, defaults (optional)
+ 01-login/
+ 01-open-base.test.mjs
+ 02-section-navigation.test.mjs
+ 02-counterparties/
+ 01-create.test.mjs
+ 02-edit-phone.test.mjs
+ 03-goods-receipt/
+ 01-fill.test.mjs
+ 02-post.test.mjs
+ 03-unpost.test.mjs
+ 04-balance-report/
+ 01-generate.test.mjs
+ 02-warehouse-filter.test.mjs
+ 05-approval-process/
+ 01-end-to-end.test.mjs # multi-user
+```
+
+Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported. Only the application-root copies are loaded.
+
+## Test file anatomy
+
+```js
+export const name = 'Создание контрагента'; // required
+export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
+export const timeout = 60000; // optional, default 30000
+// export const skip = 'pending fix #123'; // optional: true | string
+// export const only = true; // debug-only — never commit
+// export const context = 'manager'; // optional, single non-default context
+// export const contexts = ['clerk', 'manager']; // optional, multi-user test
+// export const severity = 'critical'; // optional, overrides config severity
+
+export async function setup(ctx) {
+ // per-test prep — runs before default. Skip if not needed.
+}
+
+export async function teardown(ctx) {
+ // per-test cleanup — runs after default, always (even on failure).
+}
+
+export default async function(ctx) {
+ const { navigateSection, openCommand, clickElement, fillFields,
+ readTable, closeForm, getFormState,
+ assert, step, log } = ctx;
+
+ await step('Открыть список контрагентов', async () => {
+ await navigateSection('Продажи');
+ await openCommand('Контрагенты');
+ });
+
+ await step('Создать нового контрагента', async () => {
+ await clickElement('Создать');
+ await fillFields({ 'Наименование': 'Тест ' + Date.now() });
+ await clickElement('Записать и закрыть');
+ });
+
+ await step('Убедиться, что элемент появился в списке', async () => {
+ const t = await readTable();
+ assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
+ });
+}
+```
+
+The runner injects every `browser.mjs` export into `ctx` plus `assert`, `step`, `log`, `testInfo`, `testResult` (afterEach only). For multi-context tests, each context name is its own scoped namespace (`ctx.clerk.clickElement(...)` etc.) — `step`/`assert` stay top-level.
+
+**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`, `'Проверить наличие документа в списке'`), not a tag (`'create'`, `'verify'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
+
+## webtest.config.mjs
+
+```js
+export default {
+ // Single-context: just url.
+ url: 'http://localhost:9191/myapp/ru_RU',
+
+ // OR multi-context: named contexts. Each test picks via `context`/`contexts` exports.
+ // contexts: {
+ // clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
+ // manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
+ // },
+ // defaultContext: 'clerk',
+
+ timeout: 30000,
+ retries: 0,
+ screenshot: 'on-failure',
+ record: false,
+
+ // Severity → tags mapping for Allure. Each tag at most one bucket.
+ severity: {
+ critical: ['smoke', 'crud'],
+ minor: ['recording'],
+ },
+ defaultSeverity: 'normal',
+};
+```
+
+CLI flags override config. Recommend latin context IDs + Russian `displayName` for video badges.
+
+## _hooks.mjs
+
+Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
+
+```js
+import { execSync } from 'child_process';
+
+// Infra — runs once around the whole suite.
+export async function prepare({ hookArgs, log, config }) {
+ // Restore DB, publish to Apache, build EPF, etc.
+ // hookArgs = everything after `--` on the CLI. Parse yourself.
+ if (hookArgs.includes('--rebuild-stand')) { /* full rebuild */ }
+ // Use idempotent hash-locks to skip work on warm starts.
+}
+
+export async function cleanup({ log, config }) {
+ // Tear down or leave the stand running. Choose per project.
+}
+
+// Testlevel — runs with browser ctx.
+export async function beforeAll(ctx) { /* once after first context opens */ }
+export async function afterAll(ctx) { /* once before final teardown */ }
+export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
+export async function afterEach(ctx) { /* ctx.testResult is set */ }
+
+// Per-context — runs whenever a context is created/closed.
+export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
+export async function beforeCloseContext(ctx, name, spec) { }
+```
+
+Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it.
+
+**Where to put data setup:**
+- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks on inputs — config sources, EPF spec, DB dump) so warm starts skip everything but a liveness probe.
+- Test-specific seed data (the document this test will edit, the counterparty it expects) → per-test `setup`.
+- Shared session-wide warmup → `beforeAll`.
+
+## Ready-to-paste patterns
+
+### Catalog full cycle
+
+```js
+await step('Создать контрагента', async () => {
+ await navigateSection('Продажи');
+ await openCommand('Контрагенты');
+ await clickElement('Создать');
+ await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
+ await clickElement('Записать и закрыть');
+});
+await step('Проверить наличие в списке', async () => {
+ const t = await readTable({ maxRows: 50 });
+ assert.tableHasRow(t, { 'Наименование': 'ТД Тест' });
+});
+await step('Удалить контрагента и подтвердить удаление', async () => {
+ await clickElement('ТД Тест');
+ const page = await getPage();
+ await page.keyboard.press('Delete');
+ await clickElement('Да');
+});
+```
+
+### Document create + post
+
+```js
+const marker = 'Тест-' + Date.now();
+await openCommand('Приходная накладная');
+await clickElement('Создать');
+await fillFields({ 'Контрагент': 'ООО Север', 'Комментарий': marker });
+await fillTableRow(
+ { 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
+ { table: 'Товары', add: true }
+);
+await clickElement('Провести и закрыть');
+// Verify: re-open list, filter or scan, assert by `marker`.
+```
+
+Use a unique marker (`Date.now()` or random suffix) so re-runs don't collide. Identify your own row by it, not by position or natural keys that may already exist in the DB.
+
+### DCS report
+
+```js
+await openCommand('Остатки товаров');
+// Reset user settings — 1C persists them between sessions.
+await clickElement('Ещё');
+await clickElement('Установить стандартные настройки');
+
+await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
+await clickElement('Сформировать');
+await wait(3);
+const r = await readSpreadsheet();
+assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
+assert.ok(r.data.length >= 1);
+assert.ok(r.totals?.['Сумма']);
+```
+
+### Multi-user process
+
+```js
+export const contexts = ['clerk', 'manager'];
+
+export default async function({ clerk, manager, step, assert }) {
+ await step('Кладовщик создаёт накладную', async () => {
+ await clerk.navigateSection('Склад');
+ await clerk.openCommand('Приходные накладные');
+ await clerk.clickElement('Создать');
+ await clerk.fillFields({ 'Контрагент': 'ООО Север' });
+ await clerk.clickElement('Записать');
+ });
+ await step('Менеджер утверждает накладную', async () => {
+ await manager.navigateSection('Согласование');
+ await manager.openCommand('На утверждении');
+ await manager.clickElement('ООО Север', { dblclick: true });
+ await manager.clickElement('Утвердить');
+ });
+ await step('Кладовщик видит новый статус', async () => {
+ const s = await clerk.getFormState();
+ assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
+ });
+ await step('Освободить сессию кладовщика', async () => {
+ await manager.closeContext('clerk'); // free a 1C license for the next test
+ });
+}
+```
+
+License caveat: stock 1C allows ~2 web sessions concurrently. Close contexts you no longer need before the next multi-user test starts.
+
+### Failing-test repro
+
+```js
+export const name = 'Bug #123: накладная без контрагента не должна проводиться';
+export const tags = ['bug', 'validation'];
+
+export default async function({ openCommand, clickElement, getFormState, assert, step }) {
+ await openCommand('Приходные накладные');
+ await clickElement('Создать');
+ await clickElement('Провести');
+ const s = await getFormState();
+ assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required,
+ 'Должна быть ошибка валидации или поле помечено обязательным');
+}
+```
+
+Write it red first, hand it to the user, fix the underlying issue, re-run green.
+
+## Running
+
+```bash
+node $RUN test tests// # full app suite
+node $RUN test tests//03-goods-receipt/ # one feature folder
+node $RUN test tests//02-counterparties/01-create.test.mjs # one file
+node $RUN test tests// --tags=smoke # by tag (intersection)
+node $RUN test tests// --grep='накладн' # by name regex
+node $RUN test tests// --bail --retry=1 # stop on first fail, allow 1 retry
+node $RUN test tests// --report=allure-results --format=allure --report-dir=allure-results
+node $RUN test tests// -- --rebuild-stand # everything after `--` goes to hooks
+```
+
+Default report is JSON when `--report=…` is given. Allure needs `--format=allure` + a directory. JUnit similarly with `--format=junit`.
+
+### Allure static config — `_allure/` directory
+
+The runner copies `/_allure/` into the report directory before generating Allure output. Standard Allure convention applies — three files are typically used:
+
+- **`categories.json`** — failure classification. Always emit this when setting up a suite, with 1C-specific patterns: license pool exhaustion (`Не обнаружено свободной лицензии`), 1C application errors (`ВызватьИсключение|Произошла ошибка|…`), navigation/element lookup misses, runner timeouts, assertion failures.
+- **`environment.properties`** — `key=value` lines for the Environment widget. Useful when the suite runs across builds/branches (URL, 1C platform version, git branch, configuration version). Often emitted dynamically by `prepare()` rather than committed as a static file.
+- **`executor.json`** — CI metadata (Jenkins URL, GitHub run ID, etc.). Only relevant when the suite runs on a CI server; for local runs, skip it.
+
+Discovery skips the underscored directory, so it never collides with tests.
+
+## Severity guidance
+
+When the user doesn't dictate, default to:
+
+| Test kind | Severity |
+|-----------|----------|
+| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
+| Documents posting, report generation, end-to-end processes | `critical` |
+| Field-level edge cases, formatting, optional flows | `normal` |
+| Cosmetic / recording / non-functional | `minor` |
+| Reserved for show-stopper protections | `blocker` (use sparingly) |
+
+Don't promote everything to `critical` — it loses signal in the Allure dashboard.
+
+## Anti-patterns
+
+- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — find what state you can wait on with `getFormState` instead.
+- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
+- **Raw DOM via `getPage().$$(...)`.** Use `getFormState`, `readTable`, `readSpreadsheet`. Raw selectors break across 1C platform versions.
+- **`clickElement('×')` or `clickElement('Закрыть')`** to dismiss a form. Use `closeForm({ save: true|false })` — handles confirmation correctly.
+- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by unique marker or label instead.
+- **Skipping recon** because "I know what this catalog looks like." You don't — the project's customisation almost certainly differs from a stock config.
+- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
+- **Hand-writing reset code** in `afterEach`. The runner already closes forms and dismisses errors.
+- **Cross-test state assumptions.** Each test must start from desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
+
+## After a run — failure triage
+
+1. Scan the JSON or Allure summary for `failed`.
+2. For each failure, read `error.message` + `error.step` + screenshot (saved next to the report).
+3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
+4. Classify:
+ - **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
+ - **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
+ - **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
+5. After fixes, re-run only the affected files (`node $RUN test tests/03-goods-receipt/`) before the full suite.
+
+Report back to the user with the classification, not raw failure dumps.
+
+## Reference
+
+- Browser API: [SKILL.md](SKILL.md)
+- Video and narration: [recording.md](recording.md)
diff --git a/docs/web-test-regression-guide.md b/docs/web-test-regression-guide.md
new file mode 100644
index 00000000..046d7edf
--- /dev/null
+++ b/docs/web-test-regression-guide.md
@@ -0,0 +1,391 @@
+# Регрессионное тестирование прикладного решения
+
+Навык `/web-test` умеет не только разово выполнить сценарий в браузере, но и сопровождать прикладное решение полноценным набором автотестов: каждый тест — отдельный файл, с шагами, проверками, тегами, отчётом и видеозаписью падений. После каждой правки конфигурации модель прогоняет весь набор и показывает, что ожидаемо ведёт себя как раньше, а что сломалось.
+
+```
+правка конфигурации → загрузка → обновление → публикация → прогон тестов → отчёт
+```
+
+Это про прикладное решение в целом, не про разовую проверку одной формы. Для разовых сценариев («открой накладную, проверь сумму») по-прежнему удобнее интерактивный режим из [web-test-guide.md](web-test-guide.md).
+
+## Предусловия
+
+- База опубликована через Apache (`/web-publish`).
+- Установлен Node.js 18+, зависимости подняты: `cd .claude/skills/web-test/scripts && npm install`.
+- ffmpeg — нужен только если хотите видеозапись прогона как доказательство падения. Без него падения фиксируются скриншотами. Установка описана в [web-test-recording-guide.md](web-test-recording-guide.md).
+
+## Как это устроено
+
+Набор тестов живёт в каталоге `tests/` вашего проекта. Каждое прикладное решение — отдельная подпапка. Внутри подпапки:
+
+- `_hooks.mjs` — подготовка стенда (восстановление базы, публикация) и общая очистка после прогона. Необязателен.
+- `webtest.config.mjs` — адрес базы и набор пользователей (например, кладовщик и менеджер для процессов согласования). Необязателен — если в проекте один пользователь и один URL, можно обойтись без него.
+- Сами тесты — файлы `*.test.mjs`, сгруппированные по функциональным папкам.
+
+```
+tests/
+ моя-конфигурация/
+ _hooks.mjs
+ webtest.config.mjs
+ 01-вход/
+ 01-открытие-базы.test.mjs
+ 02-контрагенты/
+ 01-создание.test.mjs
+ 02-правка-телефона.test.mjs
+ 03-поступление-товаров/
+ 01-оформление.test.mjs
+ 02-проведение.test.mjs
+ 04-отчёт-остатки/
+ 01-формирование.test.mjs
+ 05-согласование/
+ 01-полный-цикл.test.mjs
+```
+
+Порядок выполнения — по алфавиту, поэтому удобно префиксовать папки и файлы номерами. Это даёт предсказуемый сценарий: сначала вход, потом справочники, потом документы, потом отчёты, в конце — процессы с несколькими пользователями.
+
+## Быстрый старт
+
+Самый короткий путь от нуля до зелёного теста — попросить модель пройти ваш сценарий руками и зафиксировать его как тест:
+
+```
+> Покрой регрессом справочник Контрагенты в моей конфигурации.
+> Нужны проверки: создание, правка телефона, удаление.
+```
+
+Что сделает модель:
+
+1. Соберёт информацию о справочнике через `/meta-info` и `/form-info` — посмотрит реквизиты и форму элемента, чтобы знать правильные имена полей.
+2. Подключится к опубликованной базе в интерактивном режиме и **руками пройдёт** каждый сценарий — создание, правка, удаление. Это нужно, чтобы зафиксировать настоящие имена кнопок, увидеть, какие диалоги показывает 1С, понять, требуется ли подтверждение сохранения.
+3. Зафиксирует пройденный сценарий как файл `tests/<ваша-конфигурация>/02-контрагенты/01-создание.test.mjs`.
+4. Запустит его и покажет результат.
+
+При следующих прогонах ничего этого делать не нужно — модель просто запустит готовый набор.
+
+## Сценарии работы с моделью
+
+### Покрытие регрессом доработанного объекта
+
+```
+> Я добавил в справочник Номенклатура реквизит "Цена" и "Активен".
+> Покрой это регрессом — создание, редактирование, фильтрация по активности
+```
+
+Модель:
+- посмотрит структуру справочника и формы (через `/meta-info`, `/form-info`);
+- интерактивно проверит, как ведут себя новые поля в браузере;
+- сгенерирует 2-3 тестовых файла под папкой `02-номенклатура/`;
+- прогонит — покажет, что зелёное, что красное.
+
+### Тест процесса с несколькими пользователями
+
+```
+> Сделай тест для процесса согласования приходных накладных.
+> Кладовщик создаёт накладную, менеджер утверждает,
+> кладовщик видит обновлённый статус
+```
+
+Модель настроит в `webtest.config.mjs` двух пользователей (с разными URL базы — например, `app-clerk` и `app-manager`), напишет тест, который оркестрирует переключение между ними, и положит его в `05-согласование/`.
+
+```js
+export const contexts = ['кладовщик', 'менеджер'];
+
+export default async function({ кладовщик, менеджер, step, assert }) {
+ await step('Кладовщик создаёт накладную', async () => {
+ await кладовщик.navigateSection('Склад');
+ await кладовщик.openCommand('Приходные накладные');
+ await кладовщик.clickElement('Создать');
+ // ...
+ });
+ await step('Менеджер утверждает', async () => {
+ await менеджер.navigateSection('Согласование');
+ // ...
+ });
+ // ...
+}
+```
+
+Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на «свободных лицензий не осталось». Модель освобождает сессии между тестами автоматически (закрывает контексты после процессного теста), но если стенд ограничен — закладывайте это в планирование набора: один-два многопользовательских сценария вместо десяти.
+
+### Воспроизведение ошибки тестом
+
+```
+> При проведении накладной без заполненного контрагента у нас не появляется
+> ошибка валидации, документ просто проводится с пустым контрагентом — это баг.
+> Зафиксируй это падающим тестом
+```
+
+Модель воспроизведёт сценарий, напишет тест с проверкой «должна быть ошибка», получит красный — потом, когда вы поправите конфигурацию и попросите перепрогнать, тест станет зелёным. Это документирует ожидаемое поведение в виде кода.
+
+### Прогон регресса после изменений
+
+```
+> Я обновил расширение, накатил в базу. Прогони регресс
+```
+
+Модель запустит весь набор, дождётся завершения и расскажет:
+- сколько тестов прошло, сколько упало, сколько пропущено;
+- по каждому упавшему — что именно сломалось (название шага, сообщение об ошибке, ссылка на скриншот);
+- классифицирует падения: это ошибка в самом тесте (нужно поправить тест), ошибка в приложении (баг, который вы внесли изменением), или нестабильность стенда (Apache не ответил вовремя, лицензия не освободилась).
+
+```
+> Прогони только тесты по контрагентам с подробным отчётом
+```
+
+Запустит подмножество — фильтр по тегу или папке, с записью JSON-отчёта.
+
+### Подготовка автономного стенда
+
+Если вы хотите, чтобы регресс можно было запустить «с нуля» — даже на чистой машине без подготовленной базы, — модель настроит автоматическую подготовку стенда:
+
+```
+> Сделай, чтобы перед прогоном тестов база восстанавливалась из эталона,
+> а после прогона публикация снималась
+```
+
+Это пишется один раз в файле `_hooks.mjs`: при запуске тестов запускается подготовка (через навыки `/db-create`, `/db-load-xml`, `/web-publish`), а после — очистка. Внутри предусмотрено кэширование: если ничего не менялось со прошлого прогона, повторная подготовка занимает доли секунды.
+
+## Пример организации покрытия
+
+Допустим, у нас условное прикладное решение «Учёт поступлений товаров» — справочники контрагентов и номенклатуры, документ приходной накладной, отчёт остатков, процесс согласования с двумя пользователями. Логично организовать набор так:
+
+```
+tests/учёт-поступлений/
+ _hooks.mjs # подготовка: восстановление базы + публикация
+ webtest.config.mjs # URL базы, контексты кладовщика и менеджера
+ 01-вход/
+ 01-открытие-базы.test.mjs # базовая работоспособность: вход проходит, разделы видны
+ 02-навигация-по-разделам.test.mjs # обход всех разделов конфигурации
+ 02-контрагенты/
+ 01-создание.test.mjs # создание, проверка появления в списке
+ 02-редактирование.test.mjs # правка реквизита, проверка сохранения
+ 03-удаление.test.mjs # удаление с подтверждением
+ 03-номенклатура/
+ 01-создание.test.mjs
+ 02-фильтр-по-активности.test.mjs # быстрая фильтрация списка
+ 04-поступление-товаров/
+ 01-оформление.test.mjs # заполнение шапки и табличной части
+ 02-проведение.test.mjs # проведение документа, проверка движений
+ 03-отмена-проведения.test.mjs
+ 04-валидация-обязательных.test.mjs # негативный тест: пустой контрагент → ошибка
+ 05-отчёт-остатки/
+ 01-формирование.test.mjs
+ 02-отбор-по-складу.test.mjs
+ 03-расшифровка.test.mjs # переход из ячейки отчёта в исходный документ
+ 06-согласование/
+ 01-полный-цикл.test.mjs # многопользовательский тест
+```
+
+Принципы:
+
+- **Папки — по бизнес-функции**, не по типу метаданных. Лучше `04-поступление-товаров/` (что делает пользователь), чем `документы/` (что лежит в дереве конфигурации).
+- **Цифровые префиксы** — на папке и на файле. Гарантируют, что сначала отработают базовые проверки (вход, справочники), потом сложные (документы, отчёты, процессы). При падении базы остальное и так не пройдёт — нет смысла занимать стенд получасом.
+- **Один файл — одна логически связанная история.** Не «всё про контрагентов в одном файле», а «отдельно создание, отдельно правка, отдельно удаление». Когда падает — сразу видно, какой именно сценарий сломан.
+- **Негативные тесты тоже есть.** «Документ без контрагента не проводится» — такой же важный регресс, как и позитивный сценарий, особенно после правок в обработчиках проверки заполнения.
+- **Процессные тесты — в конце.** Они самые хрупкие (зависят от двух сессий, лицензий, синхронизации) и самые длинные. Если упадут — у вас уже есть данные от предыдущих тестов.
+
+## Анатомия одного теста
+
+Пользователь, как правило, тест не пишет — генерирует модель. Но прочитать и поправить полезно уметь. Стандартный файл выглядит так:
+
+```js
+export const name = 'Создание контрагента';
+export const tags = ['контрагенты', 'базовая-проверка'];
+export const timeout = 60000;
+
+export default async function({
+ navigateSection, openCommand, clickElement, fillFields,
+ readTable, closeForm, assert, step
+}) {
+ await step('Открыть список контрагентов', async () => {
+ await navigateSection('Продажи');
+ await openCommand('Контрагенты');
+ });
+
+ await step('Создать нового контрагента', async () => {
+ await clickElement('Создать');
+ await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
+ await clickElement('Записать и закрыть');
+ });
+
+ await step('Убедиться, что элемент появился в списке', async () => {
+ const t = await readTable();
+ assert.tableHasRow(t, r => r['Наименование'] === 'ТД Тест');
+ });
+}
+```
+
+Что здесь есть:
+
+- **`name`** — человекочитаемое имя теста. Появится в отчёте.
+- **`tags`** — теги для фильтрации. Можно прогонять не весь набор, а только нужные: `--tags=контрагенты`.
+- **`timeout`** — сколько максимум тест может идти. По умолчанию 30 секунд, для длинных сценариев увеличиваем.
+- **Тело теста** — функция, которая получает API браузера (см. [SKILL.md](../.claude/skills/web-test/SKILL.md)) плюс `assert` и `step`.
+- **`step('имя', async () => {...})`** — обёртка шага. Имена шагов попадают в отчёт, при падении видно, какой именно шаг сломался.
+- **`assert.*`** — проверки. `assert.tableHasRow`, `assert.equal`, `assert.ok` и т.д. Если проверка не выполнилась — тест считается упавшим.
+
+Имена шагов и теста — по-русски, описательные. Они показываются и в консоли, и в отчётах.
+
+## Запуск и отчёты
+
+### Простой прогон
+
+```
+> Прогони регресс
+```
+
+Модель запустит весь набор, дождётся, покажет сводку:
+
+```
+✓ Открытие базы (2.1s)
+✓ Создание контрагента (8.4s)
+✗ Проведение приходной накладной (12.7s)
+ └ Заполнить табличную часть (5.2s)
+ Не найден столбец "Цена" в табличной части "Товары"
+ скриншот: tests/учёт-поступлений/error-shot.png
+
+23 пройдено, 1 упал, 0 пропущено (3 мин 42 с)
+```
+
+### Подробный отчёт
+
+```
+> Прогони регресс и сохрани подробный отчёт
+```
+
+Модель добавит флаг записи отчёта (JSON или Allure) — потом по нему можно листать историю прогонов, видеть длительности шагов, открывать прикреплённые скриншоты.
+
+Allure — стандартный визуальный отчёт с категориями падений, графиками, таймлайном. Чтобы посмотреть отчёт после прогона:
+
+```bash
+# Allure CLI устанавливается отдельно (npm install -g allure-commandline)
+allure serve allure-results
+```
+
+### Категории падений в Allure
+
+Без дополнительной настройки Allure складывает все упавшие тесты в один общий список «Defects». Если в прогоне упало 15 тестов, не сразу понятно, что из этого — пятнадцать разных проблем или одна и та же ошибка (например, нехватка лицензии на стенде), которая зацепила пятнадцать тестов подряд.
+
+Чтобы Allure группировал падения по причинам, рядом с тестами кладётся каталог `_allure/` с файлом `categories.json`. Подчёркивание в имени каталога — чтобы он не воспринимался как папка с тестами; раннер копирует его содержимое в отчёт.
+
+```
+tests/моя-конфигурация/
+ _allure/
+ categories.json # классификация падений
+ environment.properties # необязательно: URL, версия 1С, ветка git
+ executor.json # необязательно: метаданные сборки CI
+ _hooks.mjs
+ 01-вход/
+ ...
+```
+
+`categories.json` — это список регулярных выражений, по которым ошибка теста относится к той или иной группе:
+
+```json
+[
+ { "name": "Нехватка лицензий 1С",
+ "matchedStatuses": ["failed", "broken"],
+ "messageRegex": ".*Не обнаружено свободной лицензии.*" },
+ { "name": "Ошибка приложения 1С",
+ "matchedStatuses": ["failed"],
+ "messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка).*" },
+ { "name": "Элемент не найден",
+ "matchedStatuses": ["failed"],
+ "messageRegex": ".*(clickElement|fillFields|selectValue).*not found.*" },
+ { "name": "Превышен лимит времени теста",
+ "matchedStatuses": ["failed", "broken"],
+ "messageRegex": "Timeout \\(\\d+ms\\)" },
+ { "name": "Несовпадение ожидания и факта",
+ "matchedStatuses": ["failed"],
+ "messageRegex": "(Expected|AssertionError).*" }
+]
+```
+
+Когда вы попросите модель в первый раз настроить регресс, она положит шаблонный `categories.json` со стандартными классами. По мере того как вы будете находить новые типичные причины падений (например, специфичные для вашего расширения тексты ошибок), категории дополняются.
+
+В виджете «Categories» итогового отчёта вы увидите примерно так:
+
+```
+Нехватка лицензий 1С — 12 падений
+Ошибка приложения 1С — 2 падения
+Несовпадение ожидания и факта — 1 падение
+```
+
+— и сразу понятно, что 12 падений — это один стенд-баг, а двумя «ошибками приложения» нужно разобраться по существу.
+
+Помимо `categories.json` в каталог `_allure/` можно положить ещё два стандартных файла:
+
+- **`environment.properties`** — список `ключ=значение` (URL базы, версия платформы 1С, имя ветки git, номер сборки). Покажется в отчёте в виджете «Environment». Полезно, когда регресс гоняется на нескольких стендах или после каждого билда — видно, на чём именно был получен результат. Этот файл удобно генерировать прямо в подготовке стенда (`_hooks.mjs`), а не держать статичной копией.
+- **`executor.json`** — метаданные системы сборки: ссылка на Jenkins-задачу, идентификатор запуска GitHub Actions и т.д. Нужен только если регресс запускается на сервере сборки. При локальном прогоне ничего класть не надо.
+
+### Прогон части набора
+
+```
+> Прогони только тесты по поступлениям товаров
+> Прогони только базовые проверки
+> Прогони только упавший вчера тест с проведением накладной
+```
+
+Модель выберет нужное подмножество — по папке, по тегу или по имени теста.
+
+### Принудительная пересборка стенда
+
+Если хотите, чтобы перед прогоном база восстановилась с нуля:
+
+```
+> Прогони регресс с полной пересборкой стенда
+```
+
+Это передаст в подготовку флаг типа `--rebuild-stand` — `_hooks.mjs` пересоздаст базу из эталона. Полезно после крупных правок или если подозреваете, что предыдущие прогоны загрязнили данные.
+
+## Что делать, когда тест упал
+
+Модель проанализирует падение и отнесёт его к одной из трёх категорий:
+
+1. **Ошибка в самом тесте.** Например, переименовали реквизит — тест ищет старое имя поля. Решение: модель обновит тест.
+2. **Ошибка в приложении.** Это и есть то, ради чего регресс существует: что-то поменялось в конфигурации, и сценарий, который раньше работал, теперь не отрабатывает. Модель опишет, что именно произошло, со скриншотом и трассировкой стека 1С, если ошибка была серверной.
+3. **Нестабильность стенда.** Apache не ответил, не освободилась лицензия, база отвалилась. Это лечится не правкой теста, а починкой подготовки стенда в `_hooks.mjs` или, реже, повторным прогоном с одним повтором.
+
+Просите модель не «исправь упавший тест», а «разберись с падением» — иначе она может молча подкрутить ожидание под текущее поведение, замаскировав настоящий баг.
+
+## Полезные подробности
+
+### Тестовые данные
+
+В прикладном решении обычно нужны какие-то стартовые данные: пара контрагентов, номенклатура, заведённые организации. Их кладём не в каждый тест, а один раз в подготовку стенда (`_hooks.mjs`) — после восстановления базы загружаются эталонные данные, на которых работают все тесты.
+
+Если конкретному тесту нужны свои данные (например, документ, который мы будем редактировать), он создаёт их сам в начале и убирает в конце.
+
+### Имена документов и уникальность
+
+Тесты прогоняются многократно. Если тест создаёт документ «Накладная-Тест», следующий прогон может натолкнуться на старую запись. Решение — добавлять к имени метку времени:
+
+```js
+const метка = 'Тест-' + Date.now();
+await fillFields({ 'Комментарий': метка });
+// ...
+const t = await readTable();
+assert.tableHasRow(t, r => r['Комментарий'] === метка);
+```
+
+Модель это делает автоматически, но если правите тест руками — держите в голове.
+
+### Видео при падении
+
+Можно включить запись видео всех тестов — тогда при падении прикладывается не только скриншот, но и MP4 со всей сессией:
+
+```
+> Прогони регресс с записью видео
+```
+
+Размер прогона при этом растёт (на 2-3 минутах теста выходит 5-10 МБ), но при отладке сложного падения видео экономит кучу времени.
+
+### Многоязычные конфигурации
+
+Если у вас есть конфигурация с командами и реквизитами на нескольких языках, тесты пишутся под один язык (как правило, тот, в котором ведётся работа в проде). При смене языка интерфейса в браузере тесты не пройдут — модель видит другие подписи кнопок.
+
+## Где смотреть дальше
+
+- API браузера, которое вызывают тесты — [SKILL.md](../.claude/skills/web-test/SKILL.md).
+- Подробная инструкция для модели по написанию тестов (на английском, технический документ) — [.claude/skills/web-test/regress.md](../.claude/skills/web-test/regress.md).
+- Интерактивный режим без тестов — [web-test-guide.md](web-test-guide.md).
+- Запись видеоинструкций — [web-test-recording-guide.md](web-test-recording-guide.md).
diff --git a/docs/web-test-runner-spec.md b/docs/web-test-runner-spec.md
deleted file mode 100644
index 82288dad..00000000
--- a/docs/web-test-runner-spec.md
+++ /dev/null
@@ -1,1051 +0,0 @@
-# web-test runner: спецификация
-
-Версия: 0.2
-Дата: 2026-05-13 (последний sync)
-
-## Обзор
-
-Единый механизм регрессионного тестирования веб-клиента 1С.
-Два сценария использования, один инструмент:
-
-1. **Внутренний регресс** -- тестирование API browser.mjs для безопасного рефакторинга
-2. **Пользовательский регресс** -- тестирование 1С-приложений (доработанных типовых или разработанных с нуля)
-
-Принцип: если удобно для пользовательского регресса, подходит и для внутреннего.
-
-Паттерны следуют конвенциям Playwright Test (обёртки шагов, хуки, утверждения).
-
----
-
-## 1. Командная строка
-
-```
-node run.mjs test [url] [флаги]
-```
-
-| Флаг | По умолчанию | Описание |
-|------|-------------|----------|
-| `--tags=smoke,crud` | (все) | Фильтр тестов по тегам (пересечение) |
-| `--grep=pattern` | (все) | Фильтр тестов по имени (регулярное выражение) |
-| `--bail` | false | Остановиться при первом падении |
-| `--retry=N` | 0 | Повторить упавшие тесты N раз |
-| `--timeout=ms` | 30000 | Таймаут на тест (мс) |
-| `--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 | Записывать видео для каждого теста (mp4 в `--report-dir`) |
-| `-- ` | -- | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) |
-
-URL необязателен, если в каталоге тестов есть `webtest.config.mjs`. CLI URL переопределяет URL дефолтного контекста.
-
-### Режим выполнения
-
-In-process (не через HTTP). Раннер:
-1. Загружает конфиг (если есть).
-2. Обнаруживает файлы `*.test.mjs`, читает каждый, извлекает метаданные.
-3. Фильтрует по `--tags`/`--grep`/`only`. Параметризованные тесты разворачиваются.
-4. Запускает браузер и default-контекст (`chromium.launch()` или `launchPersistentContext`
- в зависимости от `isolation`).
-5. Тесты выполняются последовательно **в алфавитном порядке имён файлов**
- (внутри файла — в порядке экспорта).
-6. Для каждого теста: лениво создаёт нужные BrowserContext-ы (`ensureContext`),
- переключает активный, прогоняет хуки и тело, делает встроенный reset.
-7. По завершении: финальный teardown контекстов с `beforeCloseContext`-хуками,
- `disconnect()`, `cleanup()`.
-
----
-
-## 2. Формат тест-модуля
-
-Каждый файл `*.test.mjs` -- ES-модуль.
-
-### Экспорты
-
-| Экспорт | Тип | Обязателен | По умолчанию | Описание |
-|---------|-----|-----------|-------------|----------|
-| `name` | `string` | да | -- | Читаемое имя теста |
-| `default` | `async function(ctx)` | да | -- | Тело теста |
-| `tags` | `string[]` | нет | `[]` | Теги для фильтрации |
-| `timeout` | `number` | нет | 30000 | Таймаут теста (мс) |
-| `skip` | `boolean \| string` | нет | false | Пропустить тест (строка = причина) |
-| `only` | `boolean` | нет | false | Запустить только этот тест (отладка) |
-| `context` | `string` | нет | defaultContext | Имя контекста из конфига |
-| `contexts` | `string[]` | нет | -- | Мульти-пользовательский процессный тест |
-| `params` | `object[]` | нет | -- | Параметризация (будущее) |
-| `setup` | `async function(ctx)` | нет | -- | Подготовка перед тестом |
-| `teardown` | `async function(ctx)` | нет | -- | Очистка после теста (выполняется всегда) |
-
-### Пример: тест с одним контекстом
-
-```js
-export const name = 'CRUD справочника Контрагенты';
-export const tags = ['smoke', 'crud', 'catalog'];
-export const timeout = 45000;
-
-export default async function({ navigateSection, openCommand, clickElement,
- fillFields, readTable, closeForm, getFormState, assert, step, log }) {
-
- await step('Открыть список', async () => {
- await navigateSection('Склад');
- await openCommand('Контрагенты');
- });
-
- await step('Создать элемент', async () => {
- await clickElement('Создать');
- await fillFields({ 'Наименование': 'Тест-' + Date.now() });
- await clickElement('Записать и закрыть');
- });
-
- await step('Проверить в списке', async () => {
- const table = await readTable();
- assert.tableHasRow(table, r => r['Наименование']?.startsWith('Тест-'));
- log('Элемент найден в списке');
- });
-}
-```
-
-### Пример: мульти-контекстный процессный тест
-
-Рекомендация: латинский ID контекста + кириллический `displayName` в
-`webtest.config.mjs.contexts..displayName` (см. §7).
-
-```js
-export const name = 'Согласование приходной накладной';
-export const contexts = ['clerk', 'manager'];
-export const tags = ['process'];
-
-export default async function({ clerk, manager, step }) {
-
- await step('Кладовщик создаёт накладную', async () => {
- await clerk.navigateSection('Склад');
- await clerk.openCommand('Приходные накладные');
- await clerk.clickElement('Создать');
- await clerk.fillFields({ 'Контрагент': 'ООО Поставщик' });
- await clerk.clickElement('Записать');
- });
-
- await step('Менеджер утверждает', async () => {
- await manager.navigateSection('Согласование');
- await manager.openCommand('На утверждении');
- await manager.clickElement('ООО Поставщик', { dblclick: true });
- await manager.clickElement('Утвердить');
- });
-
- await step('Освобождаем контекст clerk', async () => {
- await manager.closeContext('clerk'); // освободить лицензию 1С
- });
-}
-```
-
----
-
-## 3. Объект контекста
-
-Каждая тестовая функция получает объект контекста `ctx`:
-
-### API браузера (все экспорты browser.mjs)
-
-Все функции обёрнуты авто-обнаружением ошибок (как в `executeScript`):
-- При модальной/всплывающей ошибке 1С: скриншот + `fetchErrorStack` + throw
-- Обёрнутые ACTION_FNS: `clickElement`, `fillFields`, `fillField`, `selectValue`,
- `fillTableRow`, `deleteTableRow`, `openCommand`, `navigateSection`,
- `navigateLink`, `openFile`, `closeForm`, `filterList`, `unfilterList`
-
-Полный список доступных функций:
-
-**Навигация:** `navigateSection`, `openCommand`, `switchTab`, `navigateLink`, `openFile`
-**Состояние:** `getFormState`, `getPageState`, `getSections`, `getCommands`
-**Таблицы:** `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`, `showTitleSlide`, `hideTitleSlide`,
-`showImage`, `hideImage`, `highlight`, `unhighlight`, `setHighlight`, `isHighlightMode`
-**Утилиты:** `screenshot`, `wait`, `getPage`, `getSession`
-
-### Тестовые утилиты
-
-- `step(name, fn)` -- обёртка шага (см. раздел 4)
-- `assert.*` -- хелперы утверждений (см. раздел 5)
-- `log(...args)` -- добавить в вывод теста
-
-### Метаданные теста (`ctx.testInfo`)
-
-Декларативная информация о текущем тесте. Раннер выставляет `ctx.testInfo`
-перед каждой попыткой (до `beforeEach`), хук и тело теста могут читать.
-Не предназначено для мутации.
-
-```js
-ctx.testInfo = {
- name, // 'Навигация по разделам' (с подставленными params)
- file, // '01-navigation.test.mjs' (basename)
- filePath, // '01-navigation.test.mjs' (relative к testDir)
- tags, // ['nav', 'smoke']
- timeout, // 60000 (ms)
- attempt, // 1..maxAttempts (1-based)
- maxAttempts, // 1 + retry
- param, // { ... } | undefined (для export const params)
- contexts: { // объект, всегда 1+ ключей; зеркалит config.contexts
- a: { url, isolation, ...customFields },
- b: { ... },
- },
- primaryContext, // 'a' — имя контекста, активного на входе в тест
- // (= t.context для single, t.contexts[0] для multi)
-}
-```
-
-Доступ к специфике контекста: `testInfo.contexts[testInfo.primaryContext].displayName`.
-`primaryContext` — декларация теста, не зависит от runtime-состояния
-`getActiveContext()` (которое может меняться внутри теста).
-
-### Результат теста в afterEach (`ctx.testResult`)
-
-Только в `afterEach`. До запуска теста — `null`. После — заполняется
-раннером перед вызовом хука:
-
-```js
-ctx.testResult = {
- status, // 'passed' | 'failed'
- duration, // ms
- attempts, // фактически выполнено попыток (1..maxAttempts)
- error, // { message, step?, screenshot? } | null
- steps, // массив step-результатов
-}
-```
-
-### Мульти-контекст
-
-При `export const contexts = ['a', 'b']`:
-- `ctx.a` и `ctx.b` -- отдельные объекты контекста, каждый с полным API браузера
-- `ctx.step` и `ctx.assert` остаются на верхнем уровне
-
----
-
-## 4. step(name, fn) -- обёртка шага
-
-```js
-await step('Имя шага', async () => {
- // тело шага
-});
-```
-
-Поведение:
-- Записывает метку `start` перед `fn()`
-- Записывает метку `stop` после `fn()` (успех или ошибка)
-- При ошибке: устанавливает `status: 'failed'`, прикрепляет сообщение, пробрасывает исключение
-- При успехе: устанавливает `status: 'passed'`
-- Если стратегия скриншотов `every-step`: делает скриншот после `fn()`
-- Вложенные шаги поддерживаются (шаг внутри шага)
-- Напрямую маппится на шаги Allure
-
-Структура данных шага (для отчётов):
-
-```js
-{
- name: 'Имя шага',
- start: 1712345678000, // мс от эпохи
- stop: 1712345679200,
- status: 'passed' | 'failed',
- error: 'сообщение' | undefined,
- screenshot: 'путь' | undefined,
- steps: [] // вложенные шаги
-}
-```
-
-Реализация (~15 строк):
-
-```js
-async function step(name, fn) {
- const s = { name, start: Date.now(), status: 'passed', steps: [] };
- const parent = currentSteps;
- parent.push(s);
- const prev = currentSteps;
- currentSteps = s.steps;
- try {
- await fn();
- } catch (e) {
- s.status = 'failed';
- s.error = e.message;
- throw e;
- } finally {
- s.stop = Date.now();
- currentSteps = prev;
- }
-}
-```
-
----
-
-## 5. Утверждения (assertions)
-
-Простые хелперы утверждений. Без зависимостей. Бросают `AssertionError` со
-свойствами `.actual`, `.expected`, `.message`.
-
-### Общие
-
-```js
-assert.ok(value, msg) // истинность
-assert.equal(actual, expected, msg) // ===
-assert.notEqual(actual, expected, msg) // !==
-assert.deepEqual(actual, expected, msg) // сравнение через JSON
-assert.includes(haystack, needle, msg) // string/array .includes()
-assert.match(string, regex, msg) // проверка регулярным выражением
-assert.throws(asyncFn, msg) // ожидает исключение
-```
-
-### Специфичные для 1С
-
-```js
-assert.formHasField(state, fieldName, msg)
-// проверяет наличие state.fields[fieldName]
-
-assert.formTitle(state, expected, msg)
-// проверяет state.title === expected (или includes)
-
-assert.tableHasRow(table, predicate, msg)
-// predicate: объект (частичное совпадение) или функция
-// объект: assert.tableHasRow(table, { 'Наименование': 'Тест' })
-// функция: assert.tableHasRow(table, r => r['Сумма'] > 100)
-
-assert.tableRowCount(table, expected, msg)
-// проверяет table.rows.length === expected
-
-assert.noErrors(state, msg)
-// проверяет !state.errors
-```
-
----
-
-## 6. Хуки
-
-Все хуки определяются в `_hooks.mjs` в корне каталога тестов.
-
-### Два уровня
-
-**Инфраструктурный уровень** (без браузера):
-- `prepare({ hookArgs, log, config })` -- до подключения (восстановление БД, публикация, загрузка данных)
-- `cleanup({ hookArgs, log, config })` -- после отключения (удаление публикации, очистка)
-
-Поля:
-- `hookArgs: string[]` -- всё что в командной строке передано после разделителя `--`,
- без интерпретации со стороны раннера. Хук парсит сам (см. §6.1 ниже).
-- `log: (...args) => void` -- функция логирования раннера (структурированный вывод
- с префиксом `[hooks]`). Использовать вместо `console.log` чтобы не ломать формат отчёта.
-- `config: object` -- разобранный `webtest.config.mjs` (URL контекстов, isolation, etc.).
-
-**Тестовый уровень** (с контекстом браузера):
-- `beforeAll(ctx)` -- после подключения, перед первым тестом
-- `afterAll(ctx)` -- после последнего теста, до отключения
-- `beforeEach(ctx)` -- перед каждым тестом. На входе уже доступен `ctx.testInfo` (см. §3).
-- `afterEach(ctx)` -- после каждого теста. Дополнительно доступен `ctx.testResult`
- с результатом завершившегося теста (status/duration/error/...).
-
-**Контекстный уровень** (на каждый browser-контекст, lifecycle = создан → удалён):
-- `afterOpenContext(ctx, name, spec)` -- сразу после успешного `createContext`.
- `spec` -- запись из `config.contexts[name]` со всеми custom-полями (`displayName`,
- `url`, `isolation`, ...). Полезно: инжект persistent overlay/badge,
- preload-навигация для контекста, регистрация телеметрии.
-- `beforeCloseContext(ctx, name, spec)` -- перед `closeContext` (контекст ещё
- активен и работает). Полезно: финальный flush, сбор метрик, последний скриншот.
- Срабатывает как при явном `ctx.closeContext(name)` из теста, так и в
- финальном teardown раннера перед `disconnect`.
-
-`closeContext(name)` валиден только когда `name !== getActiveContext()` -- иначе
-бросает. В scoped API (`ctx.a.closeContext('b')`) это естественно: scoped-обёртка
-сначала `setActiveContext('a')`, потом close `'b'` -- target всегда не активен.
-
-### Порядок выполнения
-
-```
-prepare() // без браузера (восстановление БД, публикация)
- browser.launch() // запуск процесса браузера
- createContext(default) // первый контекст создан
- afterOpenContext(ctx, default) // hook: контекст готов
- beforeAll(ctx) // браузер готов, default-контекст создан
- [lazy ensureContext(name)] // для multi-context тестов
- afterOpenContext(ctx, name)
- beforeEach(ctx)
- test.setup(ctx) // подготовка теста
- test.default(ctx) // тело теста (может вызвать ctx.closeContext)
- [при ctx.closeContext(x)]: beforeCloseContext(ctx, x) → close(x)
- test.teardown(ctx) // очистка теста (всегда)
- afterEach(ctx) // всегда
- [встроенный сброс] // всегда (для каждого живого контекста теста)
- ...следующий тест...
- afterAll(ctx)
- [для каждого оставшегося контекста]: beforeCloseContext → closeContext
- browser.close() // финальный disconnect
-cleanup() // без браузера (удаление публикации)
-```
-
-### Встроенный сброс состояния
-
-После каждого теста (после `afterEach`) раннер гарантирует чистое состояние:
-
-```js
-await dismissPendingErrors();
-while (есть открытые формы) {
- await closeForm({ save: false });
-}
-```
-
-Это гарантирует, что каждый тест стартует с чистого рабочего стола,
-независимо от того, как завершился предыдущий (падение, таймаут, ошибка утверждения).
-
-### Пример _hooks.mjs
-
-```js
-import { execSync } from 'child_process';
-
-export async function prepare({ hookArgs, log, config }) {
- // Простой парсер ad-hoc флагов: hookArgs приходит как есть, без интерпретации
- // раннером (см. §6.1 ниже).
- const force = hookArgs.includes('--rebuild-stand');
- log('preparing stand, force=', force);
- execSync('powershell.exe -File scripts/restore-db.ps1');
- execSync('powershell.exe -File scripts/publish.ps1');
-}
-
-export async function cleanup({ log }) {
- log('cleaning up stand');
- execSync('powershell.exe -File scripts/unpublish.ps1');
-}
-
-export async function beforeAll(ctx) {
- // По умолчанию 1С после входа уже показывает дефолтную секцию — навигация
- // в beforeAll обычно не нужна. Хук удобен для счётчиков, телеметрии,
- // общего setup'а который должен случиться один раз для всего прогона.
-}
-
-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 раннера.
-}
-```
-
-### 6.1. Проброс пользовательских флагов через `--`
-
-Раннер не знает о пользовательских флагах хуков. Чтобы хуки получили ad-hoc
-параметры без правки `webtest.config.mjs` или окружения, используется стандартная
-shell-конвенция `--` (как у `npm`, `cargo`, `pytest`): всё что идёт после `--`
-в CLI раннера передаётся в `prepare`/`cleanup` через поле `hookArgs: string[]`
-без интерпретации.
-
-```
-node run.mjs test tests/web-test/ --bail -- --rebuild-stand --reload-data
- └─ runner ─┘ └──── hookArgs ────────────┘
-```
-
-В этом примере раннер получает `--bail`, а `hookArgs` хуков становится
-`['--rebuild-stand', '--reload-data']`. Парсинг этого массива — ответственность
-хуков.
-
-Если разделитель `--` не указан, `hookArgs` — пустой массив. Это позволяет
-раннеру и хукам эволюционировать независимо: новый builtin-флаг раннера
-никогда не пересечётся с пользовательским.
-
----
-
-## 7. Файл конфигурации
-
-`webtest.config.mjs` в корне каталога тестов. Необязателен -- если отсутствует,
-URL должен быть передан через CLI.
-
-```js
-export default {
- // Контексты: именованные URL для разных пользователей/ролей.
- // Рекомендация: латинский ID контекста (`clerk`, `manager`) + кириллический
- // `displayName` для UI/слайдов. Любые custom-поля пробрасываются как есть
- // и доступны хукам через `ctx.testInfo.contexts[name]` (см. §3).
- contexts: {
- clerk: { url: 'http://localhost/app-clerk/ru_RU', displayName: 'Кладовщик' },
- manager: { url: 'http://localhost/app-manager/ru_RU', displayName: 'Менеджер' },
- admin: { url: 'http://localhost/app-admin/ru_RU', displayName: 'Админ' },
- },
- defaultContext: 'clerk',
-
- // Значения по умолчанию (переопределяются флагами CLI)
- timeout: 30000,
- retries: 0,
- screenshot: 'on-failure', // 'every-step' | 'off'
- record: false,
-
- // Allure severity policy (опционально). Inverted map: уровень → [теги].
- // Резолв см. §9 «Severity».
- severity: {
- critical: ['smoke', 'multi-context'],
- minor: ['recording'],
- // blocker / trivial — необязательны, можно опустить
- },
- defaultSeverity: 'normal', // если ничего не подошло
-};
-```
-
-`severity` валидируется при загрузке конфига:
-- ключи — только из `blocker|critical|normal|minor|trivial`;
-- значение каждого ключа — массив тегов;
-- тег не может быть в двух bucket'ах одновременно (явная ошибка с указанием конфликта);
-- `defaultSeverity` — из стандартного набора.
-
-При нарушении любого правила раннер `die`-ает с понятным сообщением до запуска тестов.
-
-Кириллица в ID контекстов работает, но смешанный регистр затрудняет ergonomics
-(`testInfo.contexts.кладовщик.displayName` vs `testInfo.contexts.clerk.displayName`).
-Рекомендуем разделять технический ID и человекочитаемое имя.
-
-**Упрощённая форма** (один контекст, без именованных):
-
-```js
-export default {
- url: 'http://localhost/app/ru_RU',
- timeout: 30000,
-};
-```
-
-Флаги CLI всегда переопределяют значения конфига.
-
----
-
-## 8. Контексты
-
-### Механизм: Playwright BrowserContext
-
-Один процесс браузера (`chromium.launch()`), несколько изолированных контекстов.
-Каждый контекст -- отдельная сессия (куки, авторизация, состояние страницы).
-
-```
-browser (один процесс chromium)
- ├─ BrowserContext "кладовщик" → page → http://localhost/app-clerk/ru_RU
- ├─ BrowserContext "менеджер" → page → http://localhost/app-mgr/ru_RU
- └─ BrowserContext "админ" → page → http://localhost/app-admin/ru_RU
-```
-
-Преимущества:
-- **Мгновенное переключение** между пользователями (смена активного `page`)
-- **Состояние сохраняется** -- переключились на менеджера и обратно, у кладовщика
- все формы остались открытыми, ничего не потеряно
-- **Нет переподключений** -- каждая сессия живёт независимо
-- **Один процесс** -- экономия ресурсов по сравнению с несколькими браузерами
-- **Стандартный паттерн** Playwright для мульти-пользовательских сценариев
-
-### Одиночный контекст (по умолчанию)
-
-Большинство тестов. Один BrowserContext, один пользователь.
-Тест получает плоский контекст со всем API.
-
-```js
-export const context = 'кладовщик'; // необязательно, используется defaultContext
-export default async function({ clickElement, fillFields, ... }) { }
-```
-
-### Порядок выполнения и переключение контекста
-
-Раннер НЕ группирует тесты по контексту. Порядок выполнения — алфавитный
-по именам файлов (плюс порядок экспорта внутри файла). Для каждого теста:
-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()`.
-
-### Мульти-контекст (процессные тесты)
-
-```js
-export const contexts = ['кладовщик', 'менеджер'];
-export default async function({ кладовщик, менеджер, step, assert }) { }
-```
-
-Каждый именованный контекст -- полноценный объект API со своим `page`.
-Тест оркестрирует переключение между пользователями.
-Состояние каждого пользователя сохраняется между переключениями:
-
-```js
-await step('Кладовщик создаёт документ', async () => {
- await кладовщик.openCommand('Приходные накладные');
- await кладовщик.clickElement('Создать');
- await кладовщик.fillFields({ 'Контрагент': 'ООО Поставщик' });
- await кладовщик.clickElement('Записать');
- // кладовщик стоит на форме документа
-});
-
-await step('Менеджер утверждает', async () => {
- await менеджер.navigateSection('Согласование');
- await менеджер.clickElement('Утвердить');
-});
-
-await step('Кладовщик проверяет статус', async () => {
- // страница кладовщика ТА ЖЕ -- форма открыта, навигация не нужна
- const state = await кладовщик.getFormState();
- assert.equal(state.fields['Статус']?.value, 'Утверждён');
-});
-```
-
-### Реализация в browser.mjs
-
-`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`,
-не зная про множественность контекстов.
-
-Публичный контекстный 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` бросает явную ошибку.
-
----
-
-## 9. Отчёты
-
-### JSON (нативный, по умолчанию)
-
-```json
-{
- "runner": "web-test",
- "url": "http://localhost/app/ru_RU",
- "startedAt": "2026-04-05T10:00:00.000Z",
- "finishedAt": "2026-04-05T10:05:30.000Z",
- "duration": 330.0,
- "summary": {
- "total": 25,
- "passed": 23,
- "failed": 1,
- "skipped": 1
- },
- "tests": [
- {
- "name": "CRUD справочника Контрагенты",
- "file": "02-catalog-crud.test.mjs",
- "tags": ["smoke", "crud"],
- "contexts": ["clerk"],
- "status": "passed",
- "duration": 12.3,
- "attempts": 1,
- "steps": [
- {
- "name": "Открыть список",
- "start": 1712345678000,
- "stop": 1712345679200,
- "status": "passed",
- "steps": []
- }
- ],
- "output": "Элемент найден в списке",
- "error": null,
- "screenshot": null
- },
- {
- "name": "Обязательное поле",
- "file": "10-validation.test.mjs",
- "tags": ["validation"],
- "contexts": ["clerk"],
- "status": "failed",
- "duration": 8.1,
- "attempts": 2,
- "steps": [
- {
- "name": "Сохранить пустую форму",
- "start": 1712345700000,
- "stop": 1712345708100,
- "status": "failed",
- "error": "Ожидалось модальное окно ошибки, но форма сохранилась"
- }
- ],
- "output": "",
- "error": {
- "message": "Ожидалось модальное окно ошибки, но форма сохранилась",
- "step": "Сохранить пустую форму",
- "screenshot": "error-shot-10.png"
- },
- "screenshot": "error-shot-10.png"
- }
- ]
-}
-```
-
-### Allure (`--format=allure --report-dir=allure-results/`)
-
-Отдельные JSON-файлы для каждого теста в каталоге `allure-results/`:
-
-```json
-{
- "uuid": "сгенерированный-uuid",
- "name": "CRUD справочника",
- "fullName": "02-catalog-crud.test.mjs",
- "status": "passed",
- "stage": "finished",
- "start": 1712345678000,
- "stop": 1712345690300,
- "labels": [
- { "name": "tag", "value": "smoke" },
- { "name": "tag", "value": "crud" }
- ],
- "steps": [
- {
- "name": "Открыть список",
- "status": "passed",
- "start": 1712345678000,
- "stop": 1712345679200,
- "steps": []
- }
- ],
- "attachments": [
- {
- "name": "Скриншот при падении",
- "source": "uuid-attachment.png",
- "type": "image/png"
- }
- ]
-}
-```
-
-Скриншоты/видео копируются в `allure-results/` с уникальными именами.
-
-#### Авто-эмиссия label-ов
-
-Раннер всегда заполняет следующие labels:
-
-- **`tag`** — по одному label-у на каждый элемент `mod.tags[]`. Бесплатная фильтрация в Allure-дашборде.
-- **`suite`** — `dirname(t.file)`. Тесты в корне `testDir` идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
-- **`severity`** — резолв в порядке приоритета:
- 1. `export const severity = 'critical'` в самом тесте (если задано и значение валидное);
- 2. иначе **max-rank** среди тегов теста (стандартные имена `blocker|critical|normal|minor|trivial` напрямую, либо через `config.severity`-маппинг);
- 3. иначе `config.defaultSeverity` или `'normal'`.
-
- Rank: `blocker(5) > critical(4) > normal(3) > minor(2) > trivial(1)`. Max-wins инвариантен к порядку тегов в `mod.tags`.
-
-Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2).
-
-#### Доп. файлы Allure через `/_allure/`
-
-Раннер ищет каталог `_allure/` рядом с тестами и копирует все его файлы в
-`reportDir` перед генерацией отчёта. Конвенция для статичной настройки
-Allure, которой нет места внутри per-test JSON:
-
-| Файл | Назначение |
-|------|-----------|
-| `categories.json` | Классификация падений по regex (группировка failed-тестов в виджете Categories — «timeout», «license-flake», «1C modal» etc.) |
-| `environment.properties` | `key=value` строки в виджет Environment (URL, версия 1С, ветка git, build-номер) |
-| `executor.json` | CI/CD-метаданные (Jenkins URL, GitHub run-id и т.п.) |
-
-Underscore в имени — параллель `_hooks.mjs` (инфраструктура, не тест).
-Discovery каталог `_allure/` пропускает по общему правилу (`startsWith('_')`).
-Если каталога нет — no-op.
-
-Пример `categories.json` (минимальный):
-```json
-[
- { "name": "Timeout", "messageRegex": "Timeout \\(\\d+ms\\)" },
- { "name": "Assertion", "messageRegex": "(Expected|AssertionError).*" }
-]
-```
-
-Полный пример с 1С-специфичными паттернами — см. `tests/web-test/_allure/categories.json`.
-
-### JUnit XML (`--format=junit`)
-
-```xml
-
-
-
-
-
-
- Стек вызовов...
-
- Скриншот: error-shot-10.png
-
-
-
-```
-
----
-
-## 10. Консольный вывод
-
-```
-web-test -- http://localhost/app/ru_RU
-Запуск 25 тестов из tests/web-test/
-
- ✓ Навигация по разделам (2.1s)
- ✓ CRUD справочника Контрагенты (12.3s)
- ├ Открыть список (1.2s)
- ├ Создать элемент (8.0s)
- └ Проверить в списке (3.1s)
- ✗ Обязательное поле (8.1s)
- ├ Открыть форму (2.0s)
- └ ✗ Сохранить пустую форму (6.1s)
- Ожидалось модальное окно ошибки, но форма сохранилась
- скриншот: error-shot-10.png
- ○ Составной тип (skip: не реализовано)
-
-23 passed, 1 failed, 1 skipped (2m 0.5s)
-```
-
-Для passed-тестов выводится одна строка `✓ name (duration)`. Шаги печатаются
-только для упавших — после строки `✗`, с отступом, плюс сообщение ошибки и
-путь к скриншоту. Полная картина по шагам — в JSON-отчёте (`--report=...`).
-
----
-
-## 11. Скриншоты и видео
-
-### Стратегия скриншотов
-
-| Стратегия | Поведение |
-|-----------|----------|
-| `on-failure` (по умолчанию) | Скриншот при падении теста, прикрепляется к ошибке |
-| `every-step` | Скриншот в конце каждого `step()`, плюс при падении |
-| `off` | Без автоматических скриншотов |
-
-Скриншоты сохраняются в каталог отчёта по шаблону `{индекс-теста}-{имя-шага}.png`.
-
-### Видеозапись
-
-При включённом `--record`:
-- `startRecording()` перед каждым тестом
-- `stopRecording()` после каждого теста
-- Видео сохраняется как `{индекс-теста}-{имя-теста}.mp4`
-- Прикрепляется к отчёту (Allure: вложение видео)
-
----
-
-## 12. Сброс состояния
-
-Встроенный механизм, выполняется после `afterEach` (и `teardown`) каждого теста:
-
-```js
-async function resetState(ctx) {
- // 1. Убрать все ожидающие диалоги ошибок/всплывающие уведомления
- try { await ctx.dismissPendingErrors(); } catch {}
-
- // 2. Закрыть все открытые формы до рабочего стола
- for (let i = 0; i < 10; i++) {
- const state = await ctx.getFormState();
- if (!state.form) break;
- try { await ctx.closeForm({ save: false }); } catch { break; }
- }
-}
-```
-
-Гарантирует, что каждый тест стартует с чистого рабочего стола,
-независимо от того, как завершился предыдущий (падение, таймаут, ошибка утверждения).
-
----
-
-## 13. Параметризация
-
-```js
-export const name = 'Заполнение поля {type}';
-export const params = [
- { type: 'String', field: 'Наименование', value: 'Тест' },
- { type: 'Number', field: 'Цена', value: '100.50' },
- { type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' },
- { type: 'Boolean', field: 'Активен', value: true },
-];
-
-export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
- await fillFields({ [field]: value });
- const state = await getFormState();
- assert.equal(state.fields[field]?.value, String(value));
-}
-```
-
-Параметры разворачиваются в отдельные тесты на этапе discovery. Имя
-формируется подстановкой через шаблон `{key}` в `mod.name`; если шаблона
-нет — суффикс `[index]`. Тест получает `param` вторым аргументом
-(`default(ctx, param)`). В отчётах каждый набор — отдельная запись со
-своим `name` и `param` в testInfo. `ctx.testInfo.param` доступен в теле
-теста и хуках.
-
----
-
-## 14. buildContext()
-
-Общая фабрика контекста, используется и `executeScript()` (для `exec`/`run`/`start`),
-и `cmdTest()` (для `test`).
-
-**Что делает:**
-- Собирает все экспорты `browser.*` в плоский объект.
-- Оборачивает ACTION_FNS авто-обнаружением 1С-ошибок: после каждого вызова
- проверяет `state.errors.modal`/`balloon`, делает скриншот ДО того, как
- `fetchErrorStack` закроет модалку, вызывает `fetchErrorStack` для modal-ошибок,
- бросает исключение со структурированным `err.onecError = { step, args, errors, formState, stack, screenshot }`.
-- Подмешивает заглушки `noRecord` (для функций записи/озвучки в exec-режиме).
-
-**Сигнатура:** `function buildContext({ noRecord = false } = {}) -> object`
-
-**Scoped-вариант** (`buildScopedContext(name)`): тот же `buildContext()`,
-но каждый вызов функции префиксится `await browser.setActiveContext(name)`.
-Используется для мульти-контекстных тестов (`ctx.a`/`ctx.b`).
-
----
-
-## 15. Синтетическая тестовая конфигурация
-
-### Текущие объекты base-config
-
-| Объект | Поля | Форма |
-|--------|------|-------|
-| Справочник Контрагенты | ИНН (String 12), Телефон (String 20) | ФормаЭлемента: 3 поля ввода |
-| Справочник Номенклатура | Артикул (String 25), ЕдиницаИзмерения (String 10) | -- |
-| Перечисление ВидыНоменклатуры | Товар, Услуга, Работа | -- |
-| Документ ПриходнаяНакладная | Контрагент (String); ТЧ Товары (4 колонки) | ФормаДокумента |
-| РН ОстаткиТоваров | Изм: Номенклатура; Рес: Количество, Сумма | -- |
-| РС КурсыВалют | Изм: Валюта; Рес: Курс, Кратность | -- |
-| Константа ОсновнаяВалюта | String 10 | -- |
-| Отчёт ОстаткиТоваров | Схема СКД | -- |
-| Подсистема Склад | все объекты | -- |
-| Роль Кладовщик | права Read/View | -- |
-
-### Что нужно добавить
-
-| Изменение | Зачем (какой API тестируем) |
-|-----------|---------------------------|
-| Номенклатура: +Цена (Number 15.2) | fillFields -- число |
-| Номенклатура: +Активен (Boolean) | fillFields -- флажок |
-| Номенклатура: +ВидНоменклатуры (EnumRef) | fillFields -- ссылка на перечисление |
-| Номенклатура: +ДатаПоступления (Date) | fillFields -- дата |
-| Номенклатура: +Комментарий (String неограниченная) | fillFields -- многострочный текст |
-| Номенклатура: FillChecking на Наименование | Тест ошибки валидации |
-| Номенклатура: hierarchical=true | clickElement expand/collapse |
-| Номенклатура: Форма с 2 вкладками (Основное / Дополнительно) | switchTab |
-| ПриходнаяНакладная.Контрагент -> CatalogRef.Контрагенты | selectValue (ссылочное поле) |
-| +Подсистема Администрирование (КурсыВалют, ОсновнаяВалюта) | navigateSection между разделами |
-| Роль: полные права (не только Read/View) | CRUD без ограничений |
-
-### Способ сборки
-
-Интеграционный тест `build-webtest-config.test.mjs` собирает конфигурацию через
-пайплайн навыков (cf-init -> meta-compile -> form-compile -> ...).
-Результат кэшируется в `.cache/webtest-config/`.
-Первый запуск требует: загрузку в 1С (`db-load-xml`) + веб-публикацию (`web-publish`).
-
----
-
-## 16. Каталог тест-кейсов
-
-Расположение: `tests/web-test/`. По состоянию на 2026-05-13: 19 файлов.
-
-| # | Файл | Теги | Покрытие |
-|---|------|------|----------|
-| 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 (ДеревоНоменклатуры) |
-
-Полный регресс — **19/19** (~9 минут на warm-стенде).
-
-### 16.1. Вложенные каталоги
-
-Discovery (`run.mjs:900`) обходит дерево `testDir` рекурсивно, поэтому
-тесты можно раскладывать по подкаталогам без правок раннера:
-
-```
-tests/web-test/
- sales/
- 01-order-create.test.mjs
- 02-order-post.test.mjs
- warehouse/
- 01-receipt.test.mjs
-```
-
-**Что работает:**
-
-| Аспект | Поведение |
-|--------|-----------|
-| Обнаружение | Рекурсивный walk; файлы/каталоги на `_`/`.` пропускаются |
-| Порядок | `files.sort()` по полному относительному пути (`sales/01` идёт до `warehouse/01`) |
-| `file` в отчёте | `relative(testDir, file)` с `/`, например `sales/01-order-create.test.mjs` |
-| CLI-фильтр по пути | `node run.mjs test tests/web-test/sales/` запустит только подкаталог |
-| Конкретный файл | `node run.mjs test tests/web-test/sales/01-order-create.test.mjs` |
-
-**Что НЕ поддержано** (сознательно, чтобы держать модель простой):
-
-- **Per-folder `_hooks.mjs`.** Раннер ищет `_hooks.mjs` только в корне `testDir`. Подкаталоги свои хуки не получают.
-- **Per-folder `webtest.config.mjs`.** Тоже только в корне.
-- **Suite-концепция в отчётах.** Allure suite labels из дерева каталогов не строятся; группируйте через `tags`.
-- **Per-folder context default.** Каждый тест объявляет `context`/`contexts` сам; от пути контексты не наследуются.
-
-**Конвенции:**
-
-1. **Папки — для организации**, не для механики. Если хочется shared setup для «процесса» — клади в глобальный `_hooks.mjs.beforeAll` или в per-test `setup`/`teardown`.
-2. **Группировку в отчётах** делай через `tags: ['sales']`, не через путь. Это даёт фильтрацию (`--tags=sales`) и работает в Allure/JUnit без дополнительной разметки.
-3. **«Запустить только sales»** — двумя путями: `tests/web-test/sales/` (по каталогу) или `--tags=sales` (по тегу). Оба работают, выбирайте удобный.
-4. **Сортировка по полному пути** означает, что `warehouse/01-x` запустится ПОСЛЕ `sales/02-y`. Для строгого глобального порядка используйте 3-значные префиксы (`010-`/`020-`/...) либо явные теги-фазы.
-
----
-
-## 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-отчёт | 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 |