309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
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);
|
|
}
|