Skip to content

Commit 707f8ad

Browse files
authored
feat(browser): mock modules through an import map (#11534)
1 parent a2ec416 commit 707f8ad

47 files changed

Lines changed: 856 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Rules that `lint:fix` cannot fix:
149149

150150
- Strict TypeScript configuration
151151
- Use `pnpm typecheck` to verify types
152+
- Never add an `as` type cast unless `pnpm typecheck` fails without it. Narrow with a type guard or a discriminant check instead; when a cast is the only option, verify it against the library's `.d.ts` first
152153
- Configuration files: `tsconfig.base.json`, `tsconfig.build.json`, `tsconfig.check.json`
153154
- Root `pnpm typecheck` excludes `test/e2e`, `test/browser`, `test/typescript`, `docs`, and `examples` (see `tsconfig.check.json`); type errors there will not surface from the root command
154155
- Root typecheck does not cover the UI client Vue code; when changing `packages/ui/client`, also run `pnpm -C packages/ui typecheck:client`

‎docs/.vitepress/config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,10 @@ export default ({ mode }: { mode: string }) => {
716716
text: 'browser.trackUnhandledErrors',
717717
link: '/config/browser/trackunhandlederrors',
718718
},
719+
{
720+
text: 'browser.importMapMocks',
721+
link: '/config/browser/importmapmocks',
722+
},
719723
{
720724
text: 'browser.expect',
721725
link: '/config/browser/expect',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: browser.importMapMocks | Config
3+
outline: deep
4+
---
5+
6+
# browser.importMapMocks <Version type="experimental">5.1.0</Version> <Experimental />
7+
8+
- **Type:** `boolean`
9+
- **Default:** enabled when the browser supports it
10+
- **CLI:** `--browser.importMapMocks`, `--browser.importMapMocks=false`
11+
12+
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.
13+
14+
By default, Vitest checks once per browser whether it accepts an import map after a module has loaded. If it does not, Vitest intercepts requests instead.
15+
16+
An import map rule cannot be removed from the document. When [`isolate`](/config/isolate) is `false`, all test files run in the same document, so Vitest also intercepts requests by default.
17+
18+
Set the option to `true` to always use import maps, or to `false` to always intercept requests.
19+
20+
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.
21+
22+
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.
23+
24+
::: warning
25+
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.
26+
27+
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.
28+
29+
An import map rule cannot be removed either. When a test file calls `vi.unmock` with a static path, the setup files do not add a rule for that module, so both the setup files and the test file load the original module. `vi.doUnmock` cannot restore the original module.
30+
31+
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`.
32+
:::

‎docs/guide/cli-generated.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,13 @@ Serve sourcemaps of dependencies to the browser in headless runs, used by devtoo
395395

396396
Control if Vitest catches uncaught exceptions so they can be reported (default: `true`)
397397

398+
### browser.importMapMocks
399+
400+
- **CLI:** `--browser.importMapMocks`
401+
- **Config:** [browser.importMapMocks](/config/browser/importmapmocks)
402+
403+
Serve module mocks through an import map instead of request interception. By default, enabled when the browser supports it
404+
398405
### browser.trace
399406

400407
- **CLI:** `--browser.trace <mode>`

‎docs/guide/mocking/modules.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ If module runner is [disabled](/config/experimental#experimental-vitemodulerunne
326326

327327
### Browser Mode
328328

329-
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.
329+
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.
330330

331331
For example, if the module is automocked, Vitest can parse static exports and create a placeholder module:
332332

‎packages/browser-playwright/src/playwright.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,14 @@ function resolveLaunchOptions(
254254
headless: browser.headless,
255255
}
256256

257+
if (browserName === 'firefox') {
258+
// multiple import maps per document are behind a pref in Firefox 150+
259+
launchOptions.firefoxUserPrefs = {
260+
'dom.multiple_import_maps.enabled': true,
261+
...launchOptions.firefoxUserPrefs,
262+
}
263+
}
264+
257265
if (typeof browser.trace === 'object' && browser.trace.tracesDir) {
258266
launchOptions.tracesDir = browser.trace.tracesDir
259267
}

‎packages/browser/src/client/orchestrator.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Traces } from 'vitest/internal/traces'
1515
// This needs to be tree shaken properly to not include the whole runner by accident
1616
import { generateFileHash } from '../../../vitest/src/utils/tasks.js'
1717
import { getUiAPI } from './ui'
18-
import { getBrowserState, getConfig } from './utils'
18+
import { getBrowserState, getConfig, supportsLateImportMaps } from './utils'
1919

2020
const ID_ALL = '__vitest_all__'
2121
// the next document gets this long to fire "load" before the unload counts as a broken iframe
@@ -55,7 +55,7 @@ export class IframeOrchestrator {
5555
// Notify the server once the websocket is ready without blocking orchestrator creation.
5656
void client
5757
.waitForConnection()
58-
.then(() => client.rpc.onOrchestratorReady())
58+
.then(() => client.rpc.onOrchestratorReady({ lateImportMaps: supportsLateImportMaps() }))
5959
.catch((error) => {
6060
debug('failed to notify orchestrator readiness', error)
6161
})

‎packages/browser/src/client/public/esm-client-injector.js‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@
1313
const viteModule = evaluatedModules.ensureModule(moduleId, moduleId)
1414

1515
viteModule.evaluated = false
16-
viteModule.promise = new Promise((resolve, reject) => {
17-
__vitest_mocker__.prepare().finally(() => {
18-
moduleCallback().then(resolve, reject)
19-
});
20-
});
16+
viteModule.promise = __vitest_mocker__.prepare().then(() => moduleCallback())
2117
return viteModule.promise.finally(() => {
2218
viteModule.evaluated = true
2319
viteModule.promise = undefined

‎packages/browser/src/client/tester/mocker-interceptor.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { ModuleMockerInterceptor } from '@vitest/mocker/browser'
2+
import type { MockImportMap } from '../../types'
23
import type { BrowserRPC } from '../client'
34
import { getBrowserState, getWorkerState } from '../utils'
45

56
export function createModuleMockerInterceptor(): ModuleMockerInterceptor {
67
return {
7-
async register(module) {
8-
const state = getBrowserState()
9-
await rpc().registerMock(state.sessionId, module.toJSON())
8+
async register(module, importer) {
9+
const { sessionId } = getBrowserState()
10+
const importMap = await rpc().registerMock(sessionId, module.toJSON(), {
11+
importer,
12+
testFile: getWorkerState().filepath,
13+
})
14+
if (importMap) {
15+
appendImportMap(importMap)
16+
}
1017
},
1118
async delete(id) {
1219
const state = getBrowserState()
@@ -19,6 +26,25 @@ export function createModuleMockerInterceptor(): ModuleMockerInterceptor {
1926
}
2027
}
2128

29+
// the browser drops a rule for a module that the document already resolved
30+
// and only prints a console warning, so check that every rule took effect
31+
function appendImportMap(importMap: MockImportMap) {
32+
const script = document.createElement('script')
33+
script.type = 'importmap'
34+
script.textContent = JSON.stringify(importMap)
35+
document.head.append(script)
36+
for (const [specifier, url] of Object.entries(importMap.imports)) {
37+
const expected = new URL(url, location.href).href
38+
if (import.meta.resolve(specifier) !== expected) {
39+
throw new Error(
40+
`Cannot mock "${specifier}" with an import map because the browser kept its original URL. ` +
41+
`The module was already imported before vi.mock was registered, ` +
42+
`for example by a setup file or by an earlier test file when "isolate" is false.`,
43+
)
44+
}
45+
}
46+
}
47+
2248
function rpc(): BrowserRPC {
2349
return getWorkerState().rpc as any as BrowserRPC
2450
}

‎packages/browser/src/client/utils.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ export const moduleRunner = {
2626
},
2727
}
2828

29+
// a bare specifier throws until a map defines it, so a map appended now
30+
// tells whether this document accepts an import map after a module has loaded
31+
export function supportsLateImportMaps(): boolean {
32+
const specifier = '__vitest_import_map_probe__'
33+
const script = document.createElement('script')
34+
script.type = 'importmap'
35+
script.textContent = JSON.stringify({ imports: { [specifier]: `/${specifier}` } })
36+
document.head.append(script)
37+
try {
38+
import.meta.resolve(specifier)
39+
return true
40+
} catch {
41+
return false
42+
}
43+
}
44+
2945
export const now: () => number = globalThis.performance
3046
? globalThis.performance.now.bind(globalThis.performance)
3147
: Date.now

0 commit comments

Comments
 (0)