storebound/tests/helpers/browser.ts
CRT de243cdbd9 tests: profile persistence runs in a real Chrome, in its own shard
The two "session cookie survives closing and relaunching storebound's own
profile" tests failed with Playwright's headless shell, which ignores
--restore-last-session. They now live in tests/console-persist.test.ts
and launch the same browser kind console fill uses for its profile (the
resolver's "visible" kind: Google Chrome, else the full Chromium; headless
here, so no window). They skip with the reason only when neither is
installed. All other browser tests keep the fast headless shell. The file
is HEAVY in tools/ci-plan.ts, so it never shares a shard with another
browser suite.
2026-10-12 06:27:42 +08:00

136 lines
7.8 KiB
TypeScript

// 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 */
/** 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;
/** 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<unknown>, ms: number): Promise<boolean> {
let t: ReturnType<typeof setTimeout> | undefined;
const ok = await Promise.race([p.then(() => true, () => true), new Promise<boolean>(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<void>;
}
/**
* 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<TestBrowser> {
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 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.
*/
export function testScope(browser: () => any) {
const contexts = new Set<any>();
const runs = new Set<Promise<unknown>>();
return {
async newPage(opts?: Record<string, unknown>): Promise<any> {
const ctx = await browser().newContext(opts);
contexts.add(ctx);
return ctx.newPage();
},
track<T>(ctx: T): T { contexts.add(ctx); return ctx; },
/** An engine run (fillSession ...) that ends when its context closes. */
run<T>(p: Promise<T>): Promise<T> { runs.add(p); return p; },
async settle(): Promise<void> {
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 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<void> {
if (pred(lines)) return Promise.resolve();
return new Promise<void>((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);
});
},
};
}