feat(web-test): таймаут теста реально прерывает зависший await

Прогон вставал намертво на зависшем Playwright-действии (~29 мин, без движения),
при --format=allure отчёт терялся целиком — результаты писались только в конце.

Promise.race с таймером был и раньше, но не лечил: race не отменяет t.fn (промис
отменить нельзя), а весь путь после него шёл без единого таймаута. Паттерн
`try { await x } catch {}` ловит reject, но не «никогда не завершится» — а
page.evaluate не имеет таймаута в принципе, поэтому заблокированный JS-поток
рендерера вешал раннер навсегда.

Что сделано:

1. Прерывание. При таймауте движок опрашивает контекст (probeContext) и при
   вердикте hang/browser-dead уничтожает зависшее: abortContext закрывает
   страницу с runBeforeUnload:false, повисший await отваливается «Target closed»,
   следующий тест поднимает контекст лениво. Зависший тест не ретраится.

2. Диагноз в отчёте. hang (браузер жив, рендерер не отвечает) против slow
   (просто не уложился → поднять timeout). Разделитель — асимметрия пробников:
   вызов в browser-процесс отвечает за 1 мс при мёртвом рендерере, evaluate — нет.

3. Лицензии. Штатный logout идёт fetch-ем изнутри страницы и на зависшей
   странице невозможен. abortContext шлёт POST /e1cib/logout из Node: замерено —
   сеанс опознаётся seanceId в URL, кук у клиента нет вообще, после запроса
   клиент пишет «сеанс был завершен». Каскад: node → page → соседняя страница.

4. Дедлайны на весь путь очистки (deadline.mjs) — пробой печатается строкой,
   молча зависнуть больше нельзя. Попутно: disconnect слал logout по зависшей
   странице дважды (модульный `page` после multi-context ветки).

5. Инкрементальный Allure: результат теста пишется сразу по его завершении —
   зависание больше не уничтожает уже собранное.

6. --global-timeout: потолок на прогон, работает и внутри зависшего теста
   (неразрешённый промис не блокирует event loop). Коды выхода: 2 — потолок
   сработал, 3 — зависло само сворачивание. Внешний watchdog больше не нужен.

