[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.

feat: use providers request interception for module mocking (#7576)

authored by

Vladimir and committed by
GitHub
(Mar 28, 2025, 4:37 PM +0100) 7883acd6 a7ecd0f4

+426 -109
-3
pnpm-lock.yaml
··· 489 489 magic-string: 490 490 specifier: 'catalog:' 491 491 version: 0.30.17 492 - msw: 493 - specifier: 'catalog:' 494 - version: 2.7.3(@types/node@22.13.13)(typescript@5.8.2) 495 492 sirv: 496 493 specifier: 'catalog:' 497 494 version: 3.0.1
-1
packages/browser/package.json
··· 93 93 "@vitest/mocker": "workspace:*", 94 94 "@vitest/utils": "workspace:*", 95 95 "magic-string": "catalog:", 96 - "msw": "catalog:", 97 96 "sirv": "catalog:", 98 97 "tinyrainbow": "catalog:", 99 98 "ws": "catalog:"
+43 -15
packages/mocker/src/registry.ts
··· 1 1 export class MockerRegistry { 2 - private readonly registry: Map<string, MockedModule> = new Map() 2 + private readonly registryByUrl: Map<string, MockedModule> = new Map() 3 + private readonly registryById: Map<string, MockedModule> = new Map() 3 4 4 5 clear(): void { 5 - this.registry.clear() 6 + this.registryByUrl.clear() 7 + this.registryById.clear() 6 8 } 7 9 8 10 keys(): IterableIterator<string> { 9 - return this.registry.keys() 11 + return this.registryByUrl.keys() 10 12 } 11 13 12 14 add(mock: MockedModule): void { 13 - this.registry.set(mock.url, mock) 15 + this.registryByUrl.set(mock.url, mock) 16 + this.registryById.set(mock.id, mock) 14 17 } 15 18 16 19 public register( ··· 19 22 public register( 20 23 type: 'redirect', 21 24 raw: string, 25 + id: string, 22 26 url: string, 23 27 redirect: string, 24 28 ): RedirectedModule 25 29 public register( 26 30 type: 'manual', 27 31 raw: string, 32 + id: string, 28 33 url: string, 29 34 factory: () => any, 30 35 ): ManualMockedModule 31 36 public register( 32 37 type: 'automock', 33 38 raw: string, 39 + id: string, 34 40 url: string, 35 41 ): AutomockedModule 36 42 public register( 37 43 type: 'autospy', 44 + id: string, 38 45 raw: string, 39 46 url: string, 40 47 ): AutospiedModule 41 48 public register( 42 49 typeOrEvent: MockedModuleType | MockedModuleSerialized, 43 50 raw?: string, 51 + id?: string, 44 52 url?: string, 45 53 factoryOrRedirect?: string | (() => any), 46 54 ): MockedModule { ··· 91 99 throw new TypeError('[vitest] Mocks require a url string.') 92 100 } 93 101 102 + if (typeof id !== 'string') { 103 + throw new TypeError('[vitest] Mocks require an id string.') 104 + } 105 + 94 106 if (type === 'manual') { 95 107 if (typeof factoryOrRedirect !== 'function') { 96 108 throw new TypeError('[vitest] Manual mocks require a factory function.') 97 109 } 98 - const mock = new ManualMockedModule(raw, url, factoryOrRedirect) 110 + const mock = new ManualMockedModule(raw, id, url, factoryOrRedirect) 99 111 this.add(mock) 100 112 return mock 101 113 } 102 114 else if (type === 'automock' || type === 'autospy') { 103 115 const mock = type === 'automock' 104 - ? new AutomockedModule(raw, url) 105 - : new AutospiedModule(raw, url) 116 + ? new AutomockedModule(raw, id, url) 117 + : new AutospiedModule(raw, id, url) 106 118 this.add(mock) 107 119 return mock 108 120 } ··· 110 122 if (typeof factoryOrRedirect !== 'string') { 111 123 throw new TypeError('[vitest] Redirect mocks require a redirect string.') 112 124 } 113 - const mock = new RedirectedModule(raw, url, factoryOrRedirect) 125 + const mock = new RedirectedModule(raw, id, url, factoryOrRedirect) 114 126 this.add(mock) 115 127 return mock 116 128 } ··· 120 132 } 121 133 122 134 public delete(id: string): void { 123 - this.registry.delete(id) 135 + this.registryByUrl.delete(id) 124 136 } 125 137 126 138 public get(id: string): MockedModule | undefined { 127 - return this.registry.get(id) 139 + return this.registryByUrl.get(id) 140 + } 141 + 142 + public getById(id: string): MockedModule | undefined { 143 + return this.registryById.get(id) 128 144 } 129 145 130 146 public has(id: string): boolean { 131 - return this.registry.has(id) 147 + return this.registryByUrl.has(id) 132 148 } 133 149 } 134 150 ··· 150 166 151 167 constructor( 152 168 public raw: string, 169 + public id: string, 153 170 public url: string, 154 171 ) {} 155 172 156 173 static fromJSON(data: AutomockedModuleSerialized): AutospiedModule { 157 - return new AutospiedModule(data.raw, data.url) 174 + return new AutospiedModule(data.raw, data.id, data.url) 158 175 } 159 176 160 177 toJSON(): AutomockedModuleSerialized { ··· 162 179 type: this.type, 163 180 url: this.url, 164 181 raw: this.raw, 182 + id: this.id, 165 183 } 166 184 } 167 185 } ··· 170 188 type: 'automock' 171 189 url: string 172 190 raw: string 191 + id: string 173 192 } 174 193 175 194 export class AutospiedModule { ··· 177 196 178 197 constructor( 179 198 public raw: string, 199 + public id: string, 180 200 public url: string, 181 201 ) {} 182 202 183 203 static fromJSON(data: AutospiedModuleSerialized): AutospiedModule { 184 - return new AutospiedModule(data.raw, data.url) 204 + return new AutospiedModule(data.raw, data.id, data.url) 185 205 } 186 206 187 207 toJSON(): AutospiedModuleSerialized { 188 208 return { 189 209 type: this.type, 190 210 url: this.url, 211 + id: this.id, 191 212 raw: this.raw, 192 213 } 193 214 } ··· 197 218 type: 'autospy' 198 219 url: string 199 220 raw: string 221 + id: string 200 222 } 201 223 202 224 export class RedirectedModule { ··· 204 226 205 227 constructor( 206 228 public raw: string, 229 + public id: string, 207 230 public url: string, 208 231 public redirect: string, 209 232 ) {} 210 233 211 234 static fromJSON(data: RedirectedModuleSerialized): RedirectedModule { 212 - return new RedirectedModule(data.raw, data.url, data.redirect) 235 + return new RedirectedModule(data.raw, data.id, data.url, data.redirect) 213 236 } 214 237 215 238 toJSON(): RedirectedModuleSerialized { ··· 217 240 type: this.type, 218 241 url: this.url, 219 242 raw: this.raw, 243 + id: this.id, 220 244 redirect: this.redirect, 221 245 } 222 246 } ··· 225 249 export interface RedirectedModuleSerialized { 226 250 type: 'redirect' 227 251 url: string 252 + id: string 228 253 raw: string 229 254 redirect: string 230 255 } ··· 235 260 236 261 constructor( 237 262 public raw: string, 263 + public id: string, 238 264 public url: string, 239 265 public factory: () => any, 240 266 ) {} ··· 267 293 } 268 294 269 295 static fromJSON(data: ManualMockedModuleSerialized, factory: () => any): ManualMockedModule { 270 - return new ManualMockedModule(data.raw, data.url, factory) 296 + return new ManualMockedModule(data.raw, data.id, data.url, factory) 271 297 } 272 298 273 299 toJSON(): ManualMockedModuleSerialized { 274 300 return { 275 301 type: this.type, 276 302 url: this.url, 303 + id: this.id, 277 304 raw: this.raw, 278 305 } 279 306 } ··· 282 309 export interface ManualMockedModuleSerialized { 283 310 type: 'manual' 284 311 url: string 312 + id: string 285 313 raw: string 286 314 }
+13
packages/mocker/src/utils.ts
··· 2 2 export function cleanUrl(url: string): string { 3 3 return url.replace(postfixRE, '') 4 4 } 5 + 6 + export function createManualModuleSource(moduleUrl: string, exports: string[], globalAccessor = '"__vitest_mocker__"'): string { 7 + const source = `const module = globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}");` 8 + const keys = exports 9 + .map((name) => { 10 + if (name === 'default') { 11 + return `export default module["default"];` 12 + } 13 + return `export const ${name} = module["${name}"];` 14 + }) 15 + .join('\n') 16 + return `${source}\n${keys}` 17 + }
+10
packages/utils/src/source-map.ts
··· 280 280 const stackStr = e.stack || e.stackStr || '' 281 281 let stackFrames = parseStacktrace(stackStr, options) 282 282 283 + if (!stackFrames.length) { 284 + const e_ = e as any 285 + if (e_.fileName != null && e_.lineNumber != null && e_.columnNumber != null) { 286 + stackFrames = parseStacktrace(`${e_.fileName}:${e_.lineNumber}:${e_.columnNumber}`, options) 287 + } 288 + if (e_.sourceURL != null && e_.line != null && e_._column != null) { 289 + stackFrames = parseStacktrace(`${e_.sourceURL}:${e_.line}:${e_.column}`, options) 290 + } 291 + } 292 + 283 293 if (options.frameFilter) { 284 294 stackFrames = stackFrames.filter( 285 295 f => options.frameFilter!(e, f) !== false,
+16
packages/browser/src/client/client.ts
··· 1 + import type { ModuleMocker } from '@vitest/mocker/browser' 1 2 import type { CancelReason } from '@vitest/runner' 2 3 import type { BirpcReturn } from 'birpc' 3 4 import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../node/types' ··· 64 65 return 65 66 } 66 67 cdp.emit(event, payload) 68 + }, 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 + } 67 83 }, 68 84 }, 69 85 {
+6 -1
packages/browser/src/node/index.ts
··· 1 1 import type { Plugin } from 'vitest/config' 2 2 import type { TestProject } from 'vitest/node' 3 + import { MockerRegistry } from '@vitest/mocker' 4 + import { interceptorPlugin } from '@vitest/mocker/node' 3 5 import c from 'tinyrainbow' 4 6 import { createViteLogger, createViteServer } from 'vitest/node' 5 7 import { version } from '../../package.json' ··· 38 40 allowClearScreen: false, 39 41 }) 40 42 43 + const mockerRegistry = new MockerRegistry() 44 + 41 45 const vite = await createViteServer({ 42 46 ...project.options, // spread project config inlined in root workspace config 43 47 base: '/', ··· 68 72 ...prePlugins, 69 73 ...(project.options?.plugins || []), 70 74 BrowserPlugin(server), 75 + interceptorPlugin({ registry: mockerRegistry }), 71 76 ...postPlugins, 72 77 ], 73 78 }) 74 79 75 80 await vite.listen() 76 81 77 - setupBrowserRpc(server) 82 + setupBrowserRpc(server, mockerRegistry) 78 83 79 84 return server 80 85 }
+64 -2
packages/browser/src/node/rpc.ts
··· 1 + import type { MockerRegistry } from '@vitest/mocker' 1 2 import type { Duplex } from 'node:stream' 2 3 import type { ErrorWithDiff } from 'vitest' 3 4 import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node' ··· 7 8 import type { BrowserServerState } from './state' 8 9 import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' 9 10 import { existsSync, promises as fs } from 'node:fs' 11 + import { AutomockedModule, AutospiedModule, ManualMockedModule, RedirectedModule } from '@vitest/mocker' 10 12 import { ServerMockResolver } from '@vitest/mocker/node' 11 13 import { createBirpc } from 'birpc' 12 14 import { parse, stringify } from 'flatted' 13 - import { dirname } from 'pathe' 15 + import { dirname, join } from 'pathe' 14 16 import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node' 15 17 import { WebSocketServer } from 'ws' 16 18 ··· 18 20 19 21 const BROWSER_API_PATH = '/__vitest_browser_api__' 20 22 21 - export function setupBrowserRpc(globalServer: ParentBrowserProject): void { 23 + export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMockerRegistry: MockerRegistry): void { 22 24 const vite = globalServer.vite 23 25 const vitest = globalServer.vitest 24 26 ··· 113 115 const mockResolver = new ServerMockResolver(globalServer.vite, { 114 116 moduleDirectories: project.config.server?.deps?.moduleDirectories, 115 117 }) 118 + const mocker = project.browser?.provider.mocker 116 119 117 120 const rpc = createBirpc<WebSocketBrowserEvents, WebSocketBrowserHandlers>( 118 121 { ··· 250 253 }, 251 254 invalidate(ids) { 252 255 return mockResolver.invalidate(ids) 256 + }, 257 + 258 + async registerMock(sessionId, module) { 259 + if (!mocker) { 260 + // make sure modules are not processed yet in case they were imported before 261 + // and were not mocked 262 + mockResolver.invalidate([module.id]) 263 + 264 + if (module.type === 'manual') { 265 + const mock = ManualMockedModule.fromJSON(module, async () => { 266 + try { 267 + const { keys } = await rpc.resolveManualMock(module.url) 268 + return Object.fromEntries(keys.map(key => [key, null])) 269 + } 270 + catch (err) { 271 + vitest.state.catchError(err, 'Manual Mock Resolver Error') 272 + return {} 273 + } 274 + }) 275 + defaultMockerRegistry.add(mock) 276 + } 277 + else { 278 + if (module.type === 'redirect') { 279 + const redirectUrl = new URL(module.redirect) 280 + module.redirect = join(vite.config.root, redirectUrl.pathname) 281 + } 282 + defaultMockerRegistry.register(module) 283 + } 284 + return 285 + } 286 + 287 + if (module.type === 'manual') { 288 + const manualModule = ManualMockedModule.fromJSON(module, async () => { 289 + const { keys } = await rpc.resolveManualMock(module.url) 290 + return Object.fromEntries(keys.map(key => [key, null])) 291 + }) 292 + await mocker.register(sessionId, manualModule) 293 + } 294 + else if (module.type === 'redirect') { 295 + await mocker.register(sessionId, RedirectedModule.fromJSON(module)) 296 + } 297 + else if (module.type === 'automock') { 298 + await mocker.register(sessionId, AutomockedModule.fromJSON(module)) 299 + } 300 + else if (module.type === 'autospy') { 301 + await mocker.register(sessionId, AutospiedModule.fromJSON(module)) 302 + } 303 + }, 304 + clearMocks(sessionId) { 305 + if (!mocker) { 306 + return defaultMockerRegistry.clear() 307 + } 308 + return mocker.clear(sessionId) 309 + }, 310 + unregisterMock(sessionId, id) { 311 + if (!mocker) { 312 + return defaultMockerRegistry.delete(id) 313 + } 314 + return mocker.delete(sessionId, id) 253 315 }, 254 316 255 317 // CDP
+10
packages/browser/src/node/types.ts
··· 1 + import type { MockedModuleSerialized } from '@vitest/mocker' 1 2 import type { ServerIdResolution, ServerMockResolution } from '@vitest/mocker/node' 2 3 import type { TaskEventPack, TaskResultPack } from '@vitest/runner' 3 4 import type { BirpcReturn } from 'birpc' ··· 41 42 ) => SourceMap | null | { mappings: '' } | undefined 42 43 wdioSwitchContext: (direction: 'iframe' | 'parent') => void 43 44 45 + registerMock: (sessionId: string, mock: MockedModuleSerialized) => void 46 + unregisterMock: (sessionId: string, id: string) => void 47 + clearMocks: (sessionId: string) => void 48 + 44 49 // cdp 45 50 sendCdpEvent: (sessionId: string, event: string, payload?: Record<string, unknown>) => unknown 46 51 trackCdpEvent: (sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void ··· 63 68 onCancel: (reason: CancelReason) => void 64 69 createTesters: (files: string[]) => Promise<void> 65 70 cdpEvent: (event: string, payload: unknown) => void 71 + resolveManualMock: (url: string) => Promise<{ 72 + url: string 73 + keys: string[] 74 + responseId: string 75 + }> 66 76 } 67 77 68 78 export type WebSocketBrowserRPC = BirpcReturn<
+2 -11
packages/mocker/src/browser/interceptor-msw.ts
··· 3 3 import type { ManualMockedModule, MockedModule } from '../registry' 4 4 import type { ModuleMockerInterceptor } from './interceptor' 5 5 import { MockerRegistry } from '../registry' 6 - import { cleanUrl } from '../utils' 6 + import { cleanUrl, createManualModuleSource } from '../utils' 7 7 8 8 export interface ModuleMockerMSWInterceptorOptions { 9 9 /** ··· 61 61 62 62 private async resolveManualMock(mock: ManualMockedModule) { 63 63 const exports = Object.keys(await mock.resolve()) 64 - const module = `const module = globalThis[${this.options.globalThisAccessor!}].getFactoryModule("${mock.url}");` 65 - const keys = exports 66 - .map((name) => { 67 - if (name === 'default') { 68 - return `export default module["default"];` 69 - } 70 - return `export const ${name} = module["${name}"];` 71 - }) 72 - .join('\n') 73 - const text = `${module}\n${keys}` 64 + const text = createManualModuleSource(mock.url, exports, this.options.globalThisAccessor) 74 65 return new Response(text, { 75 66 headers: { 76 67 'Content-Type': 'application/javascript',
+6 -6
packages/mocker/src/browser/mocker.ts
··· 94 94 if (!mock) { 95 95 if (redirectUrl) { 96 96 const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString() 97 - mock = new RedirectedModule(rawId, mockUrl, resolvedRedirect) 97 + mock = new RedirectedModule(rawId, resolvedId, mockUrl, resolvedRedirect) 98 98 } 99 99 else { 100 - mock = new AutomockedModule(rawId, mockUrl) 100 + mock = new AutomockedModule(rawId, resolvedId, mockUrl) 101 101 } 102 102 } 103 103 ··· 157 157 158 158 let module: MockedModule 159 159 if (mockType === 'manual') { 160 - module = this.registry.register('manual', rawId, mockUrl, factory!) 160 + module = this.registry.register('manual', rawId, resolvedId, mockUrl, factory!) 161 161 } 162 162 // autospy takes higher priority over redirect, so it needs to be checked first 163 163 else if (mockType === 'autospy') { 164 - module = this.registry.register('autospy', rawId, mockUrl) 164 + module = this.registry.register('autospy', rawId, resolvedId, mockUrl) 165 165 } 166 166 else if (mockType === 'redirect') { 167 - module = this.registry.register('redirect', rawId, mockUrl, mockRedirect!) 167 + module = this.registry.register('redirect', rawId, resolvedId, mockUrl, mockRedirect!) 168 168 } 169 169 else { 170 - module = this.registry.register('automock', rawId, mockUrl) 170 + module = this.registry.register('automock', rawId, resolvedId, mockUrl) 171 171 } 172 172 173 173 await this.interceptor.register(module)
+1 -1
packages/mocker/src/browser/utils.ts
··· 21 21 hot.on(`${event}:result`, function r(data) { 22 22 resolve(data) 23 23 clearTimeout(timeout) 24 - hot.off('vitest:mocks:resolvedId:result', r) 24 + hot.off(`${event}:result`, r) 25 25 }) 26 26 }) 27 27 }
+2 -1
packages/mocker/src/node/index.ts
··· 1 + export { createManualModuleSource } from '../utils' 1 2 export { automockModule, automockPlugin } from './automockPlugin' 2 3 export type { AutomockPluginOptions } from './automockPlugin' 3 4 export { dynamicImportPlugin } from './dynamicImportPlugin' 4 5 export { hoistMocks, hoistMocksPlugin } from './hoistMocksPlugin' 5 6 export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin' 6 7 export { interceptorPlugin } from './interceptorPlugin' 7 - export type { InterceptorPluginOptions } from './interceptorPlugin' 8 8 9 + export type { InterceptorPluginOptions } from './interceptorPlugin' 9 10 export { mockerPlugin } from './mockerPlugin' 10 11 export { findMockRedirect } from './redirect' 11 12 export { ServerMockResolver } from './resolver'
+24 -37
packages/mocker/src/node/interceptorPlugin.ts
··· 3 3 import { readFile } from 'node:fs/promises' 4 4 import { join } from 'node:path/posix' 5 5 import { ManualMockedModule, MockerRegistry } from '../registry' 6 - import { cleanUrl } from '../utils' 6 + import { cleanUrl, createManualModuleSource } from '../utils' 7 7 import { automockModule } from './automockPlugin' 8 8 9 9 export interface InterceptorPluginOptions { ··· 11 11 * @default "__vitest_mocker__" 12 12 */ 13 13 globalThisAccessor?: string 14 + registry?: MockerRegistry 14 15 } 15 16 16 - export function interceptorPlugin(options: InterceptorPluginOptions): Plugin { 17 - const registry = new MockerRegistry() 17 + export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugin { 18 + const registry = options.registry || new MockerRegistry() 18 19 return { 19 20 name: 'vitest:mocks:interceptor', 20 21 enforce: 'pre', 21 - async load(id) { 22 - const mock = registry.get(id) 23 - if (!mock) { 24 - return 25 - } 26 - if (mock.type === 'manual') { 27 - const exports = Object.keys(await mock.resolve()) 28 - const accessor = options.globalThisAccessor || '"__vitest_mocker__"' 29 - const serverUrl = (mock as any).serverUrl as string 30 - const module = `const module = globalThis[${accessor}].getFactoryModule("${serverUrl}");` 31 - const keys = exports 32 - .map((name) => { 33 - if (name === 'default') { 34 - return `export default module["default"];` 35 - } 36 - return `export const ${name} = module["${name}"];` 37 - }) 38 - .join('\n') 39 - return `${module}\n${keys}` 40 - } 41 - if (mock.type === 'redirect') { 42 - return readFile(mock.redirect, 'utf-8') 43 - } 22 + load: { 23 + order: 'pre', 24 + async handler(id) { 25 + const mock = registry.getById(id) 26 + if (!mock) { 27 + return 28 + } 29 + if (mock.type === 'manual') { 30 + const exports = Object.keys(await mock.resolve()) 31 + const accessor = options.globalThisAccessor || '"__vitest_mocker__"' 32 + return createManualModuleSource(mock.url, exports, accessor) 33 + } 34 + if (mock.type === 'redirect') { 35 + return readFile(mock.redirect, 'utf-8') 36 + } 37 + }, 44 38 }, 45 39 transform: { 46 40 order: 'post', 47 41 handler(code, id) { 48 - const mock = registry.get(id) 42 + const mock = registry.getById(id) 49 43 if (!mock) { 50 44 return 51 45 } ··· 63 57 }, 64 58 configureServer(server) { 65 59 server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { 66 - const serverUrl = event.url 67 - // the browsers stores the url relative to the root 68 - // but on the server "id" operates on the file paths 69 - event.url = join(server.config.root, event.url) 70 60 if (event.type === 'manual') { 71 61 const module = ManualMockedModule.fromJSON(event, async () => { 72 - const keys = await getFactoryExports(serverUrl) 62 + const keys = await getFactoryExports(event.url) 73 63 return Object.fromEntries(keys.map(key => [key, null])) 74 - }) 75 - Object.assign(module, { 76 - serverUrl, 77 64 }) 78 65 registry.add(module) 79 66 } ··· 88 75 }) 89 76 server.ws.on('vitest:interceptor:delete', (id: string) => { 90 77 registry.delete(id) 91 - server.ws.send('vitest:interceptor:register:delete') 78 + server.ws.send('vitest:interceptor:delete:result') 92 79 }) 93 80 server.ws.on('vitest:interceptor:invalidate', () => { 94 81 registry.clear() 95 - server.ws.send('vitest:interceptor:register:invalidate') 82 + server.ws.send('vitest:interceptor:invalidate:result') 96 83 }) 97 84 98 85 function getFactoryExports(url: string) {
+1 -1
packages/mocker/src/node/resolver.ts
··· 52 52 const moduleGraph = this.server.moduleGraph 53 53 const module = moduleGraph.getModuleById(id) 54 54 if (module) { 55 - moduleGraph.invalidateModule(module, new Set(), Date.now(), true) 55 + module.transformResult = null 56 56 } 57 57 }) 58 58 }
+5
packages/vitest/src/node/error.ts
··· 264 264 'actual', 265 265 'expected', 266 266 'diffOptions', 267 + // webkit props 267 268 'sourceURL', 268 269 'column', 269 270 'line', 271 + // firefox props 272 + 'fileName', 273 + 'lineNumber', 274 + 'columnNumber', 270 275 'VITEST_TEST_NAME', 271 276 'VITEST_TEST_PATH', 272 277 'VITEST_AFTER_ENV_TEARDOWN',
+2 -1
packages/vitest/src/node/project.ts
··· 503 503 } 504 504 505 505 private _parentBrowser?: ParentProjectBrowser 506 - private _parent?: TestProject 506 + /** @internal */ 507 + public _parent?: TestProject 507 508 /** @internal */ 508 509 _initParentBrowser = deduped(async () => { 509 510 if (!this.isBrowserEnabled() || this._parentBrowser) {
+1
packages/vitest/src/public/node.ts
··· 66 66 BrowserCommandContext, 67 67 BrowserConfigOptions, 68 68 BrowserInstanceOption, 69 + BrowserModuleMocker, 69 70 BrowserOrchestrator, 70 71 BrowserProvider, 71 72 BrowserProviderInitializationOptions,
+6 -6
packages/vitest/src/runtime/mocker.ts
··· 291 291 const id = this.normalizePath(path) 292 292 293 293 if (mockType === 'manual') { 294 - registry.register('manual', originalId, id, factory!) 294 + registry.register('manual', originalId, id, id, factory!) 295 295 } 296 296 else if (mockType === 'autospy') { 297 - registry.register('autospy', originalId, id) 297 + registry.register('autospy', originalId, id, id) 298 298 } 299 299 else { 300 300 const redirect = this.resolveMockPath(id, external) 301 301 if (redirect) { 302 - registry.register('redirect', originalId, id, redirect) 302 + registry.register('redirect', originalId, id, id, redirect) 303 303 } 304 304 else { 305 - registry.register('automock', originalId, id) 305 + registry.register('automock', originalId, id, id) 306 306 } 307 307 } 308 308 ··· 333 333 if (!mock) { 334 334 const redirect = this.resolveMockPath(normalizedId, external) 335 335 if (redirect) { 336 - mock = new RedirectedModule(rawId, normalizedId, redirect) 336 + mock = new RedirectedModule(rawId, normalizedId, normalizedId, redirect) 337 337 } 338 338 else { 339 - mock = new AutomockedModule(rawId, normalizedId) 339 + mock = new AutomockedModule(rawId, normalizedId, normalizedId) 340 340 } 341 341 } 342 342
+24
packages/browser/src/client/tester/mocker-interceptor.ts
··· 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 + }
-19
packages/browser/src/client/tester/msw.ts
··· 1 - import { ModuleMockerMSWInterceptor } from '@vitest/mocker/browser' 2 - import { getConfig } from '../utils' 3 - 4 - export function createModuleMockerInterceptor(): ModuleMockerMSWInterceptor { 5 - const debug = getConfig().env.VITEST_BROWSER_DEBUG 6 - return new ModuleMockerMSWInterceptor({ 7 - globalThisAccessor: '"__vitest_mocker__"', 8 - mswOptions: { 9 - serviceWorker: { 10 - url: '/mockServiceWorker.js', 11 - options: { 12 - scope: '/', 13 - }, 14 - }, 15 - onUnhandledRequest: 'bypass', 16 - quiet: !(debug && debug !== 'false'), 17 - }, 18 - }) 19 - }
+1 -2
packages/browser/src/client/tester/tester.ts
··· 12 12 import { setupDialogsSpy } from './dialog' 13 13 import { setupConsoleLogSpy } from './logger' 14 14 import { VitestBrowserClientMocker } from './mocker' 15 - import { createModuleMockerInterceptor } from './msw' 15 + import { createModuleMockerInterceptor } from './mocker-interceptor' 16 16 import { createSafeRpc } from './rpc' 17 17 import { browserHashMap, initiateRunner } from './runner' 18 18 import { CommandsManager } from './utils' ··· 43 43 44 44 getBrowserState().commands = new CommandsManager() 45 45 46 - // TODO: expose `worker` 47 46 const interceptor = createModuleMockerInterceptor() 48 47 const mocker = new VitestBrowserClientMocker( 49 48 interceptor,
+180 -2
packages/browser/src/node/providers/playwright.ts
··· 1 + import type { MockedModule } from '@vitest/mocker' 1 2 import type { 2 3 Browser, 3 4 BrowserContext, ··· 7 8 LaunchOptions, 8 9 Page, 9 10 } from 'playwright' 11 + import type { SourceMap } from 'rollup' 12 + import type { ResolvedConfig } from 'vite' 10 13 import type { 14 + BrowserModuleMocker, 11 15 BrowserProvider, 12 16 BrowserProviderInitializationOptions, 13 17 TestProject, 14 18 } from 'vitest/node' 19 + import { createManualModuleSource } from '@vitest/mocker/node' 15 20 16 21 export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const 17 22 export type PlaywrightBrowser = (typeof playwrightBrowsers)[number] ··· 40 45 41 46 private browserPromise: Promise<Browser> | null = null 42 47 48 + public mocker: BrowserModuleMocker | undefined 49 + 43 50 getSupportedBrowsers(): readonly string[] { 44 51 return playwrightBrowsers 45 52 } ··· 51 58 this.project = project 52 59 this.browserName = browser 53 60 this.options = options as any 61 + this.mocker = this.createMocker() 54 62 } 55 63 56 64 private async openBrowser() { ··· 103 111 return this.browserPromise 104 112 } 105 113 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 + 106 244 private async createContext(sessionId: string) { 107 245 if (this.contexts.has(sessionId)) { 108 246 return this.contexts.get(sessionId)! ··· 113 251 const options = { 114 252 ...contextOptions, 115 253 ignoreHTTPSErrors: true, 116 - serviceWorkers: 'allow', 117 254 } satisfies BrowserContextOptions 118 255 if (this.project.config.browser.ui) { 119 256 options.viewport = null ··· 154 291 const timeout = setTimeout(() => { 155 292 const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`) 156 293 reject(err) 157 - }, 1000) 294 + }, 1000).unref() 158 295 page.on('frameattached', (frame) => { 159 296 clearTimeout(timeout) 160 297 resolve(frame) ··· 240 377 this.contexts.clear() 241 378 await browser?.close() 242 379 } 380 + } 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) 243 421 }
+8
packages/vitest/src/node/types/browser.ts
··· 1 + import type { MockedModule } from '@vitest/mocker' 1 2 import type { CancelReason } from '@vitest/runner' 2 3 import type { Awaitable, ErrorWithDiff, ParsedStack } from '@vitest/utils' 3 4 import type { StackTraceParserOptions } from '@vitest/utils/source-map' ··· 17 18 off: (event: string, listener: (...args: unknown[]) => void) => void 18 19 } 19 20 21 + export interface BrowserModuleMocker { 22 + register: (sessionId: string, module: MockedModule) => Promise<void> 23 + delete: (sessionId: string, url: string) => Promise<void> 24 + clear: (sessionId: string) => Promise<void> 25 + } 26 + 20 27 export interface BrowserProvider { 21 28 name: string 29 + mocker?: BrowserModuleMocker 22 30 /** 23 31 * @experimental opt-in into file parallelisation 24 32 */
+1
test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts
··· 13 13 provider: provider, 14 14 screenshotFailures: false, 15 15 instances, 16 + headless: true, 16 17 }, 17 18 }, 18 19 })