diff --git a/docs/config/browser/importmapmocks.md b/docs/config/browser/importmapmocks.md
index c13329732..8738eeb50 100644
--- a/docs/config/browser/importmapmocks.md
+++ b/docs/config/browser/importmapmocks.md
@@ -6,17 +6,19 @@ outline: deep
# browser.importMapMocks 5.1.0
- **Type:** `boolean`
-- **Default:** `false`
+- **Default:** enabled when the browser supports it
- **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.
+By default, Vitest checks once per browser whether it accepts an import map after a module has loaded, and falls back to request interception when it does not. Set the option to `true` to fail instead of falling back, or to `false` to always intercept 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.
+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 sets this preference. Other providers cannot, so Firefox falls back to request interception there.
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.
diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md
index 2dda428d8..ce05f4671 100644
--- a/docs/guide/cli-generated.md
+++ b/docs/guide/cli-generated.md
@@ -400,7 +400,7 @@ Control if Vitest catches uncaught exceptions so they can be reported (default:
- **CLI:** `--browser.importMapMocks`
- **Config:** [browser.importMapMocks](/config/browser/importmapmocks)
-Serve module mocks through an import map instead of request interception (default: `false`)
+Serve module mocks through an import map instead of request interception. By default, enabled when the browser supports it
### browser.trace
diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts
index 9d2af423d..d9d73c6b6 100644
--- a/packages/browser-playwright/src/playwright.ts
+++ b/packages/browser-playwright/src/playwright.ts
@@ -254,7 +254,7 @@ function resolveLaunchOptions(
headless: browser.headless,
}
- if (browser.importMapMocks && browserName === 'firefox') {
+ if (browserName === 'firefox') {
// multiple import maps per document are behind a pref in Firefox 150+
launchOptions.firefoxUserPrefs = {
'dom.multiple_import_maps.enabled': true,
diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts
index e1215da70..db8b436ea 100644
--- a/packages/browser/src/client/orchestrator.ts
+++ b/packages/browser/src/client/orchestrator.ts
@@ -15,7 +15,7 @@ import { Traces } from 'vitest/internal/traces'
// This needs to be tree shaken properly to not include the whole runner by accident
import { generateFileHash } from '../../../vitest/src/utils/tasks.js'
import { getUiAPI } from './ui'
-import { getBrowserState, getConfig } from './utils'
+import { getBrowserState, getConfig, supportsLateImportMaps } from './utils'
const ID_ALL = '__vitest_all__'
// the next document gets this long to fire "load" before the unload counts as a broken iframe
@@ -55,7 +55,7 @@ export class IframeOrchestrator {
// Notify the server once the websocket is ready without blocking orchestrator creation.
void client
.waitForConnection()
- .then(() => client.rpc.onOrchestratorReady())
+ .then(() => client.rpc.onOrchestratorReady({ lateImportMaps: supportsLateImportMaps() }))
.catch((error) => {
debug('failed to notify orchestrator readiness', error)
})
diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts
index 42c5ebb6e..e0942180a 100644
--- a/packages/browser/src/client/utils.ts
+++ b/packages/browser/src/client/utils.ts
@@ -26,6 +26,22 @@ export const moduleRunner = {
},
}
+// a bare specifier throws until a map defines it, so a map appended now
+// tells whether this document accepts an import map after a module has loaded
+export function supportsLateImportMaps(): boolean {
+ const specifier = '__vitest_import_map_probe__'
+ const script = document.createElement('script')
+ script.type = 'importmap'
+ script.textContent = JSON.stringify({ imports: { [specifier]: `/${specifier}` } })
+ document.head.append(script)
+ try {
+ import.meta.resolve(specifier)
+ return true
+ } catch {
+ return false
+ }
+}
+
export const now: () => number = globalThis.performance
? globalThis.performance.now.bind(globalThis.performance)
: Date.now
diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts
index f1cd21164..865492341 100644
--- a/packages/browser/src/node/index.ts
+++ b/packages/browser/src/node/index.ts
@@ -8,10 +8,11 @@ import type {
PluginHarness,
ResolvedConfig,
} from 'vitest/node'
+import type { FactoryExportResolver } from './rpc'
import { createReadStream, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { MockerRegistry } from '@vitest/mocker'
-import { interceptorPlugin } from '@vitest/mocker/node'
+import { interceptorPlugin, manualMockPlugin } from '@vitest/mocker/node'
import { distClientRoot as uiClientRoot } from '@vitest/ui'
import { cleanUrl, toArray } from '@vitest/utils/helpers'
import { join, resolve } from 'pathe'
@@ -75,6 +76,7 @@ function pinCacheControl(res: ServerResponse, value: string): void {
*/
export const createBrowserServer: BrowserServerFactory = async () => {
const mockerRegistry = new MockerRegistry()
+ const factoryExportResolvers = new Map()
const contribution: BrowserServerContribution = {
async transformIndexHtml(ctx) {
@@ -262,7 +264,7 @@ body {
// have their own URLs and keep Vite's immutable caching
const interceptsMocks = () =>
Array.from(parentServer.children).some(
- (child) => !child.project.config.browser.importMapMocks,
+ (child) => child.config.browser.importMapMocks === false || !child.state.lateImportMaps,
)
server.middlewares.use((req, res, next) => {
@@ -377,13 +379,22 @@ body {
return new ParentBrowserProject({ config, vitest }, '/')
},
setupRpc(parent) {
- setupBrowserRpc(parent as ParentBrowserProject, mockerRegistry)
+ setupBrowserRpc(parent as ParentBrowserProject, mockerRegistry, factoryExportResolvers)
},
}
contribution.plugins = [
...BrowserPlugin(contribution),
interceptorPlugin({ registry: mockerRegistry, registerWebSocketEvents: false }),
+ manualMockPlugin({
+ resolveFactoryExports(url) {
+ const resolver = factoryExportResolvers.get(url)
+ if (!resolver) {
+ throw new Error(`[vitest] The factory mock "${url}" is not registered`)
+ }
+ return resolver.resolve()
+ },
+ }),
{
name: 'vitest:browser:framework-sourcemaps',
enforce: 'post',
diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts
index 59cc01339..0b30c2ad8 100644
--- a/packages/browser/src/node/rpc.ts
+++ b/packages/browser/src/node/rpc.ts
@@ -19,7 +19,7 @@ import {
ManualMockedModule,
RedirectedModule,
} from '@vitest/mocker'
-import { ServerMockResolver } from '@vitest/mocker/node'
+import { getManualMockId, getManualMockUrl, ServerMockResolver } from '@vitest/mocker/node'
import { evaluateSnapshotFile } from '@vitest/snapshot/environment'
import { extractSourcemapFromFile } from '@vitest/utils/source-map/node'
import { createBirpc } from 'birpc'
@@ -61,8 +61,7 @@ function resolveMockImportMap(
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]: base + getManualMockUrl(module.url, module.id) } }
}
return { imports: { [key]: injectQuery(key, `mock=${module.type}`) } }
}
@@ -93,9 +92,15 @@ function resolveHeartbeatInterval(vitest: Vitest): number {
return interval
}
+export interface FactoryExportResolver {
+ sessionId: string
+ resolve: () => Promise
+}
+
export function setupBrowserRpc(
globalServer: ParentBrowserProject,
defaultMockerRegistry: MockerRegistry,
+ factoryExportResolvers: Map,
): void {
const vite = globalServer.vite
const vitest = globalServer.vitest
@@ -276,7 +281,9 @@ export function setupBrowserRpc(
const rpc = createBirpc(
{
- onOrchestratorReady() {
+ onOrchestratorReady(capabilities) {
+ ;(project.browser!.state as BrowserServerState).lateImportMaps =
+ capabilities.lateImportMaps
const sessions = vitest._browserSessions
sessions.getSession(options.sessionId)?.ready()
},
@@ -500,8 +507,36 @@ export function setupBrowserRpc(
return mockResolver.invalidate(ids)
},
- async registerMock(sessionId, module) {
- if (project.config.browser.importMapMocks) {
+ async registerMock(sessionId, module): Promise {
+ const option = project.config.browser.importMapMocks
+ const { lateImportMaps } = project.browser!.state as BrowserServerState
+ if (option === true && lateImportMaps === false) {
+ throw new Error(
+ '"browser.importMapMocks" is enabled, but this browser does not accept an import map after a module has loaded. ' +
+ 'Firefox needs the "dom.multiple_import_maps.enabled" preference.',
+ )
+ }
+ if (option !== false && lateImportMaps) {
+ if (module.type !== 'manual') {
+ return resolveMockImportMap(vite, module)
+ }
+ // the shim reads the original only when it loads; until then the
+ // server keeps a way to ask this session for the factory keys
+ // this is a last resort check only if the transformation failed
+ factoryExportResolvers.set(module.url, {
+ sessionId,
+ async resolve() {
+ const { keys } = await rpc.resolveManualMock(module.url)
+ return keys
+ },
+ })
+ // the shim is not tied to the original file, so a changed original
+ // would otherwise keep a stale export list
+ const { moduleGraph } = vite.environments.client
+ const shim = moduleGraph.getModuleById(getManualMockId(module.url, module.id))
+ if (shim) {
+ moduleGraph.invalidateModule(shim)
+ }
return resolveMockImportMap(vite, module)
}
if (!mocker) {
@@ -547,6 +582,11 @@ export function setupBrowserRpc(
}
},
clearMocks(sessionId) {
+ for (const [url, resolver] of factoryExportResolvers) {
+ if (resolver.sessionId === sessionId) {
+ factoryExportResolvers.delete(url)
+ }
+ }
if (!mocker) {
return defaultMockerRegistry.clear()
}
diff --git a/packages/browser/src/node/state.ts b/packages/browser/src/node/state.ts
index 9561c234d..d37b46ab7 100644
--- a/packages/browser/src/node/state.ts
+++ b/packages/browser/src/node/state.ts
@@ -4,4 +4,6 @@ import type { WebSocketBrowserRPC } from '../types'
export class BrowserServerState implements IBrowserServerState {
public readonly orchestrators: Map = new Map()
public readonly testers: Map = new Map()
+ // reported by the orchestrator: whether the browser accepts an import map after a module loaded
+ public lateImportMaps: boolean | undefined
}
diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts
index 2b21578cc..06702688e 100644
--- a/packages/browser/src/types.ts
+++ b/packages/browser/src/types.ts
@@ -20,6 +20,11 @@ import type {
} from 'vitest'
import type { MarkOptions } from 'vitest/browser'
+interface OrchestratorCapabilities {
+ /** the browser accepts an import map after a module has loaded */
+ lateImportMaps: boolean
+}
+
export interface MockImportMap {
imports: Record
}
@@ -43,7 +48,7 @@ export interface WebSocketBrowserHandlers {
readBenchmarkResult: (relativePath: string) => Promise
writeBenchmarkResult: (relativePath: string, data: BaselineData) => Promise
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void
- onOrchestratorReady: () => void
+ onOrchestratorReady: (capabilities: OrchestratorCapabilities) => void
cancelCurrentRun: (reason: CancelReason) => void
getCountOfFailedTests: () => number
readSnapshotFile: (id: string) => Promise
diff --git a/packages/mocker/src/node/index.ts b/packages/mocker/src/node/index.ts
index 21fec244a..e3e4bf5f9 100644
--- a/packages/mocker/src/node/index.ts
+++ b/packages/mocker/src/node/index.ts
@@ -8,7 +8,12 @@ export { hoistMockAndResolve as hoistMocks, hoistMocksPlugin } from './hoistMock
export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin'
export { interceptorPlugin } from './interceptorPlugin'
export type { ManualMockPluginOptions } from './manualMockPlugin'
-export { manualMockPlugin } from './manualMockPlugin'
+export {
+ collectEnvironmentExports,
+ getManualMockId,
+ getManualMockUrl,
+ 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
index 28bda6412..cbbd37841 100644
--- a/packages/mocker/src/node/manualMockPlugin.ts
+++ b/packages/mocker/src/node/manualMockPlugin.ts
@@ -1,4 +1,4 @@
-import type { DevEnvironment, Plugin } from 'vite'
+import type { DevEnvironment, Plugin, TransformResult } from 'vite'
import { init as initModuleLexer, parse as parseModuleSyntax } from 'es-module-lexer'
import { createManualModuleSource } from '../utils'
@@ -7,22 +7,54 @@ export interface ManualMockPluginOptions {
* @default "__vitest_mocker__"
*/
globalThisAccessor?: string
+ /**
+ * Export names for a factory mock whose original module cannot be transformed,
+ * read from the factory result of the last registered mock.
+ */
+ resolveFactoryExports?: (url: string) => Promise
+}
+
+const PREFIX = '\0vitest-mock:'
+
+// the shim id carries the registry url and the module id, but not the module
+// path, so plugins that guard the original by its path never see the shim
+export function getManualMockId(url: string, moduleId: string): string {
+ const encoded = Buffer.from(JSON.stringify({ url, id: moduleId })).toString('base64url')
+ return `${PREFIX}${encoded}`
}
-// 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 getManualMockUrl(url: string, moduleId: string): string {
+ return `/@id/__x00__${getManualMockId(url, moduleId).slice(1)}`
+}
+
+// serves the shim of a factory mock: 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',
+ resolveId(id) {
+ if (id.startsWith(PREFIX)) {
+ return id
+ }
+ },
load: {
order: 'pre',
async handler(id) {
- const url = getManualMockUrl(id)
- if (url == null || this.environment.mode !== 'dev') {
+ if (!id.startsWith(PREFIX) || this.environment.mode !== 'dev') {
return
}
- const exports = await collectExports(this.environment, url, new Set())
+ const { url, moduleId } = parseManualMockId(id)
+ let exports: string[]
+ try {
+ exports = await collectEnvironmentExports(this.environment, moduleId)
+ } catch (error) {
+ // the original cannot be transformed, so the factory result decides
+ if (!options.resolveFactoryExports) {
+ throw error
+ }
+ exports = await options.resolveFactoryExports(url)
+ }
return createManualModuleSource(
url,
exports,
@@ -34,44 +66,55 @@ export function manualMockPlugin(options: ManualMockPluginOptions = {}): Plugin
}
}
-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')
+function parseManualMockId(id: string): { url: string; moduleId: string } {
+ const encoded = id.slice(PREFIX.length)
+ const { url, id: moduleId } = JSON.parse(Buffer.from(encoded, 'base64url').toString())
+ return { url, moduleId }
+}
+
+export function collectEnvironmentExports(
+ environment: DevEnvironment,
+ moduleId: string,
+ seen: Set = new Set(),
+): Promise {
+ return collectExports(environment, moduleId, seen)
+}
+
+// import analysis rewrites re-export sources into browser urls, which
+// `transformRequest` does not accept for virtual ids
+function unwrapId(url: string): string {
+ return url.startsWith('/@id/') ? url.slice(5).replace('__x00__', '\0') : url
}
async function collectExports(
environment: DevEnvironment,
- url: string,
+ moduleId: string,
seen: Set,
): Promise {
- if (seen.has(url)) {
+ if (seen.has(moduleId)) {
return []
}
- seen.add(url)
- let result: Awaited>
+ seen.add(moduleId)
+ let result: TransformResult | null
try {
- result = await environment.transformRequest(url)
+ result = await environment.transformRequest(moduleId)
} catch (cause) {
- throw new Error(`[vitest] Cannot collect the exports of "${url}" for its factory mock`, {
- cause,
- })
+ throw new Error(
+ `[vitest] Cannot collect the exports of "${moduleId}" for its factory mock: ${(cause as Error).message}`,
+ { cause },
+ )
}
if (!result) {
- throw new Error(`[vitest] Cannot collect the exports of "${url}" for its factory mock`)
+ throw new Error(`[vitest] Cannot collect the exports of "${moduleId}" for its factory mock`)
}
await initModuleLexer
- const [imports, exports] = parseModuleSyntax(result.code, url)
+ const [imports, exports] = parseModuleSyntax(result.code, moduleId)
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)))
+ const names = await Promise.all(
+ reexports.map(({ n }) => collectExports(environment, unwrapId(n!), seen)),
+ )
return Array.from(new Set([...exports.map((entry) => entry.n), ...names.flat()]))
}
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index c1369b842..3f76e15a8 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -403,7 +403,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
importMapMocks: {
description:
- 'Serve module mocks through an import map instead of request interception (default: `false`)',
+ 'Serve module mocks through an import map instead of request interception. By default, enabled when the browser supports it',
},
trace: {
description:
diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts
index 6c1c39f13..97fe6d783 100644
--- a/packages/vitest/src/node/config/resolveConfig.ts
+++ b/packages/vitest/src/node/config/resolveConfig.ts
@@ -862,7 +862,6 @@ 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 40c9f86b9..7965652dd 100644
--- a/packages/vitest/src/node/config/serializeConfig.ts
+++ b/packages/vitest/src/node/config/serializeConfig.ts
@@ -126,7 +126,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
}
: {},
trackUnhandledErrors: browser.trackUnhandledErrors ?? true,
- importMapMocks: browser.importMapMocks ?? false,
+ importMapMocks: browser.importMapMocks,
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 fb1c33dc0..bb6835bb0 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, manualMockPlugin } from '@vitest/mocker/node'
+import { automockPlugin, hoistMocksPlugin } from '@vitest/mocker/node'
import { normalize } from 'pathe'
import { distDir } from '../../paths'
import { generateCodeFrame } from '../printError'
@@ -26,6 +26,5 @@ export function MocksPlugins(options: MocksPluginOptions = {}): Plugin[] {
},
}),
automockPlugin(),
- manualMockPlugin(),
]
}
diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts
index f94f747d4..00e55850c 100644
--- a/packages/vitest/src/node/pools/browser.ts
+++ b/packages/vitest/src/node/pools/browser.ts
@@ -32,8 +32,8 @@ interface BrowserTask {
files: FileSpecification[]
method: 'run' | 'collect'
// providers without a per-session mocker share a single mock registry
- // unless mocks go through the import map, and some of them cannot drive
- // several pages, so the instance runs alone
+ // unless mocks are forced through the import map, and some of them cannot
+ // drive several pages, so the instance runs alone
exclusive: boolean
result: DeferPromise
}
@@ -237,7 +237,7 @@ export function createBrowserPool(vitest: Vitest): BrowserPool {
method,
exclusive:
!provider.supportsParallelism ||
- (!provider.mocker && !project.config.browser.importMapMocks),
+ (!provider.mocker && project.config.browser.importMapMocks !== true),
result: createDefer(),
}
},
diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts
index 25071d1a9..950aae0ca 100644
--- a/packages/vitest/src/node/types/browser.ts
+++ b/packages/vitest/src/node/types/browser.ts
@@ -368,10 +368,12 @@ export interface BrowserConfigOptions {
* 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`).
+ * By default, Vitest checks once per browser whether it accepts an import map
+ * after a module has loaded (Chromium 133+, Safari 18.4+, Firefox 150+ with
+ * `dom.multiple_import_maps.enabled`, which the Playwright provider sets) and
+ * falls back to request interception otherwise. `true` fails in a browser that
+ * cannot, `false` always intercepts.
*
- * @default false
* @experimental
*/
importMapMocks?: boolean
@@ -542,7 +544,6 @@ 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 abe27fba4..73682a698 100644
--- a/packages/vitest/src/runtime/config.ts
+++ b/packages/vitest/src/runtime/config.ts
@@ -136,7 +136,7 @@ export interface SerializedConfig {
inlineImages: boolean
}
trackUnhandledErrors: boolean
- importMapMocks: boolean
+ importMapMocks: boolean | undefined
detailsPanelPosition: 'right' | 'bottom'
}
standalone: boolean
diff --git a/test/browser/fixtures/mocking-import-map-fallback/automocked.test.ts b/test/browser/fixtures/mocking-import-map-fallback/automocked.test.ts
new file mode 100644
index 000000000..655ac5dee
--- /dev/null
+++ b/test/browser/fixtures/mocking-import-map-fallback/automocked.test.ts
@@ -0,0 +1,9 @@
+import { expect, test, vi } from 'vitest'
+import { calculator } from './calculator'
+
+vi.mock('./calculator')
+
+test('the automock applies through request interception', () => {
+ vi.mocked(calculator).mockReturnValue(4)
+ expect(calculator('plus', 1, 2)).toBe(4)
+})
diff --git a/test/browser/fixtures/mocking-import-map-fallback/calculator.ts b/test/browser/fixtures/mocking-import-map-fallback/calculator.ts
new file mode 100644
index 000000000..fbe168ce4
--- /dev/null
+++ b/test/browser/fixtures/mocking-import-map-fallback/calculator.ts
@@ -0,0 +1,3 @@
+export function calculator(action: string, a: number, b: number) {
+ return action === 'plus' ? a + b : a - b
+}
diff --git a/test/browser/fixtures/mocking-import-map-fallback/factory.test.ts b/test/browser/fixtures/mocking-import-map-fallback/factory.test.ts
new file mode 100644
index 000000000..395ae1527
--- /dev/null
+++ b/test/browser/fixtures/mocking-import-map-fallback/factory.test.ts
@@ -0,0 +1,8 @@
+import { expect, test, vi } from 'vitest'
+import { calculator } from './calculator'
+
+vi.mock('./calculator', () => ({ calculator: () => 42 }))
+
+test('the factory applies through request interception', () => {
+ expect(calculator('plus', 1, 2)).toBe(42)
+})
diff --git a/test/browser/fixtures/mocking-import-map-fallback/vitest.config.ts b/test/browser/fixtures/mocking-import-map-fallback/vitest.config.ts
new file mode 100644
index 000000000..09ca75213
--- /dev/null
+++ b/test/browser/fixtures/mocking-import-map-fallback/vitest.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from 'node:url'
+import { playwright } from '@vitest/browser-playwright'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)),
+ test: {
+ browser: {
+ enabled: true,
+ headless: true,
+ // Firefox without the preference rejects an import map added after a module loaded
+ provider: playwright({
+ launchOptions: { firefoxUserPrefs: { 'dom.multiple_import_maps.enabled': false } },
+ }),
+ instances: [{ browser: 'firefox' }],
+ },
+ },
+})
diff --git a/test/browser/fixtures/mocking-unused-factory/browser-setup.ts b/test/browser/fixtures/mocking-unused-factory/browser-setup.ts
new file mode 100644
index 000000000..b77fcb752
--- /dev/null
+++ b/test/browser/fixtures/mocking-unused-factory/browser-setup.ts
@@ -0,0 +1,3 @@
+import { vi } from 'vitest'
+
+vi.mock('./guarded', () => ({ answer: () => 'mocked' }))
diff --git a/test/browser/fixtures/mocking-unused-factory/guarded.ts b/test/browser/fixtures/mocking-unused-factory/guarded.ts
new file mode 100644
index 000000000..2bb943e67
--- /dev/null
+++ b/test/browser/fixtures/mocking-unused-factory/guarded.ts
@@ -0,0 +1,3 @@
+export function answer() {
+ return 'original'
+}
diff --git a/test/browser/fixtures/mocking-unused-factory/no-import.test.ts b/test/browser/fixtures/mocking-unused-factory/no-import.test.ts
new file mode 100644
index 000000000..339e4c177
--- /dev/null
+++ b/test/browser/fixtures/mocking-unused-factory/no-import.test.ts
@@ -0,0 +1,5 @@
+import { expect, test } from 'vitest'
+
+test('no test imports the mocked module', () => {
+ expect(1 + 1).toBe(2)
+})
diff --git a/test/browser/fixtures/mocking-unused-factory/vitest.config.ts b/test/browser/fixtures/mocking-unused-factory/vitest.config.ts
new file mode 100644
index 000000000..ec67f59d7
--- /dev/null
+++ b/test/browser/fixtures/mocking-unused-factory/vitest.config.ts
@@ -0,0 +1,29 @@
+import { appendFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vitest/config'
+import { instances, provider } from '../../settings'
+
+const transformed = fileURLToPath(new URL('./node_modules/.vite/transformed.log', import.meta.url))
+
+export default defineConfig({
+ cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)),
+ plugins: [
+ {
+ name: 'record-transform',
+ transform(_code, id) {
+ if (id.includes('guarded')) {
+ appendFileSync(transformed, `${id}\n`)
+ }
+ },
+ },
+ ],
+ 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 6bcc34f41..2ce235ef6 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' && !server.config.browser.importMapMocks)('request interception is live once a mock is registered', async () => {
+test.runIf(server.config.name === 'chromium' && server.config.browser.importMapMocks === false)('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/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts
index 975b2add9..d6fb85297 100644
--- a/test/browser/specs/mocking.test.ts
+++ b/test/browser/specs/mocking.test.ts
@@ -1,6 +1,8 @@
+import { existsSync, rmSync } from 'node:fs'
+import { resolve } from 'pathe'
import { expect, onTestFailed, onTestFinished, test } from 'vitest'
import { editFile, runVitest } from '../../test-utils'
-import { instances } from '../settings'
+import { instances, provider } from '../settings'
// TODO: investigate `isolate: false` tests.
// Doesn't seem like we can run things in parallel if there are mocks
@@ -153,3 +155,49 @@ test('import map mocks report a module that was imported before it was mocked',
)
expect(result.exitCode).toBe(1)
})
+
+test.runIf(provider.name === 'playwright')(
+ 'mocks fall back to request interception when the browser rejects a late import map',
+ async () => {
+ const result = await runVitest({
+ root: 'fixtures/mocking-import-map-fallback',
+ })
+
+ onTestFailed(() => {
+ console.error(result.stdout)
+ console.error(result.stderr)
+ })
+
+ expect(result.stderr).toReportNoErrors()
+ expect(result.stdout).toReportPassedTest('automocked.test.ts', 'firefox')
+ expect(result.stdout).toReportPassedTest('factory.test.ts', 'firefox')
+ expect(result.exitCode).toBe(0)
+ },
+)
+
+test.each([false, undefined])(
+ 'a factory mock that no test imports does not transform the original - importMapMocks %s',
+ async (importMapMocks) => {
+ const transformed = resolve(
+ import.meta.dirname,
+ '../fixtures/mocking-unused-factory/node_modules/.vite/transformed.log',
+ )
+ rmSync(transformed, { force: true })
+
+ const result = await runVitest({
+ root: 'fixtures/mocking-unused-factory',
+ browser: { importMapMocks },
+ })
+
+ onTestFailed(() => {
+ console.error(result.stdout)
+ console.error(result.stderr)
+ })
+
+ expect(result.stderr).toReportNoErrors()
+ instances.forEach(({ browser }) => {
+ expect(result.stdout).toReportPassedTest('no-import.test.ts', browser)
+ })
+ expect(existsSync(transformed)).toBe(false)
+ },
+)