Проверено вживую на стенде: фикстура tests/web-test/_hang (заблокированный
JS-поток) — падение за 17.6с с verdict: hang вместо вечного зависания,
следующий тест зелёный, оба результата в allure-results; --global-timeout
срабатывает посреди зависания и сохраняет отчёт. Полный регресс 25 тестов:
пробоев дедлайнов ноль.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-15 17:37:18 +03:00
parent 3889c7279f
commit 037d000f20
11 changed files with 669 additions and 62 deletions
+78 -1
View File
@@ -25,6 +25,7 @@ node run.mjs test <dir|file>... [флаги]
| `--bail` | false | Остановиться при первом падении |
| `--retry=N` | 0 | Повторить упавшие тесты N раз |
| `--timeout=ms` | 30000 | Таймаут на тест (мс) |
| `--global-timeout=ms` | 0 (выкл) | Потолок на весь прогон (мс). По истечении — отчёт, освобождение сеансов, выход с кодом 2 |
| `--report=path` | (нет) | Записать машинный отчёт в файл (JSON или XML для `--format=junit`) |
| `--report=-` | (нет) | Машинный отчёт в stdout (`-` = stdout); человеческий прогресс уходит в stderr |
| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` |
@@ -42,6 +43,17 @@ URL не передаётся позиционно — он берётся из
- `--format=junit` требует `--report=<path>` (иначе некуда писать XML); иначе — завершение с ошибкой. Значение `-` (stdout) для junit допустимо.
- `--report=-` (stdout) несовместимо с `--format=allure`: allure пишет каталог, а не поток; иначе — завершение с ошибкой.
### Коды выхода
| Код | Значение |
|-----|----------|
| `0` | Все тесты прошли |
| `1` | Есть упавшие тесты |
| `2` | Сработал `--global-timeout`: отчёт записан, сеансы освобождены, прогон свёрнут |
| `3` | Сработал `--global-timeout`, но зависло само сворачивание — процесс убит принудительно |
Коды `2`/`3` отличают «тесты красные» от «стенд не отвечает» — по ним CI может принимать разные решения.
### Потоки вывода (stdout / stderr)
`test` ведёт себя как тест-раннер (jest/pytest/playwright): человеческий отчёт со сводкой в конце идёт в **stdout**. Машинный отчёт (JSON/JUnit) включается отдельно флагом `--report`.
@@ -456,6 +468,7 @@ export default {
// Значения по умолчанию (переопределяются флагами CLI)
timeout: 30000,
globalTimeout: 0, // потолок на весь прогон (мс); 0 = выключен
retries: 0,
screenshot: 'on-failure', // 'every-step' | 'off'
record: false,
@@ -632,6 +645,21 @@ await step('Кладовщик проверяет статус', async () => {
## 9. Отчёты
### Момент записи (инкрементальность)
Отчёт пишется **по мере прохождения**, а не только в конце: зависание или аварийное сворачивание не должны
уничтожать уже собранные результаты.
| Формат | Когда пишется |
|--------|---------------|
| `allure` | Файл `<uuid>-result.json` на каждый тест — сразу по его завершении |
| `json` с `--report=<path>` | Файл перезаписывается целиком после каждого теста (поле `state: 'partial'`) |
| `json` с `--report=-`, `junit` | **Не инкрементальны** — поток/файл пишется один раз в конце; при срабатывании `--global-timeout` пишутся из аварийного обработчика с тем, что успело завершиться |
Поле `state` в JSON-отчёте: `complete` — прогон дошёл до конца, `partial` — промежуточная запись,
`aborted` — прогон свёрнут по `--global-timeout`. Зависший тест попадает в отчёт как обычное падение
с вердиктом (см. §«Таймауты»).
### JSON (нативный, по умолчанию)
```json
@@ -960,9 +988,58 @@ tests/myapp/
### Таймауты
- Глобальный таймаут теста: `mod.timeout` или `config.timeout` или CLI `--timeout=ms`.
- Таймаут теста: `mod.timeout` или `config.timeout` или CLI `--timeout=ms`.
- Таймаут срабатывает на уровне теста (`testFn()` + `setup` + `teardown`), не на уровне отдельного `step` или action.
- При таймауте: текущий step помечается failed, бросается ошибка с сообщением `Timeout (<N>ms)`, далее запускается `afterEach` и встроенный сброс.
- Таймаут **безусловен**: `export const timeout` — это контракт теста, а не пожелание. Если действие ещё
выполняется, оно всё равно прерывается; чтобы разрешить долгую операцию, поднимают `timeout` теста.
#### Диагноз при таймауте (вердикт)
Таймаут не просто помечает тест красным — движок выясняет, **почему** тест не уложился, и кладёт вердикт
в сообщение об ошибке и в trace Allure. Разделитель — асимметрия двух пробников: вызов в browser-процесс
(`cookies()`) отвечает даже тогда, когда JS-поток страницы заблокирован, а `page.evaluate` — нет.
| Вердикт | Что означает | Что делать |
|---------|--------------|------------|
| `hang` | Браузер жив, рендерер не отвечает — JS-поток страницы заблокирован. Ничто, что идёт через рендерер (клики, `evaluate`, скриншот), уже не вернётся | Смотреть, что подвесило клиента; тест прерван, контекст пересоздан |
| `slow` / `slow-network` | Всё живо, тест просто не уложился в свой таймаут (`slow-network` — есть незавершённые запросы) | Поднять `export const timeout` |
| `browser-dead` | Браузер не отвечает целиком | Стенд/окружение |
Пример вывода:
```
Timeout (30000ms) — verdict: hang (renderer unresponsive, browser alive)
context "a" [tab] · renderer probe: timed out at 2000ms · browser probe: ok in 14ms
network: 0 in flight, last event 27.4s ago (312 req / 312 resp)
recovery: context aborted (logout: node, closed: page) — next test recreates it
```
#### Прерывание и восстановление
- `Promise.race` **не отменяет** зависший вызов — промис отменить нельзя. Поэтому при вердиктах
`hang`/`browser-dead` движок **уничтожает** зависшее: `abortContext` закрывает страницу
(`runBeforeUnload:false` — браузер сносит вкладку, не спрашивая её JS), и повисший `await` отваливается
с «Target closed». Следующий тест поднимает контекст заново лениво (`ensureContext`).
- **Освобождение лицензии.** Штатный logout идёт `fetch`-ем изнутри страницы и на зависшей странице
невозможен. Поэтому `abortContext` шлёт `POST /e1cib/logout?seanceId=…` **из Node** — сеанс 1С опознаётся
`seanceId`-ом в URL, кук у клиента нет, так что запрос равносилен «страничному». Каскад: Node → страница →
живая соседняя страница; если все три не удались, в отчёт пишется предупреждение об утечке сеанса.
- На `hang` пропускаются скриншот, `teardown` и встроенный сброс: на мёртвой странице они не могут
преуспеть, а слот всё равно уничтожается. По той же причине зависший тест **не ретраится** (`--retry`
игнорируется для него): заблокированный рендерер — не флейк.
- Весь путь очистки (скриншот, `teardown`, `afterEach`, сброс, запись/остановка видео, закрытие контекстов,
`disconnect`) ограничен дедлайнами. Пробой дедлайна печатается строкой `! <операция>: timed out after Nms`
и прогон едет дальше — молча зависнуть путь очистки больше не может.
- Тело прерванного теста продолжает жить (промис не отменяем). Его поздние вызовы к движку блокируются
с ошибкой `test abandoned (timeout)` — иначе `finally` упавшего теста кликал бы в окне следующего.
#### Глобальный потолок прогона
`--global-timeout=ms` (или `globalTimeout` в конфиге) — потолок на весь прогон. Работает даже когда прогон
стоит внутри зависшего теста: неразрешённый промис не блокирует event loop. По срабатыванию: пишется отчёт,
затем принудительно освобождаются все контексты (лицензии) и закрывается браузер, затем выход с кодом `2`.
Если зависло само сворачивание — выход с кодом `3`. Внешний watchdog для прогона не нужен.
### Повторы