diff --git a/.github/workflows/cf_unit_tests.yml b/.github/workflows/cf_unit_tests.yml new file mode 100644 index 000000000..0392746b6 --- /dev/null +++ b/.github/workflows/cf_unit_tests.yml @@ -0,0 +1,33 @@ +name: "Playwright for Cloudflare - Unit Tests" + +on: + pull_request: + paths: + - "packages/playwright-cloudflare/src/**" + - "packages/playwright-cloudflare/tests/**" + - ".github/workflows/cf_unit_tests.yml" + push: + branches: [main] + paths: + - "packages/playwright-cloudflare/src/**" + - "packages/playwright-cloudflare/tests/**" + - ".github/workflows/cf_unit_tests.yml" + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/playwright-cloudflare/tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: npm + cache-dependency-path: packages/playwright-cloudflare/tests/package-lock.json + - run: npm ci + - run: npm run test:unit diff --git a/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts b/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts index 86c4697df..b530a9a22 100644 --- a/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts +++ b/packages/playwright-cloudflare/src/cloudflare/webSocketTransport.ts @@ -7,6 +7,7 @@ export const transportZone = new AsyncLocalStorage(); export class WebSocketTransport implements ConnectionTransport { private _ws: WebSocket; + private _closing = false; onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; readonly sessionId: string | undefined; @@ -26,19 +27,26 @@ export class WebSocketTransport implements ConnectionTransport { this.onmessage?.(JSON.parse(event.data) as ProtocolResponse); }); this._ws.addEventListener('close', () => { + this._closing = true; this.onclose?.(); }); this._ws.addEventListener('error', e => { + if (this._closing) + return; // eslint-disable-next-line no-console console.error(`Websocket error${sessionId ? `: SessionID: ${sessionId}` : ''}`, e); }); } send(message: ProtocolRequest): void { + // Browser Run can raise an error event while completing a requested shutdown. + if (message.method === 'Browser.close') + this._closing = true; this._ws.send(JSON.stringify(message)); } close(): void { + this._closing = true; this._ws.close(); this.onclose?.(); } diff --git a/packages/playwright-cloudflare/tests/package.json b/packages/playwright-cloudflare/tests/package.json index 2d3ad3116..7aa11143c 100644 --- a/packages/playwright-cloudflare/tests/package.json +++ b/packages/playwright-cloudflare/tests/package.json @@ -7,6 +7,7 @@ "description": "", "types": "./test-d/index.d.ts", "scripts": { + "test:unit": "playwright test --config=unit/playwright.config.ts", "test-types": "tsd" }, "devDependencies": { diff --git a/packages/playwright-cloudflare/tests/unit/playwright.config.ts b/packages/playwright-cloudflare/tests/unit/playwright.config.ts new file mode 100644 index 000000000..411087cf2 --- /dev/null +++ b/packages/playwright-cloudflare/tests/unit/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + workers: 1, +}); diff --git a/packages/playwright-cloudflare/tests/unit/webSocketTransport.spec.ts b/packages/playwright-cloudflare/tests/unit/webSocketTransport.spec.ts new file mode 100644 index 000000000..9891dea36 --- /dev/null +++ b/packages/playwright-cloudflare/tests/unit/webSocketTransport.spec.ts @@ -0,0 +1,89 @@ +import { test as base, expect } from '@playwright/test'; + +import { WebSocketTransport } from '../../src/cloudflare/webSocketTransport'; + +const test = base.extend<{ errors: unknown[][] }>({ + errors: async ({}, use) => { + const errors: unknown[][] = []; + // eslint-disable-next-line no-console + const original = console.error; + // eslint-disable-next-line no-console + console.error = (...args: unknown[]) => errors.push(args); + try { + await use(errors); + } finally { + // eslint-disable-next-line no-console + console.error = original; + } + }, +}); + +class MockWebSocket extends EventTarget { + sent: string[] = []; + + send(message: string) { + this.sent.push(message); + this.dispatchEvent(new Event('error')); + } + + close() { + this.dispatchEvent(new Event('error')); + } +} + +test('reports errors while the browser session is active', async ({ errors }) => { + const ws = new MockWebSocket(); + new WebSocketTransport(ws as unknown as WebSocket, 'session-id'); + const error = new Event('error'); + + ws.dispatchEvent(error); + + expect(errors).toEqual([['Websocket error: SessionID: session-id', error]]); +}); + +test('does not report errors raised while closing the transport', async ({ errors }) => { + const ws = new MockWebSocket(); + const transport = new WebSocketTransport(ws as unknown as WebSocket, undefined); + let closed = false; + transport.onclose = () => closed = true; + + transport.close(); + + expect(errors.length).toBe(0); + expect(closed).toBe(true); +}); + +test('does not report errors after sending Browser.close', async ({ errors }) => { + const ws = new MockWebSocket(); + const transport = new WebSocketTransport(ws as unknown as WebSocket, 'session-id'); + const message = { id: -9999, method: 'Browser.close', params: {} }; + + transport.send(message); + ws.dispatchEvent(new Event('error')); + + expect(errors.length).toBe(0); + expect(ws.sent).toEqual([JSON.stringify(message)]); +}); + +test('reports errors when sending other protocol commands', async ({ errors }) => { + const ws = new MockWebSocket(); + const transport = new WebSocketTransport(ws as unknown as WebSocket, undefined); + + transport.send({ id: 1, method: 'Browser.getVersion', params: {} }); + + expect(errors).toHaveLength(1); + expect(errors[0][0]).toBe('Websocket error'); +}); + +test('does not report errors after the websocket closes', async ({ errors }) => { + const ws = new MockWebSocket(); + const transport = new WebSocketTransport(ws as unknown as WebSocket, 'session-id'); + let closed = false; + transport.onclose = () => closed = true; + + ws.dispatchEvent(new Event('close')); + ws.dispatchEvent(new Event('error')); + + expect(errors.length).toBe(0); + expect(closed).toBe(true); +});