From 3c8d5859cfd67daff2f7fd2eb1e91fac2c8f929b Mon Sep 17 00:00:00 2001 From: Lord Coughmann Date: Mon, 27 Jul 2026 19:43:06 +0800 Subject: [PATCH 1/2] feat: support external WebSocket CDP endpoints --- packages/playwright-cloudflare/src/index.ts | 58 +++++++++++++++++++ .../session-management.spec.ts | 16 +++++ 2 files changed, 74 insertions(+) diff --git a/packages/playwright-cloudflare/src/index.ts b/packages/playwright-cloudflare/src/index.ts index 51d67ff7d..cab71e06f 100644 --- a/packages/playwright-cloudflare/src/index.ts +++ b/packages/playwright-cloudflare/src/index.ts @@ -36,6 +36,10 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; const wsEndpoint = typeof endpointURLOrOptions === 'string' ? endpointURLOrOptions : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL; if (!wsEndpoint) throw new Error('No wsEndpoint provided'); + + if (isExternalWebSocketEndpoint(wsEndpoint)) + return connectToExternalWebSocket(wsEndpoint); + const wsUrl = new URL(wsEndpoint); // by default, playwright.chromium.connectOverCDP enforces persistent to true (the default behavior upstream) if (!wsUrl.searchParams.has('persistent')) @@ -45,6 +49,60 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; : launch(wsUrl.toString()); }; +function isExternalWebSocketEndpoint(endpoint: string): boolean { + return endpoint.startsWith('ws://') || endpoint.startsWith('wss://'); +} + +async function connectToExternalWebSocket(wsEndpoint: string, options?: ConnectOverCDPOptions): Promise { + resetMonotonicTime(); + const webSocket = new WebSocket(wsEndpoint); + await waitForExternalWebSocketOpen(webSocket, options?.timeout ?? 30_000); + const sessionId = new URL(wsEndpoint).searchParams.get('browser_session') ?? ''; + const transport = new WebSocketTransport(webSocket, sessionId); + const browserOptions = options && { + isLocal: options.isLocal, + logger: options.logger, + slowMo: options.slowMo, + timeout: options.timeout, + }; + return await createBrowser(transport, { persistent: true }, browserOptions); +} + +function waitForExternalWebSocketOpen(webSocket: WebSocket, timeout: number): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + const cleanup = () => { + if (timeoutId) + clearTimeout(timeoutId); + webSocket.removeEventListener('open', onOpen); + webSocket.removeEventListener('error', onError); + webSocket.removeEventListener('close', onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error('External CDP WebSocket connection failed')); + }; + const onClose = () => { + cleanup(); + reject(new Error('External CDP WebSocket closed before opening')); + }; + webSocket.addEventListener('open', onOpen); + webSocket.addEventListener('error', onError); + webSocket.addEventListener('close', onClose); + if (timeout > 0) { + timeoutId = setTimeout(() => { + cleanup(); + webSocket.close(); + reject(new Error(`Timed out after ${timeout}ms while connecting to external CDP endpoint`)); + }, timeout); + } + }); +} + async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId?: string, persistent?: boolean, browser?: string }): Promise { resetMonotonicTime(); const url = new URL(`${HTTP_FAKE_HOST}/v1/devtools/browser${options.sessionId ? `/${options.sessionId}` : ''}`); diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts index a260401eb..09fb1f566 100644 --- a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts @@ -113,6 +113,22 @@ test(`should create browser with persistent context on playwright.chromium.conne await browser.close(); }); +test(`should route external WebSocket endpoints through CDP`, async ({ playwright }) => { + const originalWebSocket = globalThis.WebSocket; + class RejectingWebSocket { + addEventListener(type: string, listener: (event: unknown) => void) { + if (type === 'error') + queueMicrotask(() => listener(new Error('external CDP test'))); + } + } + globalThis.WebSocket = RejectingWebSocket as unknown as typeof WebSocket; + try { + await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('external CDP test'); + } finally { + globalThis.WebSocket = originalWebSocket; + } +}); + test(`should launch browser with no persistent context by default`, async ({ binding }) => { const url = endpointURLString(binding); const browser = await launch(url); From ce03ed6ba773d6052249d300ab9537d815362fac Mon Sep 17 00:00:00 2001 From: Lord Coughmann Date: Mon, 27 Jul 2026 19:57:18 +0800 Subject: [PATCH 2/2] fix: handle external CDP connection options and failures --- packages/playwright-cloudflare/src/index.ts | 9 ++--- .../session-management.spec.ts | 36 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/playwright-cloudflare/src/index.ts b/packages/playwright-cloudflare/src/index.ts index cab71e06f..6bdbf609f 100644 --- a/packages/playwright-cloudflare/src/index.ts +++ b/packages/playwright-cloudflare/src/index.ts @@ -32,13 +32,14 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; // HACK this is a major hack, but we need it to make playwright-mcp and stagehand work without modifying their code extensively. // Both playwright-mcp and stagehand use playwright.chromium.connectOverCDP if a CDP endpoint is passed, // so we need to override it to use our own connectOverCDP implementation. -(playwright.chromium as any).connectOverCDP = (endpointURLOrOptions: (ConnectOverCDPOptions & { wsEndpoint?: string }) | string) => { +(playwright.chromium as any).connectOverCDP = (endpointURLOrOptions: (ConnectOverCDPOptions & { wsEndpoint?: string }) | string, options?: ConnectOverCDPOptions) => { + const connectOptions = typeof endpointURLOrOptions === 'string' ? options : endpointURLOrOptions; const wsEndpoint = typeof endpointURLOrOptions === 'string' ? endpointURLOrOptions : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL; if (!wsEndpoint) throw new Error('No wsEndpoint provided'); if (isExternalWebSocketEndpoint(wsEndpoint)) - return connectToExternalWebSocket(wsEndpoint); + return connectToExternalWebSocket(wsEndpoint, connectOptions); const wsUrl = new URL(wsEndpoint); // by default, playwright.chromium.connectOverCDP enforces persistent to true (the default behavior upstream) @@ -152,12 +153,12 @@ export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, op return url.toString(); } -async function createBrowser(transport: WebSocketTransport, options?: { persistent?: boolean }): Promise { +async function createBrowser(transport: WebSocketTransport, options?: { persistent?: boolean }, connectOptions?: Pick): Promise { return await transportZone.run(transport, async () => { const url = new URL(WS_FAKE_HOST); if (options?.persistent) url.searchParams.set('persistent', 'true'); - const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), {}) as Browser; + const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), connectOptions ?? {}) as Browser; // sessionId is undefined for kitesurf browsers // The public types express that through the SessionlessBrowser overload of launch(). browser.sessionId = () => transport.sessionId as string; diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts index 09fb1f566..980a85ac8 100644 --- a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts @@ -120,10 +120,44 @@ test(`should route external WebSocket endpoints through CDP`, async ({ playwrigh if (type === 'error') queueMicrotask(() => listener(new Error('external CDP test'))); } + removeEventListener() {} } globalThis.WebSocket = RejectingWebSocket as unknown as typeof WebSocket; try { - await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('external CDP test'); + await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('connection failed'); + } finally { + globalThis.WebSocket = originalWebSocket; + } +}); + +test(`should reject when external WebSocket closes before opening`, async ({ playwright }) => { + const originalWebSocket = globalThis.WebSocket; + class ClosingWebSocket { + addEventListener(type: string, listener: (event: unknown) => void) { + if (type === 'close') + queueMicrotask(() => listener(new Event('close'))); + } + removeEventListener() {} + close() {} + } + globalThis.WebSocket = ClosingWebSocket as unknown as typeof WebSocket; + try { + await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser')).rejects.toThrow('closed before opening'); + } finally { + globalThis.WebSocket = originalWebSocket; + } +}); + +test(`should time out while opening an external WebSocket`, async ({ playwright }) => { + const originalWebSocket = globalThis.WebSocket; + class HangingWebSocket { + addEventListener() {} + removeEventListener() {} + close() {} + } + globalThis.WebSocket = HangingWebSocket as unknown as typeof WebSocket; + try { + await expect(playwright.chromium.connectOverCDP('wss://example.test/devtools/browser', { timeout: 1 })).rejects.toThrow('Timed out'); } finally { globalThis.WebSocket = originalWebSocket; }