From d4d7e58d8bad4867da7e579389fbe39a4307889e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 9 Oct 2026 13:53:03 +0200 Subject: [PATCH] feat(browser): mock modules through an import map --- docs/.vitepress/config.ts | 4 + docs/config/browser/importmapmocks.md | 24 ++++ docs/guide/cli-generated.md | 7 ++ docs/guide/mocking/modules.md | 2 +- packages/browser-playwright/src/playwright.ts | 8 ++ .../src/client/public/esm-client-injector.js | 6 +- .../src/client/tester/mocker-interceptor.ts | 25 +++- packages/browser/src/node/index.ts | 10 +- packages/browser/src/node/rpc.ts | 39 +++++- packages/browser/src/types.ts | 9 +- packages/mocker/src/browser/mocker.ts | 7 +- packages/mocker/src/node/index.ts | 2 + packages/mocker/src/node/manualMockPlugin.ts | 77 ++++++++++++ packages/mocker/src/utils.ts | 3 +- packages/vitest/src/node/cli/cli-config.ts | 4 + .../vitest/src/node/config/resolveConfig.ts | 1 + .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/plugins/mocks.ts | 3 +- packages/vitest/src/node/types/browser.ts | 13 ++ packages/vitest/src/runtime/config.ts | 1 + .../already-imported.test.ts | 8 ++ .../browser-setup.ts | 3 + .../mocking-import-map-conflict/source.ts | 3 + .../vitest.config.ts | 16 +++ .../mocking/interception-probe.test.ts | 2 +- .../mocking/mocked-factory-star.test.ts | 16 +++ .../fixtures/mocking/src/star_barrel.ts | 4 + test/browser/fixtures/mocking/src/star_dep.ts | 1 + .../browser/fixtures/mocking/src/star_dep2.ts | 3 + .../browser/fixtures/mocking/src/star_dep3.ts | 1 + test/browser/specs/mocking.test.ts | 118 +++++++++++------- 31 files changed, 356 insertions(+), 65 deletions(-) create mode 100644 docs/config/browser/importmapmocks.md create mode 100644 packages/mocker/src/node/manualMockPlugin.ts create mode 100644 test/browser/fixtures/mocking-import-map-conflict/already-imported.test.ts create mode 100644 test/browser/fixtures/mocking-import-map-conflict/browser-setup.ts create mode 100644 test/browser/fixtures/mocking-import-map-conflict/source.ts create mode 100644 test/browser/fixtures/mocking-import-map-conflict/vitest.config.ts create mode 100644 test/browser/fixtures/mocking/mocked-factory-star.test.ts create mode 100644 test/browser/fixtures/mocking/src/star_barrel.ts create mode 100644 test/browser/fixtures/mocking/src/star_dep.ts create mode 100644 test/browser/fixtures/mocking/src/star_dep2.ts create mode 100644 test/browser/fixtures/mocking/src/star_dep3.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6e3ea4d9c..dd00153ab 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -716,6 +716,10 @@ export default ({ mode }: { mode: string }) => { text: 'browser.trackUnhandledErrors', link: '/config/browser/trackunhandlederrors', }, + { + text: 'browser.importMapMocks', + link: '/config/browser/importmapmocks', + }, { text: 'browser.expect', link: '/config/browser/expect', diff --git a/docs/config/browser/importmapmocks.md b/docs/config/browser/importmapmocks.md new file mode 100644 index 000000000..c13329732 --- /dev/null +++ b/docs/config/browser/importmapmocks.md @@ -0,0 +1,24 @@ +--- +title: browser.importMapMocks | Config +outline: deep +--- + +# browser.importMapMocks 5.1.0 + +- **Type:** `boolean` +- **Default:** `false` +- **CLI:** `--browser.importMapMocks`, `--browser.importMapMocks=false` + +Serve module mocks through an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) in the tester document instead of intercepting network requests. + +When a test file calls `vi.mock`, Vitest adds an import map rule that points the module URL to its mocked version: the automocked module, the file in the `__mocks__` folder, or a module that exports every name of the original and reads the values from the factory. The browser loads the mocked module itself, so the provider does not intercept requests (for example, with Playwright's `page.route`) and the mock stays local to the document of the test file. + +Request interception adds a cost to every request in the page, which grows with the number of modules a test file imports. An import map has no per-request cost. + +::: warning +The browser must support multiple import maps per document: Chromium 133+, Safari 18.4+, and Firefox 150+ with the `dom.multiple_import_maps.enabled` preference. The `playwright` provider enables this preference automatically. Other providers cannot, so Firefox reports an error when this option is enabled. + +An import map rule cannot change a module that the document already imported. If a module was imported before `vi.mock` registered it, for example by a setup file that does not mock it, Vitest reports an error instead of silently using the original module. + +A factory can only provide the exports that the original module declares. Vitest reads the export names from the original module, so a key that the factory adds cannot be imported, and an export that the factory omits is `undefined`. +::: diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 9ec6f1d97..2dda428d8 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -395,6 +395,13 @@ Serve sourcemaps of dependencies to the browser in headless runs, used by devtoo Control if Vitest catches uncaught exceptions so they can be reported (default: `true`) +### browser.importMapMocks + +- **CLI:** `--browser.importMapMocks` +- **Config:** [browser.importMapMocks](/config/browser/importmapmocks) + +Serve module mocks through an import map instead of request interception (default: `false`) + ### browser.trace - **CLI:** `--browser.trace ` diff --git a/docs/guide/mocking/modules.md b/docs/guide/mocking/modules.md index 61edaca9b..f168f3aa7 100644 --- a/docs/guide/mocking/modules.md +++ b/docs/guide/mocking/modules.md @@ -326,7 +326,7 @@ If module runner is [disabled](/config/experimental#experimental-vitemodulerunne ### Browser Mode -Vitest uses native ESM in the Browser Mode. This means that we cannot replace the module so easily. Instead, Vitest intercepts the fetch request (via playwright's `page.route` or a Vite plugin API if using `preview` or `webdriverio`) and serves transformed code, if the module was mocked. +Vitest uses native ESM in the Browser Mode. This means that we cannot replace the module so easily. Instead, Vitest intercepts the fetch request (via playwright's `page.route` or a Vite plugin API if using `preview` or `webdriverio`) and serves transformed code, if the module was mocked. With the experimental [`browser.importMapMocks`](/config/browser/importmapmocks) option, mocked modules are served through an import map in the tester document instead, so no request is intercepted. For example, if the module is automocked, Vitest can parse static exports and create a placeholder module: diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index b945a5da2..6594f27ee 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -204,6 +204,14 @@ function resolveLaunchOptions( headless: browser.headless, } + if (browser.importMapMocks && browserName === 'firefox') { + // multiple import maps per document are behind a pref in Firefox 150+ + launchOptions.firefoxUserPrefs = { + 'dom.multiple_import_maps.enabled': true, + ...launchOptions.firefoxUserPrefs, + } + } + if (typeof browser.trace === 'object' && browser.trace.tracesDir) { launchOptions.tracesDir = browser.trace.tracesDir } diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index 051d394d3..247c9889c 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -13,11 +13,7 @@ const viteModule = evaluatedModules.ensureModule(moduleId, moduleId) viteModule.evaluated = false - viteModule.promise = new Promise((resolve, reject) => { - __vitest_mocker__.prepare().finally(() => { - moduleCallback().then(resolve, reject) - }); - }); + viteModule.promise = __vitest_mocker__.prepare().then(() => moduleCallback()) return viteModule.promise.finally(() => { viteModule.evaluated = true viteModule.promise = undefined diff --git a/packages/browser/src/client/tester/mocker-interceptor.ts b/packages/browser/src/client/tester/mocker-interceptor.ts index 676b42c19..bfc1e6273 100644 --- a/packages/browser/src/client/tester/mocker-interceptor.ts +++ b/packages/browser/src/client/tester/mocker-interceptor.ts @@ -1,4 +1,5 @@ import type { ModuleMockerInterceptor } from '@vitest/mocker/browser' +import type { MockImportMap } from '../../types' import type { BrowserRPC } from '../client' import { getBrowserState, getWorkerState } from '../utils' @@ -6,7 +7,10 @@ export function createModuleMockerInterceptor(): ModuleMockerInterceptor { return { async register(module) { const state = getBrowserState() - await rpc().registerMock(state.sessionId, module.toJSON()) + const importMap = await rpc().registerMock(state.sessionId, module.toJSON()) + if (importMap) { + appendImportMap(importMap) + } }, async delete(id) { const state = getBrowserState() @@ -19,6 +23,25 @@ export function createModuleMockerInterceptor(): ModuleMockerInterceptor { } } +// the browser drops a rule for a module that the document already resolved +// and only prints a console warning, so check that every rule took effect +function appendImportMap(importMap: MockImportMap) { + const script = document.createElement('script') + script.type = 'importmap' + script.textContent = JSON.stringify(importMap) + document.head.append(script) + for (const [specifier, url] of Object.entries(importMap.imports)) { + const expected = new URL(url, location.href).href + if (import.meta.resolve(specifier) !== expected) { + throw new Error( + `Cannot mock "${specifier}" with an import map because the browser kept its original URL. ` + + `Either the module was already imported before vi.mock was registered, ` + + `or the browser does not support adding an import map after a module was loaded.`, + ) + } + } +} + function rpc(): BrowserRPC { return getWorkerState().rpc as any as BrowserRPC } diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 692e7f194..f1cd21164 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -257,6 +257,14 @@ body { ? [] : ['/__vitest_browser__/', `/@fs${vitestDist}`, `/@fs${distRoot}`] + // request interception serves a mocked dependency at the original URL, + // so a cached original would never reach the mock; import map mocks + // have their own URLs and keep Vite's immutable caching + const interceptsMocks = () => + Array.from(parentServer.children).some( + (child) => !child.project.config.browser.importMapMocks, + ) + server.middlewares.use((req, res, next) => { const url = req.url if (url) { @@ -268,7 +276,7 @@ body { // them in _some_ tests, while keeping original modules in others; // there is no way to configure that in Vite, so we pin no-cache // and ignore the cache-control Vite sets in the next middleware - else if (versionRegexp.test(url) && !url.includes('chunk-')) { + else if (versionRegexp.test(url) && !url.includes('chunk-') && interceptsMocks()) { pinCacheControl(res, 'no-cache') } } diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 7139d0da8..f0328b3f1 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -1,4 +1,4 @@ -import type { MockerRegistry } from '@vitest/mocker' +import type { MockedModuleSerialized, MockerRegistry } from '@vitest/mocker' import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import type { TestError } from 'vitest' @@ -9,7 +9,7 @@ import type { Vitest, } from 'vitest/node' import type { WebSocket } from 'ws' -import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' +import type { MockImportMap, WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' import type { ParentBrowserProject } from './projectParent' import type { BrowserServerState } from './state' import { existsSync, promises as fs } from 'node:fs' @@ -35,6 +35,38 @@ import { WebSocketServer } from 'ws' import { replaceUndefined, reviveUndefined } from '../shared/serialization' import { slash } from './utils' +function injectQuery(url: string, query: string): string { + return url.includes('?') ? `${url}&${query}` : `${url}?${query}` +} + +// the key must be the exact URL that import analysis emits in the browser, +// including the dependency version and the HMR timestamp +function resolveMockImportMap( + vite: ParentBrowserProject['vite'], + module: MockedModuleSerialized, +): MockImportMap { + let url = module.url + const version = /[?&]v=(\w{8})/.exec(module.id)?.[1] + if (version) { + url = injectQuery(url, `v=${version}`) + } + const node = vite.environments.client.moduleGraph.getModuleById(module.id) + if (node && node.lastHMRTimestamp > 0) { + url = injectQuery(url, `t=${node.lastHMRTimestamp}`) + } + const base = vite.config.base.replace(/\/$/, '') + const key = base + url + if (module.type === 'redirect') { + const redirect = new URL(module.redirect) + return { imports: { [key]: base + redirect.pathname + redirect.search } } + } + if (module.type === 'manual') { + const query = `mock=manual&url=${encodeURIComponent(module.url)}` + return { imports: { [key]: injectQuery(key, query) } } + } + return { imports: { [key]: injectQuery(key, `mock=${module.type}`) } } +} + const debug = createDebugger('vitest:browser:api') const BROWSER_API_PATH = '/__vitest_browser_api__' @@ -462,6 +494,9 @@ export function setupBrowserRpc( }, async registerMock(sessionId, module) { + if (project.config.browser.importMapMocks) { + return resolveMockImportMap(vite, module) + } if (!mocker) { // make sure modules are not processed yet in case they were imported before // and were not mocked diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 2e88fea94..2b21578cc 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -20,6 +20,10 @@ import type { } from 'vitest' import type { MarkOptions } from 'vitest/browser' +export interface MockImportMap { + imports: Record +} + export interface WebSocketBrowserHandlers { resolveSnapshotPath: (testPath: string) => string resolveSnapshotRawPath: (testPath: string, rawPath: string) => string @@ -65,7 +69,10 @@ export interface WebSocketBrowserHandlers { getBrowserFileSourceMap: (id: string) => SourceMap | null | { mappings: '' } | undefined wdioSwitchContext: (direction: 'iframe' | 'parent') => void - registerMock: (sessionId: string, mock: MockedModuleSerialized) => void + registerMock: ( + sessionId: string, + mock: MockedModuleSerialized, + ) => Promise unregisterMock: (sessionId: string, id: string) => void clearMocks: (sessionId: string) => void diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 8f7a6ae47..e775521b2 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -253,12 +253,7 @@ export class ModuleMocker implements TestModuleMocker { // In case there is a mocked module in the import chain public wrapDynamicImport(moduleFactory: () => Promise): Promise { if (typeof moduleFactory === 'function') { - const promise = new Promise((resolve, reject) => { - this.prepare().finally(() => { - moduleFactory().then(resolve, reject) - }) - }) - return promise + return this.prepare().then(() => moduleFactory()) } return moduleFactory } diff --git a/packages/mocker/src/node/index.ts b/packages/mocker/src/node/index.ts index 78d66a22d..21fec244a 100644 --- a/packages/mocker/src/node/index.ts +++ b/packages/mocker/src/node/index.ts @@ -7,6 +7,8 @@ export type { HoistMocksOptions, StaticMockCall } from './hoistMocks' export { hoistMockAndResolve as hoistMocks, hoistMocksPlugin } from './hoistMocksPlugin' export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin' export { interceptorPlugin } from './interceptorPlugin' +export type { ManualMockPluginOptions } from './manualMockPlugin' +export { manualMockPlugin } from './manualMockPlugin' export type { InterceptorPluginOptions } from './interceptorPlugin' export { mockerPlugin } from './mockerPlugin' diff --git a/packages/mocker/src/node/manualMockPlugin.ts b/packages/mocker/src/node/manualMockPlugin.ts new file mode 100644 index 000000000..28bda6412 --- /dev/null +++ b/packages/mocker/src/node/manualMockPlugin.ts @@ -0,0 +1,77 @@ +import type { DevEnvironment, Plugin } from 'vite' +import { init as initModuleLexer, parse as parseModuleSyntax } from 'es-module-lexer' +import { createManualModuleSource } from '../utils' + +export interface ManualMockPluginOptions { + /** + * @default "__vitest_mocker__" + */ + globalThisAccessor?: string +} + +// serves `?mock=manual&url=`: a module that exports every +// name of the original and reads the values from the factory when it evaluates +export function manualMockPlugin(options: ManualMockPluginOptions = {}): Plugin { + return { + name: 'vitest:manual-mock', + enforce: 'pre', + load: { + order: 'pre', + async handler(id) { + const url = getManualMockUrl(id) + if (url == null || this.environment.mode !== 'dev') { + return + } + const exports = await collectExports(this.environment, url, new Set()) + return createManualModuleSource( + url, + exports, + options.globalThisAccessor, + 'resolveFactoryModule', + ) + }, + }, + } +} + +function getManualMockUrl(id: string): string | null { + const queryIndex = id.indexOf('?') + if (queryIndex === -1) { + return null + } + const query = new URLSearchParams(id.slice(queryIndex + 1)) + if (query.get('mock') !== 'manual') { + return null + } + return query.get('url') +} + +async function collectExports( + environment: DevEnvironment, + url: string, + seen: Set, +): Promise { + if (seen.has(url)) { + return [] + } + seen.add(url) + let result: Awaited> + try { + result = await environment.transformRequest(url) + } catch (cause) { + throw new Error(`[vitest] Cannot collect the exports of "${url}" for its factory mock`, { + cause, + }) + } + if (!result) { + throw new Error(`[vitest] Cannot collect the exports of "${url}" for its factory mock`) + } + await initModuleLexer + const [imports, exports] = parseModuleSyntax(result.code, url) + const reexports = imports.filter(({ ss, se, n }) => { + const statement = result.code.slice(ss, se).replace(/\s+/g, ' ') + return n && statement.startsWith('export *') && !statement.startsWith('export * as') + }) + const names = await Promise.all(reexports.map(({ n }) => collectExports(environment, n!, seen))) + return Array.from(new Set([...exports.map((entry) => entry.n), ...names.flat()])) +} diff --git a/packages/mocker/src/utils.ts b/packages/mocker/src/utils.ts index 43bdaa9c1..0ea2bab0b 100644 --- a/packages/mocker/src/utils.ts +++ b/packages/mocker/src/utils.ts @@ -7,9 +7,10 @@ export function createManualModuleSource( moduleUrl: string, exports: string[], globalAccessor = '"__vitest_mocker__"', + method: 'getFactoryModule' | 'resolveFactoryModule' = 'getFactoryModule', ): string { const source = ` -const __factoryModule__ = await globalThis[${globalAccessor}].getFactoryModule(${JSON.stringify(moduleUrl)}); +const __factoryModule__ = await globalThis[${globalAccessor}].${method}(${JSON.stringify(moduleUrl)}); ` const keys = exports .map((name, index) => { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 0218263e1..c1369b842 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -401,6 +401,10 @@ export const cliOptionsConfig: VitestCLIOptions = { description: 'Control if Vitest catches uncaught exceptions so they can be reported (default: `true`)', }, + importMapMocks: { + description: + 'Serve module mocks through an import map instead of request interception (default: `false`)', + }, trace: { description: 'Enable trace view mode. Supported: "on", "off", "on-first-retry", "on-all-retries", "retain-on-failure".', diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 97fe6d783..6c1c39f13 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -862,6 +862,7 @@ export function resolveTestConfig( } resolved.browser.enabled ??= false + resolved.browser.importMapMocks ??= false resolved.browser.headless ??= isCI // disable in headless mode by default, and if CI is detected resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 318e679cc..40c9f86b9 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -126,6 +126,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { } : {}, trackUnhandledErrors: browser.trackUnhandledErrors ?? true, + importMapMocks: browser.importMapMocks ?? false, trace: browser.trace.mode, traceView: browser.traceView, } diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts index bb6835bb0..fb1c33dc0 100644 --- a/packages/vitest/src/node/plugins/mocks.ts +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -1,5 +1,5 @@ import type { Plugin } from 'vite' -import { automockPlugin, hoistMocksPlugin } from '@vitest/mocker/node' +import { automockPlugin, hoistMocksPlugin, manualMockPlugin } from '@vitest/mocker/node' import { normalize } from 'pathe' import { distDir } from '../../paths' import { generateCodeFrame } from '../printError' @@ -26,5 +26,6 @@ export function MocksPlugins(options: MocksPluginOptions = {}): Plugin[] { }, }), automockPlugin(), + manualMockPlugin(), ] } diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index bc72a54ca..7950afd1d 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -353,6 +353,18 @@ export interface BrowserConfigOptions { * @default true */ trackUnhandledErrors?: boolean + + /** + * Serve module mocks through an import map in the tester document instead of + * intercepting network requests. + * + * Requires a browser that supports multiple import maps per document + * (Chromium 133+, Safari 18.4+, Firefox 150+ with `dom.multiple_import_maps.enabled`). + * + * @default false + * @experimental + */ + importMapMocks?: boolean } export interface BrowserCommandContext { @@ -515,6 +527,7 @@ export interface ResolvedBrowserOptions extends BrowserConfigOptions { height: number } screenshotFailures: boolean + importMapMocks: boolean locators: { testIdAttribute: string exact: boolean diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 4ed880a37..abe27fba4 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -136,6 +136,7 @@ export interface SerializedConfig { inlineImages: boolean } trackUnhandledErrors: boolean + importMapMocks: boolean detailsPanelPosition: 'right' | 'bottom' } standalone: boolean diff --git a/test/browser/fixtures/mocking-import-map-conflict/already-imported.test.ts b/test/browser/fixtures/mocking-import-map-conflict/already-imported.test.ts new file mode 100644 index 000000000..818337ef2 --- /dev/null +++ b/test/browser/fixtures/mocking-import-map-conflict/already-imported.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { answer } from './source' + +vi.mock('./source') + +test('the module was imported by the setup file before it was mocked', () => { + expect(vi.isMockFunction(answer)).toBe(true) +}) diff --git a/test/browser/fixtures/mocking-import-map-conflict/browser-setup.ts b/test/browser/fixtures/mocking-import-map-conflict/browser-setup.ts new file mode 100644 index 000000000..0b91ceafe --- /dev/null +++ b/test/browser/fixtures/mocking-import-map-conflict/browser-setup.ts @@ -0,0 +1,3 @@ +import { answer } from './source' + +answer() diff --git a/test/browser/fixtures/mocking-import-map-conflict/source.ts b/test/browser/fixtures/mocking-import-map-conflict/source.ts new file mode 100644 index 000000000..2bb943e67 --- /dev/null +++ b/test/browser/fixtures/mocking-import-map-conflict/source.ts @@ -0,0 +1,3 @@ +export function answer() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-import-map-conflict/vitest.config.ts b/test/browser/fixtures/mocking-import-map-conflict/vitest.config.ts new file mode 100644 index 000000000..ef4bb524c --- /dev/null +++ b/test/browser/fixtures/mocking-import-map-conflict/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' +import { instances, provider } from '../../settings' + +export default defineConfig({ + cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)), + test: { + setupFiles: ['./browser-setup.ts'], + browser: { + enabled: true, + provider, + instances, + headless: true, + }, + }, +}) diff --git a/test/browser/fixtures/mocking/interception-probe.test.ts b/test/browser/fixtures/mocking/interception-probe.test.ts index 29328c620..6bcc34f41 100644 --- a/test/browser/fixtures/mocking/interception-probe.test.ts +++ b/test/browser/fixtures/mocking/interception-probe.test.ts @@ -3,7 +3,7 @@ import { server } from 'vitest/browser' vi.mock(import('./src/mocks_factory')) -test.runIf(server.config.name === 'chromium')('request interception is live once a mock is registered', async () => { +test.runIf(server.config.name === 'chromium' && !server.config.browser.importMapMocks)('request interception is live once a mock is registered', async () => { const response = await fetch('/__vitest_interception_probe__', { cache: 'no-store' }) expect(response.headers.get('x-vitest-probe')).toBe('1') }) diff --git a/test/browser/fixtures/mocking/mocked-factory-star.test.ts b/test/browser/fixtures/mocking/mocked-factory-star.test.ts new file mode 100644 index 000000000..d1084f0ec --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-factory-star.test.ts @@ -0,0 +1,16 @@ +import { expect, test, vi } from 'vitest' +import { fromDep, fromDep2, fromDep3, own } from './src/star_barrel' + +vi.mock(import('./src/star_barrel'), () => ({ + fromDep: 'mocked dep', + fromDep2: 'mocked dep2', + fromDep3: 'mocked dep3', + own: 'mocked own', +})) + +test('a factory replaces exports that the module re-exports with export *', () => { + expect(fromDep).toBe('mocked dep') + expect(fromDep2).toBe('mocked dep2') + expect(fromDep3).toBe('mocked dep3') + expect(own).toBe('mocked own') +}) diff --git a/test/browser/fixtures/mocking/src/star_barrel.ts b/test/browser/fixtures/mocking/src/star_barrel.ts new file mode 100644 index 000000000..64566670a --- /dev/null +++ b/test/browser/fixtures/mocking/src/star_barrel.ts @@ -0,0 +1,4 @@ +export * from './star_dep' +export * from './star_dep2' + +export const own: string = 'own' diff --git a/test/browser/fixtures/mocking/src/star_dep.ts b/test/browser/fixtures/mocking/src/star_dep.ts new file mode 100644 index 000000000..b8bd45f2a --- /dev/null +++ b/test/browser/fixtures/mocking/src/star_dep.ts @@ -0,0 +1 @@ +export const fromDep: string = 'dep' diff --git a/test/browser/fixtures/mocking/src/star_dep2.ts b/test/browser/fixtures/mocking/src/star_dep2.ts new file mode 100644 index 000000000..6ab28dbef --- /dev/null +++ b/test/browser/fixtures/mocking/src/star_dep2.ts @@ -0,0 +1,3 @@ +export * from './star_dep3' + +export const fromDep2: string = 'dep2' diff --git a/test/browser/fixtures/mocking/src/star_dep3.ts b/test/browser/fixtures/mocking/src/star_dep3.ts new file mode 100644 index 000000000..ff218b90c --- /dev/null +++ b/test/browser/fixtures/mocking/src/star_dep3.ts @@ -0,0 +1 @@ +export const fromDep3: string = 'dep3' diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts index 4c6bbc49c..975b2add9 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -4,39 +4,47 @@ import { instances } from '../settings' // TODO: investigate `isolate: false` tests. // Doesn't seem like we can run things in parallel if there are mocks -test.each([true /* , false */])('mocking works correctly - isolated %s', async (isolate) => { - const result = await runVitest({ - root: 'fixtures/mocking', - isolate, - }) - - onTestFailed(() => { - console.error(result.stdout) - console.error(result.stderr) - }) - - expect(result.stderr).toReportNoErrors() - - instances.forEach(({ browser }) => { - expect(result.stdout).toReportPassedTest('automocked.test.ts', browser) - expect(result.stdout).toReportPassedTest('automocked-default-return.test.ts', browser) - expect(result.stdout).toReportPassedTest('mocked-__mocks__.test.ts', browser) - expect(result.stdout).toReportPassedTest('mocked-factory.test.ts', browser) - expect(result.stdout).toReportPassedTest('mocked-factory-hoisted.test.ts', browser) - expect(result.stdout).toReportPassedTest('not-mocked.test.ts', browser) - expect(result.stdout).toReportPassedTest('mocked-nested.test.ts', browser) - expect(result.stdout).toReportPassedTest('not-mocked-nested.test.ts', browser) - expect(result.stdout).toReportPassedTest('import-actual-in-mock.test.ts', browser) - expect(result.stdout).toReportPassedTest('import-actual-query.test.ts', browser) - expect(result.stdout).toReportPassedTest('import-mock.test.ts', browser) - expect(result.stdout).toReportPassedTest('src/aaa-dual-id-probe.test.ts', browser) - expect(result.stdout).toReportPassedTest('src/zzz-dual-id-target.test.ts', browser) - expect(result.stdout).toReportPassedTest('mocked-do-mock-factory.test.ts', browser) - expect(result.stdout).toReportPassedTest('import-actual-dep.test.ts', browser) - }) - - expect(result.exitCode).toBe(0) -}) +test.each([ + { isolate: true, importMapMocks: false }, + { isolate: true, importMapMocks: true }, +])( + 'mocking works correctly - isolated $isolate, importMapMocks $importMapMocks', + async ({ isolate, importMapMocks }) => { + const result = await runVitest({ + root: 'fixtures/mocking', + isolate, + browser: { importMapMocks }, + }) + + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) + + expect(result.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('automocked.test.ts', browser) + expect(result.stdout).toReportPassedTest('automocked-default-return.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-__mocks__.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-factory.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-factory-hoisted.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-factory-star.test.ts', browser) + expect(result.stdout).toReportPassedTest('not-mocked.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-nested.test.ts', browser) + expect(result.stdout).toReportPassedTest('not-mocked-nested.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-in-mock.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-query.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-mock.test.ts', browser) + expect(result.stdout).toReportPassedTest('src/aaa-dual-id-probe.test.ts', browser) + expect(result.stdout).toReportPassedTest('src/zzz-dual-id-target.test.ts', browser) + expect(result.stdout).toReportPassedTest('mocked-do-mock-factory.test.ts', browser) + expect(result.stdout).toReportPassedTest('import-actual-dep.test.ts', browser) + }) + + expect(result.exitCode).toBe(0) + }, +) test('manual mocks do not leak across browser files when alias and relative ids resolve to the same module', async () => { const result = await runVitest( @@ -106,22 +114,42 @@ test('mocking out of root', async () => { }) // fix https://github.com/vitest-dev/vitest/issues/11519 -test('mocks from a setup file apply to static imports of a test file', async () => { +test.each([false, true])( + 'mocks from a setup file apply to static imports of a test file - importMapMocks %s', + async (importMapMocks) => { + const result = await runVitest({ + root: 'fixtures/mocking-setup-file', + browser: { importMapMocks }, + }) + + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) + + expect(result.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('static-import.test.ts', browser) + expect(result.stdout).toReportPassedTest('dynamic-import.test.ts', browser) + }) + + expect(result.exitCode).toBe(0) + }, +) + +test('import map mocks report a module that was imported before it was mocked', async () => { const result = await runVitest({ - root: 'fixtures/mocking-setup-file', + root: 'fixtures/mocking-import-map-conflict', + browser: { importMapMocks: true }, }) - onTestFailed(() => { - console.error(result.stdout) - console.error(result.stderr) - }) - - expect(result.stderr).toReportNoErrors() - instances.forEach(({ browser }) => { - expect(result.stdout).toReportPassedTest('static-import.test.ts', browser) - expect(result.stdout).toReportPassedTest('dynamic-import.test.ts', browser) + expect(result.stdout).not.toReportPassedTest('already-imported.test.ts', browser) }) - expect(result.exitCode).toBe(0) + expect(result.stderr).toContain( + 'Cannot mock "/source.ts" with an import map because the browser kept its original URL.', + ) + expect(result.exitCode).toBe(1) }) -- 2.51.2