// Shared plumbing of the headless browser tests (tests/console-fill.test.ts, tests/site-overflow.test.ts, // tests/console-session.test.ts). They used to time out now and then, mostly when the machine was busy: // - the afterAll browser close (30 s, "(unnamed)" hook timeout). Cause: Google Chrome (the fallback when // playwright's own Chromium is not downloaded) often hangs in its own shutdown after loading http pages and only // exits when its 10 s teardown watchdog fires ("Teardown watchdog expired" in DEBUG=pw:browser); under load that // passed 30 s. Measured on an idle machine: 10-30 s per close. The shared browser is a throwaway profile with nothing // to flush, so it is launched as a browser server and KILLED at the end (milliseconds), not closed gracefully // - Chromium's 30 s default launch timeout: one launch per file now, with an explicit, generous launch timeout; a // launch that times out fails the file instead of quietly skipping it (a skip under load would hide lost // coverage); only a browser that is not installed skips // - every context of the run left open until afterAll, and a failed test's contexts and engine loops running into // the next test: every context a test opens is tracked and closed right after it (afterEach) // - polling sleeps in the waits for log lines: event-driven now (resolved on each new line) // Hook timeouts are bounded by the numbers below (each close is capped), never open-ended. /* eslint-disable @typescript-eslint/no-explicit-any */ import { AsyncLocalStorage } from 'node:async_hooks'; import { test } from 'bun:test'; /** Launching Chromium: well above a cold start on a loaded machine (seconds), far below "hung". */ export const LAUNCH_TIMEOUT_MS = 60_000; /** One context close, or an engine run ending after its window closed; longer is abandoned (and the browser killed). */ export const CLOSE_TIMEOUT_MS = 15_000; /** * Hook timeouts. afterEach: a test's contexts close in parallel, each capped at CLOSE_TIMEOUT_MS, then the engine runs * it started, each capped at CLOSE_TIMEOUT_MS. afterAll: disconnect (capped) and kill the browser server. */ export const AFTER_EACH_MS = 2 * CLOSE_TIMEOUT_MS + 5_000; export const AFTER_ALL_MS = CLOSE_TIMEOUT_MS + 5_000; /** * The console engine's timings for tests on local fixture pages (production: a 1 s watch round, 200 ms poll rounds, * a missing step waited out for the whole step timeout). A round of 20 ms; a poll ends as "not found" once the page * is loaded and unchanged for 1.5 s (well above the one timer a fixture page has, asc-shell.html's 700 ms redirect), * so a step that is not there no longer costs the whole navTimeoutMs, while a step that is there is still waited for * as long as the machine needs. Set with engineWaits (cli/console/fill.ts) in beforeAll, restored in afterAll. */ export const TEST_TICK_MS = 20; export const TEST_WAITS = { pollMs: 50, quietMs: 1500, settleMs: 50, missMs: 300 }; /** Waits for `p` (its error ignored) or `ms`, whichever is first: true when `p` settled in time. Never rejects. */ export async function capped(p: Promise, ms: number): Promise { let t: ReturnType | undefined; const ok = await Promise.race([p.then(() => true, () => true), new Promise(r => { t = setTimeout(() => r(false), ms); })]); clearTimeout(t); return ok; } export interface TestBrowser { /** A connected Playwright Browser, or null when no Chromium is installed (then the browser tests skip). */ browser: any; skipWhy: string; /** afterAll: disconnects and kills the browser process (a throwaway profile: nothing to flush). Fast and bounded. */ close: () => Promise; } /** * Launches headless Chromium once, as a browser server the tests connect to: playwright's own build, else Google Chrome * (channel "chrome"). `browser` is null only when neither is installed; a launch that times out or crashes throws. */ export async function launchTestBrowser(pkg: 'playwright' | 'playwright-core'): Promise { const none = (e: unknown): TestBrowser => ({ browser: null, skipWhy: firstLine(e), close: async () => {} }); let chromium: any; try { chromium = (await import(pkg)).chromium; } catch (e) { return none(e); } const opts = { headless: true, timeout: LAUNCH_TIMEOUT_MS }; let server: any; try { server = await chromium.launchServer(opts); } catch (e) { if (!notInstalled(e)) throw e; try { server = await chromium.launchServer({ ...opts, channel: 'chrome' }); } catch (e2) { if (!notInstalled(e2)) throw e2; return none(e); } } let browser: any; try { browser = await chromium.connect(server.wsEndpoint(), { timeout: LAUNCH_TIMEOUT_MS }); } catch (e) { await server.kill(); throw e; } return { browser, skipWhy: '', close: async () => { await capped(browser.close(), CLOSE_TIMEOUT_MS); // a connected browser: closes its contexts and disconnects await server.kill(); // SIGKILL, then playwright removes the temporary profile }, }; } const firstLine = (e: unknown) => (e as Error).message.split('\n')[0]!; /** Playwright's "no such browser" errors (bundled build not downloaded, Chrome channel not installed). */ const notInstalled = (e: unknown) => /Executable doesn't exist|distribution '[^']+' is not found|Failed to launch .* because executable/i.test((e as Error).message); /** * The browser the persistent-profile tests (tests/console-persist.test.ts) launch: the same kind `console fill` uses * for storebound's own profile (cli/shots/browser.ts, kind "visible"): real Google Chrome (channel "chrome"), else * Playwright's FULL Chromium (channel "chromium"). Never the fast headless shell launchTestBrowser uses: it ignores * --restore-last-session, so session cookies are dropped on relaunch. `label` is null when neither is installed. */ export async function persistentBrowser(): Promise<{ label: string | null; skipWhy: string }> { const { installedBrowsers, launchCandidates } = await import('../../cli/shots/browser'); let pw: any = null; try { pw = (await import('playwright')).chromium; } catch (e) { return { label: null, skipWhy: `playwright is not installed (${firstLine(e)})` }; } // STOREBOUND_CHROME left out: the tests pin the resolver's choice of an installed Chrome / full Chromium const c = launchCandidates('visible', installedBrowsers(pw, { ...process.env, STOREBOUND_CHROME: undefined }))[0]; return c ? { label: c.label, skipWhy: '' } : { label: null, skipWhy: 'neither Google Chrome nor Playwright\'s full Chromium is installed (the headless shell drops session cookies on relaunch); install Google Chrome or run `bunx playwright install chromium`' }; } /** The contexts and engine runs of one test (see testScope). */ function plainScope(browser: () => any) { const contexts = new Set(); const runs = new Set>(); return { async newPage(opts?: Record): Promise { const ctx = await browser().newContext(opts); contexts.add(ctx); return ctx.newPage(); }, track(ctx: T): T { contexts.add(ctx); return ctx; }, /** An engine run (fillSession ...) that ends when its context closes. */ run(p: Promise): Promise { runs.add(p); return p; }, async settle(): Promise { const cs = [...contexts], rs = [...runs]; contexts.clear(); runs.clear(); await Promise.all(cs.map(c => capped(c.close(), CLOSE_TIMEOUT_MS))); await Promise.all(rs.map(r => capped(r, CLOSE_TIMEOUT_MS))); }, }; } /** * The contexts and engine runs of the current test. `newPage` opens a page in a fresh context (as browser.newPage * does) and tracks it; `track` adds one opened elsewhere; `settle` (afterEach) closes them all and waits for the runs. * `concurrent` declares a browser test that runs at the same time as the file's other concurrent tests * (test.concurrent, at most --max-concurrency at once: tools/ci.ts passes BROWSER_CONCURRENCY from tools/ci-plan.ts): it gets a scope of * its own (AsyncLocalStorage, so newPage / track / run inside it reach that scope), settled when it ends, inside its * timeout. The tests share the one browser; each has its own contexts, pages and log. */ export function testScope(browser: () => any) { type Scope = ReturnType; const shared = plainScope(browser); const own = new AsyncLocalStorage(); const cur = (): Scope => own.getStore() ?? shared; return { newPage: (opts?: Record): Promise => cur().newPage(opts), track: (ctx: T): T => cur().track(ctx), run: (p: Promise): Promise => cur().run(p), settle: (): Promise => shared.settle(), concurrent(name: string, fn: () => Promise, ms: number): void { test.concurrent(name, () => { const s = plainScope(browser); return own.run(s, async () => { try { await fn(); } finally { await s.settle(); } }); }, ms + AFTER_EACH_MS); }, }; } /** * The log lines of an engine run (`say`), with event-driven waits: `wait` resolves on the line that makes `pred` * true (checked on every new line, no polling) and fails after `ms` with every line seen so far. */ export function logLines() { const lines: string[] = []; const waiters = new Set<() => void>(); return { lines, say: (m: string) => { lines.push(m); for (const w of [...waiters]) w(); }, wait(what: string, pred: (lines: string[]) => boolean, ms = 30_000): Promise { if (pred(lines)) return Promise.resolve(); return new Promise((resolve, reject) => { const check = () => { if (pred(lines)) { done(); resolve(); } }; const t = setTimeout(() => { done(); reject(new Error(`timed out after ${ms} ms waiting for: ${what}\n${lines.join('\n')}`)); }, ms); const done = () => { clearTimeout(t); waiters.delete(check); }; waiters.add(check); }); }, }; }