Skip to content

Commit 2444ff2

Browse files
authoredOct 28, 2024··
fix(browser): initiate MSW in the same frame as tests (#6772)
1 parent 39041ee commit 2444ff2

File tree

13 files changed

+76
-216
lines changed

13 files changed

+76
-216
lines changed
 

‎packages/browser/src/client/channel.ts

+1-50
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { MockedModuleSerialized } from '@vitest/mocker'
21
import type { CancelReason } from '@vitest/runner'
32
import { getBrowserState } from './utils'
43

@@ -23,46 +22,6 @@ export interface IframeViewportEvent {
2322
id: string
2423
}
2524

26-
export interface IframeMockEvent {
27-
type: 'mock'
28-
module: MockedModuleSerialized
29-
}
30-
31-
export interface IframeUnmockEvent {
32-
type: 'unmock'
33-
url: string
34-
}
35-
36-
export interface IframeMockingDoneEvent {
37-
type: 'mock:done' | 'unmock:done'
38-
}
39-
40-
export interface IframeMockFactoryRequestEvent {
41-
type: 'mock-factory:request'
42-
eventId: string
43-
id: string
44-
}
45-
46-
export interface IframeMockFactoryResponseEvent {
47-
type: 'mock-factory:response'
48-
eventId: string
49-
exports: string[]
50-
}
51-
52-
export interface IframeMockFactoryErrorEvent {
53-
type: 'mock-factory:error'
54-
eventId: string
55-
error: any
56-
}
57-
58-
export interface IframeViewportChannelEvent {
59-
type: 'viewport:done' | 'viewport:fail'
60-
}
61-
62-
export interface IframeMockInvalidateEvent {
63-
type: 'mock:invalidate'
64-
}
65-
6625
export interface GlobalChannelTestRunCanceledEvent {
6726
type: 'cancel'
6827
reason: CancelReason
@@ -74,16 +33,8 @@ export type IframeChannelIncomingEvent =
7433
| IframeViewportEvent
7534
| IframeErrorEvent
7635
| IframeDoneEvent
77-
| IframeMockEvent
78-
| IframeUnmockEvent
79-
| IframeMockFactoryResponseEvent
80-
| IframeMockFactoryErrorEvent
81-
| IframeMockInvalidateEvent
8236

83-
export type IframeChannelOutgoingEvent =
84-
| IframeMockFactoryRequestEvent
85-
| IframeViewportChannelEvent
86-
| IframeMockingDoneEvent
37+
export type IframeChannelOutgoingEvent = never
8738

8839
export type IframeChannelEvent =
8940
| IframeChannelIncomingEvent

‎packages/browser/src/client/orchestrator.ts

-15
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { channel, client } from '@vitest/browser/client'
33
import { globalChannel, type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent } from '@vitest/browser/client'
44
import { generateHash } from '@vitest/runner/utils'
55
import { relative } from 'pathe'
6-
import { createModuleMockerInterceptor } from './tester/msw'
76
import { getUiAPI } from './ui'
87
import { getBrowserState, getConfig } from './utils'
98

@@ -13,7 +12,6 @@ const ID_ALL = '__vitest_all__'
1312
class IframeOrchestrator {
1413
private cancelled = false
1514
private runningFiles = new Set<string>()
16-
private interceptor = createModuleMockerInterceptor()
1715
private iframes = new Map<string, HTMLIFrameElement>()
1816

1917
public async init() {
@@ -186,19 +184,6 @@ class IframeOrchestrator {
186184
}
187185
break
188186
}
189-
case 'mock:invalidate':
190-
this.interceptor.invalidate()
191-
break
192-
case 'unmock':
193-
await this.interceptor.delete(e.data.url)
194-
break
195-
case 'mock':
196-
await this.interceptor.register(e.data.module)
197-
break
198-
case 'mock-factory:error':
199-
case 'mock-factory:response':
200-
// handled manually
201-
break
202187
default: {
203188
e.data satisfies never
204189

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

-32
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,7 @@
1-
import type { IframeChannelOutgoingEvent, IframeMockFactoryErrorEvent, IframeMockFactoryResponseEvent } from '@vitest/browser/client'
2-
import { channel } from '@vitest/browser/client'
31
import { ModuleMocker } from '@vitest/mocker/browser'
42
import { getBrowserState } from '../utils'
53

64
export class VitestBrowserClientMocker extends ModuleMocker {
7-
setupWorker() {
8-
channel.addEventListener(
9-
'message',
10-
async (e: MessageEvent<IframeChannelOutgoingEvent>) => {
11-
if (e.data.type === 'mock-factory:request') {
12-
try {
13-
const module = await this.resolveFactoryModule(e.data.id)
14-
const exports = Object.keys(module)
15-
channel.postMessage({
16-
type: 'mock-factory:response',
17-
eventId: e.data.eventId,
18-
exports,
19-
} satisfies IframeMockFactoryResponseEvent)
20-
}
21-
catch (err: any) {
22-
channel.postMessage({
23-
type: 'mock-factory:error',
24-
eventId: e.data.eventId,
25-
error: {
26-
name: err.name,
27-
message: err.message,
28-
stack: err.stack,
29-
},
30-
} satisfies IframeMockFactoryErrorEvent)
31-
}
32-
}
33-
},
34-
)
35-
}
36-
375
// default "vi" utility tries to access mock context to avoid circular dependencies
386
public getMockContext() {
397
return { callstack: null }
+4-57
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,9 @@
1-
import type {
2-
IframeChannelEvent,
3-
IframeMockFactoryRequestEvent,
4-
IframeMockingDoneEvent,
5-
} from '@vitest/browser/client'
6-
import type { MockedModuleSerialized } from '@vitest/mocker'
7-
import { channel } from '@vitest/browser/client'
8-
import { ManualMockedModule } from '@vitest/mocker'
91
import { ModuleMockerMSWInterceptor } from '@vitest/mocker/browser'
10-
import { nanoid } from '@vitest/utils'
11-
12-
export class VitestBrowserModuleMockerInterceptor extends ModuleMockerMSWInterceptor {
13-
override async register(event: MockedModuleSerialized): Promise<void> {
14-
if (event.type === 'manual') {
15-
const module = ManualMockedModule.fromJSON(event, async () => {
16-
const keys = await getFactoryExports(event.url)
17-
return Object.fromEntries(keys.map(key => [key, null]))
18-
})
19-
await super.register(module)
20-
}
21-
else {
22-
await this.init()
23-
this.mocks.register(event)
24-
}
25-
channel.postMessage(<IframeMockingDoneEvent>{ type: 'mock:done' })
26-
}
27-
28-
override async delete(url: string): Promise<void> {
29-
await super.delete(url)
30-
channel.postMessage(<IframeMockingDoneEvent>{ type: 'unmock:done' })
31-
}
32-
}
2+
import { getConfig } from '../utils'
333

344
export function createModuleMockerInterceptor() {
35-
return new VitestBrowserModuleMockerInterceptor({
5+
const debug = getConfig().env.VITEST_BROWSER_DEBUG
6+
return new ModuleMockerMSWInterceptor({
367
globalThisAccessor: '"__vitest_mocker__"',
378
mswOptions: {
389
serviceWorker: {
@@ -42,31 +13,7 @@ export function createModuleMockerInterceptor() {
4213
},
4314
},
4415
onUnhandledRequest: 'bypass',
45-
quiet: true,
16+
quiet: !(debug && debug !== 'false'),
4617
},
4718
})
4819
}
49-
50-
function getFactoryExports(id: string) {
51-
const eventId = nanoid()
52-
channel.postMessage({
53-
type: 'mock-factory:request',
54-
eventId,
55-
id,
56-
} satisfies IframeMockFactoryRequestEvent)
57-
return new Promise<string[]>((resolve, reject) => {
58-
channel.addEventListener(
59-
'message',
60-
function onMessage(e: MessageEvent<IframeChannelEvent>) {
61-
if (e.data.type === 'mock-factory:response' && e.data.eventId === eventId) {
62-
resolve(e.data.exports)
63-
channel.removeEventListener('message', onMessage)
64-
}
65-
if (e.data.type === 'mock-factory:error' && e.data.eventId === eventId) {
66-
reject(e.data.error)
67-
channel.removeEventListener('message', onMessage)
68-
}
69-
},
70-
)
71-
})
72-
}

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

+5-25
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type { IframeMockEvent, IframeMockInvalidateEvent, IframeUnmockEvent } from '@vitest/browser/client'
2-
import { channel, client, onCancel, waitForChannel } from '@vitest/browser/client'
1+
import { channel, client, onCancel } from '@vitest/browser/client'
32
import { page, userEvent } from '@vitest/browser/context'
43
import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser'
54
import { executor, getBrowserState, getConfig, getWorkerState } from '../utils'
65
import { setupDialogsSpy } from './dialog'
76
import { setupExpectDom } from './expect-element'
87
import { setupConsoleLogSpy } from './logger'
98
import { VitestBrowserClientMocker } from './mocker'
9+
import { createModuleMockerInterceptor } from './msw'
1010
import { createSafeRpc } from './rpc'
1111
import { browserHashMap, initiateRunner } from './runner'
1212

@@ -34,28 +34,10 @@ async function prepareTestEnvironment(files: string[]) {
3434
state.onCancel = onCancel
3535
state.rpc = rpc as any
3636

37+
// TODO: expose `worker`
38+
const interceptor = createModuleMockerInterceptor()
3739
const mocker = new VitestBrowserClientMocker(
38-
{
39-
async delete(url: string) {
40-
channel.postMessage({
41-
type: 'unmock',
42-
url,
43-
} satisfies IframeUnmockEvent)
44-
await waitForChannel('unmock:done')
45-
},
46-
async register(module) {
47-
channel.postMessage({
48-
type: 'mock',
49-
module: module.toJSON(),
50-
} satisfies IframeMockEvent)
51-
await waitForChannel('mock:done')
52-
},
53-
invalidate() {
54-
channel.postMessage({
55-
type: 'mock:invalidate',
56-
} satisfies IframeMockInvalidateEvent)
57-
},
58-
},
40+
interceptor,
5941
rpc,
6042
SpyModule.spyOn,
6143
{
@@ -79,8 +61,6 @@ async function prepareTestEnvironment(files: string[]) {
7961
}
8062
})
8163

82-
mocker.setupWorker()
83-
8464
onCancel.then((reason) => {
8565
runner.onCancel?.(reason)
8666
})
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { resolve } from 'pathe'
3+
4+
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
5+
export const distRoot = resolve(pkgRoot, 'dist')

‎packages/browser/src/node/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import BrowserPlugin from './plugin'
77
import { setupBrowserRpc } from './rpc'
88
import { BrowserServer } from './server'
99

10+
export { distRoot } from './constants'
1011
export { createBrowserPool } from './pool'
12+
1113
export type { BrowserServer } from './server'
1214

1315
export async function createBrowserServer(

‎packages/browser/src/node/plugin.ts

+27-3
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,24 @@ import type { WorkspaceProject } from 'vitest/node'
44
import type { BrowserServer } from './server'
55
import { lstatSync, readFileSync } from 'node:fs'
66
import { createRequire } from 'node:module'
7-
import { fileURLToPath } from 'node:url'
87
import { dynamicImportPlugin } from '@vitest/mocker/node'
98
import { toArray } from '@vitest/utils'
109
import MagicString from 'magic-string'
1110
import { basename, dirname, extname, resolve } from 'pathe'
1211
import sirv from 'sirv'
1312
import { coverageConfigDefaults, type Plugin } from 'vitest/config'
1413
import { getFilePoolName, resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node'
14+
import { distRoot } from './constants'
1515
import BrowserContext from './plugins/pluginContext'
1616
import { resolveOrchestrator } from './serverOrchestrator'
1717
import { resolveTester } from './serverTester'
1818

1919
export { defineBrowserCommand } from './commands/utils'
2020
export type { BrowserCommand } from 'vitest/node'
2121

22+
const versionRegexp = /(?:\?|&)v=\w{8}/
23+
2224
export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
23-
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
24-
const distRoot = resolve(pkgRoot, 'dist')
2525
const project = browserServer.project
2626

2727
function isPackageExists(pkg: string, root: string) {
@@ -160,6 +160,24 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
160160
res.end(buffer)
161161
})
162162
}
163+
server.middlewares.use((req, res, next) => {
164+
// 9000 mega head move
165+
// Vite always caches optimized dependencies, but users might mock
166+
// them in _some_ tests, while keeping original modules in others
167+
// there is no way to configure that in Vite, so we patch it here
168+
// to always ignore the cache-control set by Vite in the next middleware
169+
if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) {
170+
res.setHeader('Cache-Control', 'no-cache')
171+
const setHeader = res.setHeader.bind(res)
172+
res.setHeader = function (name, value) {
173+
if (name === 'Cache-Control') {
174+
return res
175+
}
176+
return setHeader(name, value)
177+
}
178+
}
179+
next()
180+
})
163181
},
164182
},
165183
{
@@ -325,6 +343,12 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
325343
BrowserContext(browserServer),
326344
dynamicImportPlugin({
327345
globalThisAccessor: '"__vitest_browser_runner__"',
346+
filter(id) {
347+
if (id.includes(distRoot)) {
348+
return false
349+
}
350+
return true
351+
},
328352
}),
329353
{
330354
name: 'vitest:browser:config',

‎packages/mocker/src/browser/interceptor-msw.ts

+8-29
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export interface ModuleMockerMSWInterceptorOptions {
3434
export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor {
3535
protected readonly mocks: MockerRegistry = new MockerRegistry()
3636

37-
private started = false
38-
private startPromise: undefined | Promise<unknown>
37+
private startPromise: undefined | Promise<SetupWorker>
38+
private worker: undefined | SetupWorker
3939

4040
constructor(
4141
private readonly options: ModuleMockerMSWInterceptorOptions = {},
@@ -78,9 +78,9 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor {
7878
})
7979
}
8080

81-
protected async init(): Promise<unknown> {
82-
if (this.started) {
83-
return
81+
protected async init(): Promise<SetupWorker> {
82+
if (this.worker) {
83+
return this.worker
8484
}
8585
if (this.startPromise) {
8686
return this.startPromise
@@ -101,13 +101,6 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor {
101101
http.get(/.+/, async ({ request }) => {
102102
const path = cleanQuery(request.url.slice(location.origin.length))
103103
if (!this.mocks.has(path)) {
104-
// do not cache deps like Vite does for performance
105-
// because we want to be able to update mocks without restarting the server
106-
// TODO: check if it's still neded - we invalidate modules after each test
107-
if (path.includes('/deps/')) {
108-
return fetch(bypass(request))
109-
}
110-
111104
return passthrough()
112105
}
113106

@@ -126,12 +119,12 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor {
126119
}
127120
}),
128121
)
129-
return worker.start(this.options.mswOptions)
122+
return worker.start(this.options.mswOptions).then(() => worker)
130123
}).finally(() => {
131-
this.started = true
124+
this.worker = worker
132125
this.startPromise = undefined
133126
})
134-
await this.startPromise
127+
return await this.startPromise
135128
}
136129
}
137130

@@ -151,20 +144,6 @@ function passthrough() {
151144
})
152145
}
153146

154-
function bypass(request: Request) {
155-
const clonedRequest = request.clone()
156-
clonedRequest.headers.set('x-msw-intention', 'bypass')
157-
const cacheControl = clonedRequest.headers.get('cache-control')
158-
if (cacheControl) {
159-
clonedRequest.headers.set(
160-
'cache-control',
161-
// allow reinvalidation of the cache so mocks can be updated
162-
cacheControl.replace(', immutable', ''),
163-
)
164-
}
165-
return clonedRequest
166-
}
167-
168147
const replacePercentageRE = /%/g
169148
function injectQuery(url: string, queryToInject: string): string {
170149
// encode percents for consistent behavior with pathToFileURL

‎packages/mocker/src/browser/mocker.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry'
77

88
const { now } = Date
99

10-
// TODO: define an interface thath both node.js and browser mocker can implement
1110
export class ModuleMocker {
1211
protected registry: MockerRegistry = new MockerRegistry()
1312

@@ -62,7 +61,7 @@ export class ModuleMocker {
6261
const resolved = await this.rpc.resolveId(id, importer)
6362
if (resolved == null) {
6463
throw new Error(
65-
`[vitest] Cannot resolve ${id} imported from ${importer}`,
64+
`[vitest] Cannot resolve "${id}" imported from "${importer}"`,
6665
)
6766
}
6867
const ext = extname(resolved.id)

‎packages/mocker/src/node/dynamicImportPlugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface DynamicImportPluginOptions {
1111
* @default `"__vitest_mocker__"`
1212
*/
1313
globalThisAccessor?: string
14+
filter?: (id: string) => boolean
1415
}
1516

1617
export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): Plugin {
@@ -22,6 +23,9 @@ export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): P
2223
if (!regexDynamicImport.test(source)) {
2324
return
2425
}
26+
if (options.filter && !options.filter(id)) {
27+
return
28+
}
2529
return injectDynamicImport(source, id, this.parse, options)
2630
},
2731
}

‎packages/vitest/src/node/plugins/mocks.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@ import { normalize } from 'pathe'
44
import { distDir } from '../../paths'
55
import { generateCodeFrame } from '../error'
66

7-
export function MocksPlugins(): Plugin[] {
7+
export interface MocksPluginOptions {
8+
filter?: (id: string) => boolean
9+
}
10+
11+
export function MocksPlugins(options: MocksPluginOptions = {}): Plugin[] {
812
const normalizedDistDir = normalize(distDir)
913
return [
1014
hoistMocksPlugin({
1115
filter(id) {
1216
if (id.includes(normalizedDistDir)) {
1317
return false
1418
}
19+
if (options.filter) {
20+
return options.filter(id)
21+
}
1522
return true
1623
},
1724
codeFrameGenerator(node, id, code) {

‎packages/vitest/src/node/workspace.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,21 @@ export class WorkspaceProject {
363363
return
364364
}
365365
await this.ctx.packageInstaller.ensureInstalled('@vitest/browser', this.config.root, this.ctx.version)
366-
const { createBrowserServer } = await import('@vitest/browser')
366+
const { createBrowserServer, distRoot } = await import('@vitest/browser')
367367
await this.browser?.close()
368368
const browser = await createBrowserServer(
369369
this,
370370
configFile,
371-
[...MocksPlugins()],
371+
[
372+
...MocksPlugins({
373+
filter(id) {
374+
if (id.includes(distRoot)) {
375+
return false
376+
}
377+
return true
378+
},
379+
}),
380+
],
372381
[CoverageTransform(this.ctx)],
373382
)
374383
this.browser = browser

0 commit comments

Comments
 (0)
Please sign in to comment.