import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } class CdpSocket { constructor(url) { this.url = url; this.ws = null; this.nextId = 0; this.pending = new Map(); } async connect() { const ws = new WebSocket(this.url); this.ws = ws; await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timed out connecting to CDP target ${this.url}`)), 10_000); ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); ws.addEventListener("error", (event) => { clearTimeout(timer); reject(event?.error || new Error("CDP WebSocket connection failed.")); }, { once: true }); }); ws.addEventListener("message", (event) => { let message; try { message = JSON.parse(String(event.data)); } catch { return; } if (message.id == null) return; const waiter = this.pending.get(message.id); if (!waiter) return; this.pending.delete(message.id); if (message.error) waiter.reject(new Error(message.error.message || JSON.stringify(message.error))); else waiter.resolve(message.result || {}); }); ws.addEventListener("close", () => { for (const waiter of this.pending.values()) waiter.reject(new Error("CDP target closed.")); this.pending.clear(); }); } send(method, params = {}) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error(`CDP socket is not open for ${method}.`)); } const id = ++this.nextId; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } close() { try { this.ws?.close(); } catch {} } } class CdpPage { constructor(browser, target) { this.browser = browser; this.target = target; this.timeoutMs = 30_000; this.cdp = new CdpSocket(target.webSocketDebuggerUrl); this.mouse = { click: async (x, y) => { const px = Number(x); const py = Number(y); if (!Number.isFinite(px) || !Number.isFinite(py)) { throw new TypeError(`Invalid mouse coordinates: ${x}, ${y}`); } await this.cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, }); await this.cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: px, y: py, button: "left", buttons: 1, clickCount: 1, }); await this.cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: px, y: py, button: "left", buttons: 0, clickCount: 1, }); }, }; } async init() { await this.cdp.connect(); await Promise.all([ this.cdp.send("Page.enable"), this.cdp.send("Runtime.enable"), ]); return this; } setDefaultTimeout(ms) { this.timeoutMs = Number(ms) || this.timeoutMs; } async _runtimeEvaluate(expression) { const result = await this.cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, userGesture: true, }); if (result.exceptionDetails) { const description = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Runtime.evaluate failed."; throw new Error(description); } return result.result?.value; } async evaluate(fn, arg) { if (typeof fn === "string") return this._runtimeEvaluate(fn); if (typeof fn !== "function") throw new TypeError("page.evaluate requires a function or expression string."); const argument = arguments.length >= 2 ? JSON.stringify(arg) : ""; const expression = argument ? `(${fn.toString()})(${argument})` : `(${fn.toString()})()`; return this._runtimeEvaluate(expression); } async goto(url, options = {}) { const timeout = Number(options.timeout || this.timeoutMs); const navigation = await this.cdp.send("Page.navigate", { url }); if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`); const started = Date.now(); while (Date.now() - started < timeout) { const ready = await this._runtimeEvaluate("document.readyState").catch(() => "loading"); if (ready === "interactive" || ready === "complete") return; await wait(25); } throw new Error(`Navigation timed out after ${timeout} ms: ${url}`); } async waitForFunction(fn, arg, options = {}) { const timeout = Number(options.timeout || this.timeoutMs); const started = Date.now(); let lastError = null; while (Date.now() - started < timeout) { try { if (await this.evaluate(fn, arg)) return true; lastError = null; } catch (error) { lastError = error; } await wait(40); } const suffix = lastError ? ` Last evaluation error: ${lastError.message}` : ""; throw new Error(`waitForFunction timed out after ${timeout} ms.${suffix}`); } locator(selector) { const page = this; const normalized = String(selector || ""); return { async click(options = {}) { const timeout = Number(options.timeout || page.timeoutMs); const started = Date.now(); let rect = null; while (Date.now() - started < timeout) { rect = await page.evaluate((css) => { const element = document.querySelector(css); if (!element) return null; const box = element.getBoundingClientRect(); const style = getComputedStyle(element); const disabled = Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"); if (disabled || style.display === "none" || style.visibility === "hidden" || box.width <= 0 || box.height <= 0) return null; return { x: box.left + box.width / 2, y: box.top + box.height / 2, }; }, normalized).catch(() => null); if (rect && Number.isFinite(rect.x) && Number.isFinite(rect.y)) break; await wait(25); } if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y)) { throw new Error(`Unable to click ${normalized}: element was not actionable within ${timeout} ms.`); } // CDP Input events enter Chromium through the browser input pipeline and // therefore produce trusted DOM events. This is materially different from // calling element.click(), which would bypass the input-latency gate. await page.mouse.click(rect.x, rect.y); }, }; } async close() { this.cdp.close(); try { await fetch(`${this.browser.httpOrigin}/json/close/${encodeURIComponent(this.target.id)}`); } catch {} } } class CdpBrowser { constructor(process, userDataDir, httpOrigin) { this.process = process; this.userDataDir = userDataDir; this.httpOrigin = httpOrigin; } async newPage() { const response = await fetch(`${this.httpOrigin}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" }); if (!response.ok) throw new Error(`Unable to create Chromium target: HTTP ${response.status}`); const target = await response.json(); return new CdpPage(this, target).init(); } async close() { if (this.process.exitCode == null && !this.process.killed) { try { this.process.kill("SIGTERM"); } catch {} await Promise.race([ new Promise((resolve) => this.process.once("exit", resolve)), wait(3000), ]); } if (this.process.exitCode == null && !this.process.killed) { try { this.process.kill("SIGKILL"); } catch {} } await rm(this.userDataDir, { recursive: true, force: true }).catch(() => {}); } } function defaultChromiumExecutable() { const candidates = process.platform === "win32" ? [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs", "Microsoft Edge", "Application", "msedge.exe"), process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe"), process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Microsoft", "Edge", "Application", "msedge.exe"), process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe"), ] : ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"]; return candidates.filter(Boolean).find((candidate) => existsSync(candidate)) || candidates.filter(Boolean)[0]; } export async function launchChromiumCdp({ executablePath = process.env.CHROMIUM_PATH || defaultChromiumExecutable() } = {}) { const userDataDir = await mkdtemp(join(tmpdir(), "jmg-chromium-")); const child = spawn(executablePath, [ "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", // Headless targets otherwise receive Chromium's background renderer/worker // scheduling priority. Additional-generation acceptance is defined for an // actively used foreground map, so keep the CDP fallback at foreground // scheduling semantics rather than benchmarking an artificially throttled // tab. "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--no-proxy-server", "--host-resolver-rules=MAP jmg.test 127.0.0.1", "--disable-default-apps", "--disable-extensions", "--disable-sync", "--metrics-recording-only", "--mute-audio", "--no-first-run", "--enable-precise-memory-info", "--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, "about:blank", ], { stdio: ["ignore", "ignore", "pipe"] }); let stderr = ""; const endpoint = await new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Chromium did not expose a DevTools endpoint.\n${stderr.slice(-4000)}`)); }, 15_000); const onData = (chunk) => { const text = chunk.toString(); stderr = `${stderr}${text}`.slice(-8000); const match = stderr.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); if (!match) return; clearTimeout(timer); child.stderr.off("data", onData); resolve(`http://127.0.0.1:${match[1]}`); }; child.stderr.on("data", onData); child.once("error", (error) => { clearTimeout(timer); reject(error); }); child.once("exit", (code) => { if (code == null || code === 0) return; clearTimeout(timer); reject(new Error(`Chromium exited before CDP startup with code ${code}.\n${stderr}`)); }); }).catch(async (error) => { try { child.kill("SIGKILL"); } catch {} await rm(userDataDir, { recursive: true, force: true }).catch(() => {}); throw error; }); return new CdpBrowser(child, userDataDir, endpoint); }