/** * Deadlines for the e2e harnesses — so a wait that cannot end FAILS, named, instead of * hanging. * * ── Why this module exists ─────────────────────────────────────────────────── * A harness that hangs is worse than one that fails. A failure names a suspect and costs a * minute; a hang costs an hour and leaves every measurement of the session undecidable — * was the suite slow, was the broker slow, or was it stuck? Two of the waits these suites * lean on have NO bound at all: `frame.evaluate()` (which is what every `sdk(...)` call in * `run.ts` is) and `context.newPage()`. Playwright applies no timeout to either. * * And the worst one is in the teardown. VERIFIED 2026-08-11: when the browser goes away * mid-run, `BrowserContext.close()` in a `finally` never resolves — so the suite dies * INSIDE its own cleanup, after its last journey, without ever printing its summary or its * failures. That is the "prints the setup lines, then nothing for 68 minutes" the harness * was killed for, three times. * * So: every wait that can block gets a deadline, and on expiry an error that says WHAT it * was waiting for and WHERE — the chain of journeys and steps it sits inside (see * {@link enclosing}) — because a bound whose message is "Timeout" only moves the guessing * from "which wait" to "which of these thirty-two". * * ── Bounds are generous on purpose ─────────────────────────────────────────── * The numbers are sized from OBSERVED healthy timings with a wide margin (see each * caller). The goal is to catch a hang, never to make a healthy-but-slow run flaky: a * bound that fires on a slow broker manufactures exactly the false diagnosis it exists to * prevent. */ /** Thrown when a bounded wait outlives its deadline. */ export class DeadlineExceeded extends Error { constructor(what: string, ms: number, where: string) { super(`[e2e deadline] gave up after ${fmtMs(ms)} waiting for: ${what}\n ${where}`); this.name = "DeadlineExceeded"; } } /** * Thrown at every wait in flight when the browser they all depend on has gone away. * * Without it, a dead browser is discovered one 60-second timeout at a time — or never, on * the waits Playwright does not bound. The suite has nothing left to measure at that * point, so the useful thing is to say so once, immediately, and name the loss. */ export class BrowserGone extends Error { constructor(reason: string, what: string, where: string) { super(`[e2e] ${reason}\n it was waiting for: ${what}\n ${where}`); this.name = "BrowserGone"; } } function fmtMs(ms: number): string { return ms >= 60000 ? `${(ms / 60000).toFixed(1)} min` : `${Math.round(ms / 1000)}s`; } interface Pending { what: string; where: string; ms: number; startedAt: number; abandon: (e: Error) => void; } /** Everything currently being waited on, so a loss can name every casualty at once. */ const pending = new Set(); /** Set once the run has lost the thing every wait depends on. */ let lost: string | null = null; /** * Where a wait sits, as the chain of waits enclosing it — `journey X › alice to sign in`. * * Deliberately NOT a file:line read off a stack. The runner is Bun, and Bun elides frames * across `await` boundaries: measured 2026-08-11, a `within` called from an async function * reports `moduleEvaluation (native:1:11)` and nothing else, so a stack-derived call site * is silently wrong exactly when it is needed. The enclosing chain is better anyway — a * reader wants "which journey, which step" far more than a line number, and journeys and * steps are themselves bounded waits, so the chain is already there to be read. * * These suites are strictly sequential, which is what makes "everything else in flight" the * same thing as "everything enclosing this". A concurrent harness would need real context * propagation. */ function enclosing(): string { const chain = [...pending].map((p) => p.what); return chain.length === 0 ? "(the suite's top level)" : `while: ${chain.join(" › ")}`; } /** * Run `task` under a deadline. On expiry — or the moment {@link browserLost} is declared — * reject with an error naming what was being waited for and where. * * The losing task is NOT cancelled; nothing here can cancel a browser round-trip. Its * eventual rejection is absorbed instead, because a race loser surfacing as an unhandled * rejection would crash the process minutes after the real failure was already reported. */ export function within(what: string, ms: number, task: () => Promise): Promise { if (lost !== null) return Promise.reject(new BrowserGone(lost, what, enclosing())); return bounded(what, ms, enclosing(), task); } /** The race itself, shared by {@link within} and the teardown path that outlives a loss. */ function bounded(what: string, ms: number, where: string, task: () => Promise): Promise { let timer: ReturnType | undefined; let entry!: Pending; const interrupted = new Promise((_, reject) => { entry = { what, where, ms, startedAt: Date.now(), abandon: reject }; timer = setTimeout(() => reject(new DeadlineExceeded(what, ms, where)), ms); }); pending.add(entry); const running = task(); running.catch(() => {}); // absorbed: the race's loser must not become an unhandled rejection return Promise.race([running, interrupted]).finally(() => { if (timer !== undefined) clearTimeout(timer); pending.delete(entry); }); } /** * Declare that the browser every wait depends on has gone, and abandon them all now. * * Idempotent, and one-way: once a run has lost its browser there is nothing further to * measure, so later waits are refused rather than left to time out one by one. */ export function browserLost(reason: string): void { if (lost !== null) return; lost = reason; console.error(`\n[e2e] ${reason}`); if (pending.size > 0) { console.error(` ${pending.size} wait(s) were in flight and are abandoned:`); for (const p of pending) console.error(` - ${p.what} [${p.where}]`); } for (const p of [...pending]) p.abandon(new BrowserGone(reason, p.what, p.where)); } /** Teardown bound: a close that has not returned in 30s is not going to. */ export const CLOSE_MS = 30_000; /** * Close a page/context/server under a deadline, reporting rather than throwing. * * Teardown is where a bound matters most and an exception matters least: the verdict is * already decided, so a close that never returns must not be what the run dies of. This is * the exact shape of the observed hang — `BrowserContext.close()` on a browser that had * already exited, inside a `finally`, swallowing the summary that was on its way out. * * Deliberately NOT refused after a loss, unlike {@link within}: a lost browser is when * closing matters most. Skipping it there would leave the Chromium processes of a failed * run alive, and the next run would inherit them. */ export async function closeQuietly(what: string, close: () => Promise): Promise { try { await bounded(`${what} to close`, CLOSE_MS, enclosing(), async () => { await close(); }); } catch (e) { console.warn(` [warn] ${what} did not close cleanly: ${String((e as Error)?.message ?? e)}`); } } /** * Arm the suite's own wall clock. On expiry, name every wait still in flight and exit. * * The last resort behind the per-wait deadlines: it catches the wait nobody wrapped. It * reports before it dies, because "the run was killed" is the uninformative message that * cost the hours this module exists to stop spending. * * `unref`ed, so a healthy run is never held open by its own watchdog. */ export function armSuiteDeadline(suite: string, ms: number): void { const startedAt = Date.now(); const timer = setTimeout(() => { console.error( `\n[e2e deadline] ${suite} exceeded its wall clock of ${fmtMs(ms)} — aborting.\n` + " This is a HANG, not a verdict.", ); if (pending.size === 0) { console.error( " Nothing was inside a bounded wait, so the block is in unbounded code: " + "wrap the step it stopped at with `within(...)`.", ); } else { console.error(` Waits still in flight (${pending.size}):`); for (const p of pending) { console.error( ` - ${p.what} — ${fmtMs(Date.now() - p.startedAt)} of ${fmtMs(p.ms)}\n at ${p.where}`, ); } } console.error(` Total elapsed: ${fmtMs(Date.now() - startedAt)}`); process.exit(1); }, ms); timer.unref?.(); } /** * Playwright's per-context defaults, set explicitly so the bound on every locator action and * navigation is a decision in this file rather than a library default nobody looked up. * * The value is Playwright's own 30s, deliberately: raising it to 120s was tried on * 2026-08-11 and made things WORSE, because a bound is not only a hang-catcher — it is also * how fast a genuine failure is reported. The wallet-creation flow on nextgraph.eu can * re-render under a click ("element was detached from the DOM, retrying"), and at 120s that * flake took two minutes to surface instead of thirty seconds. Every action and navigation * here already had a bound; the waits that had NONE are the ones this module wraps * (`evaluate`, `newPage`, `close`), and the slow broker calls pass their own timeout. */ export const CONTEXT_ACTION_MS = 30_000; export const CONTEXT_NAVIGATION_MS = 30_000;