186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import test from "node:test";
|
|
import type { Server } from "node:http";
|
|
import type { Duplex } from "node:stream";
|
|
|
|
import { callFunction, connect, evaluate } from "../src/cdp.ts";
|
|
import { evaluateOnSurface } from "../src/surface.ts";
|
|
import type { Surface } from "../src/surface.ts";
|
|
|
|
const TARGET_ID = "page-1";
|
|
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function portFromServer(server: Server): number {
|
|
const address = server.address();
|
|
if (typeof address === "object" && address !== null) return address.port;
|
|
throw new Error("Test server did not expose a TCP port.");
|
|
}
|
|
|
|
function websocketFrame(payload: string): Buffer {
|
|
const body = Buffer.from(payload);
|
|
if (body.length < 126) return Buffer.concat([Buffer.from([0x81, body.length]), body]);
|
|
const header = Buffer.alloc(4);
|
|
header[0] = 0x81;
|
|
header[1] = 126;
|
|
header.writeUInt16BE(body.length, 2);
|
|
return Buffer.concat([header, body]);
|
|
}
|
|
|
|
function handleClientFrames(socket: Duplex): void {
|
|
let buffered = Buffer.alloc(0);
|
|
socket.on("data", (chunk: Buffer) => {
|
|
buffered = Buffer.concat([buffered, chunk]);
|
|
while (buffered.length >= 2) {
|
|
const lengthCode = buffered[1] & 0x7f;
|
|
const lengthBytes = lengthCode === 126 ? 2 : 0;
|
|
if (lengthCode === 127) throw new Error("Test websocket frame is unexpectedly large.");
|
|
const headerLength = 2 + lengthBytes + 4;
|
|
if (buffered.length < headerLength) return;
|
|
const payloadLength = lengthCode === 126 ? buffered.readUInt16BE(2) : lengthCode;
|
|
if (buffered.length < headerLength + payloadLength) return;
|
|
const maskOffset = 2 + lengthBytes;
|
|
const payload = Buffer.alloc(payloadLength);
|
|
for (let index = 0; index < payloadLength; index += 1) {
|
|
payload[index] = buffered[headerLength + index] ^ buffered[maskOffset + (index % 4)];
|
|
}
|
|
buffered = buffered.subarray(headerLength + payloadLength);
|
|
const message: unknown = JSON.parse(payload.toString());
|
|
if (!isRecord(message) || typeof message.id !== "number") continue;
|
|
let result = {};
|
|
if (message.method === "Runtime.evaluate") {
|
|
const params = isRecord(message.params) ? message.params : {};
|
|
result = params.expression === "globalThis"
|
|
? { result: { objectId: "global-object" } }
|
|
: { result: { value: "recovered" } };
|
|
} else if (message.method === "Runtime.callFunctionOn") {
|
|
const params = isRecord(message.params) ? message.params : {};
|
|
const args = Array.isArray(params.arguments) ? params.arguments : [];
|
|
const first = args[0];
|
|
result = { result: { value: isRecord(first) ? first.value : undefined } };
|
|
}
|
|
socket.write(websocketFrame(JSON.stringify({ id: message.id, result })));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function startCdpServer(stalledConnections: number): Promise<{
|
|
baseUrl: string;
|
|
websocketUrl: string;
|
|
connections(): number;
|
|
close(): Promise<void>;
|
|
}> {
|
|
const sockets = new Set<Duplex>();
|
|
let connectionCount = 0;
|
|
const server = createServer((request, response) => {
|
|
if (request.url === "/json/version") {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ Browser: "test" }));
|
|
return;
|
|
}
|
|
if (request.url === "/json/list") {
|
|
const port = portFromServer(server);
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify([{
|
|
id: TARGET_ID,
|
|
type: "page",
|
|
title: "OpenWork",
|
|
url: "http://127.0.0.1/app",
|
|
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
|
|
}]));
|
|
return;
|
|
}
|
|
response.writeHead(404);
|
|
response.end("not found");
|
|
});
|
|
server.on("upgrade", (request, socket) => {
|
|
const key = request.headers["sec-websocket-key"];
|
|
if (typeof key !== "string") {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
sockets.add(socket);
|
|
socket.once("close", () => sockets.delete(socket));
|
|
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
|
|
socket.write([
|
|
"HTTP/1.1 101 Switching Protocols",
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
`Sec-WebSocket-Accept: ${accept}`,
|
|
"",
|
|
"",
|
|
].join("\r\n"));
|
|
connectionCount += 1;
|
|
if (connectionCount > stalledConnections) handleClientFrames(socket);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const port = portFromServer(server);
|
|
return {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
websocketUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
|
|
connections: () => connectionCount,
|
|
close: async () => {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
},
|
|
};
|
|
}
|
|
|
|
test("a stalled websocket can be condemned without awaiting close", async () => {
|
|
const server = await startCdpServer(1);
|
|
try {
|
|
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
|
await assert.rejects(evaluate(client, "1", { timeoutMs: 30 }), /CDP call Runtime\.evaluate timed out/);
|
|
|
|
const pending = evaluate(client, "2", { timeoutMs: 500 });
|
|
if (!client.abort) throw new Error("Connected CDP client did not expose abort().");
|
|
const startedAt = Date.now();
|
|
client.abort(new Error("probe timed out"));
|
|
assert.ok(Date.now() - startedAt < 100, "abort() waited for the peer to complete websocket close");
|
|
await assert.rejects(pending, /CDP transport stalled: probe timed out/);
|
|
await assert.rejects(evaluate(client, "3", { timeoutMs: 500 }), /CDP socket is not open/);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("function calls pass dynamic values as structured CDP arguments", async () => {
|
|
const server = await startCdpServer(0);
|
|
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
|
try {
|
|
const dynamicValue = `"); throw new Error("must stay data"); //`;
|
|
const value = await callFunction(client, "function (value) { return value; }", [dynamicValue]);
|
|
assert.equal(value, dynamicValue);
|
|
} finally {
|
|
client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("surface evaluation heals after the first websocket consumes its budget", async () => {
|
|
const server = await startCdpServer(1);
|
|
try {
|
|
const firstClient = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
|
|
const surface: Surface = {
|
|
handle: { name: "test", kind: "electron", hostKind: "test", cdpUrl: server.baseUrl },
|
|
client: firstClient,
|
|
};
|
|
|
|
const value = await evaluateOnSurface(surface, "42", { timeoutMs: 30 });
|
|
|
|
assert.equal(value, "recovered");
|
|
assert.notEqual(surface.client, firstClient);
|
|
assert.equal(server.connections(), 2);
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|