70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
1066 lines
32 KiB
TypeScript
1066 lines
32 KiB
TypeScript
import { Miniflare } from "miniflare";
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
test,
|
|
type TestOptions,
|
|
vi,
|
|
} from "vitest";
|
|
import { useDispose } from "../../test-shared";
|
|
import type { MiniflareOptions } from "miniflare";
|
|
|
|
async function sendMessage(ws: WebSocket, message: unknown) {
|
|
// adapted from https://github.com/cloudflare/puppeteer/blob/b4984452165437e26dd1c8e516581cec2a02b4cd/packages/puppeteer-core/src/cloudflare/chunking.ts#L6-L30
|
|
const messageToChunks = (data: string): Uint8Array[] => {
|
|
const HEADER_SIZE = 4; // Uint32
|
|
const MAX_MESSAGE_SIZE = 1048575; // Workers size is < 1MB
|
|
const FIRST_CHUNK_DATA_SIZE = MAX_MESSAGE_SIZE - HEADER_SIZE;
|
|
|
|
const encoder = new TextEncoder();
|
|
const encodedUint8Array = encoder.encode(data);
|
|
|
|
// We only include the header into the first chunk
|
|
const firstChunk = new Uint8Array(
|
|
Math.min(MAX_MESSAGE_SIZE, HEADER_SIZE + encodedUint8Array.length)
|
|
);
|
|
const view = new DataView(firstChunk.buffer);
|
|
view.setUint32(0, encodedUint8Array.length, true);
|
|
firstChunk.set(
|
|
encodedUint8Array.slice(0, FIRST_CHUNK_DATA_SIZE),
|
|
HEADER_SIZE
|
|
);
|
|
|
|
const chunks: Uint8Array[] = [firstChunk];
|
|
for (
|
|
let i = FIRST_CHUNK_DATA_SIZE;
|
|
i < data.length;
|
|
i += MAX_MESSAGE_SIZE
|
|
) {
|
|
chunks.push(encodedUint8Array.slice(i, i + MAX_MESSAGE_SIZE));
|
|
}
|
|
return chunks;
|
|
};
|
|
|
|
// we need to send the message in chunks
|
|
for (const chunk of messageToChunks(JSON.stringify(message))) {
|
|
ws.send(chunk);
|
|
}
|
|
}
|
|
|
|
async function waitForClosedConnection(ws: WebSocket): Promise<void> {
|
|
if (ws.readyState === ws.CLOSED) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => {
|
|
ws.addEventListener("close", () => {
|
|
if (ws.readyState !== ws.CLOSED) {
|
|
ws.close();
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
const BROWSER_RENDERING_RETRY = {
|
|
retry: {
|
|
condition: /Chrome readiness probe .* timed out|Test timed out/i,
|
|
count: 3,
|
|
delay: 1_000,
|
|
},
|
|
} satisfies TestOptions;
|
|
|
|
const BROWSER_WORKER_SCRIPT = () => `
|
|
export default {
|
|
async fetch(request, env) {
|
|
if (request.url.endsWith("session")) {
|
|
const newBrowserSession = await env.MYBROWSER.fetch("https://localhost/v1/acquire")
|
|
return new Response(await newBrowserSession.text())
|
|
}
|
|
}
|
|
};
|
|
`;
|
|
|
|
// We need to run browser rendering tests in a serial manner to avoid a race condition installing the browser.
|
|
// We set the timeout quite high here as one of these tests will need to download the Chrome headless browser.
|
|
describe.sequential("browser rendering", { timeout: 20_000 }, () => {
|
|
// The CLI spinner outputs to stdout, so we mute it during tests
|
|
beforeEach(() => {
|
|
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test(
|
|
"it creates a browser session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: BROWSER_WORKER_SCRIPT(),
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost/session");
|
|
const text = await res.text();
|
|
expect(text.includes("sessionId")).toBe(true);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"two workers with different browser bindings can coexist",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const workerScript = (bindingName: string) => `
|
|
export default {
|
|
async fetch(request, env) {
|
|
if (request.url.endsWith("session")) {
|
|
const newBrowserSession = await env.${bindingName}.fetch("https://localhost/v1/acquire")
|
|
return new Response(await newBrowserSession.text())
|
|
}
|
|
}
|
|
};
|
|
`;
|
|
const mf = new Miniflare({
|
|
workers: [
|
|
{
|
|
name: "worker-a",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: workerScript("BROWSER_A"),
|
|
browserRendering: { binding: "BROWSER_A" },
|
|
},
|
|
{
|
|
name: "worker-b",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: workerScript("BROWSER_B"),
|
|
browserRendering: { binding: "BROWSER_B" },
|
|
},
|
|
],
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost/session");
|
|
const text = await res.text();
|
|
expect(text.includes("sessionId")).toBe(true);
|
|
}
|
|
);
|
|
|
|
const BROWSER_WORKER_CLOSE_SCRIPT = `
|
|
${sendMessage.toString()}
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const acquireResponse = await env.MYBROWSER.fetch("https://localhost/v1/acquire");
|
|
const { sessionId } = await acquireResponse.json();
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
|
|
sendMessage(ws, { method: "Browser.close", id: -1 });
|
|
await waitForClosedConnection(ws);
|
|
|
|
return new Response("Browser closed");
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"it closes a browser session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: BROWSER_WORKER_CLOSE_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost/close");
|
|
expect(await res.text()).toBe("Browser closed");
|
|
}
|
|
);
|
|
|
|
const BROWSER_WORKER_REUSE_SCRIPT = `
|
|
${sendMessage.toString()}
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const acquireResponse = await env.MYBROWSER.fetch("https://localhost/v1/acquire");
|
|
const { sessionId } = await acquireResponse.json();
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
ws.close();
|
|
|
|
await waitForClosedConnection(ws);
|
|
|
|
// Reuse the same session by connecting to it again
|
|
const { webSocket: ws2 } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws2.accept();
|
|
ws2.close();
|
|
|
|
return new Response("Browser session reused");
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"it reuses a browser session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: BROWSER_WORKER_REUSE_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
expect(await res.text()).toBe("Browser session reused");
|
|
}
|
|
);
|
|
|
|
const BROWSER_WORKER_RECONNECT_SCRIPT = `
|
|
${sendMessage.toString()}
|
|
${waitForClosedConnection.toString()}
|
|
|
|
async function waitForMessage(ws) {
|
|
const chunks = [];
|
|
return new Promise((resolve) => {
|
|
ws.addEventListener("message", function handler(event) {
|
|
const data = new Uint8Array(event.data);
|
|
if (chunks.length === 0) {
|
|
// First chunk contains a 4-byte little-endian length header
|
|
const view = new DataView(data.buffer);
|
|
const totalLength = view.getUint32(0, true);
|
|
chunks.push({ data: data.slice(4), totalLength });
|
|
} else {
|
|
chunks[0].data = new Uint8Array([...chunks[0].data, ...data]);
|
|
}
|
|
const accumulated = chunks[0];
|
|
if (accumulated.data.length >= accumulated.totalLength) {
|
|
ws.removeEventListener("message", handler);
|
|
const text = new TextDecoder().decode(accumulated.data.slice(0, accumulated.totalLength));
|
|
resolve(JSON.parse(text));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const acquireResponse = await env.MYBROWSER.fetch("https://localhost/v1/acquire");
|
|
const { sessionId } = await acquireResponse.json();
|
|
|
|
// First connection
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
|
|
// Send a CDP command and verify we get a response
|
|
sendMessage(ws, { method: "Target.getTargets", id: 1 });
|
|
const msg1 = await waitForMessage(ws);
|
|
if (!msg1 || !msg1.result) {
|
|
return new Response("First CDP command failed: " + JSON.stringify(msg1));
|
|
}
|
|
|
|
// Disconnect
|
|
ws.close();
|
|
await waitForClosedConnection(ws);
|
|
|
|
// Reconnect with the same sessionId
|
|
const resp2 = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
if (!resp2.webSocket) {
|
|
return new Response("Reconnect failed with status: " + resp2.status);
|
|
}
|
|
const ws2 = resp2.webSocket;
|
|
ws2.accept();
|
|
|
|
// Send a CDP command on the reconnected socket
|
|
sendMessage(ws2, { method: "Target.getTargets", id: 2 });
|
|
const msg2 = await waitForMessage(ws2);
|
|
if (!msg2 || !msg2.result) {
|
|
return new Response("Second CDP command failed: " + JSON.stringify(msg2));
|
|
}
|
|
|
|
ws2.close();
|
|
return new Response("Reconnect successful");
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"it reconnects and sends CDP commands after disconnect",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: BROWSER_WORKER_RECONNECT_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
expect(await res.text()).toBe("Reconnect successful");
|
|
}
|
|
);
|
|
|
|
const BROWSER_WORKER_ALREADY_USED_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const acquireResponse = await env.MYBROWSER.fetch("https://localhost/v1/acquire");
|
|
const { sessionId } = await acquireResponse.json();
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// try to open new connection for the same session
|
|
const resp2 = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
if (resp2.status === 409) {
|
|
return new Response("Failed to connect to browser session");
|
|
}
|
|
|
|
return new Response("Should not reach here");
|
|
}
|
|
};
|
|
`;
|
|
|
|
test.skipIf(process.platform === "win32")(
|
|
"fails if browser session already in use",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: BROWSER_WORKER_ALREADY_USED_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
expect(await res.text()).toBe("Failed to connect to browser session");
|
|
}
|
|
);
|
|
|
|
const GET_SESSIONS_SCRIPT = `
|
|
${sendMessage.toString()}
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessions: emptySessions } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(res => res.json());
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(res => res.json());
|
|
const { sessions: acquiredSessions } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(res => res.json());
|
|
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
|
|
// send a close message to the browser
|
|
sendMessage(ws, { method: "Browser.close", id: -1 });
|
|
|
|
await waitForClosedConnection(ws);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
const { sessions: afterClosedSessions } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(res => res.json());
|
|
|
|
return Response.json({ emptySessions, acquiredSessions, afterClosedSessions });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"gets sessions while acquiring and closing session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: GET_SESSIONS_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const { emptySessions, acquiredSessions, afterClosedSessions } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((res) => res.json())) as any;
|
|
expect(emptySessions.length).toBe(0);
|
|
expect(acquiredSessions.length).toBe(1);
|
|
expect(
|
|
typeof acquiredSessions[0].sessionId === "string" &&
|
|
typeof acquiredSessions[0].startTime === "number" &&
|
|
!acquiredSessions[0].connectionId &&
|
|
!acquiredSessions[0].connectionId
|
|
).toBe(true);
|
|
expect(afterClosedSessions.length).toBe(0);
|
|
}
|
|
);
|
|
|
|
const GET_SESSIONS_AFTER_DISCONNECT_SCRIPT = `
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(res => res.json());
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(\`https://localhost/v1/connectDevtools?browser_session=\${sessionId}\`, {
|
|
headers: { "Upgrade": "websocket" },
|
|
});
|
|
ws.accept();
|
|
|
|
const { sessions: [connectedSession] } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(res => res.json());
|
|
ws.close();
|
|
|
|
await waitForClosedConnection(ws);
|
|
|
|
const { sessions: [disconnectedSession] } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(res => res.json());
|
|
|
|
return Response.json({ connectedSession, disconnectedSession });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"gets sessions while connecting and disconnecting session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const opts: MiniflareOptions = {
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: GET_SESSIONS_AFTER_DISCONNECT_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
};
|
|
const mf = new Miniflare(opts);
|
|
useDispose(mf);
|
|
|
|
const { connectedSession, disconnectedSession } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((res) => res.json())) as any;
|
|
expect(connectedSession.sessionId).toBe(disconnectedSession.sessionId);
|
|
expect(
|
|
typeof connectedSession.connectionId === "string" &&
|
|
typeof connectedSession.connectionStartTime === "number"
|
|
).toBe(true);
|
|
expect(
|
|
!disconnectedSession.connectionId &&
|
|
!disconnectedSession.connectionStartTime
|
|
).toBe(true);
|
|
}
|
|
);
|
|
|
|
test("returns limits", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: `export default { async fetch(req, env) {
|
|
return env.MYBROWSER.fetch("https://localhost/v1/limits");
|
|
} }`,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
const body = (await res.json()) as any;
|
|
expect(res.status).toBe(200);
|
|
expect(typeof body.maxConcurrentSessions).toBe("number");
|
|
expect(typeof body.allowedBrowserAcquisitions).toBe("number");
|
|
expect(typeof body.timeUntilNextAllowedBrowserAcquisition).toBe("number");
|
|
});
|
|
|
|
test("returns empty history", async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: `export default { async fetch(req, env) {
|
|
return env.MYBROWSER.fetch("https://localhost/v1/history");
|
|
} }`,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const res = await mf.dispatchFetch("https://localhost");
|
|
const body = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(body).toEqual([]);
|
|
});
|
|
|
|
const DEVTOOLS_SESSION_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const emptyList = await env.MYBROWSER.fetch("https://localhost/v1/devtools/session").then(r => r.json());
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const list = await env.MYBROWSER.fetch("https://localhost/v1/devtools/session").then(r => r.json());
|
|
const detail = await env.MYBROWSER.fetch(\`https://localhost/v1/devtools/session/\${sessionId}\`).then(r => r.json());
|
|
const missing = await env.MYBROWSER.fetch("https://localhost/v1/devtools/session/does-not-exist");
|
|
return Response.json({ emptyList, list, detail, missingStatus: missing.status });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"devtools session list and detail endpoints",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_SESSION_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { emptyList, list, detail, missingStatus } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(emptyList).toEqual([]);
|
|
expect(list.length).toBe(1);
|
|
expect(typeof list[0].sessionId).toBe("string");
|
|
expect(detail.sessionId).toBe(list[0].sessionId);
|
|
expect(missingStatus).toBe(404);
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_JSON_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
|
|
const version = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/version\`
|
|
).then(r => r.json());
|
|
|
|
const list = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/list\`
|
|
).then(r => r.json());
|
|
|
|
const listAlias = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json\`
|
|
).then(r => r.json());
|
|
|
|
return Response.json({ version, list, listAlias });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"devtools json/version, json/list, json endpoints",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_JSON_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { version, list, listAlias } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(typeof version.Browser).toBe("string");
|
|
expect(typeof version["Protocol-Version"]).toBe("string");
|
|
expect(Array.isArray(list)).toBe(true);
|
|
// The page title for about:blank can change between sequential
|
|
// requests (from "" to "about:blank"), so strip the volatile
|
|
// `title` field before comparing the two alias endpoints.
|
|
const stripTitle = (arr: any[]) =>
|
|
arr.map(({ title, ...rest }: any) => rest);
|
|
expect(stripTitle(list)).toEqual(stripTitle(listAlias));
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_DELETE_SCRIPT = `
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
ws.accept();
|
|
const deleteResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ method: "DELETE" }
|
|
);
|
|
const deleteBody = await deleteResp.json();
|
|
// Verify the session is gone after DELETE
|
|
const { sessions } = await env.MYBROWSER.fetch(\`https://localhost/v1/sessions?sessionId=\${sessionId}\`).then(r => r.json());
|
|
const sessionGone = sessions.length === 0;
|
|
await waitForClosedConnection(ws);
|
|
const wsClosed = ws.readyState === WebSocket.CLOSED;
|
|
return Response.json({
|
|
deleteStatus: deleteResp.status,
|
|
deleteBody,
|
|
sessionGone,
|
|
wsClosed,
|
|
});
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"DELETE /v1/devtools/browser/:session_id closes browser",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_DELETE_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { deleteStatus, deleteBody, sessionGone, wsClosed } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(deleteStatus).toBe(200);
|
|
expect(deleteBody.status).toBe("closed");
|
|
expect(sessionGone).toBe(true);
|
|
expect(wsClosed).toBe(true);
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_BROWSER_WS_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const postResp = await env.MYBROWSER.fetch("https://localhost/v1/devtools/browser", {
|
|
method: "POST",
|
|
});
|
|
const { sessionId } = await postResp.json();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
const getResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
const sessionIdFromGet = getResp.headers.get("cf-browser-session-id");
|
|
const ws = getResp.webSocket;
|
|
ws.accept();
|
|
const browserVersion = await new Promise((resolve) => {
|
|
ws.addEventListener("message", (m) => resolve(JSON.parse(m.data)));
|
|
ws.send(JSON.stringify({ id: 1, method: "Browser.getVersion" }));
|
|
});
|
|
ws.close();
|
|
|
|
return Response.json({
|
|
postStatus: postResp.status,
|
|
sessionId,
|
|
getStatus: getResp.status,
|
|
sessionIdFromGet,
|
|
browserProduct: browserVersion.result?.product,
|
|
});
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"POST /v1/devtools/browser acquires session, GET /v1/devtools/browser/:id connects and returns cf-browser-session-id",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_BROWSER_WS_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const {
|
|
postStatus,
|
|
sessionId,
|
|
getStatus,
|
|
sessionIdFromGet,
|
|
browserProduct,
|
|
} = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(postStatus).toBe(200);
|
|
expect(typeof sessionId).toBe("string");
|
|
expect(getStatus).toBe(101);
|
|
expect(sessionIdFromGet).toBe(sessionId);
|
|
expect(typeof browserProduct).toBe("string");
|
|
expect(browserProduct).toContain("Chrome");
|
|
}
|
|
);
|
|
|
|
test(
|
|
"GET /v1/devtools/browser acquires and connects",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
const resp = await env.MYBROWSER.fetch("https://localhost/v1/devtools/browser", {
|
|
headers: { Upgrade: "websocket" },
|
|
});
|
|
const sessionId = resp.headers.get("cf-browser-session-id");
|
|
const ws = resp.webSocket;
|
|
ws.accept();
|
|
const browserVersion = await new Promise((resolve) => {
|
|
ws.addEventListener("message", (m) => resolve(JSON.parse(m.data)));
|
|
ws.send(JSON.stringify({ id: 1, method: "Browser.getVersion" }));
|
|
});
|
|
ws.close();
|
|
return Response.json({ status: resp.status, sessionId, browserProduct: browserVersion.result?.product });
|
|
}
|
|
};`,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { status, sessionId, browserProduct } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(status).toBe(101);
|
|
expect(typeof sessionId).toBe("string");
|
|
expect(typeof browserProduct).toBe("string");
|
|
expect(browserProduct).toContain("Chrome");
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_JSON_PROTOCOL_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const protocol = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/protocol\`
|
|
).then(r => r.json());
|
|
return Response.json({ hasDomains: Array.isArray(protocol.domains) });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"devtools json/protocol endpoint",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_JSON_PROTOCOL_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { hasDomains } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(hasDomains).toBe(true);
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_JSON_NEW_ACTIVATE_CLOSE_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const newTarget = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/new?url=about:blank\`,
|
|
{ method: "PUT" }
|
|
).then(r => r.json());
|
|
const activateResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/activate/\${newTarget.id}\`
|
|
);
|
|
const closeResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/close/\${newTarget.id}\`
|
|
);
|
|
return Response.json({
|
|
targetType: newTarget.type,
|
|
activateStatus: activateResp.status,
|
|
closeStatus: closeResp.status,
|
|
});
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"devtools json/new, json/activate, json/close endpoints",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_JSON_NEW_ACTIVATE_CLOSE_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { targetType, activateStatus, closeStatus } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(targetType).toBe("page");
|
|
expect(activateStatus).toBe(200);
|
|
expect(closeStatus).toBe(200);
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_PAGE_WS_SCRIPT = `
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const targets = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/list\`
|
|
).then(r => r.json());
|
|
const targetId = targets[0].id;
|
|
const { webSocket: ws } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/page/\${targetId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
ws.accept();
|
|
const result = await new Promise((resolve) => {
|
|
ws.addEventListener("message", (m) => resolve(JSON.parse(m.data)));
|
|
ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1+1" } }));
|
|
});
|
|
ws.close();
|
|
return Response.json({ resultValue: result.result?.result?.value });
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"devtools page/:target_id WebSocket endpoint",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_PAGE_WS_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { resultValue } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(resultValue).toBe(2);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"DELETE without prior WebSocket connection",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
const deleteResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ method: "DELETE" }
|
|
);
|
|
const deleteBody = await deleteResp.json();
|
|
const { sessions } = await env.MYBROWSER.fetch("https://localhost/v1/sessions").then(r => r.json());
|
|
return Response.json({
|
|
deleteStatus: deleteResp.status,
|
|
deleteBody,
|
|
sessionGone: sessions.length === 0,
|
|
});
|
|
}
|
|
};`,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { deleteStatus, deleteBody, sessionGone } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(deleteStatus).toBe(200);
|
|
expect(deleteBody.status).toBe("closed");
|
|
expect(sessionGone).toBe(true);
|
|
}
|
|
);
|
|
|
|
const DEVTOOLS_DELETE_ALL_WS_SCRIPT = `
|
|
${waitForClosedConnection.toString()}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
|
|
// Connect browser-level WebSocket
|
|
const { webSocket: browserWs } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
browserWs.accept();
|
|
|
|
// Connect page-level WebSocket
|
|
const targets = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/json/list\`
|
|
).then(r => r.json());
|
|
const { webSocket: pageWs } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}/page/\${targets[0].id}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
pageWs.accept();
|
|
|
|
// DELETE should close everything
|
|
const deleteResp = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ method: "DELETE" }
|
|
);
|
|
const deleteBody = await deleteResp.json();
|
|
|
|
await Promise.all([
|
|
waitForClosedConnection(browserWs),
|
|
waitForClosedConnection(pageWs),
|
|
]);
|
|
|
|
return Response.json({
|
|
deleteStatus: deleteResp.status,
|
|
deleteBody,
|
|
browserWsClosed: browserWs.readyState === WebSocket.CLOSED,
|
|
pageWsClosed: pageWs.readyState === WebSocket.CLOSED,
|
|
});
|
|
}
|
|
};
|
|
`;
|
|
|
|
test(
|
|
"DELETE closes all WebSocket connections (browser + page)",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: DEVTOOLS_DELETE_ALL_WS_SCRIPT,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { deleteStatus, deleteBody, browserWsClosed, pageWsClosed } =
|
|
(await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(deleteStatus).toBe(200);
|
|
expect(deleteBody.status).toBe("closed");
|
|
expect(browserWsClosed).toBe(true);
|
|
expect(pageWsClosed).toBe(true);
|
|
}
|
|
);
|
|
|
|
test(
|
|
"multiple concurrent raw WebSocket connections to same session",
|
|
BROWSER_RENDERING_RETRY,
|
|
async ({ expect }) => {
|
|
const mf = new Miniflare({
|
|
name: "worker",
|
|
compatibilityDate: "2024-11-20",
|
|
modules: true,
|
|
script: `export default {
|
|
async fetch(request, env) {
|
|
const { sessionId } = await env.MYBROWSER.fetch("https://localhost/v1/acquire").then(r => r.json());
|
|
|
|
// Open two raw WebSocket connections to the same browser session
|
|
const { webSocket: ws1 } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
ws1.accept();
|
|
|
|
const { webSocket: ws2 } = await env.MYBROWSER.fetch(
|
|
\`https://localhost/v1/devtools/browser/\${sessionId}\`,
|
|
{ headers: { Upgrade: "websocket" } }
|
|
);
|
|
ws2.accept();
|
|
|
|
// Send Browser.getVersion on both and verify responses
|
|
const v1 = new Promise((resolve) => {
|
|
ws1.addEventListener("message", (m) => resolve(JSON.parse(m.data)));
|
|
ws1.send(JSON.stringify({ id: 1, method: "Browser.getVersion" }));
|
|
});
|
|
const v2 = new Promise((resolve) => {
|
|
ws2.addEventListener("message", (m) => resolve(JSON.parse(m.data)));
|
|
ws2.send(JSON.stringify({ id: 2, method: "Browser.getVersion" }));
|
|
});
|
|
|
|
const [r1, r2] = await Promise.all([v1, v2]);
|
|
ws1.close();
|
|
ws2.close();
|
|
|
|
return Response.json({
|
|
product1: r1.result?.product,
|
|
product2: r2.result?.product,
|
|
});
|
|
}
|
|
};`,
|
|
browserRendering: { binding: "MYBROWSER" },
|
|
});
|
|
useDispose(mf);
|
|
|
|
const { product1, product2 } = (await mf
|
|
.dispatchFetch("https://localhost")
|
|
.then((r) => r.json())) as any;
|
|
|
|
expect(typeof product1).toBe("string");
|
|
expect(product1).toContain("Chrome");
|
|
expect(typeof product2).toBe("string");
|
|
expect(product2).toContain("Chrome");
|
|
}
|
|
);
|
|
});
|