diff --git a/packages/browser/package.json b/packages/browser/package.json index 636dc451f..329671d6b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -93,7 +93,6 @@ "@vitest/mocker": "workspace:*", "@vitest/utils": "workspace:*", "magic-string": "catalog:", - "msw": "catalog:", "sirv": "catalog:", "tinyrainbow": "catalog:", "ws": "catalog:" diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 72834befd..a1ec6d73e 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -1,3 +1,4 @@ +import type { ModuleMocker } from '@vitest/mocker/browser' import type { CancelReason } from '@vitest/runner' import type { BirpcReturn } from 'birpc' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../node/types' @@ -65,6 +66,21 @@ function createClient() { } cdp.emit(event, payload) }, + async resolveManualMock(url: string) { + // @ts-expect-error not typed global API + const mocker = globalThis.__vitest_mocker__ as ModuleMocker | undefined + const responseId = getBrowserState().sessionId + if (!mocker) { + return { url, keys: [], responseId } + } + const exports = await mocker.resolveFactoryModule(url) + const keys = Object.keys(exports) + return { + url, + keys, + responseId, + } + }, }, { post: msg => ctx.ws.send(msg), diff --git a/packages/browser/src/client/tester/mocker-interceptor.ts b/packages/browser/src/client/tester/mocker-interceptor.ts new file mode 100644 index 000000000..25ce8bd12 --- /dev/null +++ b/packages/browser/src/client/tester/mocker-interceptor.ts @@ -0,0 +1,24 @@ +import type { ModuleMockerInterceptor } from '@vitest/mocker/browser' +import type { BrowserRPC } from '../client' +import { getBrowserState, getWorkerState } from '../utils' + +export function createModuleMockerInterceptor(): ModuleMockerInterceptor { + return { + async register(module) { + const state = getBrowserState() + await rpc().registerMock(state.sessionId, module.toJSON()) + }, + async delete(id) { + const state = getBrowserState() + await rpc().unregisterMock(state.sessionId, id) + }, + async invalidate() { + const state = getBrowserState() + await rpc().clearMocks(state.sessionId) + }, + } +} + +export function rpc(): BrowserRPC { + return getWorkerState().rpc as any as BrowserRPC +} diff --git a/packages/browser/src/client/tester/msw.ts b/packages/browser/src/client/tester/msw.ts deleted file mode 100644 index e4a06ba81..000000000 --- a/packages/browser/src/client/tester/msw.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ModuleMockerMSWInterceptor } from '@vitest/mocker/browser' -import { getConfig } from '../utils' - -export function createModuleMockerInterceptor(): ModuleMockerMSWInterceptor { - const debug = getConfig().env.VITEST_BROWSER_DEBUG - return new ModuleMockerMSWInterceptor({ - globalThisAccessor: '"__vitest_mocker__"', - mswOptions: { - serviceWorker: { - url: '/mockServiceWorker.js', - options: { - scope: '/', - }, - }, - onUnhandledRequest: 'bypass', - quiet: !(debug && debug !== 'false'), - }, - }) -} diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 3ddab0f3a..b5da1f70a 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -12,7 +12,7 @@ import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' import { setupDialogsSpy } from './dialog' import { setupConsoleLogSpy } from './logger' import { VitestBrowserClientMocker } from './mocker' -import { createModuleMockerInterceptor } from './msw' +import { createModuleMockerInterceptor } from './mocker-interceptor' import { createSafeRpc } from './rpc' import { browserHashMap, initiateRunner } from './runner' import { CommandsManager } from './utils' @@ -43,7 +43,6 @@ async function prepareTestEnvironment(files: string[]) { getBrowserState().commands = new CommandsManager() - // TODO: expose `worker` const interceptor = createModuleMockerInterceptor() const mocker = new VitestBrowserClientMocker( interceptor, diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index f124c92b5..79d5eec64 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -1,5 +1,7 @@ import type { Plugin } from 'vitest/config' import type { TestProject } from 'vitest/node' +import { MockerRegistry } from '@vitest/mocker' +import { interceptorPlugin } from '@vitest/mocker/node' import c from 'tinyrainbow' import { createViteLogger, createViteServer } from 'vitest/node' import { version } from '../../package.json' @@ -38,6 +40,8 @@ export async function createBrowserServer( allowClearScreen: false, }) + const mockerRegistry = new MockerRegistry() + const vite = await createViteServer({ ...project.options, // spread project config inlined in root workspace config base: '/', @@ -68,13 +72,14 @@ export async function createBrowserServer( ...prePlugins, ...(project.options?.plugins || []), BrowserPlugin(server), + interceptorPlugin({ registry: mockerRegistry }), ...postPlugins, ], }) await vite.listen() - setupBrowserRpc(server) + setupBrowserRpc(server, mockerRegistry) return server } diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 33960e7ea..3d762ea6c 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -1,3 +1,4 @@ +import type { MockedModule } from '@vitest/mocker' import type { Browser, BrowserContext, @@ -7,11 +8,15 @@ import type { LaunchOptions, Page, } from 'playwright' +import type { SourceMap } from 'rollup' +import type { ResolvedConfig } from 'vite' import type { + BrowserModuleMocker, BrowserProvider, BrowserProviderInitializationOptions, TestProject, } from 'vitest/node' +import { createManualModuleSource } from '@vitest/mocker/node' export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const export type PlaywrightBrowser = (typeof playwrightBrowsers)[number] @@ -40,6 +45,8 @@ export class PlaywrightBrowserProvider implements BrowserProvider { private browserPromise: Promise | null = null + public mocker: BrowserModuleMocker | undefined + getSupportedBrowsers(): readonly string[] { return playwrightBrowsers } @@ -51,6 +58,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { this.project = project this.browserName = browser this.options = options as any + this.mocker = this.createMocker() } private async openBrowser() { @@ -103,6 +111,136 @@ export class PlaywrightBrowserProvider implements BrowserProvider { return this.browserPromise } + private createMocker(): BrowserModuleMocker { + const idPreficates = new Map boolean>() + const sessionIds = new Map() + + function createPredicate(sessionId: string, url: string) { + const moduleUrl = new URL(url, 'http://localhost') + const predicate = (url: URL) => { + if (url.searchParams.has('_vitest_original')) { + return false + } + + // different modules, ignore request + if (url.pathname !== moduleUrl.pathname) { + return false + } + + url.searchParams.delete('t') + url.searchParams.delete('v') + url.searchParams.delete('import') + + // different search params, ignore request + if (url.searchParams.size !== moduleUrl.searchParams.size) { + return false + } + + // check that all search params are the same + for (const [param, value] of url.searchParams.entries()) { + if (moduleUrl.searchParams.get(param) !== value) { + return false + } + } + + return true + } + const ids = sessionIds.get(sessionId) || [] + ids.push(moduleUrl.href) + sessionIds.set(sessionId, ids) + idPreficates.set(moduleUrl.href, predicate) + return predicate + } + + return { + register: async (sessionId: string, module: MockedModule): Promise => { + const page = this.getPage(sessionId) + await page.route(createPredicate(sessionId, module.url), async (route) => { + if (module.type === 'manual') { + const exports = Object.keys(await module.resolve()) + const body = createManualModuleSource(module.url, exports) + return route.fulfill({ + body, + headers: getHeaders(this.project.browser!.vite.config), + }) + } + + // webkit doesn't support redirect responses + // https://github.com/microsoft/playwright/issues/18318 + const isWebkit = this.browserName === 'webkit' + if (isWebkit) { + const url = module.type === 'redirect' + ? (() => { + // url has http:// which vite.trasnformRequest doesn't understand + const url = new URL(module.redirect) + return url.href.slice(url.origin.length) + })() + : (() => { + const url = new URL(route.request().url()) + url.searchParams.set('mock', module.type) + return url.href.slice(url.origin.length) + })() + const result = await this.project.browser!.vite.transformRequest(url).catch(() => null) + if (!result) { + return route.continue() + } + let content = result.code + if (result.map && 'version' in result.map && result.map.mappings) { + const type = isDirectCSSRequest(url) ? 'css' : 'js' + content = getCodeWithSourcemap(type, content.toString(), result.map) + } + return route.fulfill({ + body: content, + headers: getHeaders(this.project.browser!.vite.config), + }) + } + + if (module.type === 'redirect') { + return route.fulfill({ + status: 302, + headers: { + Location: module.redirect, + }, + }) + } + else if (module.type === 'automock' || module.type === 'autospy') { + const url = new URL(route.request().url()) + url.searchParams.set('mock', module.type) + return route.fulfill({ + status: 302, + headers: { + Location: url.href, + }, + }) + } + else { + // all types are exhausted + const _module: never = module + } + }) + }, + delete: async (sessionId: string, id: string): Promise => { + const page = this.getPage(sessionId) + const predicate = idPreficates.get(id) + if (predicate) { + await page.unroute(predicate).finally(() => idPreficates.delete(id)) + } + }, + clear: async (sessionId: string): Promise => { + const page = this.getPage(sessionId) + const ids = sessionIds.get(sessionId) || [] + const promises = ids.map((id) => { + const predicate = idPreficates.get(id) + if (predicate) { + return page.unroute(predicate).finally(() => idPreficates.delete(id)) + } + return null + }) + await Promise.all(promises).finally(() => sessionIds.delete(sessionId)) + }, + } + } + private async createContext(sessionId: string) { if (this.contexts.has(sessionId)) { return this.contexts.get(sessionId)! @@ -113,7 +251,6 @@ export class PlaywrightBrowserProvider implements BrowserProvider { const options = { ...contextOptions, ignoreHTTPSErrors: true, - serviceWorkers: 'allow', } satisfies BrowserContextOptions if (this.project.config.browser.ui) { options.viewport = null @@ -154,7 +291,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { const timeout = setTimeout(() => { const err = new Error(`Cannot find "vitest-iframe" on the page. This is a bug in Vitest, please report it.`) reject(err) - }, 1000) + }, 1000).unref() page.on('frameattached', (frame) => { clearTimeout(timeout) resolve(frame) @@ -241,3 +378,44 @@ export class PlaywrightBrowserProvider implements BrowserProvider { await browser?.close() } } + +function getHeaders(config: ResolvedConfig) { + const headers: Record = { + 'Content-Type': 'application/javascript', + } + + for (const name in config.server.headers) { + headers[name] = String(config.server.headers[name]!) + } + return headers +} + +function getCodeWithSourcemap( + type: 'js' | 'css', + code: string, + map: SourceMap, +): string { + if (type === 'js') { + code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}` + } + else if (type === 'css') { + code += `\n/*# sourceMappingURL=${genSourceMapUrl(map)} */` + } + + return code +} + +function genSourceMapUrl(map: SourceMap | string): string { + if (typeof map !== 'string') { + map = JSON.stringify(map) + } + return `data:application/json;base64,${Buffer.from(map).toString('base64')}` +} + +const CSS_LANGS_RE + = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/ +const directRequestRE = /[?&]direct\b/ + +function isDirectCSSRequest(request: string): boolean { + return CSS_LANGS_RE.test(request) && directRequestRE.test(request) +} diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index aa631930b..a22abef44 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -1,3 +1,4 @@ +import type { MockerRegistry } from '@vitest/mocker' import type { Duplex } from 'node:stream' import type { ErrorWithDiff } from 'vitest' import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node' @@ -7,10 +8,11 @@ import type { WebdriverBrowserProvider } from './providers/webdriver' import type { BrowserServerState } from './state' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' import { existsSync, promises as fs } from 'node:fs' +import { AutomockedModule, AutospiedModule, ManualMockedModule, RedirectedModule } from '@vitest/mocker' import { ServerMockResolver } from '@vitest/mocker/node' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' -import { dirname } from 'pathe' +import { dirname, join } from 'pathe' import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node' import { WebSocketServer } from 'ws' @@ -18,7 +20,7 @@ const debug = createDebugger('vitest:browser:api') const BROWSER_API_PATH = '/__vitest_browser_api__' -export function setupBrowserRpc(globalServer: ParentBrowserProject): void { +export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMockerRegistry: MockerRegistry): void { const vite = globalServer.vite const vitest = globalServer.vitest @@ -113,6 +115,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void { const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config.server?.deps?.moduleDirectories, }) + const mocker = project.browser?.provider.mocker const rpc = createBirpc( { @@ -252,6 +255,65 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject): void { return mockResolver.invalidate(ids) }, + async registerMock(sessionId, module) { + if (!mocker) { + // make sure modules are not processed yet in case they were imported before + // and were not mocked + mockResolver.invalidate([module.id]) + + if (module.type === 'manual') { + const mock = ManualMockedModule.fromJSON(module, async () => { + try { + const { keys } = await rpc.resolveManualMock(module.url) + return Object.fromEntries(keys.map(key => [key, null])) + } + catch (err) { + vitest.state.catchError(err, 'Manual Mock Resolver Error') + return {} + } + }) + defaultMockerRegistry.add(mock) + } + else { + if (module.type === 'redirect') { + const redirectUrl = new URL(module.redirect) + module.redirect = join(vite.config.root, redirectUrl.pathname) + } + defaultMockerRegistry.register(module) + } + return + } + + if (module.type === 'manual') { + const manualModule = ManualMockedModule.fromJSON(module, async () => { + const { keys } = await rpc.resolveManualMock(module.url) + return Object.fromEntries(keys.map(key => [key, null])) + }) + await mocker.register(sessionId, manualModule) + } + else if (module.type === 'redirect') { + await mocker.register(sessionId, RedirectedModule.fromJSON(module)) + } + else if (module.type === 'automock') { + await mocker.register(sessionId, AutomockedModule.fromJSON(module)) + } + else if (module.type === 'autospy') { + await mocker.register(sessionId, AutospiedModule.fromJSON(module)) + } + }, + clearMocks(sessionId) { + if (!mocker) { + return defaultMockerRegistry.clear() + } + return mocker.clear(sessionId) + }, + unregisterMock(sessionId, id) { + if (!mocker) { + return defaultMockerRegistry.delete(id) + } + return mocker.delete(sessionId, id) + }, + // CDP async sendCdpEvent(sessionId: string, event: string, payload?: Record) { const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) diff --git a/packages/browser/src/node/types.ts b/packages/browser/src/node/types.ts index 53637d714..73adf3054 100644 --- a/packages/browser/src/node/types.ts +++ b/packages/browser/src/node/types.ts @@ -1,3 +1,4 @@ +import type { MockedModuleSerialized } from '@vitest/mocker' import type { ServerIdResolution, ServerMockResolution } from '@vitest/mocker/node' import type { TaskEventPack, TaskResultPack } from '@vitest/runner' import type { BirpcReturn } from 'birpc' @@ -41,6 +42,10 @@ export interface WebSocketBrowserHandlers { ) => SourceMap | null | { mappings: '' } | undefined wdioSwitchContext: (direction: 'iframe' | 'parent') => void + registerMock: (sessionId: string, mock: MockedModuleSerialized) => void + unregisterMock: (sessionId: string, id: string) => void + clearMocks: (sessionId: string) => void + // cdp sendCdpEvent: (sessionId: string, event: string, payload?: Record) => unknown trackCdpEvent: (sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void @@ -63,6 +68,11 @@ export interface WebSocketBrowserEvents { onCancel: (reason: CancelReason) => void createTesters: (files: string[]) => Promise cdpEvent: (event: string, payload: unknown) => void + resolveManualMock: (url: string) => Promise<{ + url: string + keys: string[] + responseId: string + }> } export type WebSocketBrowserRPC = BirpcReturn< diff --git a/packages/mocker/src/browser/interceptor-msw.ts b/packages/mocker/src/browser/interceptor-msw.ts index fe480d913..6c8fae794 100644 --- a/packages/mocker/src/browser/interceptor-msw.ts +++ b/packages/mocker/src/browser/interceptor-msw.ts @@ -3,7 +3,7 @@ import type { SetupWorker, StartOptions } from 'msw/browser' import type { ManualMockedModule, MockedModule } from '../registry' import type { ModuleMockerInterceptor } from './interceptor' import { MockerRegistry } from '../registry' -import { cleanUrl } from '../utils' +import { cleanUrl, createManualModuleSource } from '../utils' export interface ModuleMockerMSWInterceptorOptions { /** @@ -61,16 +61,7 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { private async resolveManualMock(mock: ManualMockedModule) { const exports = Object.keys(await mock.resolve()) - const module = `const module = globalThis[${this.options.globalThisAccessor!}].getFactoryModule("${mock.url}");` - const keys = exports - .map((name) => { - if (name === 'default') { - return `export default module["default"];` - } - return `export const ${name} = module["${name}"];` - }) - .join('\n') - const text = `${module}\n${keys}` + const text = createManualModuleSource(mock.url, exports, this.options.globalThisAccessor) return new Response(text, { headers: { 'Content-Type': 'application/javascript', diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 14a2c785d..023d367fe 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -94,10 +94,10 @@ export class ModuleMocker { if (!mock) { if (redirectUrl) { const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString() - mock = new RedirectedModule(rawId, mockUrl, resolvedRedirect) + mock = new RedirectedModule(rawId, resolvedId, mockUrl, resolvedRedirect) } else { - mock = new AutomockedModule(rawId, mockUrl) + mock = new AutomockedModule(rawId, resolvedId, mockUrl) } } @@ -157,17 +157,17 @@ export class ModuleMocker { let module: MockedModule if (mockType === 'manual') { - module = this.registry.register('manual', rawId, mockUrl, factory!) + module = this.registry.register('manual', rawId, resolvedId, mockUrl, factory!) } // autospy takes higher priority over redirect, so it needs to be checked first else if (mockType === 'autospy') { - module = this.registry.register('autospy', rawId, mockUrl) + module = this.registry.register('autospy', rawId, resolvedId, mockUrl) } else if (mockType === 'redirect') { - module = this.registry.register('redirect', rawId, mockUrl, mockRedirect!) + module = this.registry.register('redirect', rawId, resolvedId, mockUrl, mockRedirect!) } else { - module = this.registry.register('automock', rawId, mockUrl) + module = this.registry.register('automock', rawId, resolvedId, mockUrl) } await this.interceptor.register(module) diff --git a/packages/mocker/src/browser/utils.ts b/packages/mocker/src/browser/utils.ts index 827c5cdc8..cd4f748ef 100644 --- a/packages/mocker/src/browser/utils.ts +++ b/packages/mocker/src/browser/utils.ts @@ -21,7 +21,7 @@ export function rpc(event: string, data?: any): Promise { hot.on(`${event}:result`, function r(data) { resolve(data) clearTimeout(timeout) - hot.off('vitest:mocks:resolvedId:result', r) + hot.off(`${event}:result`, r) }) }) } diff --git a/packages/mocker/src/node/index.ts b/packages/mocker/src/node/index.ts index 7dc991a48..42f7cf80f 100644 --- a/packages/mocker/src/node/index.ts +++ b/packages/mocker/src/node/index.ts @@ -1,11 +1,12 @@ +export { createManualModuleSource } from '../utils' export { automockModule, automockPlugin } from './automockPlugin' export type { AutomockPluginOptions } from './automockPlugin' export { dynamicImportPlugin } from './dynamicImportPlugin' export { hoistMocks, hoistMocksPlugin } from './hoistMocksPlugin' export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin' export { interceptorPlugin } from './interceptorPlugin' -export type { InterceptorPluginOptions } from './interceptorPlugin' +export type { InterceptorPluginOptions } from './interceptorPlugin' export { mockerPlugin } from './mockerPlugin' export { findMockRedirect } from './redirect' export { ServerMockResolver } from './resolver' diff --git a/packages/mocker/src/node/interceptorPlugin.ts b/packages/mocker/src/node/interceptorPlugin.ts index f03e5be33..19641ca0e 100644 --- a/packages/mocker/src/node/interceptorPlugin.ts +++ b/packages/mocker/src/node/interceptorPlugin.ts @@ -3,7 +3,7 @@ import type { MockedModuleSerialized } from '../registry' import { readFile } from 'node:fs/promises' import { join } from 'node:path/posix' import { ManualMockedModule, MockerRegistry } from '../registry' -import { cleanUrl } from '../utils' +import { cleanUrl, createManualModuleSource } from '../utils' import { automockModule } from './automockPlugin' export interface InterceptorPluginOptions { @@ -11,41 +11,35 @@ export interface InterceptorPluginOptions { * @default "__vitest_mocker__" */ globalThisAccessor?: string + registry?: MockerRegistry } -export function interceptorPlugin(options: InterceptorPluginOptions): Plugin { - const registry = new MockerRegistry() +export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugin { + const registry = options.registry || new MockerRegistry() return { name: 'vitest:mocks:interceptor', enforce: 'pre', - async load(id) { - const mock = registry.get(id) - if (!mock) { - return - } - if (mock.type === 'manual') { - const exports = Object.keys(await mock.resolve()) - const accessor = options.globalThisAccessor || '"__vitest_mocker__"' - const serverUrl = (mock as any).serverUrl as string - const module = `const module = globalThis[${accessor}].getFactoryModule("${serverUrl}");` - const keys = exports - .map((name) => { - if (name === 'default') { - return `export default module["default"];` - } - return `export const ${name} = module["${name}"];` - }) - .join('\n') - return `${module}\n${keys}` - } - if (mock.type === 'redirect') { - return readFile(mock.redirect, 'utf-8') - } + load: { + order: 'pre', + async handler(id) { + const mock = registry.getById(id) + if (!mock) { + return + } + if (mock.type === 'manual') { + const exports = Object.keys(await mock.resolve()) + const accessor = options.globalThisAccessor || '"__vitest_mocker__"' + return createManualModuleSource(mock.url, exports, accessor) + } + if (mock.type === 'redirect') { + return readFile(mock.redirect, 'utf-8') + } + }, }, transform: { order: 'post', handler(code, id) { - const mock = registry.get(id) + const mock = registry.getById(id) if (!mock) { return } @@ -63,18 +57,11 @@ export function interceptorPlugin(options: InterceptorPluginOptions): Plugin { }, configureServer(server) { server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { - const serverUrl = event.url - // the browsers stores the url relative to the root - // but on the server "id" operates on the file paths - event.url = join(server.config.root, event.url) if (event.type === 'manual') { const module = ManualMockedModule.fromJSON(event, async () => { - const keys = await getFactoryExports(serverUrl) + const keys = await getFactoryExports(event.url) return Object.fromEntries(keys.map(key => [key, null])) }) - Object.assign(module, { - serverUrl, - }) registry.add(module) } else { @@ -88,11 +75,11 @@ export function interceptorPlugin(options: InterceptorPluginOptions): Plugin { }) server.ws.on('vitest:interceptor:delete', (id: string) => { registry.delete(id) - server.ws.send('vitest:interceptor:register:delete') + server.ws.send('vitest:interceptor:delete:result') }) server.ws.on('vitest:interceptor:invalidate', () => { registry.clear() - server.ws.send('vitest:interceptor:register:invalidate') + server.ws.send('vitest:interceptor:invalidate:result') }) function getFactoryExports(url: string) { diff --git a/packages/mocker/src/node/resolver.ts b/packages/mocker/src/node/resolver.ts index 1aa407a36..888f404b0 100644 --- a/packages/mocker/src/node/resolver.ts +++ b/packages/mocker/src/node/resolver.ts @@ -52,7 +52,7 @@ export class ServerMockResolver { const moduleGraph = this.server.moduleGraph const module = moduleGraph.getModuleById(id) if (module) { - moduleGraph.invalidateModule(module, new Set(), Date.now(), true) + module.transformResult = null } }) } diff --git a/packages/mocker/src/registry.ts b/packages/mocker/src/registry.ts index 4412bc8fb..ff0ebec42 100644 --- a/packages/mocker/src/registry.ts +++ b/packages/mocker/src/registry.ts @@ -1,16 +1,19 @@ export class MockerRegistry { - private readonly registry: Map = new Map() + private readonly registryByUrl: Map = new Map() + private readonly registryById: Map = new Map() clear(): void { - this.registry.clear() + this.registryByUrl.clear() + this.registryById.clear() } keys(): IterableIterator { - return this.registry.keys() + return this.registryByUrl.keys() } add(mock: MockedModule): void { - this.registry.set(mock.url, mock) + this.registryByUrl.set(mock.url, mock) + this.registryById.set(mock.id, mock) } public register( @@ -19,28 +22,33 @@ export class MockerRegistry { public register( type: 'redirect', raw: string, + id: string, url: string, redirect: string, ): RedirectedModule public register( type: 'manual', raw: string, + id: string, url: string, factory: () => any, ): ManualMockedModule public register( type: 'automock', raw: string, + id: string, url: string, ): AutomockedModule public register( type: 'autospy', + id: string, raw: string, url: string, ): AutospiedModule public register( typeOrEvent: MockedModuleType | MockedModuleSerialized, raw?: string, + id?: string, url?: string, factoryOrRedirect?: string | (() => any), ): MockedModule { @@ -91,18 +99,22 @@ export class MockerRegistry { throw new TypeError('[vitest] Mocks require a url string.') } + if (typeof id !== 'string') { + throw new TypeError('[vitest] Mocks require an id string.') + } + if (type === 'manual') { if (typeof factoryOrRedirect !== 'function') { throw new TypeError('[vitest] Manual mocks require a factory function.') } - const mock = new ManualMockedModule(raw, url, factoryOrRedirect) + const mock = new ManualMockedModule(raw, id, url, factoryOrRedirect) this.add(mock) return mock } else if (type === 'automock' || type === 'autospy') { const mock = type === 'automock' - ? new AutomockedModule(raw, url) - : new AutospiedModule(raw, url) + ? new AutomockedModule(raw, id, url) + : new AutospiedModule(raw, id, url) this.add(mock) return mock } @@ -110,7 +122,7 @@ export class MockerRegistry { if (typeof factoryOrRedirect !== 'string') { throw new TypeError('[vitest] Redirect mocks require a redirect string.') } - const mock = new RedirectedModule(raw, url, factoryOrRedirect) + const mock = new RedirectedModule(raw, id, url, factoryOrRedirect) this.add(mock) return mock } @@ -120,15 +132,19 @@ export class MockerRegistry { } public delete(id: string): void { - this.registry.delete(id) + this.registryByUrl.delete(id) } public get(id: string): MockedModule | undefined { - return this.registry.get(id) + return this.registryByUrl.get(id) + } + + public getById(id: string): MockedModule | undefined { + return this.registryById.get(id) } public has(id: string): boolean { - return this.registry.has(id) + return this.registryByUrl.has(id) } } @@ -150,11 +166,12 @@ export class AutomockedModule { constructor( public raw: string, + public id: string, public url: string, ) {} static fromJSON(data: AutomockedModuleSerialized): AutospiedModule { - return new AutospiedModule(data.raw, data.url) + return new AutospiedModule(data.raw, data.id, data.url) } toJSON(): AutomockedModuleSerialized { @@ -162,6 +179,7 @@ export class AutomockedModule { type: this.type, url: this.url, raw: this.raw, + id: this.id, } } } @@ -170,6 +188,7 @@ export interface AutomockedModuleSerialized { type: 'automock' url: string raw: string + id: string } export class AutospiedModule { @@ -177,17 +196,19 @@ export class AutospiedModule { constructor( public raw: string, + public id: string, public url: string, ) {} static fromJSON(data: AutospiedModuleSerialized): AutospiedModule { - return new AutospiedModule(data.raw, data.url) + return new AutospiedModule(data.raw, data.id, data.url) } toJSON(): AutospiedModuleSerialized { return { type: this.type, url: this.url, + id: this.id, raw: this.raw, } } @@ -197,6 +218,7 @@ export interface AutospiedModuleSerialized { type: 'autospy' url: string raw: string + id: string } export class RedirectedModule { @@ -204,12 +226,13 @@ export class RedirectedModule { constructor( public raw: string, + public id: string, public url: string, public redirect: string, ) {} static fromJSON(data: RedirectedModuleSerialized): RedirectedModule { - return new RedirectedModule(data.raw, data.url, data.redirect) + return new RedirectedModule(data.raw, data.id, data.url, data.redirect) } toJSON(): RedirectedModuleSerialized { @@ -217,6 +240,7 @@ export class RedirectedModule { type: this.type, url: this.url, raw: this.raw, + id: this.id, redirect: this.redirect, } } @@ -225,6 +249,7 @@ export class RedirectedModule { export interface RedirectedModuleSerialized { type: 'redirect' url: string + id: string raw: string redirect: string } @@ -235,6 +260,7 @@ export class ManualMockedModule { constructor( public raw: string, + public id: string, public url: string, public factory: () => any, ) {} @@ -267,13 +293,14 @@ export class ManualMockedModule { } static fromJSON(data: ManualMockedModuleSerialized, factory: () => any): ManualMockedModule { - return new ManualMockedModule(data.raw, data.url, factory) + return new ManualMockedModule(data.raw, data.id, data.url, factory) } toJSON(): ManualMockedModuleSerialized { return { type: this.type, url: this.url, + id: this.id, raw: this.raw, } } @@ -282,5 +309,6 @@ export class ManualMockedModule { export interface ManualMockedModuleSerialized { type: 'manual' url: string + id: string raw: string } diff --git a/packages/mocker/src/utils.ts b/packages/mocker/src/utils.ts index c07da4712..abbf172f7 100644 --- a/packages/mocker/src/utils.ts +++ b/packages/mocker/src/utils.ts @@ -2,3 +2,16 @@ const postfixRE = /[?#].*$/ export function cleanUrl(url: string): string { return url.replace(postfixRE, '') } + +export function createManualModuleSource(moduleUrl: string, exports: string[], globalAccessor = '"__vitest_mocker__"'): string { + const source = `const module = globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}");` + const keys = exports + .map((name) => { + if (name === 'default') { + return `export default module["default"];` + } + return `export const ${name} = module["${name}"];` + }) + .join('\n') + return `${source}\n${keys}` +} diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index af256e964..d4fdede0a 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -280,6 +280,16 @@ export function parseErrorStacktrace( const stackStr = e.stack || e.stackStr || '' let stackFrames = parseStacktrace(stackStr, options) + if (!stackFrames.length) { + const e_ = e as any + if (e_.fileName != null && e_.lineNumber != null && e_.columnNumber != null) { + stackFrames = parseStacktrace(`${e_.fileName}:${e_.lineNumber}:${e_.columnNumber}`, options) + } + if (e_.sourceURL != null && e_.line != null && e_._column != null) { + stackFrames = parseStacktrace(`${e_.sourceURL}:${e_.line}:${e_.column}`, options) + } + } + if (options.frameFilter) { stackFrames = stackFrames.filter( f => options.frameFilter!(e, f) !== false, diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 0d96badb6..260d982a0 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -264,9 +264,14 @@ const skipErrorProperties = new Set([ 'actual', 'expected', 'diffOptions', + // webkit props 'sourceURL', 'column', 'line', + // firefox props + 'fileName', + 'lineNumber', + 'columnNumber', 'VITEST_TEST_NAME', 'VITEST_TEST_PATH', 'VITEST_AFTER_ENV_TEARDOWN', diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index d190d5dcb..efdec524b 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -503,7 +503,8 @@ export class TestProject { } private _parentBrowser?: ParentProjectBrowser - private _parent?: TestProject + /** @internal */ + public _parent?: TestProject /** @internal */ _initParentBrowser = deduped(async () => { if (!this.isBrowserEnabled() || this._parentBrowser) { diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 8b3fc22d9..cd0afc36c 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -1,3 +1,4 @@ +import type { MockedModule } from '@vitest/mocker' import type { CancelReason } from '@vitest/runner' import type { Awaitable, ErrorWithDiff, ParsedStack } from '@vitest/utils' import type { StackTraceParserOptions } from '@vitest/utils/source-map' @@ -17,8 +18,15 @@ export interface CDPSession { off: (event: string, listener: (...args: unknown[]) => void) => void } +export interface BrowserModuleMocker { + register: (sessionId: string, module: MockedModule) => Promise + delete: (sessionId: string, url: string) => Promise + clear: (sessionId: string) => Promise +} + export interface BrowserProvider { name: string + mocker?: BrowserModuleMocker /** * @experimental opt-in into file parallelisation */ diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index b44ddd3fb..89616f8cd 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -66,6 +66,7 @@ export type { BrowserCommandContext, BrowserConfigOptions, BrowserInstanceOption, + BrowserModuleMocker, BrowserOrchestrator, BrowserProvider, BrowserProviderInitializationOptions, diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index e4ca3fc0d..3f379fa00 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -291,18 +291,18 @@ export class VitestMocker { const id = this.normalizePath(path) if (mockType === 'manual') { - registry.register('manual', originalId, id, factory!) + registry.register('manual', originalId, id, id, factory!) } else if (mockType === 'autospy') { - registry.register('autospy', originalId, id) + registry.register('autospy', originalId, id, id) } else { const redirect = this.resolveMockPath(id, external) if (redirect) { - registry.register('redirect', originalId, id, redirect) + registry.register('redirect', originalId, id, id, redirect) } else { - registry.register('automock', originalId, id) + registry.register('automock', originalId, id, id) } } @@ -333,10 +333,10 @@ export class VitestMocker { if (!mock) { const redirect = this.resolveMockPath(normalizedId, external) if (redirect) { - mock = new RedirectedModule(rawId, normalizedId, redirect) + mock = new RedirectedModule(rawId, normalizedId, normalizedId, redirect) } else { - mock = new AutomockedModule(rawId, normalizedId) + mock = new AutomockedModule(rawId, normalizedId, normalizedId) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 115c9bfd4..810d9bc64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,9 +489,6 @@ importers: magic-string: specifier: 'catalog:' version: 0.30.17 - msw: - specifier: 'catalog:' - version: 2.7.3(@types/node@22.13.13)(typescript@5.8.2) sirv: specifier: 'catalog:' version: 3.0.1 diff --git a/test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts b/test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts index 87eb62502..3f7b9dcdf 100644 --- a/test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts +++ b/test/browser/fixtures/mocking-out-of-root/project1/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ provider: provider, screenshotFailures: false, instances, + headless: true, }, }, })