cdp.ts
// A small typed CDP client: JSON-RPC over a websocket to one page target.
//
// Trusted input dispatch (Input.dispatchKeyEvent / Input.dispatchMouseEvent --
// synthetic DOM events cannot drive real focus traversal, real typing, or real
// hover) plus Runtime.evaluate state reads between steps.
// Runs on Node 22's built-in WebSocket; no dependencies.
// The named (non-printable) keys that key() accepts:
// windowsVirtualKeyCode + code, exactly what a real keyboard produces.
const KEYDEFS: Record<string, [number, string]> = {
Tab: [9, "Tab"],
Enter: [13, "Enter"],
Escape: [27, "Escape"],
ArrowUp: [38, "ArrowUp"],
ArrowDown: [40, "ArrowDown"],
ArrowLeft: [37, "ArrowLeft"],
ArrowRight: [39, "ArrowRight"],
Home: [36, "Home"],
End: [35, "End"],
Delete: [46, "Delete"],
" ": [32, "Space"],
};
interface PendingCommand {
resolve: (result: unknown) => void;
reject: (err: Error) => void;
}
// A protocol event (a message with a method and no id), e.g. Runtime.consoleAPICalled.
export interface CdpEvent {
method: string;
params: unknown;
}
// The slice of WebSocket that Cdp uses. Cdp.connect supplies a real one;
// Cdp.fromSocket takes anything with this shape, which is how the unit tests
// drive the client without a browser.
export interface CdpSocket {
readonly readyState: number;
addEventListener(type: "message", listener: (ev: { data: unknown }) => void): void;
addEventListener(type: "close" | "error", listener: () => void): void;
send(data: string): void;
close(): void;
}
export class Cdp {
private readonly ws: CdpSocket;
private msgid = 0;
private readonly pending = new Map<number, PendingCommand>();
// Every protocol event received on this connection, in arrival order.
// Domains emit nothing until enabled (e.g. Runtime.enable for console capture),
// so this stays empty unless a scenario asks for events.
readonly events: CdpEvent[] = [];
private constructor(ws: CdpSocket) {
this.ws = ws;
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
ws.addEventListener("close", () => this.failPending());
ws.addEventListener("error", () => this.failPending());
}
static async connect(wsUrl: string): Promise<Cdp> {
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", () => reject(new Error(`websocket connect failed: ${wsUrl}`)), {
once: true,
});
});
return new Cdp(ws);
}
// Wrap an already-open socket.
static fromSocket(ws: CdpSocket): Cdp {
return new Cdp(ws);
}
close(): void {
this.ws.close();
}
// Nothing outstanding can be answered once the socket is gone; reject it all
// so a dead browser surfaces as an error rather than a hang.
private failPending(): void {
for (const waiter of this.pending.values()) {
waiter.reject(new Error("CDP connection closed"));
}
this.pending.clear();
}
private onMessage(raw: string): void {
const msg = JSON.parse(raw) as {
id?: number;
method?: string;
params?: unknown;
result?: unknown;
error?: unknown;
};
if (msg.method !== undefined) {
this.events.push({ method: msg.method, params: msg.params });
return;
}
if (msg.id === undefined) return;
const waiter = this.pending.get(msg.id);
if (waiter === undefined) return;
this.pending.delete(msg.id);
if (msg.error !== undefined) {
waiter.reject(new Error(`CDP error: ${JSON.stringify(msg.error)}`));
} else {
waiter.resolve(msg.result);
}
}
cmd(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
if (this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error(`CDP connection closed; cannot send ${method}`));
}
this.msgid += 1;
const id = this.msgid;
const sent = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
this.ws.send(JSON.stringify({ id, method, params }));
return sent;
}
// Dispatch a trusted key press (down + up). `key` is a DOM KeyboardEvent.key
// value: a named key from KEYDEFS, or a single printable character.
// Shift is only meaningful on named keys (shift+Tab); for a printable
// character, pass the shifted character itself.
// Enter and Space carry their text payload on a full keyDown
// (rawKeyDown skips text insertion; native widgets need the text to act).
async key(key: string, opts: { shift?: boolean } = {}): Promise<void> {
const mods = opts.shift === true ? 8 : 0;
const def = KEYDEFS[key];
if (def === undefined && key.length !== 1) {
throw new Error(`key ${JSON.stringify(key)} is not a single character and not in KEYDEFS`);
}
if (def === undefined && mods !== 0) {
throw new Error(`key ${JSON.stringify(key)}: shift applies to named keys only; pass the shifted character`);
}
if (def !== undefined) {
const [vk, code] = def;
const text = key === " " ? " " : key === "Enter" ? "\r" : "";
const down: Record<string, unknown> = {
type: "rawKeyDown",
windowsVirtualKeyCode: vk,
code,
key,
modifiers: mods,
};
if (text !== "") {
down.type = "keyDown";
down.text = text;
}
await this.cmd("Input.dispatchKeyEvent", down);
await this.cmd("Input.dispatchKeyEvent", {
type: "keyUp",
windowsVirtualKeyCode: vk,
code,
key,
modifiers: mods,
});
return;
}
const vk = key.toUpperCase().charCodeAt(0);
const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : "";
await this.cmd("Input.dispatchKeyEvent", {
type: "keyDown",
windowsVirtualKeyCode: vk,
code,
key,
text: key,
modifiers: mods,
});
await this.cmd("Input.dispatchKeyEvent", {
type: "keyUp",
windowsVirtualKeyCode: vk,
code,
key,
modifiers: mods,
});
}
async mouseMove(x: number, y: number): Promise<void> {
await this.cmd("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
}
async mouseDown(x: number, y: number, button = "left"): Promise<void> {
await this.cmd("Input.dispatchMouseEvent", {
type: "mousePressed",
x,
y,
button,
clickCount: 1,
});
}
async mouseUp(x: number, y: number, button = "left"): Promise<void> {
await this.cmd("Input.dispatchMouseEvent", {
type: "mouseReleased",
x,
y,
button,
clickCount: 1,
});
}
async click(x: number, y: number, button = "left"): Promise<void> {
await this.mouseDown(x, y, button);
await this.mouseUp(x, y, button);
}
// A trusted wheel tick at (x, y). Positive deltaY scrolls down.
async wheel(x: number, y: number, deltaY: number): Promise<void> {
await this.cmd("Input.dispatchMouseEvent", {
type: "mouseWheel",
x,
y,
deltaX: 0,
deltaY,
});
}
// Evaluate an expression in the page, returning its value by JSON.
// Rejects if the expression throws, carrying the page-side exception text.
// awaitPromise resolves a returned promise first (needed by probes that
// measure over a time window).
async js(expr: string, opts: { awaitPromise?: boolean } = {}): Promise<unknown> {
const params: Record<string, unknown> = { expression: expr, returnByValue: true };
if (opts.awaitPromise === true) params.awaitPromise = true;
const r = (await this.cmd("Runtime.evaluate", params)) as {
result?: { value?: unknown };
exceptionDetails?: { text: string; exception?: { description?: string } };
};
if (r.exceptionDetails !== undefined) {
const detail = r.exceptionDetails.exception?.description ?? "";
throw new Error(`page exception in js(${JSON.stringify(expr)}): ${r.exceptionDetails.text} ${detail}`);
}
return r.result?.value;
}
async screenshot(): Promise<Buffer> {
const r = (await this.cmd("Page.captureScreenshot", { format: "png" })) as { data: string };
return Buffer.from(r.data, "base64");
}
}
packages/yukey-testkit/src/cdp.ts · 7.6 KiB · 1037346e88