/** * Every browser this harness opens, launched and watched the same way. */ import { chromium, type BrowserContext, type Page } from "playwright"; import { CONTEXT_ACTION_MS, CONTEXT_NAVIGATION_MS, browserLost, closeQuietly, within, } from "./deadline"; /** Launching a browser is local — 30 s is Playwright's own default, doubled. */ export const LAUNCH_MS = 60_000; /** * Opening a page in a live browser is instant — measured 0.0–0.1 s over a run. Bounded at * 10 s, which is a hundred times the measurement and still fails while a reader is watching. * Exported because a caller that wraps `newPage` in a TIGHTER bound of its own would fire * first and report its own name instead of this one. */ export const NEW_PAGE_MS = 10_000; /** * What this harness needs Chromium to allow: the application under test is served from * `127.0.0.1` and loaded inside a broker iframe on a public origin, which is a private-network * request and a cross-origin one at once. */ const LAUNCH_ARGS = [ "--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations", "--allow-insecure-localhost", "--disable-web-security", ]; /** * A real Chromium rather than the headless shell: the shell has no support for the extensions * of a full browser, and the wallet application's flows have been observed only on the full * build. Falls back to Playwright's own choice when no full build is installed beside it. */ function resolveChromePath(): string | undefined { const p = chromium .executablePath() .replace("chrome-headless-shell", "chrome") .replace("chromium_headless_shell", "chromium"); return p.includes("headless") ? undefined : p; } /** Contexts we are closing ON PURPOSE — so their `close` event is not read as a loss. */ const closingOnPurpose = new WeakSet(); /** * Launch a persistent context on `dir`, bounded, with the harness's own timeouts applied and * its disappearance turned into an immediate, named failure. * * The watch is the load-bearing part. VERIFIED 2026-08-11: a browser can exit mid-run — the * devtools pipe between the runner and Chromium is terminated and Chromium shuts down * (exitCode=0) — and Playwright does NOT reject the calls already waiting on it. A bounded * wait then burns its whole timeout; an unbounded one (`newPage`, `evaluate`, and * `context.close()` in a `finally`) waits for ever. That is how a 60-second failure became * three runs killed at 50 and 68 minutes having printed nothing. * * So the context's own `close` event is listened to, and anything it was not asked to do is * declared a loss once, loudly, for every wait at once. */ export async function launchWatchedContext(label: string, dir: string): Promise { const ctx = await within(`the ${label} browser to launch`, LAUNCH_MS, () => chromium.launchPersistentContext(dir, { headless: true, executablePath: resolveChromePath(), args: LAUNCH_ARGS, timeout: LAUNCH_MS, }), ); ctx.setDefaultTimeout(CONTEXT_ACTION_MS); ctx.setDefaultNavigationTimeout(CONTEXT_NAVIGATION_MS); const gone = (how: string): void => { if (closingOnPurpose.has(ctx)) return; browserLost( `the ${label} browser went away mid-run — ${how}. Every wait on it is now ` + "unanswerable, so the run stops here instead of waiting on a browser that " + "no longer exists", ); }; // Both signals, and NEITHER of them covers the loss that hurts most — which is the whole // reason the deadlines are not optional. // // VERIFIED 2026-08-11: on a normal teardown both `close` and `disconnected` fire. On the // failure this harness actually suffers — Chromium logging "Connection terminated while // reading from pipe" and exiting — Playwright fires NEITHER, four times out of four. Its // client never learns the pipe is gone, so every call already in flight simply waits, and // every call after it waits too. That is why a browser dying used to cost an hour of // silence, and why no event-based guard can be the protection here: only a deadline can // (and `known-failures.ts` is what turns the deadline back into the right name). // // They are wired anyway because they DO catch the losses they can see (a context closed by // something nobody asked), and those are free to catch immediately rather than at the end // of a bound. ctx.on("close", () => gone("its context closed and nobody asked it to")); ctx.browser()?.on("disconnected", () => gone("its devtools connection dropped")); return ctx; } /** Close a context we own, bounded, without its `close` event being read as a loss. */ export async function closeContext(label: string, ctx: BrowserContext): Promise { closingOnPurpose.add(ctx); await closeQuietly(`the ${label} context`, () => ctx.close()); } /** Open a page under a bound: `context.newPage()` carries no timeout of its own. */ export function newPage(label: string, ctx: BrowserContext): Promise { return within(`a new page for ${label}`, NEW_PAGE_MS, () => ctx.newPage()); }