yukey

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;
}

// 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).  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 · 7.0 KiB · 1cd98c6430