Skip to content

Commit 7883acd

Browse files
authored
feat: use providers request interception for module mocking (#7576)
1 parent a7ecd0f commit 7883acd

File tree

25 files changed

+426
-109
lines changed

25 files changed

+426
-109
lines changed

packages/browser/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@
9393
"@vitest/mocker": "workspace:*",
9494
"@vitest/utils": "workspace:*",
9595
"magic-string": "catalog:",
96-
"msw": "catalog:",
9796
"sirv": "catalog:",
9897
"tinyrainbow": "catalog:",
9998
"ws": "catalog:"

packages/browser/src/client/client.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ModuleMocker } from '@vitest/mocker/browser'
12
import type { CancelReason } from '@vitest/runner'
23
import type { BirpcReturn } from 'birpc'
34
import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../node/types'
@@ -65,6 +66,21 @@ function createClient() {
6566
}
6667
cdp.emit(event, payload)
6768
},
69+
async resolveManualMock(url: string) {
70+
// @ts-expect-error not typed global API
71+
const mocker = globalThis.__vitest_mocker__ as ModuleMocker | undefined
72+
const responseId = getBrowserState().sessionId
73+
if (!mocker) {
74+
return { url, keys: [], responseId }
75+
}
76+
const exports = await mocker.resolveFactoryModule(url)
77+
const keys = Object.keys(exports)
78+
return {
79+
url,
80+
keys,
81+
responseId,
82+
}
83+
},
6884
},
6985
{
7086
post: msg => ctx.ws.send(msg),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ModuleMockerInterceptor } from '@vitest/mocker/browser'
2+
import type { BrowserRPC } from '../client'
3+
import { getBrowserState, getWorkerState } from '../utils'
4+
5+
export function createModuleMockerInterceptor(): ModuleMockerInterceptor {
6+
return {
7+
async register(module) {
8+
const state = getBrowserState()
9+
await rpc().registerMock(state.sessionId, module.toJSON())
10+
},
11+
async delete(id) {
12+
const state = getBrowserState()
13+
await rpc().unregisterMock(state.sessionId, id)
14+
},
15+
async invalidate() {
16+
const state = getBrowserState()
17+
await rpc().clearMocks(state.sessionId)
18+
},
19+
}
20+
}
21+
22+
export function rpc(): BrowserRPC {
23+
return getWorkerState().rpc as any as BrowserRPC
24+
}

packages/browser/src/client/tester/msw.ts

-19
This file was deleted.

packages/browser/src/client/tester/tester.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { executor, getBrowserState, getConfig, getWorkerState } from '../utils'
1212
import { setupDialogsSpy } from './dialog'
1313
import { setupConsoleLogSpy } from './logger'
1414
import { VitestBrowserClientMocker } from './mocker'
15-
import { createModuleMockerInterceptor } from './msw'
15+
import { createModuleMockerInterceptor } from './mocker-interceptor'
1616
import { createSafeRpc } from './rpc'
1717
import { browserHashMap, initiateRunner } from './runner'
1818
import { CommandsManager } from './utils'
@@ -43,7 +43,6 @@ async function prepareTestEnvironment(files: string[]) {
4343

4444
getBrowserState().commands = new CommandsManager()
4545

46-
// TODO: expose `worker`
4746
const interceptor = createModuleMockerInterceptor()
4847
const mocker = new VitestBrowserClientMocker(
4948
interceptor,

packages/browser/src/node/index.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Plugin } from 'vitest/config'
22
import type { TestProject } from 'vitest/node'
3+
import { MockerRegistry } from '@vitest/mocker'
4+
import { interceptorPlugin } from '@vitest/mocker/node'
35
import c from 'tinyrainbow'
46
import { createViteLogger, createViteServer } from 'vitest/node'
57
import { version } from '../../package.json'
@@ -38,6 +40,8 @@ export async function createBrowserServer(
3840
allowClearScreen: false,
3941
})
4042

43+
const mockerRegistry = new MockerRegistry()
44+
4145
const vite = await createViteServer({
4246
...project.options, // spread project config inlined in root workspace config
4347
base: '/',
@@ -68,13 +72,14 @@ export async function createBrowserServer(
6872
...prePlugins,
6973
...(project.options?.plugins || []),
7074
BrowserPlugin(server),
75+
interceptorPlugin({ registry: mockerRegistry }),
7176
...postPlugins,
7277
],
7378
})
7479

