feat(web-test): бюджеты дедлайнов в конфиг + пробой сброса прерывает контекст

Числа были зашиты в код и подобраны на лёгком синтетическом стенде. На тяжёлом
прикладном решении тот же resetState честно идёт дольше — и упирался бы в чужой
дефолт без возможности его поднять.

- `deadlines: {...}` в webtest.config.mjs переопределяет любой бюджет поштучно.
  Неизвестный ключ или неположительное значение — ошибка до старта прогона:
  опечатка в имени означала бы, что переопределение молча не действует.
- Пробой resetState теперь ПРЕРЫВАЕТ контекст, а не пишет строку и едет дальше.
  После неудавшегося сброса состояние UI неизвестно, и переиспользование слота
  утекало бы грязным состоянием в следующий тест — худший исход плохо подобранного
  бюджета: тихий дрейф вместо видимой ошибки. Теперь слишком тесный бюджет стоит
  перезапуска контекста, но никогда — неверного результата теста.

Проверено: опечатка ключа → внятная ошибка с перечнем допустимых; deadlines
{resetState:1} → пробой виден строкой, контекст прерван, следующий тест зелёный.
check.mjs 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-15 18:03:10 +03:00
parent 9352d28008
commit 57c101ef3c
3 changed files with 72 additions and 6 deletions
@@ -23,6 +23,9 @@ export async function cmdTest(rawArgs) {
// and can hang forever against a wedged renderer (page.evaluate has no timeout of its own),
// so none of them may be awaited bare. A breach is always logged — silent swallowing is what
// turned the original incident into a 29-minute mystery.
//
// These defaults are sized for a light stand. A heavy application legitimately needs more —
// override per key via `deadlines: {...}` in webtest.config.mjs rather than editing this.
const D = {
screenshot: 10000,
teardown: 15000,
@@ -139,6 +142,17 @@ export async function cmdTest(rawArgs) {
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
opts.globalTimeout = ownArgs.some(a => a.startsWith('--global-timeout=')) ? opts.globalTimeout : (config.globalTimeout || opts.globalTimeout);
// Per-key deadline overrides. Defaults suit a light stand; a heavy application may honestly
// need longer (a big form's resetState, a slow close). Unknown keys are a typo, not a wish —
// fail fast rather than silently ignoring an override the author believed was in effect.
if (config.deadlines) {
for (const [k, v] of Object.entries(config.deadlines)) {
if (!(k in D)) die(`Invalid deadlines.${k} in config (expected one of: ${Object.keys(D).join(', ')})`);
if (typeof v !== 'number' || !(v > 0)) die(`Invalid deadlines.${k}=${v} (expected a positive number of ms)`);
D[k] = v;
}
}
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
browser.setPreserveClipboard(false);
}
@@ -242,6 +256,24 @@ export async function cmdTest(rawArgs) {
return r;
}
/**
* Reset one context between tests. If the reset breaches its deadline, the slot's UI state is
* unknown — reusing it would leak dirty state into the next test, which is the WORST outcome
* of a badly-sized budget: silent drift instead of a visible error. So destroy it; the next
* test recreates a clean one via ensureContext. A deadline that is merely too tight then costs
* a context relaunch, never a wrong test result.
*/
async function resetOrAbort(cn, ctx) {
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
if (!sw.ok) return false;
const r = await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
if (r.ok) return true;
W.write(` ! context "${cn}" осталось несброшенным — прерываю его, следующий тест получит чистый\n`);
await bounded(browser.abortContext(cn), D.closeContext, `abortContext(${cn})`);
dropLru(lruOrder, cn);
return false;
}
// Bumped for every attempt. A timed-out test's body keeps running — a promise cannot be
// cancelled, so when its pending call finally rejects, its own `finally` would go on to
// drive the UI of whichever test is running by then. Silent cross-test corruption.
@@ -540,9 +572,7 @@ export async function cmdTest(rawArgs) {
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
for (const cn of testContextNames) {
if (!browser.hasContext(cn)) continue;
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
if (!sw.ok) break;
await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
await resetOrAbort(cn, ctx);
}
for (const k of scopedKeys) delete ctx[k];
@@ -633,9 +663,7 @@ export async function cmdTest(rawArgs) {
if (!dead) {
for (const cn of testContextNames) {
if (!browser.hasContext(cn)) continue;
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
if (!sw.ok) break;
await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
await resetOrAbort(cn, ctx);
}
}
for (const k of scopedKeys) delete ctx[k];
+34
View File
@@ -473,6 +473,13 @@ export default {
screenshot: 'on-failure', // 'every-step' | 'off'
record: false,
// Бюджеты служебных операций (мс). Дефолты рассчитаны на лёгкий стенд; тяжёлому
// прикладному решению честно нужно больше — переопределяются поштучно.
deadlines: {
resetState: 60000, // встроенный сброс на тяжёлых формах
closeContext: 30000,
},
// Дефолтный тег-фильтр. Применяется только если CLI не передал --tags.
// Удобно для сценариев «прогон по умолчанию = smoke», при этом --tags=full
// (или --tags=) с CLI прозрачно перекрывает.
@@ -1031,6 +1038,33 @@ Timeout (30000ms) — verdict: hang (renderer unresponsive, browser alive)
- Весь путь очистки (скриншот, `teardown`, `afterEach`, сброс, запись/остановка видео, закрытие контекстов,
`disconnect`) ограничен дедлайнами. Пробой дедлайна печатается строкой `! <операция>: timed out after Nms`
и прогон едет дальше — молча зависнуть путь очистки больше не может.
#### Бюджеты служебных операций (`deadlines`)
Дефолты рассчитаны на лёгкий стенд и переопределяются поштучно ключом `deadlines` в `webtest.config.mjs`
(см. §7). Неизвестный ключ или неположительное значение — ошибка до старта прогона (опечатка в имени ключа
означала бы, что переопределение молча не действует).
| Ключ | Default | Что ограничивает |
|------|---------|------------------|
| `screenshot` | 10000 | Скриншот при падении |
| `teardown` / `afterEach` | 15000 | `teardown` теста / хук `afterEach` |
| `setActive` | 5000 | Переключение активного контекста |
| `resetState` | 20000 | Встроенный сброс состояния (до 10 итераций `getFormState` + `closeForm`) |
| `startRecording` / `stopRecording` | 15000 / 40000 | Видео (у ffmpeg внутри свои 30 с) |
| `closeContext` | 20000 | Закрытие/прерывание контекста |
| `disconnect` | 30000 | Финальное закрытие браузера |
| `hooks` | 120000 | `afterAll` / `cleanup` |
| `abortAll` | 30000 | Весь блок прерывания одного зависшего теста |
| `probe` | 2000 | Пробники рендерера/браузера при таймауте |
`prepare` и `beforeAll` **не ограничены сознательно**: `prepare` пересобирает стенд, и его честная
длительность зависит от размера прикладного решения; его падение обязано оставаться фатальным.
**Пробой `resetState` прерывает контекст** (а не просто пишет строку): состояние UI после неудавшегося
сброса неизвестно, и переиспользование слота утекло бы грязным состоянием в следующий тест — худший исход
для плохо подобранного бюджета. Вместо этого слот уничтожается, следующий тест получает чистый через
`ensureContext`. Слишком тесный бюджет стоит перезапуска контекста, но никогда — неверного результата теста.
- Тело прерванного теста продолжает жить (промис не отменяем). Его поздние вызовы к движку блокируются
с ошибкой `test abandoned (timeout)` — иначе `finally` упавшего теста кликал бы в окне следующего.
+4
View File
@@ -86,6 +86,10 @@ node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-e
- **`contexts.a` / `contexts.b` / `contexts.c`** — независимые 1C-сеансы (разные cookies) на той же URL. `a`,`b` — два «пользователя» мультиконтекст-тестов; `c` задействован только `14-multi-context-routing` и служит топливом для проверки вытеснения пула.
- **`defaultContext: 'a'`** — большинство тестов работают в одном контексте.
- **`isolation: 'tab'`** — вкладки в одном окне (default). Альтернатива `'window'` — отдельный BrowserContext (полная изоляция cookies).
- **`deadlines: {...}`** — бюджеты служебных операций (сброс состояния, закрытие контекста, скриншот…).
Дефолты рассчитаны на лёгкий стенд; здесь не переопределяются. Если в выводе появились строки
`! resetState(a): timed out after Nms` — стенд стал тяжелее бюджета, поднимай ключ, а не терпи:
пробой сброса прерывает контекст (следующий тест получит чистый ценой перезапуска).
- **`maxContexts: 2` / `contextPolicy: 'reuse'` / `pinnedContexts: []`** — дай-фуд управления пулом лицензий. Лимит 2 одновременных сеанса: на границе `14 (c) → 15 (a,b)` раннер автоматически вытесняет LRU-контекст `c` (проверяется первым шагом `15-multi-context-handover`). Благодаря лимиту 3 контекста никогда не живут одновременно.
## Env переменные