map/tests/chromium-cdp-page.mjs
2026-08-10 13:59:33 +09:00

296 lines
10 KiB
JavaScript

import { spawn } from "node:child_process";
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(() => {});
}
}
export async function launchChromiumCdp({ executablePath = process.env.CHROMIUM_PATH || "/usr/bin/chromium" } = {}) {
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);
}