7580
await vite.listen()
7681

77-
setupBrowserRpc(server)
82+
setupBrowserRpc(server, mockerRegistry)
7883

7984
return server
8085
}

packages/browser/src/node/providers/playwright.ts

+180-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { MockedModule } from '@vitest/mocker'
12
import type {
23
Browser,
34
BrowserContext,
@@ -7,11 +8,15 @@ import type {
78
LaunchOptions,
89
Page,
910
} from 'playwright'
11+
import type { SourceMap } from 'rollup'
12+
import type { ResolvedConfig } from 'vite'
1013
import type {
14+
BrowserModuleMocker,
1115
BrowserProvider,
1216
BrowserProviderInitializationOptions,
1317
TestProject,
1418
} from 'vitest/node'
19+
import { createManualModuleSource } from '@vitest/mocker/node'
1520

1621
export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const
1722
export type PlaywrightBrowser = (typeof playwrightBrowsers)[number]
@@ -40,6 +45,8 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
4045

4146
private browserPromise: Promise<Browser> | null = null
4247

48+
public mocker: BrowserModuleMocker | undefined
49+
4350
getSupportedBrowsers(): readonly string[] {
4451
return playwrightBrowsers
4552
}
@@ -51,6 +58,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
5158
this.project = project
5259
this.browserName = browser
5360
this.options = options as any
61+
this.mocker = this.createMocker()
5462
}
5563

5664
private async openBrowser() {
@@ -103,6 +111,136 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
103111
return this.browserPromise
104112
}
105113

