diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md index 5dc1b441..9869aacc 100644 --- a/.claude/skills/web-test/regress.md +++ b/.claude/skills/web-test/regress.md @@ -377,9 +377,18 @@ node $RUN test tests// --grep='накладн' # node $RUN test tests// --bail --retry=1 # stop on first fail, allow 1 retry node $RUN test tests// --report=allure-results --format=allure --report-dir=allure-results node $RUN test tests// --report=- # machine JSON to stdout, progress to stderr +node $RUN test tests// --global-timeout=3600000 # ceiling for the whole run (exit 2) node $RUN test tests// -- --rebuild-stand # after `--` → hookArgs ``` +**Timeouts and hangs.** A test's `timeout` is a contract, not a wish: when it expires the runner probes the +context and destroys whatever is wedged, so the run always moves on. The failure carries a verdict — `hang` +(browser alive, renderer's JS thread blocked; the context is aborted, its 1C seance released from Node, and +the next test recreates it) versus `slow`/`slow-network` (nothing is broken — raise `export const timeout`). +A `hang` is never retried. Exit codes: `1` red tests, `2` `--global-timeout` fired (report written, seances +released), `3` the shutdown itself wedged. Allure results are written per test as it finishes, so a hang +cannot destroy the results collected before it — no external watchdog needed. + **Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.) ### Allure static config — `_allure/` diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 3c9f6b2c..dcdb8f8f 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1,4 +1,4 @@ -// web-test browser v1.18 — engine facade: re-exports the public API from engine/* +// web-test browser v1.19 — engine facade: re-exports the public API from engine/* // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Public API of the web-test engine. Pure re-export facade — no logic here. @@ -22,6 +22,9 @@ export { connect, disconnect, attach, detach, getSession, createContext, setActiveContext, listContexts, getActiveContext, hasContext, closeContext, + // Unresponsive-context handling (test runner). abortContext is the sanctioned way to + // mutate the registry from outside — the `contexts` Map itself stays private. + abortContext, probeContext, getContextDiagnostics, } from './engine/core/session.mjs'; // ── navigation ──────────────────────────────────────────────────────────── diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs index 49a63f39..58b56b54 100644 --- a/.claude/skills/web-test/scripts/cli/commands/test.mjs +++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs @@ -1,9 +1,9 @@ -// web-test cli/commands/test v1.5 — regression test runner +// web-test cli/commands/test v1.6 — regression test runner // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs'; import { resolve, dirname, basename, relative } from 'path'; import * as browser from '../../browser.mjs'; -import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs'; +import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps, softDeadline } from '../util.mjs'; import { buildContext, buildScopedContext, setErrorShotDir } from '../exec-context.mjs'; import { createAssertions } from '../test-runner/assertions.mjs'; import { buildSeverityIndex } from '../test-runner/severity.mjs'; @@ -19,8 +19,27 @@ export async function cmdTest(rawArgs) { const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs; const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : []; + // Deadline budgets for the cleanup path. Every one of these calls reaches into Playwright + // 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. + const D = { + screenshot: 10000, + teardown: 15000, + afterEach: 15000, + setActive: 5000, + resetState: 20000, + startRecording: 15000, + stopRecording: 40000, // ffmpeg has its own 30s inside + closeContext: 20000, + disconnect: 30000, + hooks: 120000, // prepare/cleanup/beforeAll/afterAll do real work (db rebuilds) + abortAll: 30000, // whole abort+cleanup sequence for one hung test + probe: 2000, + }; + // Parse flags - const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; + const opts = { bail: false, retry: 0, timeout: 30000, globalTimeout: 0, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; let tags = null, grep = null, urlFlag = null; const positional = []; for (const a of ownArgs) { @@ -30,6 +49,7 @@ export async function cmdTest(rawArgs) { else if (a === '--bail') opts.bail = true; else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0; else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000; + else if (a.startsWith('--global-timeout=')) opts.globalTimeout = parseInt(a.slice(17)) || 0; else if (a.startsWith('--report=')) opts.report = a.slice(9); else if (a.startsWith('--format=')) opts.format = a.slice(9); else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13); @@ -118,6 +138,7 @@ export async function cmdTest(rawArgs) { if (!tags && config.tags) tags = config.tags; 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); if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) { browser.setPreserveClipboard(false); } @@ -210,9 +231,102 @@ export async function cmdTest(rawArgs) { const results = []; let passCount = 0, failCount = 0, skipCount = 0; + /** + * Bounded best-effort await: the replacement for `try { await x } catch {}`. + * Same tolerance for failure, but a call that never settles can no longer stall the run, + * and every breach leaves a visible line instead of a silent 29-minute stall. + */ + async function bounded(promise, ms, label) { + const r = await softDeadline(promise, ms, label); + if (!r.ok) W.write(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`); + return r; + } + + // 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. + // + // The run shares one `ctx` object (hooks hold it too), so an epoch stamped on ctx could not + // tell the zombie from the live caller — both are the same object. Each attempt therefore + // gets its own Proxy view bound to its epoch; calls through a stale view throw. + let abortEpoch = 0; + function makeTestCtx(base, epoch) { + return new Proxy(base, { + get(target, prop, recv) { + const v = Reflect.get(target, prop, recv); + if (typeof v !== 'function') return v; + return (...args) => { + if (epoch !== abortEpoch) { + throw new Error(`test abandoned (timeout) — blocked a late ${String(prop)}() call from its body; it would have hit the next test`); + } + return v.apply(target, args); + }; + }, + }); + } + + function buildReport(state) { + const totalDuration = results.reduce((s, r) => s + r.duration, 0); + return { + runner: 'web-test', url, startedAt, finishedAt: new Date().toISOString(), + state, + duration: totalDuration, + summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, + tests: results, + }; + } + + let allureWritten = false; + /** + * Record a finished test AND persist it immediately. The report used to be written only + * after the loop, so a single hang destroyed every result collected so far. + * writeAllure([tr]) is byte-identical to the batch call: it mints its own uuid per test and + * severityIndex is read-only. + */ + function recordResult(tr) { + results.push(tr); + if (opts.format === 'allure') { + try { writeAllure([tr], reportDir, severityIndex); allureWritten = true; } catch (e) { W.write(` ! allure write: ${e.message}\n`); } + } else if (opts.format === 'json' && opts.report && !reportToStdout) { + try { writeFileSync(resolve(opts.report), JSON.stringify(buildReport('partial'), null, 2)); } catch {} + } + } + const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`); const hookEnv = { hookArgs, log: hookLog, config }; - if (hooks.prepare) await hooks.prepare(hookEnv); + if (hooks.prepare) await bounded(hooks.prepare(hookEnv), D.hooks, 'hooks.prepare'); + + /** Force-release every open context (frees 1C licenses), then drop the browser. */ + async function shutdownAll() { + for (const name of browser.listContexts()) { + await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`); + } + await bounded(browser.disconnect(), D.disconnect, 'disconnect'); + } + + /** + * Wall-clock ceiling for the whole run. This works even while a test is wedged: a pending + * Playwright await does not block the event loop, it is merely an unsettled promise — which + * is precisely why the original incident stalled quietly instead of crashing. + * Report first (that's what the user needs), hygiene second, exit unconditionally. + */ + let globalTimer = null; + let hardStopping = false; + async function hardStop(reason) { + if (hardStopping) return; + hardStopping = true; + W.write(`\n!! ${reason}: run exceeded --global-timeout=${opts.globalTimeout}ms — forcing shutdown\n`); + abortEpoch++; + // Last-resort exit if the shutdown itself wedges. Referenced on purpose: it must survive. + const bailout = setTimeout(() => process.exit(3), 20000); + try { writeFinalReport('aborted'); } catch (e) { W.write(` ! report: ${e.message}\n`); } + await softDeadline(shutdownAll(), 15000, 'shutdown'); + clearTimeout(bailout); + process.exit(2); + } + if (opts.globalTimeout > 0) { + globalTimer = setTimeout(() => { void hardStop('global-timeout'); }, opts.globalTimeout); + } // Lazy context creation async function ensureContext(name) { @@ -271,7 +385,7 @@ export async function cmdTest(rawArgs) { if (t.skip) { const reason = typeof t.skip === 'string' ? t.skip : ''; W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); + recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); skipCount++; continue; } @@ -319,7 +433,7 @@ export async function cmdTest(rawArgs) { touchLru(lruOrder, testContextNames); } catch (e) { W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); + recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); failCount++; if (opts.bail) break; continue; @@ -353,7 +467,8 @@ export async function cmdTest(rawArgs) { let videoFile = null; if (opts.record) { videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`); - try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; } + const rec = await bounded(browser.startRecording(videoFile, { force: true }), D.startRecording, 'startRecording'); + if (!rec.ok) videoFile = null; } ctx.log = (...a) => output.push(a.map(String).join(' ')); @@ -394,6 +509,9 @@ export async function cmdTest(rawArgs) { } } + const myEpoch = ++abortEpoch; + let timedOut = false; + try { if (hooks.beforeEach) await hooks.beforeEach(ctx); if (t.setup) await t.setup(ctx); @@ -401,8 +519,8 @@ export async function cmdTest(rawArgs) { let timeoutTimer; try { await Promise.race([ - t.fn(ctx, t.param), - new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }), + t.fn(makeTestCtx(ctx, myEpoch), t.param), + new Promise((_, reject) => { timeoutTimer = setTimeout(() => { timedOut = true; reject(new Error(`Timeout (${t.timeout}ms)`)); }, t.timeout); }), ]); } finally { // Clear the guard timer — otherwise it stays armed in the event loop and, @@ -411,16 +529,21 @@ export async function cmdTest(rawArgs) { clearTimeout(timeoutTimer); } - if (t.teardown) try { await t.teardown(ctx); } catch {} + // Bounded even on the green path: a test can pass and still leave the UI in a state + // where resetState wedges — that would stall the run just as dead as a failure would. + if (t.teardown) await bounded(t.teardown(ctx), D.teardown, 'teardown'); ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps }; - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} + if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach'); for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + 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})`); } for (const k of scopedKeys) delete ctx[k]; if (videoFile) { - try { await browser.stopRecording(); } catch {} + await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording'); } const dur = elapsed(t0); testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile }; @@ -428,14 +551,58 @@ export async function cmdTest(rawArgs) { break; } catch (e) { + // ── Timeout: diagnose, then destroy what hung. Everything below this point that + // goes through the renderer (screenshot, teardown, resetState) is pointless on a + // wedged page and would itself hang — so on `hang` we skip straight to the abort. + let diagnosis = null; + if (timedOut) { + const active = browser.getActiveContext(); + const probe = active ? await browser.probeContext(active, { ms: D.probe }) : null; + const diag = active ? browser.getContextDiagnostics(active) : null; + const verdict = !probe ? 'no-context' + : !probe.browserAlive ? 'browser-dead' + : !probe.rendererAlive ? 'hang' + : diag?.net.inFlight > 0 ? 'slow-network' + : 'slow'; + + const lines = [ + `verdict: ${verdict}` + (verdict === 'hang' ? ' (renderer unresponsive, browser alive)' : ''), + ` context "${active}" [${diag?.isolation}] · renderer probe: ${probe?.rendererAlive ? `ok in ${probe.rendererMs}ms` : `timed out at ${D.probe}ms`}` + + ` · browser probe: ${probe?.browserAlive ? `ok in ${probe.browserMs}ms` : `timed out at ${D.probe}ms`}`, + ` network: ${diag?.net.inFlight} in flight, last event ${diag?.msSinceLastNetEvent != null ? (diag.msSinceLastNetEvent / 1000).toFixed(1) + 's ago' : 'never'}` + + ` (${diag?.net.requests} req / ${diag?.net.responses} resp)`, + ]; + // Same failure, different remedy — say which, or the next person guesses. + if (verdict === 'slow' || verdict === 'slow-network') { + lines.push(' no hang detected — the test is simply slower than its declared timeout; raise `export const timeout`'); + } + + if (verdict === 'hang' || verdict === 'browser-dead') { + const ab = await bounded(browser.abortContext(active), D.abortAll, 'abortContext'); + const r = ab.ok ? ab.value : null; + lines.push(` recovery: ${r ? `context aborted (logout: ${r.logout}, closed: ${r.closed}${r.escalated ? ', escalated to browser kill' : ''})` : 'abort failed'} — next test recreates it`); + if (r?.notes?.length) lines.push(` notes: ${r.notes.join('; ')}`); + if (active) dropLru(lruOrder, active); + } + diagnosis = { verdict, probe, net: diag?.net }; + e.message = `${e.message} — ${lines[0]}`; + output.push(...lines); + W.write(lines.map(l => ` ${l}\n`).join('')); + } + + const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead'); + // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI. + // Skipped on a dead page: it goes through the renderer, so it can only hang. let shotFile = e.onecError?.screenshot; - if (!shotFile && opts.screenshot !== 'off') { - try { - const png = await browser.screenshot(); - shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); - writeFileSync(shotFile, png); - } catch {} + if (!shotFile && opts.screenshot !== 'off' && !dead) { + const shot = await bounded(browser.screenshot(), D.screenshot, 'screenshot'); + if (shot.ok) { + try { + shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); + writeFileSync(shotFile, shot.value); + } catch { shotFile = undefined; } + } } else if (shotFile && dirname(resolve(shotFile)) !== reportDir) { // Shot came from a context built before setErrorShotDir (e.g. a server // session started earlier): reporters attach by basename, so anything @@ -453,21 +620,32 @@ export async function cmdTest(rawArgs) { } } - if (t.teardown) try { await t.teardown(ctx); } catch {} - const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError }; + if (t.teardown && !dead) await bounded(t.teardown(ctx), D.teardown, 'teardown'); + const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError, diagnosis }; ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps }; - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} - for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach'); + // resetState drives the UI (up to 10 × getFormState + closeForm, all page.evaluate). + // On a dead page it cannot succeed — the slot is already gone anyway. + 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})`); + } } for (const k of scopedKeys) delete ctx[k]; if (videoFile) { - try { await browser.stopRecording(); } catch {} + await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording'); } lastError = errInfo; const dur = elapsed(t0); testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile }; + + // A wedged renderer is not flakiness — retrying just buys another full timeout + // plus another abort. Stop after the first hang. + if (dead) break; } } @@ -491,7 +669,7 @@ export async function cmdTest(rawArgs) { } } - results.push(testResult); + recordResult(testResult); if (testResult.status === 'passed') { passCount++; @@ -507,23 +685,26 @@ export async function cmdTest(rawArgs) { if (opts.bail && testResult.status === 'failed') break; } - if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {} + if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll'); } finally { + clearTimeout(globalTimer); // Per-context teardown try { const remaining = browser.listContexts(); if (remaining.length > 0) { const survivor = remaining[0]; - try { await browser.setActiveContext(survivor); } catch {} + await bounded(browser.setActiveContext(survivor), D.setActive, `setActiveContext(${survivor})`); for (let i = remaining.length - 1; i >= 1; i--) { const name = remaining[i]; if (hooks.beforeCloseContext && hookCtx) { try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } } - try { await browser.closeContext(name); } - catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); } + // closeContext goes through the page (logout + close). If it breaches, fall back to + // abortContext: it logs out from Node, which is the path that survives a dead page. + const cc = await bounded(browser.closeContext(name), D.closeContext, `closeContext(${name})`); + if (!cc.ok) await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`); } if (hooks.beforeCloseContext && hookCtx) { try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); } @@ -533,32 +714,35 @@ export async function cmdTest(rawArgs) { } catch (e) { hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`); } - try { await browser.disconnect(); } catch {} - if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {} + await bounded(browser.disconnect(), D.disconnect, 'disconnect'); + if (hooks.cleanup) await bounded(hooks.cleanup(hookEnv), D.hooks, 'hooks.cleanup'); } - const finishedAt = new Date().toISOString(); const totalDuration = results.reduce((s, r) => s + r.duration, 0); - W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`); - const report = { - runner: 'web-test', url, startedAt, finishedAt, - duration: totalDuration, - summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, - tests: results, - }; - if (opts.format === 'allure') { - writeAllure(results, reportDir, severityIndex); - syncAllureExtras(testDir, reportDir); - } else if (opts.format === 'junit') { - if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n'); - else writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); - } else if (reportToStdout) { - out(report); - } else if (opts.report) { - writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); - } + writeFinalReport('complete'); if (failCount > 0) process.exit(1); + + /** + * Allure results are already on disk (recordResult writes each test as it finishes), so this + * only completes the formats that need whole-run totals. Also called from hardStop, where + * `state` is 'aborted' and `results` holds whatever finished before the ceiling hit. + */ + function writeFinalReport(state) { + const report = buildReport(state); + if (opts.format === 'allure') { + // Guard against a result-producing path that skipped recordResult; normally a no-op. + if (!allureWritten) writeAllure(results, reportDir, severityIndex); + syncAllureExtras(testDir, reportDir); + } else if (opts.format === 'junit') { + if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n'); + else writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); + } else if (reportToStdout) { + out(report); + } else if (opts.report) { + writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); + } + } } diff --git a/.claude/skills/web-test/scripts/cli/util.mjs b/.claude/skills/web-test/scripts/cli/util.mjs index 36bfc8a8..9a2d7dec 100644 --- a/.claude/skills/web-test/scripts/cli/util.mjs +++ b/.claude/skills/web-test/scripts/cli/util.mjs @@ -1,6 +1,10 @@ -// web-test cli/util v1.3 — generic helpers for CLI commands +// web-test cli/util v1.4 — generic helpers for CLI commands // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// Wall-clock bounds live in the engine (session.mjs needs them too, and the engine must not +// depend on cli/). Re-exported here so CLI callers have one import site. +export { withDeadline, softDeadline, DeadlineError } from '../engine/core/deadline.mjs'; + export function out(obj) { process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); } diff --git a/.claude/skills/web-test/scripts/engine/core/deadline.mjs b/.claude/skills/web-test/scripts/engine/core/deadline.mjs new file mode 100644 index 00000000..0d3aa48f --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/core/deadline.mjs @@ -0,0 +1,48 @@ +// web-test engine/core/deadline v1.0 — wall-clock bounds for calls that can hang +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Why this exists: `try { await x } catch {}` guards against a REJECTION, not against +// a promise that never settles. A Playwright call against a wedged renderer (page.evaluate +// has no timeout at all) does exactly that — it neither resolves nor rejects, and the runner +// waits forever. Every such call must be bounded by a wall-clock timer instead. +// +// Neither helper CANCELS the underlying work — that is impossible for a promise. They only +// stop *waiting* on it. Whoever breaks a deadline must also destroy the thing that hung +// (see session.abortContext) or the pending call keeps holding its resources. + +export class DeadlineError extends Error { + constructor(label, ms) { + super(`${label} timed out after ${ms}ms`); + this.name = 'DeadlineError'; + this.label = label; + this.ms = ms; + } +} + +/** + * Await `promise`, but give up after `ms`. + * @throws {DeadlineError} when the deadline is reached first. + */ +export function withDeadline(promise, ms, label = 'operation') { + let timer; + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => { timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms); }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Best-effort variant: never throws, reports what happened instead. + * Use it where the old code said `try { await x } catch {}` — the point is that a breach + * becomes VISIBLE (callers are expected to log `err`) rather than silently swallowed. + * @returns {Promise<{ok: boolean, value?: any, err?: Error, timedOut: boolean, ms: number}>} + */ +export async function softDeadline(promise, ms, label = 'operation') { + const t0 = Date.now(); + try { + const value = await withDeadline(promise, ms, label); + return { ok: true, value, timedOut: false, ms: Date.now() - t0 }; + } catch (err) { + return { ok: false, err, timedOut: err instanceof DeadlineError, ms: Date.now() - t0 }; + } +} diff --git a/.claude/skills/web-test/scripts/engine/core/session.mjs b/.claude/skills/web-test/scripts/engine/core/session.mjs index 1f2e9d5a..d883df97 100644 --- a/.claude/skills/web-test/scripts/engine/core/session.mjs +++ b/.claude/skills/web-test/scripts/engine/core/session.mjs @@ -1,7 +1,8 @@ -// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. +// web-test core/session v1.18 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { chromium } from 'playwright'; +import { softDeadline } from './deadline.mjs'; import { statSync, mkdirSync, readdirSync, rmSync } from 'fs'; import { join as pathJoin } from 'path'; import { tmpdir } from 'os'; @@ -138,15 +139,21 @@ async function logoutSlot(slot, waitMs = 500) { * Sends POST /e1cib/logout to release the license before closing. */ export async function disconnect() { + const wasMultiContext = contexts.size > 0; + // Multi-context path: stop recording + logout each slot before closing browser - if (contexts.size > 0) { + if (wasMultiContext) { saveActiveSlot(); // Recorder is global — one stop covers all contexts if (recorder) { - try { await stopRecording(); } catch {} + await softDeadline(stopRecording(), 40000, 'stopRecording'); } for (const [, slot] of contexts.entries()) { - await logoutSlot(slot); + // Deadline-bounded: an unresponsive slot must not hold up the shutdown of the others. + // nodeLogout is the fallback that needs no renderer — it is what keeps the license + // from leaking when the page is the thing that died. + const own = await softDeadline(logoutSlot(slot), 3000, 'logoutSlot'); + if (!own.ok) await nodeLogout(slot, 3000); } contexts.clear(); setActiveContextName(null); @@ -155,13 +162,20 @@ export async function disconnect() { // Single-session path (connect): auto-stop recording if active if (recorder) { - try { await stopRecording(); } catch {} + await softDeadline(stopRecording(), 40000, 'stopRecording'); } if (browser) { - // Graceful logout — release the 1C license (single-session connect path) - await logoutSlot({ page, sessionPrefix, seanceId }, 1000); - await browser.close().catch(() => {}); + // Graceful logout — release the 1C license (single-session connect path). + // Skipped after the multi-context path: `page` still mirrors the last active slot, which + // was just logged out above — re-sending it would pay a hung page's cost a second time. + if (!wasMultiContext) { + await softDeadline(logoutSlot({ page, sessionPrefix, seanceId }, 1000), 4000, 'logoutSlot'); + } + await softDeadline(browser.close(), 10000, 'browser.close'); + // Floor: if Chromium ignored close(), take the process out — never leave an orphan. + try { browser.browser?.()?.process?.()?.kill('SIGKILL'); } catch {} + try { browser.process?.()?.kill('SIGKILL'); } catch {} setBrowser(null); setPage(null); setSessionPrefix(null); @@ -241,7 +255,22 @@ function activateSlot(name) { /** Attach 1C session listeners to a page, writing into the given slot. */ function attachSessionListeners(pg, slot, name) { pg.on('dialog', dialog => dialog.accept().catch(() => {})); + + // Network counters feed the hang/slow diagnosis (see probeContext). These events are + // emitted by the BROWSER process, so they keep flowing even when the page's JS thread is + // wedged and page.evaluate() can no longer answer. Supporting colour only, not a verdict: + // a wedged renderer cannot issue requests, so "quiet" looks the same as "idle waiting". + // Counters only — never buffer URLs, this runs for the whole suite. + slot.net = { lastEventAt: Date.now(), inFlight: 0, requests: 0, responses: 0 }; + const settled = () => { slot.net.inFlight = Math.max(0, slot.net.inFlight - 1); slot.net.lastEventAt = Date.now(); }; + pg.on('requestfinished', settled); + pg.on('requestfailed', settled); + pg.on('response', () => { slot.net.responses++; slot.net.lastEventAt = Date.now(); }); + pg.on('request', req => { + slot.net.requests++; + slot.net.inFlight++; + slot.net.lastEventAt = Date.now(); if (slot.seanceId) return; const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); if (m) { @@ -402,3 +431,211 @@ export async function closeContext(name) { } contexts.delete(name); } + +/** + * Release a 1C seance straight from Node — no renderer, no CDP, no browser involved. + * + * Measured on the webtest stand: the seance is identified by `seanceId` in the URL and the + * client holds NO cookies at all (context.cookies() → []), so this request is equivalent to + * the one logoutSlot makes from inside the page. Verified end-to-end: after this call the + * web client reports "сеанс был завершен" on its next action. + * + * This is the only logout that still works when the renderer is wedged — which is exactly + * when a license would otherwise leak until the server-side seance timeout. + * + * @returns {Promise} true only on a 2xx answer (a 401/404 must not pass for success). + */ +async function nodeLogout(slot, ms = 3000) { + if (!slot?.sessionPrefix || !slot?.seanceId) return false; + try { + const res = await fetch(`${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"root":{}}', + signal: AbortSignal.timeout(ms), + }); + return res.status >= 200 && res.status < 300; + } catch { + return false; + } +} + +/** + * Is this context's browser alive, and is its renderer still answering? + * + * The two probes separate the failure modes that look identical from the outside: + * browserAlive && !rendererAlive → the page's JS thread is wedged: a hang. Nothing that + * goes through the renderer (evaluate, screenshot, clicks) can ever come back. + * both alive → nothing is broken; the test simply outran its timeout. + * + * cookies() is served by the browser process (measured: 1ms against a wedged renderer), + * page.evaluate() is not — that asymmetry is the whole trick. + * + * @returns {Promise<{browserAlive: boolean, rendererAlive: boolean, browserMs: number, rendererMs: number, pageClosed: boolean}>} + */ +export async function probeContext(name, { ms = 2000 } = {}) { + const slot = contexts.get(name); + if (!slot?.page) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true }; + if (slot.page.isClosed()) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true }; + + const [b, r] = await Promise.all([ + softDeadline(slot.page.context().cookies(), ms, 'browser probe'), + softDeadline(slot.page.evaluate(() => 1), ms, 'renderer probe'), + ]); + return { + browserAlive: b.ok, + rendererAlive: r.ok, + browserMs: b.ms, + rendererMs: r.ms, + pageClosed: false, + }; +} + +/** Read-only view of a slot's network activity. Never hands out the slot itself. */ +export function getContextDiagnostics(name) { + const slot = contexts.get(name); + if (!slot) return null; + const net = slot.net || { lastEventAt: 0, inFlight: 0, requests: 0, responses: 0 }; + return { + name, + isolation: activeMode, + net: { ...net }, + msSinceLastNetEvent: net.lastEventAt ? Date.now() - net.lastEventAt : null, + pageClosed: slot.page ? slot.page.isClosed() : true, + }; +} + +/** + * Force-release an unresponsive context — including the ACTIVE one, which closeContext + * refuses to touch. Every step is wall-clock bounded, so this path is never at the mercy + * of the thing that hung: it is bounded by timers, never by browser cooperation. + * + * Ordering matters: logout FIRST (a dead page can't release its own license), close second. + * + * @param {string} name + * @param {object} [opts] + * @param {number} [opts.logoutMs=3000] budget per logout attempt + * @param {number} [opts.closeMs=5000] budget for page.close()/context.close() + * @param {string} [opts.parkOn] context to activate afterwards (default: any survivor) + * @returns {Promise<{name, logout: 'node'|'page'|'sibling'|'failed'|'skipped', closed: 'page'|'context'|'browser-killed'|'failed', escalated: boolean, notes: string[]}>} + */ +export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, parkOn } = {}) { + const out = { name, logout: 'skipped', closed: 'failed', escalated: false, notes: [] }; + const slot = contexts.get(name); + if (!slot) { out.notes.push('not registered'); return out; } + + // The recorder follows the active page; if we are about to close that page, stop it first + // or the CDP screencast is dead for the rest of the run. + if (recorder && activeContextName === name) { + const r = await softDeadline(stopRecording(), 10000, 'stopRecording'); + if (!r.ok) out.notes.push(`stopRecording: ${r.err.message.split('\n')[0]}`); + } + + // ── Logout cascade: first success wins. node first — it needs neither renderer nor CDP. + if (slot.seanceId && slot.sessionPrefix) { + if (await nodeLogout(slot, logoutMs)) { + out.logout = 'node'; + } else { + const own = await softDeadline(logoutSlot(slot, 0), logoutMs, 'logoutSlot'); + if (own.ok) { + out.logout = 'page'; + } else { + // Same origin ⇒ same seance namespace; a live sibling can post the logout for us. + const sibling = [...contexts.entries()].find(([n, s]) => + n !== name && s.page && !s.page.isClosed() && s.sessionPrefix === slot.sessionPrefix); + if (sibling) { + const url = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`; + const sib = await softDeadline( + sibling[1].page.evaluate(async (u) => { + const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' }); + return r.status; + }, url), logoutMs, 'sibling logout'); + if (sib.ok && sib.value >= 200 && sib.value < 300) out.logout = 'sibling'; + else out.logout = 'failed'; + } else { + out.logout = 'failed'; + } + } + } + if (out.logout === 'failed') out.notes.push('license may leak until the 1C seance times out'); + } + + // In tab mode `browser` is a persistent BrowserContext (see createContext): closing its LAST + // page leaves Chromium with no windows and it exits, so the next createContext() would fail + // with "Failed to open a new tab". When this is the only slot, tear the browser down on + // purpose and reset state — the next createContext() then relaunches cleanly. + const lastPageInTabMode = activeMode === 'tab' && contexts.size === 1; + + // ── Close. runBeforeUnload:false is what survives a wedged renderer: the browser process + // tears the target down instead of asking the page's JS to agree. + if (activeMode === 'tab') { + // tab mode: slot.context IS the shared browser — closing it would kill every context. + const c = await softDeadline(slot.page.close({ runBeforeUnload: false }), closeMs, 'page.close'); + if (c.ok) out.closed = 'page'; + } else { + const c = await softDeadline(slot.context.close(), closeMs, 'context.close'); + if (c.ok) out.closed = 'context'; + } + + // ── Escalate: if even the close hung, the browser itself is suspect. Kill it and reset + // state, otherwise the next createContext() would call newPage() on a dead object and + // every remaining test would fail. + if (out.closed === 'failed') { + out.escalated = true; + out.notes.push('close breached its deadline — killing the browser'); + const b = browser; + await softDeadline(Promise.resolve(b?.close?.()), 5000, 'browser.close'); + try { b?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {} + try { b?.process?.()?.kill('SIGKILL'); } catch {} + out.closed = 'browser-killed'; + contexts.clear(); + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); + setActiveContextName(null); + setActiveMode(null); + if (persistentUserDataDir) { + try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + setPersistentUserDataDir(null); + } + return out; + } + + contexts.delete(name); + + if (lastPageInTabMode) { + out.notes.push('last tab closed — browser torn down, next createContext relaunches it'); + await softDeadline(Promise.resolve(browser?.close?.()), 5000, 'browser.close'); + try { browser?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {} + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); + setActiveContextName(null); + setActiveMode(null); + if (persistentUserDataDir) { + try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + setPersistentUserDataDir(null); + } + return out; + } + + // ── Park the active pointer on a survivor (or nothing). Deliberately NOT via + // setActiveContext()/saveActiveSlot() — those would write the dying page back into a slot. + if (activeContextName === name) { + const survivor = (parkOn && contexts.has(parkOn)) ? parkOn : [...contexts.keys()][0]; + if (survivor) { + activateSlot(survivor); + if (recorder) { try { await recorder._attachPage(page); } catch { /* recording is best-effort */ } } + } else { + // No slots left: isConnected() goes false and engine calls fail fast until the next + // ensureContext() recreates one. That is the intended contract, not a leak. + setPage(null); + setSessionPrefix(null); + setSeanceId(null); + setActiveContextName(null); + } + } + return out; +} diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md index adfe5bf3..9671d75d 100644 --- a/docs/web-test-regression-spec.md +++ b/docs/web-test-regression-spec.md @@ -25,6 +25,7 @@ node run.mjs test ... [флаги] | `--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=` (иначе некуда писать 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` | Файл `-result.json` на каждый тест — сразу по его завершении | +| `json` с `--report=` | Файл перезаписывается целиком после каждого теста (поле `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 (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 для прогона не нужен. ### Повторы diff --git a/tests/web-test/README.md b/tests/web-test/README.md index 0f20cd0f..1d88b9ea 100644 --- a/tests/web-test/README.md +++ b/tests/web-test/README.md @@ -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` | Формат отчёта | diff --git a/tests/web-test/_hang/01-renderer-block.test.mjs b/tests/web-test/_hang/01-renderer-block.test.mjs new file mode 100644 index 00000000..1998e114 --- /dev/null +++ b/tests/web-test/_hang/01-renderer-block.test.mjs @@ -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'); +} diff --git a/tests/web-test/_hang/02-survivor.test.mjs b/tests/web-test/_hang/02-survivor.test.mjs new file mode 100644 index 00000000..a7ec0991 --- /dev/null +++ b/tests/web-test/_hang/02-survivor.test.mjs @@ -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, 'команды раздела читаются'); +} diff --git a/tests/web-test/_hang/webtest.config.mjs b/tests/web-test/_hang/webtest.config.mjs new file mode 100644 index 00000000..fd6e652e --- /dev/null +++ b/tests/web-test/_hang/webtest.config.mjs @@ -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, +};