Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f83c8ec
feat!: update msw peer-dependency to ^2.13.2
christoph-fricke Apr 8, 2026
ba95269
feat: scaffold `PlaywrightSource` as the new network source
christoph-fricke Apr 8, 2026
65d32b3
feat: add basic `PlaywrightHttpNetworkFrame` implementation
christoph-fricke Apr 8, 2026
f9b2c6e
test: make first tests pass with network source architecture
christoph-fricke Apr 8, 2026
dc880cd
chore: remove resolved todo comment
christoph-fricke Apr 9, 2026
ee93708
test: migrate all tests to network source api
christoph-fricke Apr 9, 2026
4340e11
refactor: remove override keyword usage
christoph-fricke Apr 9, 2026
e21c712
feat: add empty websocket frame
christoph-fricke Apr 9, 2026
e1402a1
feat: implement websocket frame based on `WebSocketNetworkFrame`
christoph-fricke Apr 12, 2026
c6cf6ca
feat: infer baseUrl from intercepted request
christoph-fricke Apr 13, 2026
36d598c
feat: accept either `Page` or `BrowserContext`
christoph-fricke Apr 13, 2026
6ff9100
feat: infer baseUrl for intercepted WebSocket connections
christoph-fricke Apr 15, 2026
2e3c505
test: bypass unhandled frames in tests
christoph-fricke Apr 15, 2026
bb06227
refactor: rename utils to route-utils
christoph-fricke Apr 15, 2026
4e2f76f
refactor: capsulate route handled error workaround in utils
christoph-fricke Apr 15, 2026
3238938
refactor: unify route handler registration
christoph-fricke Apr 15, 2026
7da7eee
chore: organize imports
christoph-fricke Apr 15, 2026
a616f61
feat: add support for custom route patterns
christoph-fricke Apr 15, 2026
f087d52
test: deduplicate internal route registration tests
christoph-fricke Apr 15, 2026
d5e06cd
chore: remove open todo comments
christoph-fricke Apr 15, 2026
3f60c5a
feat: use network source api in defineNetworkFixture
christoph-fricke May 6, 2026
d5f6804
refactor: remove quit override from network frames
christoph-fricke May 6, 2026
a1ba6c3
refactor: always provide inferred base url to frame resolution
christoph-fricke May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"msw": "^2.12.10"
"msw": "^2.13.2"
},
"devDependencies": {
"@epic-web/test-server": "^0.1.6",
"@ossjs/release": "^0.10.1",
"@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",
Expand Down
37 changes: 27 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions src/frames/http-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Route } from '@playwright/test'
import { HttpNetworkFrame } from 'msw/experimental'
import { fulfillResponse, handleRouteSafely } from '../utils.js'

interface PlaywrightHttpNetworkFrameOptions {
request: Request
id?: string
route: Route
}

export class PlaywrightHttpNetworkFrame extends HttpNetworkFrame {
#route: Route
constructor(options: PlaywrightHttpNetworkFrameOptions) {
super(options)
this.#route = options.route
}

override async respondWith(response?: Response): Promise<void> {
if (!response) return

if (response.status === 0) {
return await handleRouteSafely(() => this.#route.abort())
}

return await handleRouteSafely(() => fulfillResponse(this.#route, response))
}

override passthrough(): Promise<void> {
return handleRouteSafely(() => this.#route.fallback())
}

override errorWith(reason?: unknown): Promise<void> {
if (reason instanceof Response) {
return handleRouteSafely(() => fulfillResponse(this.#route, reason))
}

return handleRouteSafely(() => this.#route.abort())
}
}
55 changes: 55 additions & 0 deletions src/playwright-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { BrowserContext, Route } from '@playwright/test'
import { isCommonAssetRequest } from 'msw'
import { NetworkSource } from 'msw/experimental'
import { PlaywrightHttpNetworkFrame } from './frames/http-frame.js'
import {
convertToRequest,
handleRouteSafely,
INTERNAL_MATCH_ALL_REG_EXP,
} from './utils.js'

interface PlaywrightSourceOptions {
context: BrowserContext
skipAssetRequests?: boolean

@christoph-fricke christoph-fricke Apr 15, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 With support for custom route patterns, skipAssetRequests might be obsolete.

I added support for custom patterns, because in my experience most mock setups mock API(s) behind one (a few at most) endpoints. Alongside registering multiple PlaywrightSource sources, custom patterns can help reduce the interception overhead for this common case. What do you think about this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if we go with the route of mapping handlers to page.route(), then I suppose there's no need in skipAssetRequests anymore. Just need to make sure the tests pass.

}

export class PlaywrightSource extends NetworkSource<PlaywrightHttpNetworkFrame> {
#context: BrowserContext
#skipAssetRequests: boolean

constructor(options: PlaywrightSourceOptions) {
super()
this.#context = options.context
this.#skipAssetRequests = options.skipAssetRequests ?? true
}

override async enable(): Promise<void> {
await this.#context.route(
INTERNAL_MATCH_ALL_REG_EXP,
this.#handleRouteRequest.bind(this),
)
}

override async disable(): Promise<void> {
super.disable()
await this.#context.unroute(INTERNAL_MATCH_ALL_REG_EXP)
}

async #handleRouteRequest(route: Route): Promise<void> {
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.#skipAssetRequests && isCommonAssetRequest(request)) {
return await handleRouteSafely(() => route.fallback())
}

// TODO: Do we need a id for the frame? Do we need to store the frame in a map like Interceptor- and ServiceWorkerSource?
Comment thread
christoph-fricke marked this conversation as resolved.
Outdated
const frame = new PlaywrightHttpNetworkFrame({ route, request })
await this.queue(frame)
Comment thread
kettanaito marked this conversation as resolved.
}
}
54 changes: 54 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Route } from '@playwright/test'

/**
* @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__)?/

export async function convertToRequest(route: Route): Promise<Request> {
const request = route.request()
return new Request(request.url(), {
method: request.method(),
headers: new Headers(await request.allHeaders()),
// TODO: Can we get rid of the type cast?
body: request.postDataBuffer() as null | ArrayBuffer,
})
}

export async function fulfillResponse(
route: Route,
response: Response,
): Promise<void> {
await route.fulfill({
status: response.status,
headers: Object.fromEntries(response.headers),
body: response.body ? Buffer.from(await response.arrayBuffer()) : undefined,
})
}

export async function handleRouteSafely(
callback: () => Promise<void>,
): Promise<void> {
try {
await callback()
} catch (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
*/
if (
error instanceof Error &&
/route is already handled/i.test(error.message)
) {
return
}

throw error
}
}
13 changes: 9 additions & 4 deletions tests/requests.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { test as testBase, expect } from '@playwright/test'
import { http, type AnyHandler } from 'msw'
import { defineNetworkFixture, type NetworkFixture } from '../src/index.js'
import { defineNetwork } from 'msw/experimental'
import { PlaywrightSource } from '../src/playwright-source.js'

interface Fixtures {
handlers: Array<AnyHandler>
network: NetworkFixture
network: ReturnType<typeof defineNetwork<PlaywrightSource[]>>
}

const test = testBase.extend<Fixtures>({
handlers: [[], { option: true }],
network: [
async ({ context, handlers }, use) => {
const network = defineNetworkFixture({
context,
const network = defineNetwork({
sources: [new PlaywrightSource({ context })],
handlers,
context: {
// TODO: Extract baseUrl from request and somehow pass it along into HttpNetworkFrame.prototype.resolve().
baseUrl: 'http://localhost:5173',
},
})

await network.enable()
Expand Down
Loading