yukey

cdp.test.ts

// Cdp against a fake socket: no Chrome, no network.  The fake records every
// frame sent and lets a test script the replies and the socket's lifecycle.

import { describe, expect, it } from "vitest";
import { Cdp, type CdpSocket } from "./cdp.js";

interface Frame {
	id: number;
	method: string;
	params: Record<string, unknown>;
}

class FakeSocket implements CdpSocket {
	readyState: number = WebSocket.OPEN;
	readonly sent: Frame[] = [];
	private readonly listeners = new Map<string, ((ev: { data: unknown }) => void)[]>();
	// How to answer each frame; the default acknowledges with an empty result, undefined never answers.
	reply: (frame: Frame) => Record<string, unknown> | undefined = () => ({ result: {} });

	addEventListener(type: string, listener: (ev: { data: unknown }) => void): void {
		const list = this.listeners.get(type) ?? [];
		list.push(listener);
		this.listeners.set(type, list);
	}

	// Like the real thing, a frame sent on a socket that isn't open is dropped silently.
	send(data: string): void {
		if (this.readyState !== WebSocket.OPEN) return;
		const frame = JSON.parse(data) as Frame;
		this.sent.push(frame);
		const reply = this.reply(frame);
		if (reply !== undefined) this.emit("message", { data: JSON.stringify({ id: frame.id, ...reply }) });
	}

	close(): void {
		this.readyState = WebSocket.CLOSED;
		this.emit("close", { data: undefined });
	}

	emit(type: string, ev: { data: unknown }): void {
		for (const l of this.listeners.get(type) ?? []) l(ev);
	}
}

// Whether a promise has settled (either way) once the microtask queue drains.
async function settled(p: Promise<unknown>): Promise<boolean> {
	let done = false;
	p.then(() => (done = true), () => (done = true));
	await new Promise<void>((r) => setImmediate(r));
	return done;
}

describe("Cdp.key", () => {
	async function keyParams(key: string, opts?: { shift?: boolean }): Promise<Record<string, unknown>[]> {
		const sock = new FakeSocket();
		await Cdp.fromSocket(sock).key(key, opts);
		for (const f of sock.sent) expect(f.method).toBe("Input.dispatchKeyEvent");
		return sock.sent.map((f) => f.params);
	}

	it("sends Tab as a rawKeyDown/keyUp pair with the virtual key code", async () => {
		expect(await keyParams("Tab")).toEqual([
			{ type: "rawKeyDown", windowsVirtualKeyCode: 9, code: "Tab", key: "Tab", modifiers: 0 },
			{ type: "keyUp", windowsVirtualKeyCode: 9, code: "Tab", key: "Tab", modifiers: 0 },
		]);
	});

	it("sends shift+Tab with the shift modifier bit on both halves", async () => {
		const params = await keyParams("Tab", { shift: true });
		expect(params.map((p) => p.modifiers)).toEqual([8, 8]);
	});

	it("sends Enter as a full keyDown carrying its text", async () => {
		expect(await keyParams("Enter")).toEqual([
			{ type: "keyDown", windowsVirtualKeyCode: 13, code: "Enter", key: "Enter", modifiers: 0, text: "\r" },
			{ type: "keyUp", windowsVirtualKeyCode: 13, code: "Enter", key: "Enter", modifiers: 0 },
		]);
	});

	it("sends space as a full keyDown carrying its text", async () => {
		expect(await keyParams(" ")).toEqual([
			{ type: "keyDown", windowsVirtualKeyCode: 32, code: "Space", key: " ", modifiers: 0, text: " " },
			{ type: "keyUp", windowsVirtualKeyCode: 32, code: "Space", key: " ", modifiers: 0 },
		]);
	});

	it("sends a printable character with its text and KeyX code", async () => {
		expect(await keyParams("a")).toEqual([
			{ type: "keyDown", windowsVirtualKeyCode: 65, code: "KeyA", key: "a", modifiers: 0, text: "a" },
			{ type: "keyUp", windowsVirtualKeyCode: 65, code: "KeyA", key: "a", modifiers: 0 },
		]);
	});

	it("rejects a multi-character name that isn't a known key, sending nothing", async () => {
		const sock = new FakeSocket();
		await expect(Cdp.fromSocket(sock).key("F1")).rejects.toThrow(/KEYDEFS/);
		expect(sock.sent).toEqual([]);
	});

	it("rejects shift on a printable character (type the shifted character instead)", async () => {
		const sock = new FakeSocket();
		await expect(Cdp.fromSocket(sock).key("a", { shift: true })).rejects.toThrow(/shift/);
		expect(sock.sent).toEqual([]);
	});
});

describe("Cdp.cmd", () => {
	it("resolves with the reply's result", async () => {
		const sock = new FakeSocket();
		sock.reply = () => ({ result: { answer: 42 } });
		expect(await Cdp.fromSocket(sock).cmd("Any.thing", { x: 1 })).toEqual({ answer: 42 });
		expect(sock.sent).toEqual([{ id: 1, method: "Any.thing", params: { x: 1 } }]);
	});

	it("rejects on an error reply, with the error in the message", async () => {
		const sock = new FakeSocket();
		sock.reply = () => ({ error: { code: -32601, message: "no such method" } });
		await expect(Cdp.fromSocket(sock).cmd("Nope.nope")).rejects.toThrow(/no such method/);
	});

	it("records a method message as an event", async () => {
		const sock = new FakeSocket();
		const cdp = Cdp.fromSocket(sock);
		sock.emit("message", { data: JSON.stringify({ method: "Runtime.consoleAPICalled", params: { type: "log" } }) });
		expect(cdp.events).toEqual([{ method: "Runtime.consoleAPICalled", params: { type: "log" } }]);
	});

	it("rejects an outstanding command when the socket closes", async () => {
		const sock = new FakeSocket();
		sock.reply = () => undefined;
		const pending = Cdp.fromSocket(sock).cmd("Slow.thing");
		sock.close();
		expect(await settled(pending)).toBe(true);
		await expect(pending).rejects.toThrow(/closed/);
	});

	it("rejects a command sent after the socket closed", async () => {
		const sock = new FakeSocket();
		const cdp = Cdp.fromSocket(sock);
		sock.close();
		const late = cdp.cmd("Late.thing");
		expect(await settled(late)).toBe(true);
		await expect(late).rejects.toThrow(/closed/);
	});
});

describe("Cdp.js", () => {
	it("returns the evaluated value", async () => {
		const sock = new FakeSocket();
		sock.reply = () => ({ result: { result: { type: "number", value: 2 } } });
		expect(await Cdp.fromSocket(sock).js("1+1")).toBe(2);
	});

	it("rejects when the expression throws in the page, with the exception text", async () => {
		const sock = new FakeSocket();
		sock.reply = () => ({
			result: {
				result: { type: "object", subtype: "error", description: "ReferenceError: nosuchvar is not defined" },
				exceptionDetails: {
					text: "Uncaught",
					exception: { description: "ReferenceError: nosuchvar is not defined\n    at <anonymous>:1:1" },
				},
			},
		});
		await expect(Cdp.fromSocket(sock).js("nosuchvar.x")).rejects.toThrow(/nosuchvar is not defined/);
	});
});

packages/yukey-testkit/src/cdp.test.ts · 6.3 KiB · 84fca238b7