/** Minimal Chrome DevTools Protocol driver for the editor e2e: headless profiles, OOPIF frames, screenshots. */ import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const CHROME = process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const POLL_MS = 250; const DEFAULT_WAIT_MS = 45_000; const START_ATTEMPTS = 80; type Reply = { result?: unknown; error?: { message: string } }; export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function waitFor( label: string, probe: () => Promise, timeoutMs = DEFAULT_WAIT_MS, ): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown = null; while (Date.now() < deadline) { try { const value = await probe(); if (value) return value; } catch (error) { lastError = error; } await sleep(POLL_MS); } throw new Error(`timed out waiting for ${label}${lastError ? ` (${String(lastError)})` : ""}`); } /** One headless Chrome with its own profile, so two instances are two independent browsers (no shared cookies). */ export class Browser { private socket!: WebSocket; private nextId = 1; private pending = new Map void>(); readonly frames: Array<{ sessionId: string; url: string }> = []; readonly logs: string[] = []; private constructor( private readonly chrome: ChildProcess, private readonly profile: string, ) {} static async launch(label: string): Promise { const port = 9500 + Math.floor(Math.random() * 400); const profile = mkdtempSync(join(tmpdir(), `editor-e2e-${label}-`)); const chrome = spawn(CHROME, [ "--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "--no-first-run", "--window-size=1400,900", "about:blank", ]); const browser = new Browser(chrome, profile); let url = ""; for (let attempt = 0; attempt < START_ATTEMPTS && !url; attempt++) { await sleep(POLL_MS); const targets = await fetch(`http://127.0.0.1:${port}/json`).then( (response) => response.json() as Promise>, () => [], ); url = targets.find((target) => target.type === "page")?.webSocketDebuggerUrl ?? ""; } if (!url) throw new Error("Chrome did not start"); browser.socket = new WebSocket(url); await new Promise((resolve) => browser.socket.addEventListener("open", resolve, { once: true })); browser.socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); if (message.id && browser.pending.has(message.id)) browser.pending.get(message.id)?.(message); if (message.method === "Target.attachedToTarget" && message.params.targetInfo.type === "iframe") { browser.frames.push({ sessionId: message.params.sessionId, url: message.params.targetInfo.url }); void browser.send("Runtime.enable", {}, message.params.sessionId).catch(() => undefined); void browser.send("Log.enable", {}, message.params.sessionId).catch(() => undefined); void browser.send("Runtime.runIfWaitingForDebugger", {}, message.params.sessionId).catch(() => undefined); } if (message.method === "Runtime.consoleAPICalled") { const text = message.params.args .map((arg: { value?: unknown; description?: string }) => arg.value ?? arg.description) .join(" "); browser.logs.push(`[${message.params.type}] ${text}`); } if (message.method === "Runtime.exceptionThrown") { browser.logs.push( `[exception] ${message.params.exceptionDetails.exception?.description ?? message.params.exceptionDetails.text}`, ); } if (message.method === "Log.entryAdded") browser.logs.push( `[${message.params.entry.level}] ${message.params.entry.text} ${message.params.entry.url ?? ""}`, ); if (message.method === "Target.detachedFromTarget") { const index = browser.frames.findIndex((frame) => frame.sessionId === message.params.sessionId); if (index >= 0) browser.frames.splice(index, 1); } }); await browser.send("Page.enable"); await browser.send("Runtime.enable"); await browser.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }); return browser; } async send(method: string, params: Record = {}, sessionId?: string): Promise { const id = this.nextId++; const reply = await new Promise((resolve) => { this.pending.set(id, resolve); this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); this.pending.delete(id); if (reply.error) throw new Error(`${method}: ${reply.error.message}`); return reply.result as T; } async evaluate(expression: string, sessionId?: string): Promise { const result = await this.send<{ result: { value: T }; exceptionDetails?: { text: string; exception?: { description?: string } }; }>("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId); if (result.exceptionDetails) { throw new Error(`evaluate: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`); } return result.result.value; } async navigate(url: string): Promise { await this.send("Page.navigate", { url }); await waitFor("page load", () => this.evaluate("document.readyState === 'complete'")); } async screenshot(path: string, sessionId?: string): Promise { const shot = await this.send<{ data: string }>("Page.captureScreenshot", { format: "png" }, sessionId); writeFileSync(path, Buffer.from(shot.data, "base64")); } async type(text: string, sessionId?: string): Promise { for (const char of text) { if (char === "\n") { await this.send( "Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" }, sessionId, ); await this.send( "Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, sessionId, ); } else { await this.send("Input.insertText", { text: char }, sessionId); } } } async key(key: string, code: string, keyCode: number, modifiers = 0, sessionId?: string): Promise { await this.send( "Input.dispatchKeyEvent", { type: "rawKeyDown", key, code, windowsVirtualKeyCode: keyCode, modifiers }, sessionId, ); await this.send( "Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: keyCode, modifiers }, sessionId, ); } async click(x: number, y: number, sessionId?: string): Promise { for (const type of ["mousePressed", "mouseReleased"]) { await this.send("Input.dispatchMouseEvent", { type, x, y, button: "left", clickCount: 1 }, sessionId); } } /** Signs in through the real login form (React needs native value setters to see the input). */ async login(base: string, login: string, password: string): Promise { await this.navigate(`${base}/login`); await waitFor("login form", () => this.evaluate(`!!document.querySelector('input[name="password"]')`)); await sleep(2000); await this.evaluate(`(() => { const set = (name, value) => { const input = document.querySelector('input[name="' + name + '"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }; set("login", ${JSON.stringify(login)}); set("password", ${JSON.stringify(password)}); document.querySelector('button[type="submit"]').click(); })()`); await waitFor("sign-in", () => this.evaluate("!location.pathname.startsWith('/login')")); } /** The OOPIF session whose document matches `predicate` (evaluated inside each frame). */ frame(predicate: string, label: string, timeoutMs?: number): Promise { return waitFor( label, async () => { for (const frame of [...this.frames]) { const ok = await this.evaluate(predicate, frame.sessionId).catch(() => false); if (ok) return frame.sessionId; } return null; }, timeoutMs, ); } close(): void { try { this.socket.close(); } catch { // already closed } this.chrome.kill(); rmSync(this.profile, { recursive: true, force: true }); } }