212 lines
13 KiB
TypeScript
212 lines
13 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 */
|
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import { test } from 'bun:test';
|
|
import { LOAD, loadFactor, loadNote, RETRY_LOAD, RETRY_MARK, scaled } from './load';
|
|
|
|
export { LOAD, scaled } from './load';
|
|
|
|
/** Launching Chromium: well above a cold start on a loaded machine (seconds), far below "hung". */
|
|
export const LAUNCH_TIMEOUT_MS = scaled(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 = scaled(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<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 () => {} });
|
|
console.log(`browser tests: ${loadNote()}`);
|
|
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, persistentExecutable } = 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 (headless on
|
|
// macOS: Chromium first, so a test run never counts as the owner's running "Google Chrome", cli/console/chrome-guard.ts)
|
|
let c: { label: string } | null = null;
|
|
try { c = persistentExecutable('visible', installedBrowsers(pw, { ...process.env, STOREBOUND_CHROME: undefined }), {}, { headless: true }); } catch { /* none */ }
|
|
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<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 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<typeof plainScope>;
|
|
const shared = plainScope(browser);
|
|
const own = new AsyncLocalStorage<Scope>();
|
|
const cur = (): Scope => own.getStore() ?? shared;
|
|
return {
|
|
newPage: (opts?: Record<string, unknown>): Promise<any> => cur().newPage(opts),
|
|
track: <T>(ctx: T): T => cur().track(ctx),
|
|
run: <T>(p: Promise<T>): Promise<T> => cur().run(p),
|
|
settle: (): Promise<void> => shared.settle(),
|
|
/** `ms`: the test's budget, already scaled by the load (scaled(), tests/helpers/load.ts). A run that fails by timing
|
|
* out (a wait, a Playwright timeout or the budget itself) while the load factor is above RETRY_LOAD runs once
|
|
* more in a fresh scope and prints RETRY_MARK; an assertion failure is never retried. */
|
|
concurrent(name: string, fn: () => Promise<void>, ms: number): void {
|
|
const attempt = () => {
|
|
const s = plainScope(browser);
|
|
return own.run(s, () => withBudget(fn, ms, () => s.settle()));
|
|
};
|
|
test.concurrent(name, () => retryUnderLoad(name, attempt), 2 * (ms + AFTER_EACH_MS));
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A wait that ran out of time (logLines().wait, a test's budget): the one kind of failure a loaded machine may cause. */
|
|
export class WaitTimeout extends Error { override name = 'WaitTimeout'; }
|
|
/** A failure by timing out (ours or Playwright's), never an assertion. */
|
|
export const isTimeout = (e: unknown): boolean => e instanceof WaitTimeout || (e as Error | null)?.name === 'TimeoutError';
|
|
|
|
/** Runs `fn` within `ms` (else fails with a WaitTimeout), then `after` (the test's settle) either way. */
|
|
export async function withBudget(fn: () => Promise<void>, ms: number, after: () => Promise<void> = async () => {}): Promise<void> {
|
|
let t: ReturnType<typeof setTimeout> | undefined;
|
|
const over = new Promise<never>((_, reject) => { t = setTimeout(() => reject(new WaitTimeout(`test budget of ${ms} ms used up`)), ms); });
|
|
try { await Promise.race([fn(), over]); } finally { clearTimeout(t); await after(); }
|
|
}
|
|
|
|
/** Runs `attempt`; when it fails by timing out while the load factor is above RETRY_LOAD, says RETRY_MARK (tools/ci.ts
|
|
* lists those in its summary) and runs it once more. Any other failure, or a timeout on a quiet machine, is final. */
|
|
export async function retryUnderLoad(name: string, attempt: () => Promise<void>, factor: () => number = loadFactor, say: (m: string) => void = m => console.error(m)): Promise<void> {
|
|
try { await attempt(); } catch (e) {
|
|
const f = factor();
|
|
if (!isTimeout(e) || f <= RETRY_LOAD) throw e;
|
|
// the whole error under the mark line: a wait's timeout lists every log line with its time (where it went)
|
|
const rest = (e as Error).message.split('\n').slice(1).map(l => ` ${l}`).join('\n');
|
|
say(`${RETRY_MARK} ${name} (load x${f}): ${firstLine(e)}${rest ? `\n${rest}` : ''}`);
|
|
await attempt();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 at: number[] = [];
|
|
const t0 = Date.now();
|
|
const waiters = new Set<() => void>();
|
|
/** The lines with the seconds since the log started (a timeout dump shows where the time went). */
|
|
const timed = () => lines.map((l, i) => `+${((at[i]! - t0) / 1000).toFixed(1)}s ${l}`).join('\n');
|
|
return {
|
|
lines,
|
|
say: (m: string) => { lines.push(m); at.push(Date.now()); for (const w of [...waiters]) w(); },
|
|
/** `ms`: already scaled by the caller (scaled()); the default is 30 s scaled by the load. */
|
|
wait(what: string, pred: (lines: string[]) => boolean, ms = scaled(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 WaitTimeout(`timed out after ${ms} ms waiting for: ${what}\n${timed()}`)); }, ms);
|
|
const done = () => { clearTimeout(t); waiters.delete(check); };
|
|
waiters.add(check);
|
|
});
|
|
},
|
|
};
|
|
}
|