[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(browser): initiate MSW in the same frame as tests (#6772)

authored by

Vladimir and committed by
GitHub
(Oct 28, 2024, 1:19 PM +0100) 2444ff22 39041ee5

+76 -216
+1 -50
packages/browser/src/client/channel.ts
··· 1 - import type { MockedModuleSerialized } from '@vitest/mocker' 2 1 import type { CancelReason } from '@vitest/runner' 3 2 import { getBrowserState } from './utils' 4 3 ··· 23 22 id: string 24 23 } 25 24 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 - 66 25 export interface GlobalChannelTestRunCanceledEvent { 67 26 type: 'cancel' 68 27 reason: CancelReason ··· 74 33 | IframeViewportEvent 75 34 | IframeErrorEvent 76 35 | IframeDoneEvent 77 - | IframeMockEvent 78 - | IframeUnmockEvent 79 - | IframeMockFactoryResponseEvent 80 - | IframeMockFactoryErrorEvent 81 - | IframeMockInvalidateEvent 82 36 83 - export type IframeChannelOutgoingEvent = 84 - | IframeMockFactoryRequestEvent 85 - | IframeViewportChannelEvent 86 - | IframeMockingDoneEvent 37 + export type IframeChannelOutgoingEvent = never 87 38 88 39 export type IframeChannelEvent = 89 40 | IframeChannelIncomingEvent
-15
packages/browser/src/client/orchestrator.ts
··· 3 3 import { globalChannel, type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent } from '@vitest/browser/client' 4 4 import { generateHash } from '@vitest/runner/utils' 5 5 import { relative } from 'pathe' 6 - import { createModuleMockerInterceptor } from './tester/msw' 7 6 import { getUiAPI } from './ui' 8 7 import { getBrowserState, getConfig } from './utils' 9 8 ··· 13 12 class IframeOrchestrator { 14 13 private cancelled = false 15 14 private runningFiles = new Set<string>() 16 - private interceptor = createModuleMockerInterceptor() 17 15 private iframes = new Map<string, HTMLIFrameElement>() 18 16 19 17 public async init() { ··· 186 184 } 187 185 break 188 186 } 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 202 187 default: { 203 188 e.data satisfies never 204 189
+5
packages/browser/src/node/constants.ts
··· 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')
+2
packages/browser/src/node/index.ts
··· 7 7 import { setupBrowserRpc } from './rpc' 8 8 import { BrowserServer } from './server' 9 9 10 + export { distRoot } from './constants' 10 11 export { createBrowserPool } from './pool' 12 + 11 13 export type { BrowserServer } from './server' 12 14 13 15 export async function createBrowserServer(
+27 -3
packages/browser/src/node/plugin.ts
··· 4 4 import type { BrowserServer } from './server' 5 5 import { lstatSync, readFileSync } from 'node:fs' 6 6 import { createRequire } from 'node:module' 7 - import { fileURLToPath } from 'node:url' 8 7 import { dynamicImportPlugin } from '@vitest/mocker/node' 9 8 import { toArray } from '@vitest/utils' 10 9 import MagicString from 'magic-string' ··· 12 11 import sirv from 'sirv' 13 12 import { coverageConfigDefaults, type Plugin } from 'vitest/config' 14 13 import { getFilePoolName, resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node' 14 + import { distRoot } from './constants' 15 15 import BrowserContext from './plugins/pluginContext' 16 16 import { resolveOrchestrator } from './serverOrchestrator' 17 17 import { resolveTester } from './serverTester' ··· 19 19 export { defineBrowserCommand } from './commands/utils' 20 20 export type { BrowserCommand } from 'vitest/node' 21 21 22 + const versionRegexp = /(?:\?|&)v=\w{8}/ 23 + 22 24 export default (browserServer: BrowserServer, base = '/'): Plugin[] => { 23 - const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') 24 - const distRoot = resolve(pkgRoot, 'dist') 25 25 const project = browserServer.project 26 26 27 27 function isPackageExists(pkg: string, root: string) { ··· 160 160 res.end(buffer) 161 161 }) 162 162 } 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 + }) 163 181 }, 164 182 }, 165 183 { ··· 325 343 BrowserContext(browserServer), 326 344 dynamicImportPlugin({ 327 345 globalThisAccessor: '"__vitest_browser_runner__"', 346 + filter(id) { 347 + if (id.includes(distRoot)) { 348 + return false 349 + } 350 + return true 351 + }, 328 352 }), 329 353 { 330 354 name: 'vitest:browser:config',
+8 -29
packages/mocker/src/browser/interceptor-msw.ts
··· 34 34 export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { 35 35 protected readonly mocks: MockerRegistry = new MockerRegistry() 36 36 37 - private started = false 38 - private startPromise: undefined | Promise<unknown> 37 + private startPromise: undefined | Promise<SetupWorker> 38 + private worker: undefined | SetupWorker 39 39 40 40 constructor( 41 41 private readonly options: ModuleMockerMSWInterceptorOptions = {}, ··· 78 78 }) 79 79 } 80 80 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 84 84 } 85 85 if (this.startPromise) { 86 86 return this.startPromise ··· 101 101 http.get(/.+/, async ({ request }) => { 102 102 const path = cleanQuery(request.url.slice(location.origin.length)) 103 103 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 - 111 104 return passthrough() 112 105 } 113 106 ··· 126 119 } 127 120 }), 128 121 ) 129 - return worker.start(this.options.mswOptions) 122 + return worker.start(this.options.mswOptions).then(() => worker) 130 123 }).finally(() => { 131 - this.started = true 124 + this.worker = worker 132 125 this.startPromise = undefined 133 126 }) 134 - await this.startPromise 127 + return await this.startPromise 135 128 } 136 129 } 137 130 ··· 149 142 'x-msw-intention': 'passthrough', 150 143 }, 151 144 }) 152 - } 153 - 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 145 } 167 146 168 147 const replacePercentageRE = /%/g
+1 -2
packages/mocker/src/browser/mocker.ts
··· 7 7 8 8 const { now } = Date 9 9 10 - // TODO: define an interface thath both node.js and browser mocker can implement 11 10 export class ModuleMocker { 12 11 protected registry: MockerRegistry = new MockerRegistry() 13 12 ··· 62 61 const resolved = await this.rpc.resolveId(id, importer) 63 62 if (resolved == null) { 64 63 throw new Error( 65 - `[vitest] Cannot resolve ${id} imported from ${importer}`, 64 + `[vitest] Cannot resolve "${id}" imported from "${importer}"`, 66 65 ) 67 66 } 68 67 const ext = extname(resolved.id)
+4
packages/mocker/src/node/dynamicImportPlugin.ts
··· 11 11 * @default `"__vitest_mocker__"` 12 12 */ 13 13 globalThisAccessor?: string 14 + filter?: (id: string) => boolean 14 15 } 15 16 16 17 export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): Plugin { ··· 20 21 transform(source, id) { 21 22 // TODO: test is not called for static imports 22 23 if (!regexDynamicImport.test(source)) { 24 + return 25 + } 26 + if (options.filter && !options.filter(id)) { 23 27 return 24 28 } 25 29 return injectDynamicImport(source, id, this.parse, options)
+11 -2
packages/vitest/src/node/workspace.ts
··· 363 363 return 364 364 } 365 365 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') 367 367 await this.browser?.close() 368 368 const browser = await createBrowserServer( 369 369 this, 370 370 configFile, 371 - [...MocksPlugins()], 371 + [ 372 + ...MocksPlugins({ 373 + filter(id) { 374 + if (id.includes(distRoot)) { 375 + return false 376 + } 377 + return true 378 + }, 379 + }), 380 + ], 372 381 [CoverageTransform(this.ctx)], 373 382 ) 374 383 this.browser = browser
-32
packages/browser/src/client/tester/mocker.ts
··· 1 - import type { IframeChannelOutgoingEvent, IframeMockFactoryErrorEvent, IframeMockFactoryResponseEvent } from '@vitest/browser/client' 2 - import { channel } from '@vitest/browser/client' 3 1 import { ModuleMocker } from '@vitest/mocker/browser' 4 2 import { getBrowserState } from '../utils' 5 3 6 4 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 - 37 5 // default "vi" utility tries to access mock context to avoid circular dependencies 38 6 public getMockContext() { 39 7 return { callstack: null }
+4 -57
packages/browser/src/client/tester/msw.ts
··· 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' 9 1 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' 33 3 34 4 export function createModuleMockerInterceptor() { 35 - return new VitestBrowserModuleMockerInterceptor({ 5 + const debug = getConfig().env.VITEST_BROWSER_DEBUG 6 + return new ModuleMockerMSWInterceptor({ 36 7 globalThisAccessor: '"__vitest_mocker__"', 37 8 mswOptions: { 38 9 serviceWorker: { ··· 42 13 }, 43 14 }, 44 15 onUnhandledRequest: 'bypass', 45 - quiet: true, 16 + quiet: !(debug && debug !== 'false'), 46 17 }, 47 - }) 48 - } 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 18 }) 72 19 }
+5 -25
packages/browser/src/client/tester/tester.ts
··· 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' 3 2 import { page, userEvent } from '@vitest/browser/context' 4 3 import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser' 5 4 import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' ··· 7 6 import { setupExpectDom } from './expect-element' 8 7 import { setupConsoleLogSpy } from './logger' 9 8 import { VitestBrowserClientMocker } from './mocker' 9 + import { createModuleMockerInterceptor } from './msw' 10 10 import { createSafeRpc } from './rpc' 11 11 import { browserHashMap, initiateRunner } from './runner' 12 12 ··· 34 34 state.onCancel = onCancel 35 35 state.rpc = rpc as any 36 36 37 + // TODO: expose `worker` 38 + const interceptor = createModuleMockerInterceptor() 37 39 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, 59 41 rpc, 60 42 SpyModule.spyOn, 61 43 { ··· 78 60 browserHashMap.set(filename, version) 79 61 } 80 62 }) 81 - 82 - mocker.setupWorker() 83 63 84 64 onCancel.then((reason) => { 85 65 runner.onCancel?.(reason)
+8 -1
packages/vitest/src/node/plugins/mocks.ts
··· 4 4 import { distDir } from '../../paths' 5 5 import { generateCodeFrame } from '../error' 6 6 7 - export function MocksPlugins(): Plugin[] { 7 + export interface MocksPluginOptions { 8 + filter?: (id: string) => boolean 9 + } 10 + 11 + export function MocksPlugins(options: MocksPluginOptions = {}): Plugin[] { 8 12 const normalizedDistDir = normalize(distDir) 9 13 return [ 10 14 hoistMocksPlugin({ 11 15 filter(id) { 12 16 if (id.includes(normalizedDistDir)) { 13 17 return false 18 + } 19 + if (options.filter) { 20 + return options.filter(id) 14 21 } 15 22 return true 16 23 },