diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs index 86c1814d..b6593fa8 100644 --- a/.claude/skills/web-test/scripts/cli/commands/test.mjs +++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs @@ -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]; diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md index 9671d75d..8e510091 100644 --- a/docs/web-test-regression-spec.md +++ b/docs/web-test-regression-spec.md @@ -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` упавшего теста кликал бы в окне следующего. diff --git a/tests/web-test/README.md b/tests/web-test/README.md index ad682022..78f81d3a 100644 --- a/tests/web-test/README.md +++ b/tests/web-test/README.md @@ -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 переменные