import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; /** Path of a usable Chrome, or null (the browser tests are skipped without one). */ export const chromePath = Bun.which("google-chrome") ?? Bun.which("chromium") ?? Bun.which("chromium-browser"); /** A minimal Chrome DevTools Protocol driver for headless end-to-end checks. */ export class Chrome { private ws!: WebSocket; private id = 0; private pending = new Map void>(); readonly console: string[] = []; readonly dialogs: string[] = []; private proc!: Bun.Subprocess; private dir!: string; static async launch(): Promise { const c = new Chrome(); c.dir = mkdtempSync(join(tmpdir(), "stc-chrome-")); const port = 9400 + Math.floor(Math.random() * 500); c.proc = Bun.spawn([chromePath!, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${c.dir}`, "about:blank"], { stdout: "ignore", stderr: "ignore" }); let url: string | null = null; for (let i = 0; i < 100 && !url; i++) { await Bun.sleep(100); try { const list = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()) as { type: string; webSocketDebuggerUrl: string }[]; url = list.find((t) => t.type === "page")?.webSocketDebuggerUrl ?? null; } catch { /* not up yet */ } } if (!url) throw new Error("chrome did not start"); c.ws = new WebSocket(url); await new Promise((r) => c.ws.addEventListener("open", r, { once: true })); c.ws.addEventListener("message", (ev) => { const m = JSON.parse(String(ev.data)); if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)!(m); c.pending.delete(m.id); } if (m.method === "Runtime.consoleAPICalled") c.console.push(`${m.params.type}: ${m.params.args.map((a: any) => a.value ?? a.description).join(" ")}`); if (m.method === "Runtime.exceptionThrown") c.console.push(`exception: ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`); if (m.method === "Page.javascriptDialogOpening") { c.dialogs.push(m.params.message); void c.send("Page.handleJavaScriptDialog", { accept: true }); } }); await c.send("Page.enable"); await c.send("Runtime.enable"); return c; } send(method: string, params: object = {}): Promise<{ result?: any; error?: any }> { return new Promise((r) => { const i = ++this.id; this.pending.set(i, r); this.ws.send(JSON.stringify({ id: i, method, params })); }); } async goto(url: string, settleMs = 1500) { await this.send("Page.navigate", { url }); await Bun.sleep(settleMs); } /** Evaluates `js` in the page (awaiting promises) and returns the JSON value. */ async eval(js: string): Promise { const r = await this.send("Runtime.evaluate", { expression: js, awaitPromise: true, returnByValue: true }); if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description ?? "evaluation failed"); return r.result?.result?.value as T; } async close() { try { this.ws.close(); } catch { /* closed */ } this.proc.kill(); await this.proc.exited; rmSync(this.dir, { recursive: true, force: true }); } }