# web-test runner: спецификация

Версия: 0.2
Дата: 2026-05-13 (последний sync)

## Обзор

Единый механизм регрессионного тестирования веб-клиента 1С.
Два сценария использования, один инструмент:

1. **Внутренний регресс** -- тестирование API browser.mjs для безопасного рефакторинга
2. **Пользовательский регресс** -- тестирование 1С-приложений (доработанных типовых или разработанных с нуля)

Принцип: если удобно для пользовательского регресса, подходит и для внутреннего.

Паттерны следуют конвенциям Playwright Test (обёртки шагов, хуки, утверждения).

---

## 1. Командная строка

```
node run.mjs test [url] <dir|file> [флаги]
```

| Флаг | По умолчанию | Описание |
|------|-------------|----------|
| `--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`) |
| `-- <hookArgs...>` | -- | Всё после `--` пробрасывается в `_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.<id>.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<name, slot>`. Переключение между слотами:
`_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 через `<testDir>/_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
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="web-test" tests="25" failures="1" skipped="1" time="330.0">
  <testsuite name="tests/web-test" tests="25" failures="1" skipped="1">
    <testcase name="CRUD справочника" classname="02-catalog-crud.test.mjs" time="12.3"/>
    <testcase name="Обязательное поле" classname="10-validation.test.mjs" time="8.1">
      <failure message="Ожидалось модальное окно ошибки, но форма сохранилась">
        Стек вызовов...
      </failure>
      <system-out>Скриншот: error-shot-10.png</system-out>
    </testcase>
  </testsuite>
</testsuites>
```

---

## 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 |
