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
+1
View File
@@ -35,6 +35,7 @@ Exit code: 0 = все прошли, 1 = есть падения.
| `--bail` | Остановиться на первой ошибке |
| `--retry=N` | Перепрогон упавших тестов N раз |
| `--timeout=ms` | Таймаут одного теста (default 30000) |
| `--global-timeout=ms` | Потолок на весь прогон; по истечении — отчёт, освобождение сеансов, выход с кодом 2 |
| `--report=path` | Сохранить машинный отчёт в файл |
| `--report=-` | Машинный отчёт в stdout (прогресс → stderr) |
| `--format=json\|allure\|junit` | Формат отчёта |
@@ -0,0 +1,20 @@
// Fixture — NOT part of the normal suite. `_`-prefixed dirs are skipped by discoverTests'
// walk(), so `test tests/web-test/` never picks this up; `test tests/web-test/_hang` does,
// because the explicitly-passed root is not filtered, only its children are.
//
// Reproduces the reported failure mode: a wedged renderer JS thread. page.evaluate has no
// timeout in Playwright at all, so before the abort work this test hung the whole run forever
// (the reported incident: ~29 min, no progress, report lost).
//
// Expected with the fix: fails at its own timeout with `verdict: hang`, the context is
// aborted (seance released from Node), and 02 below still passes on a fresh context.
export const name = 'hang: заблокированный JS-поток рендерера';
export const tags = ['hang'];
export const timeout = 10000;
export default async function({ getPage, log }) {
const page = await getPage();
log('wedging the renderer main thread — nothing after this line can ever run');
await page.evaluate(() => { while (true) {} });
log('UNREACHABLE');
}
+15
View File
@@ -0,0 +1,15 @@
// Runs right after the hang: proves the run KEEPS GOING and the aborted context is
// recreated lazily with a fresh 1C seance (i.e. the license came back — otherwise the
// fix would only trade a hang for "no free license").
export const name = 'hang: следующий тест работает после прерывания';
export const tags = ['hang'];
export const timeout = 60000;
export default async function({ navigateSection, getPageState, assert, log }) {
const state = await getPageState();
const names = (state.sections || []).map(s => s.name);
log('sections after abort: ' + names.join(', '));
assert.ok(names.length >= 2, 'разделы доступны → сеанс живой, лицензия получена');
const r = await navigateSection('Склад');
assert.ok(r.commands?.length > 0, 'команды раздела читаются');
}
+9
View File
@@ -0,0 +1,9 @@
// Config for the hang fixtures only. Deliberately minimal: one context, no _hooks.mjs
// (the stand must already be published — these fixtures exercise the runner, not the stand).
export default {
contexts: {
a: { url: 'http://localhost:9191/webtest-runner/ru_RU' },
},
defaultContext: 'a',
timeout: 60000,
};