/** * The failure modes this harness cannot fix, and must therefore NAME. * * ── Why naming is the whole of the job ─────────────────────────────────────── * Both modes below present as a bounded wait expiring on whatever operation happened to be in * flight — a `fill`, a `selectOption`, a `click`. Reported that way they read as product * defects, and they have been diagnosed as such more than once: a run whose browser had * stopped answering reported three timeouts on three different innocent selectors, none of * them naming the browser. A whole day went into one of those. * * So when a wait fails, the honest question is asked before the verdict is written: does the * browser still answer AT ALL? A trivial round-trip settles it in milliseconds when things are * healthy, so asking costs a healthy run nothing. * * ── The two modes ──────────────────────────────────────────────────────────── * **A dropped devtools pipe.** Chromium's control pipe drops mid-run: it logs a terminated-pipe * message and exits cleanly, and Playwright emits NEITHER `close` NOR `disconnected` — observed * four times out of four. From the client's side the browser simply stops answering, so every * wait on it burns its bound and the unbounded ones wait for ever. It is not caused by how the * child process is spawned, nor by a leftover holding the profile, nor by overlapping launches * — all three were probed and ruled out. It looks like Playwright losing its file descriptors * without telling its client. * * **A context that stops answering.** The same shape at frame level: a frame that is attached, * on the right URL, and holds NOTHING — what a RELOADED iframe looks like from the outside. * VERIFIED 2026-08-16, one actor's frame reached it mid-run and the next three journeys each * reported a 30 s timeout on a different innocent selector. * * ── What a named deadline does NOT prove ───────────────────────────────────── * That the transport is at fault. A deadline says only that something did not happen in time; * reaching for the environment is the comfortable answer because it absolves the code. The * worst instance of that reflex here was a one-line harness bug — an application frame matched * by SUBSTRING — blamed on the broker and on the host network for a day. Read your own harness * first, and call it transport only once you can name the mechanism. */ import type { BrowserContext, Frame, Page } from "playwright"; import { DeadlineExceeded, firstLine, lossDeclared, within } from "./deadline"; /** Asking a live browser something trivial: it answers in milliseconds, or it is gone. */ export const BROWSER_PROBE_MS = 5_000; /** * Asking a live frame whether it still holds the application. A `count()` is one round-trip * and does not wait for the element, so it answers in milliseconds or the frame is gone — the * probe cannot itself become the hang it exists to name. */ export const FRAME_PROBE_MS = 10_000; /** * Whether the browser has stopped answering — as a sentence naming the mode, or `null` when it * answers normally and the operation that failed is the real suspect. * * Consult this on a failure path, never in the success path: it exists to REPLACE a misleading * verdict, not to add a check. */ export async function browserTrouble(label: string, ctx: BrowserContext): Promise { // Already established, by the `close`/`disconnected` listeners that can see their losses. // Answering from it costs nothing and says the same thing. const declared = lossDeclared(); if (declared !== null) return declared; const live = ctx.pages().filter((p) => !p.isClosed()); if (live.length === 0) return null; // nothing to ask — no verdict, rather than a wrong one const page = live[0]!; try { await within(`the ${label} browser to answer a trivial question`, BROWSER_PROBE_MS, () => page.evaluate(() => 1), ); return null; } catch (e) { if (e instanceof DeadlineExceeded) { return ( `the ${label} browser STOPPED ANSWERING — a trivial round-trip did not come back in ` + `${BROWSER_PROBE_MS / 1000}s. This is the dropped devtools pipe (Chromium exits and ` + "Playwright emits neither `close` nor `disconnected`), so whatever operation was in " + "flight is a casualty and not the cause. It is not ours to fix — re-run, and do not " + "read this as a verdict on the code under test" ); } return `the ${label} browser refused a trivial question: ${firstLine(e)}`; } } /** * Why `frame` cannot be driven, or `null` when it can. * * `marker` is the selector that proves the application is still in the frame — the caller's, * because only the application knows what its own presence looks like. The THIRD state is the * one that actually happens and the one no naive check catches: attached, on the right URL, * and empty. */ export async function frameTrouble( id: string, page: Page, frame: Frame, marker: string, ): Promise { if (page.isClosed()) return `${id}'s page has been closed`; if (frame.isDetached()) return `${id}'s application frame is detached`; const shell = await within(`${id}'s frame to answer`, FRAME_PROBE_MS, () => frame.locator(marker).count(), ).catch((e: unknown) => firstLine(e)); if (typeof shell === "string") return `${id}'s frame did not answer (${shell})`; if (shell === 0) return `${id}'s frame no longer holds the application — it reloaded`; return null; }