diff --git a/package.json b/package.json index 689882d..7ee5e83 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "@ossjs/release": "^0.10.1", "@playwright/test": "^1.57.0", "@types/node": "^22.15.29", + "@types/sinon": "^21.0.0", "msw": "^2.12.7", + "sinon": "^21.0.1", "tsdown": "^0.12.7", "typescript": "^5.9.3", "vite": "^7.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89f3c57..8eda338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,15 @@ importers: '@types/node': specifier: ^22.15.29 version: 22.15.29 + '@types/sinon': + specifier: ^21.0.0 + version: 21.0.0 msw: specifier: ^2.12.7 version: 2.12.7(@types/node@22.15.29)(typescript@5.9.3) + sinon: + specifier: ^21.0.1 + version: 21.0.1 tsdown: specifier: ^0.12.7 version: 0.12.7(publint@0.3.16)(typescript@5.9.3) @@ -510,6 +516,15 @@ packages: cpu: [x64] os: [win32] + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.1.0': + resolution: {integrity: sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -531,6 +546,12 @@ packages: '@types/semver@7.7.0': resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + '@types/sinon@21.0.0': + resolution: {integrity: sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -784,6 +805,10 @@ packages: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -1058,6 +1083,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sinon@21.0.1: + resolution: {integrity: sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==} + sonic-boom@2.8.0: resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} @@ -1122,6 +1150,10 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -1183,6 +1215,14 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -1665,6 +1705,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.44.1': optional: true + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.1.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -1688,6 +1741,12 @@ snapshots: '@types/semver@7.7.0': {} + '@types/sinon@21.0.0': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/statuses@2.0.6': {} '@types/ws@8.18.1': @@ -1915,6 +1974,8 @@ snapshots: has-flag@3.0.0: {} + has-flag@4.0.0: {} + headers-polyfill@4.0.3: {} hono@4.7.11: {} @@ -2211,6 +2272,14 @@ snapshots: signal-exit@4.1.0: {} + sinon@21.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.1.0 + '@sinonjs/samsam': 8.0.3 + diff: 8.0.2 + supports-color: 7.2.0 + sonic-boom@2.8.0: dependencies: atomic-sleep: 1.0.0 @@ -2272,6 +2341,10 @@ snapshots: dependencies: has-flag: 3.0.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tagged-tag@1.0.0: {} thread-stream@0.15.2: @@ -2334,6 +2407,10 @@ snapshots: tslib@2.8.1: optional: true + type-detect@4.0.8: {} + + type-detect@4.1.0: {} + type-fest@0.21.3: {} type-fest@5.3.1: diff --git a/src/fixture.ts b/src/fixture.ts index c04d90f..ce497ed 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -10,10 +10,11 @@ import type { } from '@playwright/test' import { type LifeCycleEventsMap, + type UnhandledRequestStrategy, SetupApi, RequestHandler, WebSocketHandler, - getResponse, + handleRequest, } from 'msw' import { type WebSocketClientEventMap, @@ -26,7 +27,8 @@ import { } from '@mswjs/interceptors/WebSocket' export interface CreateNetworkFixtureArgs { - initialHandlers: Array + initialHandlers?: Array + onUnhandledRequest?: UnhandledRequestStrategy } /** @@ -50,7 +52,6 @@ export interface CreateNetworkFixtureArgs { */ export function createNetworkFixture( args?: CreateNetworkFixtureArgs, - /** @todo `onUnhandledRequest`? */ ): [ TestFixture, { auto: boolean }, @@ -60,6 +61,7 @@ export function createNetworkFixture( const worker = new NetworkFixture({ page, initialHandlers: args?.initialHandlers || [], + onUnhandledRequest: args?.onUnhandledRequest, }) await worker.start() @@ -79,19 +81,19 @@ export function createNetworkFixture( export const INTERNAL_MATCH_ALL_REG_EXP = /.+(__MSW_PLAYWRIGHT_PREDICATE__)?/ export class NetworkFixture extends SetupApi { - #page: Page - - constructor(args: { - page: Page - initialHandlers: Array - }) { + constructor( + protected args: { + page: Page + initialHandlers: Array + onUnhandledRequest?: UnhandledRequestStrategy + }, + ) { super(...args.initialHandlers) - this.#page = args.page } public async start(): Promise { // Handle HTTP requests. - await this.#page.route( + await this.args.page.route( INTERNAL_MATCH_ALL_REG_EXP, async (route: Route, request: Request) => { const fetchRequest = new Request(request.url(), { @@ -100,13 +102,29 @@ export class NetworkFixture extends SetupApi { body: request.postDataBuffer(), }) - const response = await getResponse( - this.handlersController.currentHandlers().filter((handler) => { + const handlers = this.handlersController + .currentHandlers() + .filter((handler) => { return handler instanceof RequestHandler - }), + }) + + /** + * @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, { - baseUrl: this.getPageUrl(), + onUnhandledRequest: this.args.onUnhandledRequest || 'bypass', + }, + this.emitter, + { + resolutionContext: { + quiet: true, + baseUrl: this.getPageUrl(), + }, }, ) @@ -131,44 +149,47 @@ export class NetworkFixture extends SetupApi { ) // Handle WebSocket connections. - await this.#page.routeWebSocket(INTERNAL_MATCH_ALL_REG_EXP, async (ws) => { - const allWebSocketHandlers = this.handlersController - .currentHandlers() - .filter((handler) => { - return handler instanceof WebSocketHandler - }) - - if (allWebSocketHandlers.length === 0) { - ws.connectToServer() - return - } + await this.args.page.routeWebSocket( + INTERNAL_MATCH_ALL_REG_EXP, + async (ws) => { + const allWebSocketHandlers = this.handlersController + .currentHandlers() + .filter((handler) => { + return handler instanceof WebSocketHandler + }) - const client = new PlaywrightWebSocketClientConnection(ws) - const server = new PlaywrightWebSocketServerConnection(ws) + if (allWebSocketHandlers.length === 0) { + ws.connectToServer() + return + } - for (const handler of allWebSocketHandlers) { - await handler.run( - { - client, - server, - info: { protocols: [] }, - }, - { - baseUrl: this.getPageUrl(), - }, - ) - } - }) + const client = new PlaywrightWebSocketClientConnection(ws) + const server = new PlaywrightWebSocketServerConnection(ws) + + for (const handler of allWebSocketHandlers) { + await handler.run( + { + client, + server, + info: { protocols: [] }, + }, + { + baseUrl: this.getPageUrl(), + }, + ) + } + }, + ) } public async stop(): Promise { super.dispose() - await this.#page.unroute(INTERNAL_MATCH_ALL_REG_EXP) - await unrouteWebSocket(this.#page, INTERNAL_MATCH_ALL_REG_EXP) + await this.args.page.unroute(INTERNAL_MATCH_ALL_REG_EXP) + await unrouteWebSocket(this.args.page, INTERNAL_MATCH_ALL_REG_EXP) } private getPageUrl(): string | undefined { - const url = this.#page.url() + const url = this.args.page.url() return url !== 'about:blank' ? url : undefined } } diff --git a/tests/auto.test.ts b/tests/auto.test.ts deleted file mode 100644 index 2f482aa..0000000 --- a/tests/auto.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test as testBase, expect } from '@playwright/test' -import { http } from 'msw' -import { createNetworkFixture, type NetworkFixture } from '../src/index.js' - -interface Fixtures { - network: NetworkFixture -} - -const test = testBase.extend({ - network: createNetworkFixture({ - initialHandlers: [ - http.get('/resource', () => { - return new Response('hello world') - }), - ], - }), -}) - -test('automatically applies the network fixture', async ({ page }) => { - await page.goto('/') - - const data = await page.evaluate(() => { - return fetch('/resource').then((response) => { - return response.text() - }) - }) - - expect(data).toBe('hello world') -}) diff --git a/tests/on-unhandled-request.test.ts b/tests/on-unhandled-request.test.ts new file mode 100644 index 0000000..5d1b3bd --- /dev/null +++ b/tests/on-unhandled-request.test.ts @@ -0,0 +1,35 @@ +import { test as testBase, expect } from '@playwright/test' +import { http } from 'msw' +import sinon from 'sinon' +import { createNetworkFixture, type NetworkFixture } from '../src/index.js' + +interface Fixtures { + network: NetworkFixture +} + +const test = testBase.extend({ + network: createNetworkFixture({ + onUnhandledRequest: 'warn', + }), +}) + +test.afterAll(() => { + sinon.restore() +}) + +test('prints a warning on an unhandled request', async ({ page, network }) => { + const consoleSpy = sinon.stub(console, 'warn') + + await page.goto('/') + await page.evaluate(() => fetch('/unhandled')) + + expect.soft(consoleSpy.callCount).toBe(2) + expect(consoleSpy.getCall(1).args).toEqual([ + `[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost:5173/unhandled + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/http/intercepting-requests`, + ]) +})