cdp.ts
// A small typed CDP client: JSON-RPC over a websocket to one page target.
//
// This is the shared core of the e2e scenarios: 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.
// Key definitions for the non-printable keys the scenarios use:
// 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;
}
export class Cdp {
private readonly ws: WebSocket;
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: WebSocket) {
this.ws = ws;
ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
}
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);
}
close(): void {
this.ws.close();
}
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> {
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). Named keys come from KEYDEFS;
// anything else is treated as a printable character.
// 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) {
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 (the map
// reads that as zoom OUT); negative zooms in, anchored under the cursor.
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.
// 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 } };
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 · 5.9 KiB · f2166ce42f