diff --git a/package.json b/package.json index 72258ca..786b370 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "msw": "^2.12.10" + "msw": "^2.13.2" }, "devDependencies": { "@epic-web/test-server": "^0.1.6", @@ -58,7 +58,7 @@ "@playwright/test": "^1.59.1", "@types/node": "^22.15.29", "@types/sinon": "^21.0.1", - "msw": "^2.12.14", + "msw": "^2.13.2", "publint": "^0.3.18", "sinon": "^21.0.3", "tsdown": "^0.21.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d35d8..9e8b5a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: ^21.0.1 version: 21.0.1 msw: - specifier: ^2.12.14 - version: 2.12.14(@types/node@22.15.29)(typescript@5.9.3) + specifier: ^2.13.2 + version: 2.13.2(@types/node@22.15.29)(typescript@5.9.3) publint: specifier: ^0.3.18 version: 0.3.18 @@ -50,7 +50,7 @@ importers: version: 7.3.1(@types/node@22.15.29)(jiti@2.4.2) vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@22.15.29)(msw@2.12.14(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)) + version: 4.1.2(@types/node@22.15.29)(msw@2.13.2(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)) packages: @@ -388,36 +388,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -479,56 +485,67 @@ packages: resolution: {integrity: sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.1': resolution: {integrity: sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.1': resolution: {integrity: sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.1': resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.1': resolution: {integrity: sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.1': resolution: {integrity: sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.1': resolution: {integrity: sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.1': resolution: {integrity: sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.1': resolution: {integrity: sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.1': resolution: {integrity: sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.1': resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.1': resolution: {integrity: sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==} @@ -958,8 +975,8 @@ packages: resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} engines: {node: '>=4'} - msw@2.12.14: - resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + msw@2.13.2: + resolution: {integrity: sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -1932,13 +1949,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2))': + '@vitest/mocker@4.1.2(msw@2.13.2(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.14(@types/node@22.15.29)(typescript@5.9.3) + msw: 2.13.2(@types/node@22.15.29)(typescript@5.9.3) vite: 7.3.1(@types/node@22.15.29)(jiti@2.4.2) '@vitest/pretty-format@4.1.2': @@ -2238,7 +2255,7 @@ snapshots: mri@1.1.4: {} - msw@2.12.14(@types/node@22.15.29)(typescript@5.9.3): + msw@2.13.2(@types/node@22.15.29)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@22.15.29) '@mswjs/interceptors': 0.41.3 @@ -2672,10 +2689,10 @@ snapshots: fsevents: 2.3.3 jiti: 2.4.2 - vitest@4.1.2(@types/node@22.15.29)(msw@2.12.14(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)): + vitest@4.1.2(@types/node@22.15.29)(msw@2.13.2(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)) + '@vitest/mocker': 4.1.2(msw@2.13.2(@types/node@22.15.29)(typescript@5.9.3))(vite@7.3.1(@types/node@22.15.29)(jiti@2.4.2)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 diff --git a/src/fixture.ts b/src/fixture.ts index 86a4abc..d7ca00f 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -1,465 +1,63 @@ -import { invariant } from 'outvariant' -import type { - BrowserContext, - Page, - Request as PlaywrightRequest, - Route, - WebSocketRoute, -} from '@playwright/test' -import { WebSocketHandler } from 'msw' +import type { BrowserContext, Page } from '@playwright/test' +import { type AnyHandler, type UnhandledRequestStrategy } from 'msw' +import { defineNetwork } from 'msw/experimental' import { - SetupApi, - handleRequest, - isCommonAssetRequest, - type AnyHandler, - type LifeCycleEventsMap, - type UnhandledRequestStrategy, -} from 'msw' -import { - type WebSocketClientEventMap, - type WebSocketData, - type WebSocketServerEventMap, - CancelableMessageEvent, - CancelableCloseEvent, - WebSocketClientConnectionProtocol, - WebSocketServerConnectionProtocol, -} from '@mswjs/interceptors/WebSocket' -import { RequestHandler } from 'msw' + PlaywrightSource, + type PlaywrightSourceOptions, +} from './playwright-source.js' -export interface NetworkFixtureOptions { - context: BrowserContext +import { fromLegacyOnUnhandledRequest } from '../node_modules/msw/lib/core/experimental/compat.mjs' +import { + NetworkReadyState, + type NetworkApi, +} from '../node_modules/msw/lib/core/experimental/define-network.mjs' + +export interface NetworkFixture extends Pick< + NetworkApi<[PlaywrightSource]>, + | 'enable' + | 'disable' + | 'use' + | 'restoreHandlers' + | 'resetHandlers' + | 'listHandlers' + | 'events' +> {} + +export interface NetworkFixtureOptions extends PlaywrightSourceOptions { + context: BrowserContext | Page handlers?: Array onUnhandledRequest?: UnhandledRequestStrategy - /** - * Skip common asset requests (e.g. `*.html`, `*.css`, `*.js`, etc). - * This improves performance for certian projects. - * @default true - * - * @see https://mswjs.io/docs/api/is-common-asset-request - */ - skipAssetRequests?: boolean -} - -export type NetworkFixture = Omit, 'dispose'> & { - enable: () => Promise - disable: () => Promise } export function defineNetworkFixture( options: NetworkFixtureOptions, ): NetworkFixture { - return new SetupPlaywrightApi({ - context: options.context, - initialHandlers: options.handlers || [], - onUnhandledRequest: options.onUnhandledRequest, - skipAssetRequests: options.skipAssetRequests ?? true, + const network = defineNetwork({ + sources: [ + new PlaywrightSource(options.context, options), + ], + onUnhandledFrame: fromLegacyOnUnhandledRequest( + () => options.onUnhandledRequest || 'bypass', + ), + handlers: options.handlers, + context: { quiet: true }, }) -} - -interface SetupPlaywrightOptions { - context: BrowserContext - initialHandlers: Array - onUnhandledRequest?: UnhandledRequestStrategy - skipAssetRequests?: boolean -} - -/** - * @note Use a match-all RegExp with an optional group as the predicate - * for the `page.route()`/`page.unroute()` calls. Playwright treats given RegExp - * as the handler ID, which allows us to remove only those handlers introduces by us - * without carrying the reference to the handler function around. - */ -export const INTERNAL_MATCH_ALL_REG_EXP = /.+(__MSW_PLAYWRIGHT_PREDICATE__)?/ - -class SetupPlaywrightApi extends SetupApi { - constructor(private readonly options: SetupPlaywrightOptions) { - super(...options.initialHandlers) - } - - public async enable(): Promise { - const { context } = this.options - - // Handle HTTP requests. - await context.route( - INTERNAL_MATCH_ALL_REG_EXP, - async (route: Route, request: PlaywrightRequest) => { - const fetchRequest = new Request(request.url(), { - method: request.method(), - headers: new Headers(await request.allHeaders()), - body: request.postDataBuffer() as ArrayBuffer | null, - }) - - /** - * @note Skip common asset requests (default). - * Playwright seems to experience performance degradation when routing all - * requests through the matching logic below. - * @see https://github.com/mswjs/playwright/issues/13 - */ - if ( - this.options.skipAssetRequests && - isCommonAssetRequest(fetchRequest) - ) { - return this.safelyHandleRoute(() => route.fallback()) - } - - const handlers = this.handlersController - .currentHandlers() - .filter((handler) => { - return handler instanceof RequestHandler - }) - - const baseUrl = request.headers().referer - ? new URL(request.headers().referer).origin - : undefined - - /** - * @note Use `handleRequest` instead of `getResponse` so we can pass - * the `onUnhandledRequest` option as-is and benefit from MSW's default behaviors. - */ - const response = await handleRequest( - fetchRequest, - crypto.randomUUID(), - handlers, - { - onUnhandledRequest: this.options.onUnhandledRequest || 'bypass', - }, - this.emitter, - { - resolutionContext: { - quiet: true, - baseUrl, - }, - }, - ) - - if (response) { - if (response.status === 0) { - return this.safelyHandleRoute(() => route.abort()) - } - - return this.safelyHandleRoute(async () => { - return route.fulfill({ - status: response.status, - headers: Object.fromEntries(response.headers), - body: response.body - ? Buffer.from(await response.arrayBuffer()) - : undefined, - }) - }) - } - - return this.safelyHandleRoute(() => route.fallback()) - }, - ) - - // Handle WebSocket connections. - await context.routeWebSocket(INTERNAL_MATCH_ALL_REG_EXP, async (route) => { - const allWebSocketHandlers = this.handlersController - .currentHandlers() - .filter((handler) => { - return handler instanceof WebSocketHandler - }) - - if (allWebSocketHandlers.length === 0) { - route.connectToServer() - return - } - - const client = new PlaywrightWebSocketClientConnection(route) - const server = new PlaywrightWebSocketServerConnection(route) - - const pages = this.options.context.pages() - const lastPage = pages[pages.length - 1] - const baseUrl = lastPage ? this.getPageUrl(lastPage) : undefined - - for (const handler of allWebSocketHandlers) { - await handler.run( - { - client, - server, - info: { protocols: [] }, - }, - { - baseUrl, - }, - ) - } - }) - } - - public async disable(): Promise { - super.dispose() - await this.options.context.unroute(INTERNAL_MATCH_ALL_REG_EXP) - await unrouteWebSocket(this.options.context, INTERNAL_MATCH_ALL_REG_EXP) - } - - private getPageUrl(page: Page): string | undefined { - const url = page.url() - - if (url === 'about:blank') { - return - } - - // Encode/decode to preserve escape characters. - return decodeURI(new URL(encodeURI(url)).origin) - } - private async safelyHandleRoute( - callback: () => Promise, - ): Promise { - try { - await callback() - } catch (error) { + return { + events: network.events, + enable: network.enable.bind(network), + use: network.use.bind(network), + restoreHandlers: network.restoreHandlers.bind(network), + resetHandlers: network.resetHandlers.bind(network), + listHandlers: network.listHandlers.bind(network), + disable() { /** - * @note Ignore "Route is already handled!" errors. - * Playwright has a bug where requests terminated due to navigation - * cause your in-flight route handlers to throw. There's no means to - * detect that scenario as both "route.handled" and "route._handlingPromise" are internal. - * @see https://github.com/mswjs/playwright/issues/35 + * @note Ignore closing after closed for backwards compatibility. */ - if ( - error instanceof Error && - /route is already handled/i.test(error.message) - ) { - return + if (network.readyState === NetworkReadyState.DISABLED) { + return Promise.resolve() } - - throw error - } - } -} - -class PlaywrightWebSocketClientConnection implements WebSocketClientConnectionProtocol { - public id: string - public url: URL - - constructor(protected readonly ws: WebSocketRoute) { - this.id = crypto.randomUUID() - this.url = new URL(ws.url()) - } - - public send(data: WebSocketData): void { - if (data instanceof Blob) { - /** - * @note Playwright does not support sending Blob data. - * Read the blob as buffer, then send the buffer instead. - */ - data.bytes().then((bytes) => { - this.ws.send(Buffer.from(bytes)) - }) - return - } - - if (typeof data === 'string') { - this.ws.send(data) - return - } - - this.ws.send( - /** - * @note Forcefully cast all data to Buffer because Playwright - * has trouble digesting ArrayBuffer and Blob directly. - */ - Buffer.from( - /** - * @note Playwright type definitions are tailored to Node.js - * while MSW describes all data types that can be sent over - * the WebSocket protocol, like ArrayBuffer and Blob. - */ - data as any, - ), - ) - } - - public close(code?: number, reason?: string): void { - const resolvedCode = code ?? 1000 - this.ws.close({ code: resolvedCode, reason }) - } - - public addEventListener( - type: EventType, - listener: ( - this: WebSocket, - event: WebSocketClientEventMap[EventType], - ) => void, - options?: AddEventListenerOptions | boolean, - ): void { - /** - * @note Playwright does not expose the actual WebSocket reference. - */ - const target = {} as WebSocket - - switch (type) { - case 'message': { - this.ws.onMessage((data) => { - listener.call( - target, - new CancelableMessageEvent('message', { - data, - }) as any, - ) - }) - break - } - - case 'close': { - this.ws.onClose((code, reason) => { - listener.call( - target, - new CancelableCloseEvent('close', { - code, - reason, - }) as any, - ) - }) - break - } - } - } - - public removeEventListener( - event: EventType, - listener: ( - this: WebSocket, - event: WebSocketClientEventMap[EventType], - ) => void, - options?: EventListenerOptions | boolean, - ): void { - console.warn( - '@msw/playwright: WebSocketRoute does not support removing event listeners', - ) - } -} - -class PlaywrightWebSocketServerConnection implements WebSocketServerConnectionProtocol { - #server?: WebSocketRoute - #bufferedEvents: Array< - Parameters - > - #bufferedData: Array - - constructor(protected readonly ws: WebSocketRoute) { - this.#bufferedEvents = [] - this.#bufferedData = [] - } - - public connect(): void { - this.#server = this.ws.connectToServer() - - /** - * @note Playwright does not support event buffering. - * Manually add event listeners that might have been registered - * before `connect()` was called. - */ - for (const [type, listener, options] of this.#bufferedEvents) { - this.addEventListener(type, listener, options) - } - this.#bufferedEvents.length = 0 - - // Same for the buffered data. - for (const data of this.#bufferedData) { - this.send(data) - } - this.#bufferedData.length = 0 - } - - public send(data: WebSocketData): void { - if (this.#server == null) { - this.#bufferedData.push(data) - return - } - - this.#server.send(data as any) - } - - public close(code?: number, reason?: string): void { - invariant( - this.#server, - 'Failed to close connection to the actual WebSocket server: connection not established. Did you forget to call `connect()`?', - ) - - this.#server.close({ code, reason }) - } - - public addEventListener( - type: EventType, - listener: ( - this: WebSocket, - event: WebSocketServerEventMap[EventType], - ) => void, - options?: AddEventListenerOptions | boolean, - ): void { - if (this.#server == null) { - this.#bufferedEvents.push([type, listener as any, options]) - return - } - - const target = {} as WebSocket - switch (type) { - case 'message': { - this.#server.onMessage((data) => { - listener.call( - target, - new CancelableMessageEvent('message', { data }) as any, - ) - }) - break - } - - case 'close': { - this.#server.onClose((code, reason) => { - listener.call( - target, - new CancelableCloseEvent('close', { code, reason }) as any, - ) - }) - break - } - } - } - - public removeEventListener( - type: EventType, - listener: ( - this: WebSocket, - event: WebSocketServerEventMap[EventType], - ) => void, - options?: EventListenerOptions | boolean, - ): void { - console.warn( - '@msw/playwright: WebSocketRoute does not support removing event listeners', - ) - } -} - -interface InternalWebSocketRoute { - url: Parameters[0] - handler: Parameters[1] -} - -/** - * Custom implementation of the missing `page.unrouteWebSocket()` to remove - * WebSocket route handlers from the page. Loosely inspired by `page.unroute()`. - */ -async function unrouteWebSocket( - target: BrowserContext, - url: InternalWebSocketRoute['url'], - handler?: InternalWebSocketRoute['handler'], -): Promise { - if ( - !('_webSocketRoutes' in target && Array.isArray(target._webSocketRoutes)) - ) { - return - } - - for (let i = target._webSocketRoutes.length - 1; i >= 0; i--) { - const route = target._webSocketRoutes[i] as InternalWebSocketRoute - - if ( - route.url === url && - (handler != null ? route.handler === handler : true) - ) { - target._webSocketRoutes.splice(i, 1) - } + return network.disable() + }, } } diff --git a/src/frames/http-frame.ts b/src/frames/http-frame.ts new file mode 100644 index 0000000..e909ee7 --- /dev/null +++ b/src/frames/http-frame.ts @@ -0,0 +1,62 @@ +import type { Route } from '@playwright/test' +import { HttpNetworkFrame } from 'msw/experimental' +import { + abortRequest, + fulfillResponse, + passthroughRequest, +} from '../route-utils.js' + +import type { RequestHandler } from 'msw' +import type { NetworkFrameResolutionContext } from '../../node_modules/msw/lib/core/experimental/frames/network-frame.mjs' +import type { UnhandledFrameHandle } from '../../node_modules/msw/lib/core/experimental/on-unhandled-frame.mjs' + +interface PlaywrightHttpNetworkFrameOptions { + request: Request + id?: string + route: Route + inferredBaseUrl?: string +} + +export class PlaywrightHttpNetworkFrame extends HttpNetworkFrame { + #route: Route + #inferredBaseUrl?: string + + constructor(options: PlaywrightHttpNetworkFrameOptions) { + super(options) + this.#route = options.route + this.#inferredBaseUrl = options.inferredBaseUrl + } + + resolve( + handlers: Array, + onUnhandledFrame: UnhandledFrameHandle, + resolutionContext?: NetworkFrameResolutionContext, + ): Promise { + return super.resolve(handlers, onUnhandledFrame, { + ...resolutionContext, + baseUrl: this.#inferredBaseUrl, + }) + } + + async respondWith(response?: Response): Promise { + if (!response) return + + if (response.status === 0) { + return await abortRequest(this.#route) + } + + return await fulfillResponse(this.#route, response) + } + + passthrough(): Promise { + return passthroughRequest(this.#route) + } + + errorWith(reason?: unknown): Promise { + if (reason instanceof Response) { + return fulfillResponse(this.#route, reason) + } + + return abortRequest(this.#route) + } +} diff --git a/src/frames/websocket-frame.ts b/src/frames/websocket-frame.ts new file mode 100644 index 0000000..69226f8 --- /dev/null +++ b/src/frames/websocket-frame.ts @@ -0,0 +1,264 @@ +import { + CancelableCloseEvent, + CancelableMessageEvent, + WebSocketClientConnectionProtocol, + type WebSocketClientEventMap, + type WebSocketData, + type WebSocketServerConnectionProtocol, + type WebSocketServerEventMap, +} from '@mswjs/interceptors/WebSocket' +import type { WebSocketRoute } from '@playwright/test' +import { WebSocketNetworkFrame } from 'msw/experimental' +import { invariant } from 'outvariant' + +import type { WebSocketHandler } from 'msw' +import type { NetworkFrameResolutionContext } from '../../node_modules/msw/lib/core/experimental/frames/network-frame.mjs' +import type { UnhandledFrameHandle } from '../../node_modules/msw/lib/core/experimental/on-unhandled-frame.mjs' + +interface PlaywrightWebSocketNetworkFrameOptions { + route: WebSocketRoute + inferredBaseUrl?: string +} + +export class PlaywrightWebSocketNetworkFrame extends WebSocketNetworkFrame { + #route: WebSocketRoute + #inferredBaseUrl?: string + + constructor(options: PlaywrightWebSocketNetworkFrameOptions) { + super({ + connection: { + // @ts-expect-error WebSocketClientConnectionProtocol not assignable to WebSocketClientConnection + client: new PlaywrightWebSocketClientConnection(options.route), + // @ts-expect-error WebSocketServerConnectionProtocol not assignable to WebSocketServerConnection + server: new PlaywrightWebSocketServerConnection(options.route), + info: { protocols: [] }, + }, + }) + this.#route = options.route + this.#inferredBaseUrl = options.inferredBaseUrl + } + + resolve( + handlers: Array, + onUnhandledFrame: UnhandledFrameHandle, + resolutionContext?: NetworkFrameResolutionContext, + ): Promise { + return super.resolve(handlers, onUnhandledFrame, { + ...resolutionContext, + baseUrl: this.#inferredBaseUrl, + }) + } + + passthrough(): void { + this.#route.connectToServer() + } + + errorWith(reason?: unknown): void { + if (!(reason instanceof Error)) return + + /** @note Playwright does support error events. Close with error instead. */ + this.#route.close({ code: 1011, reason: reason.message }) + } +} + +class PlaywrightWebSocketClientConnection implements WebSocketClientConnectionProtocol { + readonly #route: WebSocketRoute + + readonly id: string + readonly url: URL + + constructor(route: WebSocketRoute) { + this.#route = route + this.id = crypto.randomUUID() + this.url = new URL(route.url()) + } + + send(data: WebSocketData): void { + if (data instanceof Blob) { + /** + * @note Playwright does not support sending Blob data. + * Read the blob as buffer, then send the buffer instead. + */ + data.bytes().then((bytes) => { + this.#route.send(Buffer.from(bytes)) + }) + return + } + + if (typeof data === 'string') { + this.#route.send(data) + return + } + + this.#route.send( + /** + * @note Forcefully cast all data to Buffer because Playwright + * has trouble digesting ArrayBuffer and Blob directly. + */ + Buffer.from( + /** + * @note Playwright type definitions are tailored to Node.js + * while MSW describes all data types that can be sent over + * the WebSocket protocol, like ArrayBuffer and Blob. + */ + data as any, + ), + ) + } + + close(code?: number, reason?: string): void { + const resolvedCode = code ?? 1000 + this.#route.close({ code: resolvedCode, reason }) + } + + addEventListener( + type: EventType, + listener: ( + this: WebSocket, + event: WebSocketClientEventMap[EventType], + ) => void, + options?: AddEventListenerOptions | boolean, + ): void { + /** + * @note Playwright does not expose the actual WebSocket reference. + */ + const target = {} as WebSocket + + switch (type) { + case 'message': { + this.#route.onMessage((data) => { + listener.call( + target, + new CancelableMessageEvent('message', { data }) as any, + ) + }) + break + } + case 'close': { + this.#route.onClose((code, reason) => { + listener.call( + target, + new CancelableCloseEvent('close', { code, reason }) as any, + ) + }) + break + } + } + } + + removeEventListener( + event: EventType, + listener: ( + this: WebSocket, + event: WebSocketClientEventMap[EventType], + ) => void, + options?: EventListenerOptions | boolean, + ): void { + console.warn( + '@msw/playwright: WebSocketRoute does not support removing event listeners', + ) + } +} + +class PlaywrightWebSocketServerConnection implements WebSocketServerConnectionProtocol { + readonly #route: WebSocketRoute + #server?: WebSocketRoute + #bufferedEvents: Array< + Parameters + > + #bufferedData: Array + + constructor(route: WebSocketRoute) { + this.#route = route + this.#bufferedEvents = [] + this.#bufferedData = [] + } + + connect(): void { + this.#server = this.#route.connectToServer() + + /** + * @note Playwright does not support event buffering. + * Manually add event listeners that might have been registered + * before `connect()` was called. + */ + for (const [type, listener, options] of this.#bufferedEvents) { + this.addEventListener(type, listener, options) + } + this.#bufferedEvents.length = 0 + + // Same for the buffered data. + for (const data of this.#bufferedData) { + this.send(data) + } + this.#bufferedData.length = 0 + } + + send(data: WebSocketData): void { + if (this.#server == null) { + this.#bufferedData.push(data) + return + } + + this.#server.send(data as any) + } + + close(code?: number, reason?: string): void { + invariant( + this.#server, + 'Failed to close connection to the actual WebSocket server: connection not established. Did you forget to call `connect()`?', + ) + + this.#server.close({ code, reason }) + } + + addEventListener( + type: EventType, + listener: ( + this: WebSocket, + event: WebSocketServerEventMap[EventType], + ) => void, + options?: AddEventListenerOptions | boolean, + ): void { + if (this.#server == null) { + this.#bufferedEvents.push([type, listener as any, options]) + return + } + + const target = {} as WebSocket + switch (type) { + case 'message': { + this.#server.onMessage((data) => { + listener.call( + target, + new CancelableMessageEvent('message', { data }) as any, + ) + }) + break + } + + case 'close': { + this.#server.onClose((code, reason) => { + listener.call( + target, + new CancelableCloseEvent('close', { code, reason }) as any, + ) + }) + break + } + } + } + + removeEventListener( + type: EventType, + listener: ( + this: WebSocket, + event: WebSocketServerEventMap[EventType], + ) => void, + options?: EventListenerOptions | boolean, + ): void { + console.warn( + '@msw/playwright: WebSocketRoute does not support removing event listeners', + ) + } +} diff --git a/src/index.ts b/src/index.ts index d1d1f0f..63b8f36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,8 @@ export { type NetworkFixture, type NetworkFixtureOptions, } from './fixture.js' + +export { + PlaywrightSource, + type PlaywrightSourceOptions, +} from './playwright-source.js' diff --git a/src/playwright-source.ts b/src/playwright-source.ts new file mode 100644 index 0000000..ea8d79e --- /dev/null +++ b/src/playwright-source.ts @@ -0,0 +1,116 @@ +import type { + BrowserContext, + Page, + Route, + WebSocketRoute, +} from '@playwright/test' +import { isCommonAssetRequest } from 'msw' +import { NetworkSource } from 'msw/experimental' +import { PlaywrightHttpNetworkFrame } from './frames/http-frame.js' +import { PlaywrightWebSocketNetworkFrame } from './frames/websocket-frame.js' +import { + convertToRequest, + inferPageBaseUrl, + inferRouteBaseUrl, + passthroughRequest, + registerRouteHandler, + registerWebSocketRouteHandler, + type UnrouteFn, +} from './route-utils.js' + +export interface PlaywrightSourceOptions { + /** + * Skip common asset requests (e.g. `*.html`, `*.css`, `*.js`, etc). + * This improves performance for certain projects. + * @default true + * + * @see https://mswjs.io/docs/api/is-common-asset-request + */ + skipAssetRequests?: boolean + /** + * A custom route pattern used when registering a Playwright route handler. + * + * @see https://playwright.dev/docs/api/class-page#page-route-option-url + */ + routePattern?: Parameters[0] + /** + * A custom route pattern used when registering a Playwright WebSocket route handler. + * + * @see https://playwright.dev/docs/api/class-page#page-route-web-socket-option-url + */ + websocketPattern?: Parameters[0] +} + +export class PlaywrightSource extends NetworkSource< + PlaywrightHttpNetworkFrame | PlaywrightWebSocketNetworkFrame +> { + #target: BrowserContext | Page + #options: Required + + #routeCleanup: UnrouteFn | null = null + #wsRouteCleanup: UnrouteFn | null = null + + constructor( + target: BrowserContext | Page, + options?: PlaywrightSourceOptions, + ) { + super() + this.#target = target + this.#options = { + skipAssetRequests: true, + routePattern: '**', + websocketPattern: '**', + ...options, + } + } + + async enable(): Promise { + this.#routeCleanup ??= await registerRouteHandler( + this.#target, + this.#options.routePattern, + this.#handleRequestRoute.bind(this), + ) + this.#wsRouteCleanup ??= await registerWebSocketRouteHandler( + this.#target, + this.#options.websocketPattern, + this.#handleWebSocketRoute.bind(this), + ) + } + + async disable(): Promise { + super.disable() + await this.#routeCleanup?.() + await this.#wsRouteCleanup?.() + this.#routeCleanup = null + this.#wsRouteCleanup = null + } + + async #handleRequestRoute(route: Route): Promise { + const request = await convertToRequest(route) + + /** + * @note Skip common asset requests (default). + * Playwright seems to experience performance degradation when routing all + * requests through the matching logic below. + * @see https://github.com/mswjs/playwright/issues/13 + */ + if (this.#options.skipAssetRequests && isCommonAssetRequest(request)) { + return await passthroughRequest(route) + } + + const frame = new PlaywrightHttpNetworkFrame({ + route, + request, + inferredBaseUrl: inferRouteBaseUrl(route), + }) + await this.queue(frame) + } + + async #handleWebSocketRoute(route: WebSocketRoute): Promise { + const frame = new PlaywrightWebSocketNetworkFrame({ + route, + inferredBaseUrl: inferPageBaseUrl(this.#target), + }) + await this.queue(frame) + } +} diff --git a/src/route-utils.ts b/src/route-utils.ts new file mode 100644 index 0000000..5739352 --- /dev/null +++ b/src/route-utils.ts @@ -0,0 +1,144 @@ +import type { BrowserContext, Page, Route } from '@playwright/test' + +export async function convertToRequest(route: Route): Promise { + const request = route.request() + return new Request(request.url(), { + method: request.method(), + headers: new Headers(await request.allHeaders()), + body: request.postDataBuffer() as null | ArrayBuffer, + }) +} + +export function inferRouteBaseUrl(route: Route): string | undefined { + const request = route.request() + let url = request.headers().referer + if (!url && request.isNavigationRequest()) { + url = request.url() + } else if (!url && request.serviceWorker() === null) { + url = request.frame().url() + } + + if (!url || url === 'about:blank') { + return undefined + } + return new URL(url).origin +} + +export function inferPageBaseUrl( + target: BrowserContext | Page, +): string | undefined { + const url = 'url' in target ? target.url() : target.pages().at(-1)?.url() + + if (!url || url === 'about:blank') { + return undefined + } + return decodeURI(new URL(encodeURI(url)).origin) +} + +export async function fulfillResponse( + route: Route, + response: Response, +): Promise { + try { + await route.fulfill({ + status: response.status, + headers: Object.fromEntries(response.headers), + body: response.body + ? Buffer.from(await response.arrayBuffer()) + : undefined, + }) + } catch (error) { + ignoreRouteHandledError(error) + } +} + +export async function abortRequest(route: Route): Promise { + try { + await route.abort() + } catch (error) { + ignoreRouteHandledError(error) + } +} + +export async function passthroughRequest(route: Route): Promise { + try { + await route.fallback() + } catch (error) { + ignoreRouteHandledError(error) + } +} + +/** + * @note Ignore "Route is already handled!" errors. + * Playwright has a bug where requests terminated due to navigation + * cause your in-flight route handlers to throw. There's no means to + * detect that scenario as both "route.handled" and "route._handlingPromise" are internal. + * @see https://github.com/mswjs/playwright/issues/35 + */ +function ignoreRouteHandledError(error: unknown): void { + if ( + error instanceof Error && + /route is already handled/i.test(error.message) + ) { + return + } + + throw error +} + +/** Callback for unmounting a configured route. */ +export type UnrouteFn = () => Promise | void + +export async function registerRouteHandler( + target: BrowserContext | Page, + url: Parameters[0], + handler: Parameters[1], + options?: Parameters[2], +): Promise { + const result = await target.route(url, handler, options) + /** @note Earlier version (pre Playwright v1.59) did return `void`. */ + if (result) return result.dispose.bind(result) + + return () => target.unroute(url, handler) +} + +export async function registerWebSocketRouteHandler( + target: BrowserContext | Page, + url: Parameters[0], + handler: Parameters[1], +): Promise { + await target.routeWebSocket(url, handler) + return () => unrouteWebSocket(target, url, handler) +} + +interface InternalWebSocketRoute { + url: Parameters[0] + handler: Parameters[1] +} + +/** + * Custom implementation of the missing `page.unrouteWebSocket()` to remove + * WebSocket route handlers from the page. Loosely inspired by `page.unroute()`. + */ +function unrouteWebSocket( + target: BrowserContext | Page, + url: InternalWebSocketRoute['url'], + handler?: InternalWebSocketRoute['handler'], +): void { + if ( + !('_webSocketRoutes' in target && Array.isArray(target._webSocketRoutes)) + ) { + return + } + + for (let i = target._webSocketRoutes.length - 1; i >= 0; i--) { + const route = target._webSocketRoutes[i] as InternalWebSocketRoute + + if ( + route.url === url && + (handler != null ? route.handler === handler : true) + ) { + target._webSocketRoutes.splice(i, 1) + } + } +} diff --git a/tests/custom-route-pattern.test.ts b/tests/custom-route-pattern.test.ts new file mode 100644 index 0000000..7805be3 --- /dev/null +++ b/tests/custom-route-pattern.test.ts @@ -0,0 +1,130 @@ +import { test as testBase, expect } from '@playwright/test' +import { http, HttpResponse, ws, type AnyHandler } from 'msw' +import { defineNetworkFixture, type NetworkFixture } from '../src/index.js' + +interface Fixtures { + handlers: Array + network: NetworkFixture +} + +const test = testBase.extend({ + handlers: [[], { option: true }], + network: [ + async ({ context, handlers }, use) => { + const network = defineNetworkFixture({ + context, + handlers, + routePattern: `/api/**`, + websocketPattern: `**/realtime`, + }) + + /** + * @note Fallback used to verify the "ignore" case. MUST be registered + * before the network is enabled for the test to be meaningful, because + * Playwright also picks most recently registered, matching routes first. + */ + await context.route('/other/endpoint', (route) => + route.fulfill({ body: 'fallback response' }), + ) + + await network.enable() + await use(network) + await network.disable() + }, + { auto: true }, + ], +}) + +test('intercepts an HTTP request matching the custom route pattern', async ({ + network, + page, +}) => { + network.use( + http.get('/api/resource', () => { + return HttpResponse.text('hello world') + }), + ) + + await page.goto('/') + + const data = await page.evaluate(async () => { + const response = await fetch('/api/resource') + return response.text() + }) + + expect(data).toBe('hello world') +}) + +test('ignores HTTP request unrelated to the custom route pattern', async ({ + network, + page, +}) => { + network.use( + http.get('/other/endpoint', () => { + return HttpResponse.text('hello world') + }), + ) + + await page.goto('/') + + const data = await page.evaluate(async () => { + const response = await fetch('/other/endpoint') + return response.text() + }) + + expect(data).toBe('fallback response') +}) + +test('intercepts a WebSocket connection matching the custom WebSocket pattern', async ({ + network, + page, +}) => { + const realtime = ws.link('ws://localhost/realtime') + network.use( + realtime.addEventListener('connection', ({ client }) => { + client.send('hello world') + }), + ) + + await page.goto('/') + + const message = await page.evaluate(() => { + const ws = new WebSocket('ws://localhost/realtime') + + return new Promise((resolve, reject) => { + ws.onerror = () => reject(new Error('WebSocket connection failed')) + ws.onmessage = (event) => { + resolve(event.data) + } + }) + }) + + expect(message).toBe('hello world') +}) + +test('ignores a WebSocket connection unrelated to the custom WebSocket pattern', async ({ + network, + page, +}) => { + const other = ws.link('ws://localhost/other') + network.use( + other.addEventListener('connection', ({ client }) => { + client.send('hello world') + }), + ) + + await page.goto('/') + + const connection = page.evaluate(() => { + const ws = new WebSocket('ws://localhost/other') + + return new Promise((resolve, reject) => { + ws.onerror = () => reject(new Error('WebSocket connection failed')) + ws.onmessage = (event) => { + resolve(event.data) + } + }) + }) + + expect(connection).rejects.toThrow('WebSocket connection failed') +}) diff --git a/tests/internal/routes.test.ts b/tests/internal/routes.test.ts index 7e4f543..32ed8f9 100644 --- a/tests/internal/routes.test.ts +++ b/tests/internal/routes.test.ts @@ -3,90 +3,164 @@ * with Playwright internals. It is not ideal but the best we can do, * given Playwright doesn't expose proper means to list route handlers. */ -import { test as testBase, expect } from '@playwright/test' +import { + test as testBase, + expect, + type BrowserContext, + type Page, +} from '@playwright/test' import type { AnyHandler } from 'msw' -import { INTERNAL_MATCH_ALL_REG_EXP } from '../../src/fixture.js' import { defineNetworkFixture, type NetworkFixture } from '../../src/index.js' interface Fixtures { handlers: Array network: NetworkFixture + target: BrowserContext | Page } -const test = testBase.extend({ - handlers: [[], { option: true }], - network: [ - async ({ context, handlers }, use) => { - const network = defineNetworkFixture({ - context, - handlers, - }) - - await network.enable() - await use(network) +const DEFAULT_PATTERN = '**' +const targets = ['context', 'page'] as const + +for (const target of targets) { + const test = testBase.extend({ + target: ({ context, page }, use) => + use(target === 'context' ? context : page), + handlers: [[], { option: true }], + network: [ + async ({ target, handlers }, use) => { + const network = defineNetworkFixture({ + context: target, + handlers, + }) + + await network.enable() + await use(network) + await network.disable() + }, + { auto: true }, + ], + }) + + test.describe(`registering routes on target "${target}"`, () => { + test('registers a single HTTP route', async ({ target }) => { + expect(Reflect.get(target, '_routes')).toEqual([ + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + }) + + test('unroutes the HTTP route when the fixture is stopped', async ({ + target, + network, + }) => { + await network.disable() + expect(Reflect.get(target, '_routes')).toEqual([]) + }) + + test('preserves user-defined HTTP routes', async ({ target, network }) => { + const routeHandler = () => {} + await target.route('/user-defined', routeHandler) + + expect(Reflect.get(target, '_routes')).toEqual([ + expect.objectContaining({ + url: '/user-defined', + handler: routeHandler, + }), + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + + await network.disable() + expect(Reflect.get(target, '_routes')).toEqual([ + expect.objectContaining({ + url: '/user-defined', + handler: routeHandler, + }), + ]) + }) + + test('preserves user-defined HTTP routes with the same pattern', async ({ + target, + network, + }) => { + const routeHandler = () => {} + await target.route(DEFAULT_PATTERN, routeHandler) + + expect(Reflect.get(target, '_routes')).toEqual([ + expect.objectContaining({ + url: DEFAULT_PATTERN, + handler: routeHandler, + }), + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + await network.disable() - }, - { auto: true }, - ], -}) - -test('registers a single HTTP route', async ({ context }) => { - expect(Reflect.get(context, '_routes')).toEqual([ - expect.objectContaining({ url: INTERNAL_MATCH_ALL_REG_EXP }), - ]) -}) - -test('unroutes the HTTP route when the fixture is stopped', async ({ - context, - network, -}) => { - await network.disable() - expect(Reflect.get(context, '_routes')).toEqual([]) -}) - -test('preserves user-defined HTTP routes', async ({ context, network }) => { - const routeHandler = () => {} - await context.route('/user-defined', routeHandler) - - expect(Reflect.get(context, '_routes')).toEqual([ - expect.objectContaining({ url: '/user-defined', handler: routeHandler }), - expect.objectContaining({ url: INTERNAL_MATCH_ALL_REG_EXP }), - ]) - - await network.disable() - expect(Reflect.get(context, '_routes')).toEqual([ - expect.objectContaining({ url: '/user-defined', handler: routeHandler }), - ]) -}) - -test('registers a single WebSocket handler', async ({ context }) => { - expect(Reflect.get(context, '_webSocketRoutes')).toEqual([ - expect.objectContaining({ url: INTERNAL_MATCH_ALL_REG_EXP }), - ]) -}) - -test('unroutes the WebSocket handler when the fixture is stopped', async ({ - context, - network, -}) => { - await network.disable() - expect(Reflect.get(context, '_webSocketRoutes')).toEqual([]) -}) - -test('preserves user-defined WebSocket routes', async ({ - context, - network, -}) => { - const routeHandler = () => {} - await context.routeWebSocket('/user-defined', routeHandler) - - expect(Reflect.get(context, '_webSocketRoutes')).toEqual([ - expect.objectContaining({ url: '/user-defined', handler: routeHandler }), - expect.objectContaining({ url: INTERNAL_MATCH_ALL_REG_EXP }), - ]) - - await network.disable() - expect(Reflect.get(context, '_webSocketRoutes')).toEqual([ - expect.objectContaining({ url: '/user-defined', handler: routeHandler }), - ]) -}) + expect(Reflect.get(target, '_routes')).toEqual([ + expect.objectContaining({ + url: DEFAULT_PATTERN, + handler: routeHandler, + }), + ]) + }) + + test('registers a single WebSocket handler', async ({ target }) => { + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([ + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + }) + + test('unroutes the WebSocket handler when the fixture is stopped', async ({ + target, + network, + }) => { + await network.disable() + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([]) + }) + + test('preserves user-defined WebSocket routes', async ({ + target, + network, + }) => { + const routeHandler = () => {} + await target.routeWebSocket('/user-defined', routeHandler) + + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([ + expect.objectContaining({ + url: '/user-defined', + handler: routeHandler, + }), + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + + await network.disable() + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([ + expect.objectContaining({ + url: '/user-defined', + handler: routeHandler, + }), + ]) + }) + + test('preserves user-defined WebSocket routes with the same pattern', async ({ + target, + network, + }) => { + const routeHandler = () => {} + await target.routeWebSocket(DEFAULT_PATTERN, routeHandler) + + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([ + expect.objectContaining({ + url: DEFAULT_PATTERN, + handler: routeHandler, + }), + expect.objectContaining({ url: DEFAULT_PATTERN }), + ]) + + await network.disable() + expect(Reflect.get(target, '_webSocketRoutes')).toEqual([ + expect.objectContaining({ + url: DEFAULT_PATTERN, + handler: routeHandler, + }), + ]) + }) + }) +} diff --git a/tests/on-unhandled-request.test.ts b/tests/on-unhandled-request.test.ts index 2d4436d..3003664 100644 --- a/tests/on-unhandled-request.test.ts +++ b/tests/on-unhandled-request.test.ts @@ -36,8 +36,8 @@ test('prints a warning on an unhandled request', async ({ page }) => { await page.goto('/') await page.evaluate(() => fetch('/unhandled')) - expect.soft(consoleSpy.callCount).toBe(2) - expect(consoleSpy.getCall(1)?.args).toEqual([ + expect.soft(consoleSpy.callCount).toBe(3) + expect(consoleSpy.getCall(2)?.args).toEqual([ `[MSW] Warning: intercepted a request without a matching request handler: • GET http://localhost:5173/unhandled diff --git a/tests/responses.test.ts b/tests/responses.test.ts index c08b065..2fc8a4b 100644 --- a/tests/responses.test.ts +++ b/tests/responses.test.ts @@ -1,6 +1,6 @@ import { test as testBase, expect } from '@playwright/test' import { http, HttpResponse, type AnyHandler } from 'msw' -import { defineNetworkFixture, type NetworkFixture } from '../src/fixture.js' +import { defineNetworkFixture, type NetworkFixture } from '../src/index.js' interface Fixtures { handlers: Array diff --git a/tests/typings/context.test-d.ts b/tests/typings/context.test-d.ts index 92a03f3..49c2207 100644 --- a/tests/typings/context.test-d.ts +++ b/tests/typings/context.test-d.ts @@ -1,9 +1,9 @@ import { it, expectTypeOf } from 'vitest' -import { type BrowserContext } from '@playwright/test' +import { type BrowserContext, type Page } from '@playwright/test' import { type NetworkFixtureOptions } from '../../build/index.mjs' -it('accepts the playwright context type', () => { - expectTypeOf().toEqualTypeOf< +it('accepts the playwright context or page type', () => { + expectTypeOf().toEqualTypeOf< NetworkFixtureOptions['context'] >() })