114+
private createMocker(): BrowserModuleMocker {
115+
const idPreficates = new Map<string, (url: URL) => boolean>()
116+
const sessionIds = new Map<string, string[]>()
117+
118+
function createPredicate(sessionId: string, url: string) {
119+
const moduleUrl = new URL(url, 'http://localhost')
120+
const predicate = (url: URL) => {
121+
if (url.searchParams.has('_vitest_original')) {
122+
return false
123+
}
124+
125+
// different modules, ignore request
126+
if (url.pathname !== moduleUrl.pathname) {
127+
return false
128+
}
129+
130+
url.searchParams.delete('t')
131+
url.searchParams.delete('v')
132+
url.searchParams.delete('import')
133+
134+
// different search params, ignore request
135+
if (url.searchParams.size !== moduleUrl.searchParams.size) {
136+
return false
137+
}
138+
139+
// check that all search params are the same
140+
for (const [param, value] of url.searchParams.entries()) {
141+
if (moduleUrl.searchParams.get(param) !== value) {
142+
return false
143+
}
144+
}
145+
146+
return true
147+
}
148+
const ids = sessionIds.get(sessionId) || []
149+
ids.push(moduleUrl.href)
150+
sessionIds.set(sessionId, ids)
151+
idPreficates.set(moduleUrl.href, predicate)
152+
return predicate
153+
}
154+
155+
return {
156+
register: async (sessionId: string, module: MockedModule): Promise<void> => {
157+
const page = this.getPage(sessionId)
158+
await page.route(createPredicate(sessionId, module.url), async (route) => {
159+
if (module.type === 'manual') {
160+
const exports = Object.keys(await module.resolve())
161+
const body = createManualModuleSource(module.url, exports)
162+
return route.fulfill({
163+
body,
164+
headers: getHeaders(this.project.browser!.vite.config),
165+
})
166+
}
167+
168+
// webkit doesn't support redirect responses
169+
// https://github.com/microsoft/playwright/issues/18318
170+
const isWebkit = this.browserName === 'webkit'
171+
if (isWebkit) {
172+
const url = module.type === 'redirect'
173+
? (() => {
174+
// url has http:// which vite.trasnformRequest doesn't understand
175+
const url = new URL(module.redirect)
176+
return url.href.slice(url.origin.length)
177+
})()
178+
: (() => {
179+
const url = new URL(route.request().url())
180+
url.searchParams.set('mock', module.type)
181+
return url.href.slice(url.origin.length)
182+
})()
183+
const result = await this.project.browser!.vite.transformRequest(url).catch(() => null)
184+
if (!result) {
185+
return route.continue()
186+
}
187+
let content = result.code
188+
if (result.map && 'version' in result.map && result.map.mappings) {
189+
const type = isDirectCSSRequest(url) ? 'css' : 'js'
190+
content = getCodeWithSourcemap(type, content.toString(), result.map)
191+
}
192+
return route.fulfill({
193+
body: content,
194+
headers: getHeaders(this.project.browser!.vite.config),
195+
})
196+
}
197+
198+
if (module.type === 'redirect') {
199+
return route.fulfill({
200+
status: 302,
201+
headers: {
202+
Location: module.redirect,
203+
},
204+
})
205+
}
206+
else if (module.type === 'automock' || module.type === 'autospy') {
207+
const url = new URL(route.request().url())
208+
url.searchParams.set('mock', module.type)
209+
return route.fulfill({
210+
status: 302,
211+
headers: {
212+
Location: url.href,
213+
},
214+
})
215+
}
216+
else {
217+
// all types are exhausted
218+
const _module: never = module
219+
}
220+
})
221+
},
222+
delete: async (sessionId: string, id: string): Promise<void> => {
223+
const page = this.getPage(sessionId)
224+
const predicate = idPreficates.get(id)
225+
if (predicate) {
226+
await page.unroute(predicate).finally(() => idPreficates.delete(id))
227+
}
228+
},
229+
clear: async (sessionId: string): Promise<void> => {
230+
const page = this.getPage(sessionId)
231+
const ids = sessionIds.get(sessionId) || []
232+
const promises = ids.map((id) => {
233+
const predicate = idPreficates.get(id)
234+
if (predicate) {
235+
return page.unroute(predicate).finally(() => idPreficates.delete(id))
236+
}
237+
return null
238+
})
239+
await Promise.all(promises).finally(() => sessionIds.delete(sessionId))
240+
},
241+
}
242+
}
243+
106244
private async createContext(sessionId: string) {
107245
if (this.contexts.has(sessionId)) {
108246
return this.contexts.get(sessionId)!
@@ -113,7 +251,6 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
113251
const options = {
114252
...contextOptions,
115253
ignoreHTTPSErrors: true,
116-
serviceWorkers: 'allow',
117254
} satisfies BrowserContextOptions
118255
if (this.project.config.browser.ui) {
119256
options.viewport = null
@@ -154,7 +291,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
154291
const timeout = setTimeout(() => {
155292
const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`)
156293
reject(err)
157-
}, 1000)
294+
}, 1000).unref()
158295
page.on('frameattached', (frame) => {
159296
clearTimeout(timeout)
160297
resolve(frame)
@@ -241,3 +378,44 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
241378
await browser?.close()
242379
}
243380
}
381+
382+
function getHeaders(config: ResolvedConfig) {
383+
const headers: Record<string, string> = {
384+
'Content-Type': 'application/javascript',
385+
}
386+
387+
for (const name in config.server.headers) {
388+
headers[name] = String(config.server.headers[name]!)
389+
}
390+
return headers
391+
}
392+
393+
function getCodeWithSourcemap(
394+
type: 'js' | 'css',
395+
code: string,
396+
map: SourceMap,
397+
): string {
398+
if (type === 'js') {
399+
code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}`
400+
}
401+
else if (type === 'css') {
402+
code += `\n/*# sourceMappingURL=${genSourceMapUrl(map)} */`
403+
}
404+
405+
return code
406+
}
407+
408+
function genSourceMapUrl(map: SourceMap | string): string {
409+
if (typeof map !== 'string') {
410+
map = JSON.stringify(map)
411+
}
412+
return `data:application/json;base64,${Buffer.from(map).toString('base64')}`
413+
}
414+
415+
const CSS_LANGS_RE
416+
= /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/
417+
const directRequestRE = /[?&]direct\b/
418+
419+
function isDirectCSSRequest(request: string): boolean {
420+
return CSS_LANGS_RE.test(request) && directRequestRE.test(request)
421+
}

0 commit comments

Comments
 (0)