From 707f8adf4bdaffd508e3023e8b44ba1ceafa737f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 19 Oct 2026 10:41:41 +0200 Subject: [PATCH] feat(browser): mock modules through an import map (#11534) --- AGENTS.md | 1 + docs/.vitepress/config.ts | 4 + docs/config/browser/importmapmocks.md | 32 +++ docs/guide/cli-generated.md | 7 + docs/guide/mocking/modules.md | 2 +- packages/browser-playwright/src/playwright.ts | 8 + packages/browser/src/client/orchestrator.ts | 4 +- .../src/client/public/esm-client-injector.js | 6 +- .../src/client/tester/mocker-interceptor.ts | 32 ++- packages/browser/src/client/utils.ts | 16 ++ packages/browser/src/node/index.ts | 23 +- packages/browser/src/node/mocks.ts | 222 ++++++++++++++++++ packages/browser/src/node/rpc.ts | 100 ++------ packages/browser/src/node/state.ts | 14 +- packages/browser/src/types.ts | 22 +- packages/mocker/src/browser/interceptor.ts | 2 +- packages/mocker/src/browser/mocker.ts | 9 +- packages/mocker/src/node/index.ts | 7 + packages/mocker/src/node/manualMockPlugin.ts | 120 ++++++++++ packages/mocker/src/utils.ts | 3 +- packages/vitest/src/node/cli/cli-config.ts | 4 + .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/pools/browser.ts | 7 +- packages/vitest/src/node/types/browser.ts | 14 ++ 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 ++ .../automocked.test.ts | 9 + .../mocking-import-map-fallback/calculator.ts | 3 + .../factory.test.ts | 8 + .../vitest.config.ts | 18 ++ .../fixtures/mocking-unmock/mocked.test.ts | 8 + .../mocking-unmock/unmock-do-mock.test.ts | 10 + .../mocking-unused-factory/browser-setup.ts | 3 + .../mocking-unused-factory/guarded.ts | 3 + .../mocking-unused-factory/no-import.test.ts | 5 + .../mocking-unused-factory/vitest.config.ts | 29 +++ .../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 | 191 +++++++++++---- .../test/mock-importActual.test.ts | 6 +- 47 files changed, 856 insertions(+), 155 deletions(-) create mode 100644 docs/config/browser/importmapmocks.md create mode 100644 packages/browser/src/node/mocks.ts 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-import-map-fallback/automocked.test.ts create mode 100644 test/browser/fixtures/mocking-import-map-fallback/calculator.ts create mode 100644 test/browser/fixtures/mocking-import-map-fallback/factory.test.ts create mode 100644 test/browser/fixtures/mocking-import-map-fallback/vitest.config.ts create mode 100644 test/browser/fixtures/mocking-unmock/mocked.test.ts create mode 100644 test/browser/fixtures/mocking-unmock/unmock-do-mock.test.ts create mode 100644 test/browser/fixtures/mocking-unused-factory/browser-setup.ts create mode 100644 test/browser/fixtures/mocking-unused-factory/guarded.ts create mode 100644 test/browser/fixtures/mocking-unused-factory/no-import.test.ts create mode 100644 test/browser/fixtures/mocking-unused-factory/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/AGENTS.md b/AGENTS.md index 9cd0706f0..d3ee88b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,7 @@ Rules that `lint:fix` cannot fix: - Strict TypeScript configuration - Use `pnpm typecheck` to verify types +- 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 - Configuration files: `tsconfig.base.json`, `tsconfig.build.json`, `tsconfig.check.json` - 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 - Root typecheck does not cover the UI client Vue code; when changing `packages/ui/client`, also run `pnpm -C packages/ui typecheck:client` 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..7498ec953 --- /dev/null +++ b/docs/config/browser/importmapmocks.md @@ -0,0 +1,32 @@ +--- +title: browser.importMapMocks | Config +outline: deep +--- + +# browser.importMapMocks 5.1.0 + +- **Type:** `boolean` +- **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. If it does not, Vitest intercepts requests instead. + +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. + +Set the option to `true` to always use import maps, 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 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. + +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. + +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..ce05f4671 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. By default, enabled when the browser supports it + ### 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 baa1451d2..b821085f7 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -254,6 +254,14 @@ function resolveLaunchOptions( headless: browser.headless, } + if (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/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 5fec6bd41..fe0d36050 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/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..b02da66c2 100644 --- a/packages/browser/src/client/tester/mocker-interceptor.ts +++ b/packages/browser/src/client/tester/mocker-interceptor.ts @@ -1,12 +1,19 @@ import type { ModuleMockerInterceptor } from '@vitest/mocker/browser' +import type { MockImportMap } from '../../types' 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 register(module, importer) { + const { sessionId } = getBrowserState() + const importMap = await rpc().registerMock(sessionId, module.toJSON(), { + importer, + testFile: getWorkerState().filepath, + }) + if (importMap) { + appendImportMap(importMap) + } }, async delete(id) { const state = getBrowserState() @@ -19,6 +26,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. ` + + `The module was already imported before vi.mock was registered, ` + + `for example by a setup file or by an earlier test file when "isolate" is false.`, + ) + } + } +} + function rpc(): BrowserRPC { return getWorkerState().rpc as any as BrowserRPC } 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 692e7f194..e1cfc47ba 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 './mocks' 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) { @@ -257,6 +259,12 @@ 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.state.usesImportMaps(child.config)) + 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') } } @@ -369,13 +377,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/mocks.ts b/packages/browser/src/node/mocks.ts new file mode 100644 index 000000000..cf794172c --- /dev/null +++ b/packages/browser/src/node/mocks.ts @@ -0,0 +1,222 @@ +import type { MockedModuleSerialized, MockerRegistry } from '@vitest/mocker' +import type { StaticMockCall } from '@vitest/mocker/node' +import type { TransformResult, ViteDevServer } from 'vite' +import type { TestProject } from 'vitest/node' +import type { MockImportMap, WebSocketBrowserHandlers, WebSocketBrowserRPC } from '../types' +import type { BrowserServerState } from './state' +import { + AutomockedModule, + AutospiedModule, + ManualMockedModule, + RedirectedModule, +} from '@vitest/mocker' +import { getManualMockId, getManualMockUrl, ServerMockResolver } from '@vitest/mocker/node' +import { join } from 'pathe' +import { slash } from './utils' + +export interface FactoryExportResolver { + sessionId: string + resolve: () => Promise +} + +type MockHandlers = Pick< + WebSocketBrowserHandlers, + 'resolveId' | 'resolveMock' | 'invalidate' | 'registerMock' | 'unregisterMock' | 'clearMocks' +> + +export function createMockHandlers( + project: TestProject, + { + registry, + factoryExportResolvers, + checkFileAccess, + rpc, + }: { + registry: MockerRegistry + factoryExportResolvers: Map + checkFileAccess: (path: string) => void + // the tester connection is created after its handlers + rpc: () => WebSocketBrowserRPC + }, +): MockHandlers { + const vite = project.vite + const mockResolver = new ServerMockResolver(vite, { + moduleDirectories: project.config?.deps?.moduleDirectories, + }) + const mocker = project.browser?.provider?.mocker + + return { + resolveId(id, importer) { + return mockResolver.resolveId(id, importer) + }, + resolveMock(rawId, importer, options) { + return mockResolver.resolveMock(rawId, importer, options) + }, + invalidate(ids) { + return mockResolver.invalidate(ids) + }, + async registerMock( + sessionId, + module, + { importer, testFile }, + ): Promise { + const option = project.config.browser.importMapMocks + const state = project.browser!.state as BrowserServerState + if (option === true && state.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 (state.usesImportMaps(project.config)) { + // a rule cannot be removed later, so a mock from another module, like + // a setup file, is not added when the test file restores the module + if ( + testFile && + importer !== testFile && + (await getStaticUnmocks(vite, mockResolver, testFile)).has(module.id) + ) { + return + } + 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) { + // 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) { + project.vitest.state.catchError(err, 'Manual Mock Resolver Error') + return {} + } + }) + registry.add(mock) + } else { + if (module.type === 'redirect') { + const redirectUrl = new URL(module.redirect) + const redirect = join(vite.config.root, slash(decodeURI(redirectUrl.pathname))) + checkFileAccess(redirect) + module.redirect = redirect + } + registry.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) { + for (const [url, resolver] of factoryExportResolvers) { + if (resolver.sessionId === sessionId) { + factoryExportResolvers.delete(url) + } + } + if (!mocker) { + return registry.clear() + } + return mocker.clear(sessionId) + }, + unregisterMock(sessionId, id) { + if (!mocker) { + return registry.delete(id) + } + return mocker.delete(sessionId, id) + }, + } +} + +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: ViteDevServer, 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') { + return { imports: { [key]: base + getManualMockUrl(module.url, module.id) } } + } + return { imports: { [key]: injectQuery(key, `mock=${module.type}`) } } +} + +const staticUnmocks = new WeakMap>>() + +// resolved ids of the modules that a test file restores with a static `vi.unmock` call +async function getStaticUnmocks( + vite: ViteDevServer, + resolver: ServerMockResolver, + testFile: string, +): Promise> { + const environment = vite.environments.client + // the url the tester requests, so the transform is shared with the import + const url = /^\w:/.test(testFile) ? `/@fs/${testFile}` : testFile + // a test file that fails to transform reports the error when it is imported + const transformed = await environment.transformRequest(url).catch(() => null) + const id = (await environment.moduleGraph.getModuleByUrl(url))?.id + if (!transformed || !id) { + return new Set() + } + let unmocks = staticUnmocks.get(transformed) + if (!unmocks) { + const calls: StaticMockCall[] = + environment.pluginContainer.getModuleInfo(id)?.meta?.vitestStaticMocks ?? [] + unmocks = Promise.all( + calls + .filter((call) => call.method === 'unmock') + .map((call) => resolver.resolveId(call.specifier, id).catch(() => null)), + ).then((results) => new Set(results.flatMap((resolved) => (resolved ? [resolved.id] : [])))) + staticUnmocks.set(transformed, unmocks) + } + return unmocks +} diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index f5554a1d4..76e3ef186 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -9,22 +9,20 @@ import type { Vitest, } from 'vitest/node' import type { WebSocket } from 'ws' -import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' +import type { + WebSocketBrowserEvents, + WebSocketBrowserHandlers, + WebSocketBrowserRPC, +} from '../types' +import type { FactoryExportResolver } from './mocks' import type { ParentBrowserProject } from './projectParent' import type { BrowserServerState } from './state' import { existsSync, promises as fs } from 'node:fs' -import { - AutomockedModule, - AutospiedModule, - ManualMockedModule, - RedirectedModule, -} from '@vitest/mocker' -import { ServerMockResolver } from '@vitest/mocker/node' import { evaluateSnapshotFile } from '@vitest/snapshot/environment' import { extractSourcemapFromFile } from '@vitest/utils/source-map/node' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' -import { dirname, join, resolve } from 'pathe' +import { dirname, resolve } from 'pathe' import { BrowserConnectionError, createDebugger, @@ -33,6 +31,7 @@ import { } from 'vitest/node' import { WebSocketServer } from 'ws' import { replaceUndefined, reviveUndefined } from '../shared/serialization' +import { createMockHandlers } from './mocks' import { slash } from './utils' const debug = createDebugger('vitest:browser:api') @@ -64,6 +63,7 @@ function resolveHeartbeatInterval(vitest: Vitest): number { export function setupBrowserRpc( globalServer: ParentBrowserProject, defaultMockerRegistry: MockerRegistry, + factoryExportResolvers: Map, ): void { const vite = globalServer.vite const vitest = globalServer.vitest @@ -242,14 +242,11 @@ export function setupBrowserRpc( sessionId: string }, ) { - const mockResolver = new ServerMockResolver(globalServer.vite, { - moduleDirectories: project.config?.deps?.moduleDirectories, - }) - const mocker = project.browser?.provider?.mocker - - const rpc = createBirpc( + const rpc: WebSocketBrowserRPC = createBirpc( { - onOrchestratorReady() { + onOrchestratorReady(capabilities) { + ;(project.browser!.state as BrowserServerState).lateImportMaps = + capabilities.lateImportMaps const sessions = vitest._browserSessions sessions.getSession(options.sessionId)?.ready() }, @@ -415,9 +412,6 @@ export function setupBrowserRpc( cancelCurrentRun(reason) { vitest.cancelCurrentRun(reason) }, - async resolveId(id, importer) { - return mockResolver.resolveId(id, importer) - }, debug(...args) { vitest.logger.console.debug(...args) }, @@ -466,68 +460,12 @@ export function setupBrowserRpc( ) as any as BrowserCommandContext return await project.browser!.triggerCommand(command as any, context, ...payload) }, - resolveMock(rawId, importer, options) { - return mockResolver.resolveMock(rawId, importer, options) - }, - invalidate(ids) { - 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) - const redirect = join(vite.config.root, slash(decodeURI(redirectUrl.pathname))) - checkFileAccess(redirect) - module.redirect = redirect - } - 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) - }, + ...createMockHandlers(project, { + registry: defaultMockerRegistry, + factoryExportResolvers, + checkFileAccess, + rpc: () => rpc, + }), // CDP async sendCdpEvent(sessionId: string, event: string, payload?: Record) { diff --git a/packages/browser/src/node/state.ts b/packages/browser/src/node/state.ts index 9561c234d..41e681a9f 100644 --- a/packages/browser/src/node/state.ts +++ b/packages/browser/src/node/state.ts @@ -1,7 +1,19 @@ -import type { BrowserServerState as IBrowserServerState } from 'vitest/node' +import type { BrowserServerState as IBrowserServerState, TestProject } from 'vitest/node' 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 + + // a rule stays in the document until the run ends, and a non-isolated + // run shares one document between its files + public usesImportMaps(config: TestProject['config']): boolean { + const option = config.browser.importMapMocks + if (option === true) { + return true + } + return option !== false && this.lateImportMaps === true && config.isolate + } } diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 2e88fea94..14d2cae0d 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -20,6 +20,15 @@ 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 +} + export interface WebSocketBrowserHandlers { resolveSnapshotPath: (testPath: string) => string resolveSnapshotRawPath: (testPath: string, rawPath: string) => string @@ -39,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 @@ -65,7 +74,16 @@ 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, + context: { + /** the module that called `vi.mock` */ + importer: string + /** the test file that is running; its static `vi.unmock` calls cancel mocks from other modules */ + testFile?: string + }, + ) => Promise unregisterMock: (sessionId: string, id: string) => void clearMocks: (sessionId: string) => void diff --git a/packages/mocker/src/browser/interceptor.ts b/packages/mocker/src/browser/interceptor.ts index bbc9cfe15..379e6d175 100644 --- a/packages/mocker/src/browser/interceptor.ts +++ b/packages/mocker/src/browser/interceptor.ts @@ -1,7 +1,7 @@ import type { MockedModule } from '../registry' export interface ModuleMockerInterceptor { - register: (module: MockedModule) => Promise + register: (module: MockedModule, importer: string) => Promise delete: (url: string) => Promise invalidate: () => Promise } diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 1c94d59f3..6a4a8c808 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -225,7 +225,7 @@ export class ModuleMocker implements TestModuleMocker { module = this.registry.register('automock', rawId, resolvedId, mockUrl) } - await this.interceptor.register(module) + await this.interceptor.register(module, importer) }) .finally(() => { this.queue.delete(promise) @@ -255,12 +255,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..e3e4bf5f9 100644 --- a/packages/mocker/src/node/index.ts +++ b/packages/mocker/src/node/index.ts @@ -7,6 +7,13 @@ 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 { + 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 new file mode 100644 index 000000000..cbbd37841 --- /dev/null +++ b/packages/mocker/src/node/manualMockPlugin.ts @@ -0,0 +1,120 @@ +import type { DevEnvironment, Plugin, TransformResult } 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 + /** + * 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}` +} + +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) { + if (!id.startsWith(PREFIX) || this.environment.mode !== 'dev') { + return + } + 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, + options.globalThisAccessor, + 'resolveFactoryModule', + ) + }, + }, + } +} + +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, + moduleId: string, + seen: Set, +): Promise { + if (seen.has(moduleId)) { + return [] + } + seen.add(moduleId) + let result: TransformResult | null + try { + result = await environment.transformRequest(moduleId) + } catch (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 "${moduleId}" for its factory mock`) + } + await initModuleLexer + 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, unwrapId(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..3f76e15a8 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. By default, enabled when the browser supports it', + }, 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/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 318e679cc..7965652dd 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, trace: browser.trace.mode, traceView: browser.traceView, } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 177d151a0..75e65f61f 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -32,7 +32,8 @@ interface BrowserTask { files: FileSpecification[] method: 'run' | 'collect' // providers without a per-session mocker share a single mock registry - // 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 } @@ -234,7 +235,9 @@ export function createBrowserPool(vitest: Vitest): BrowserPool { project, files, method, - exclusive: !provider.mocker || !provider.supportsParallelism, + exclusive: + !provider.supportsParallelism || + (!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 607618381..3f013dce4 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -363,6 +363,20 @@ export interface BrowserConfigOptions { * @default true */ trackUnhandledErrors?: boolean + + /** + * Serve module mocks through an import map 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 (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. + * + * @experimental + */ + importMapMocks?: boolean } export interface BrowserCommandContext { diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 4ed880a37..73682a698 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 | undefined 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-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-unmock/mocked.test.ts b/test/browser/fixtures/mocking-unmock/mocked.test.ts new file mode 100644 index 000000000..a1d973830 --- /dev/null +++ b/test/browser/fixtures/mocking-unmock/mocked.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from 'vitest' +import { source as automocked } from './src/setup-automock' +import { source as factory } from './src/setup-factory' + +test('modules mocked in the setup file are mocked', () => { + expect(automocked()).toBeUndefined() + expect(factory()).toBe('factory') +}) diff --git a/test/browser/fixtures/mocking-unmock/unmock-do-mock.test.ts b/test/browser/fixtures/mocking-unmock/unmock-do-mock.test.ts new file mode 100644 index 000000000..eb045d1eb --- /dev/null +++ b/test/browser/fixtures/mocking-unmock/unmock-do-mock.test.ts @@ -0,0 +1,10 @@ +import { expect, test, vi } from 'vitest' + +vi.unmock('./src/setup-factory') + +test('vi.doMock applies after vi.unmock', async () => { + vi.doMock('./src/setup-factory', () => ({ source: () => 'do mock' })) + + const { source } = await import('./src/setup-factory') + expect(source()).toBe('do mock') +}) 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 29328c620..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')('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/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 dcbafbb6a..cb4d25012 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -1,43 +1,54 @@ +import { existsSync, rmSync } from 'node:fs' +import { resolve } from 'pathe' import { expect, onTestFailed, onTestFinished, test } from 'vitest' +import { defaultExclude } from 'vitest/config' import { editFile, runVitest } from '../../test-utils' -import { instances } from '../settings' +import { instances, provider } from '../settings' import { runInlineBrowserTests } from './utils' // 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, - }) +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) - }) + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) - expect(result.stderr).toReportNoErrors() + 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) - }) + 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) -}) + 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( @@ -107,29 +118,123 @@ 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 () => { - const result = await runVitest({ - root: 'fixtures/mocking-setup-file', - }) +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) - }) + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) - expect(result.stderr).toReportNoErrors() + 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-import-map-conflict', + browser: { importMapMocks: true }, + }) 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) }) +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) + }, +) + +test.each([undefined, true])( + 'vi.unmock in a test file removes the mocks of a setup file - importMapMocks %s', + async (importMapMocks) => { + const result = await runVitest({ + root: 'fixtures/mocking-unmock', + exclude: [...defaultExclude, '**/do-unmock.test.ts'], + browser: { importMapMocks }, + }) + + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) + }) + + expect(result.stderr).toReportNoErrors() + + instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('mocked.test.ts', browser) + expect(result.stdout).toReportPassedTest('unmock.test.ts', browser) + expect(result.stdout).toReportPassedTest('unmock-do-mock.test.ts', browser) + }) + + expect(result.exitCode).toBe(0) + }, +) + +// an import map rule cannot be removed, so vi.doUnmock needs request interception test('vi.unmock and vi.doUnmock restore modules mocked in a setup file', async () => { const result = await runVitest({ root: 'fixtures/mocking-unmock', + browser: { importMapMocks: false }, }) onTestFailed(() => { @@ -140,8 +245,10 @@ test('vi.unmock and vi.doUnmock restore modules mocked in a setup file', async ( expect(result.stderr).toReportNoErrors() instances.forEach(({ browser }) => { + expect(result.stdout).toReportPassedTest('mocked.test.ts', browser) expect(result.stdout).toReportPassedTest('unmock.test.ts', browser) expect(result.stdout).toReportPassedTest('do-unmock.test.ts', browser) + expect(result.stdout).toReportPassedTest('unmock-do-mock.test.ts', browser) }) expect(result.exitCode).toBe(0) diff --git a/test/coverage-test/test/mock-importActual.test.ts b/test/coverage-test/test/mock-importActual.test.ts index 925addade..ec0825420 100644 --- a/test/coverage-test/test/mock-importActual.test.ts +++ b/test/coverage-test/test/mock-importActual.test.ts @@ -1,5 +1,5 @@ import { expect } from 'vitest' -import { isBrowser, isNativeRunner, isV8Provider, readCoverageMap, runVitest, test } from '../utils' +import { isNativeRunner, readCoverageMap, runVitest, test } from '../utils' test('vi.importActual() collects coverage of original module', async () => { await runVitest({ @@ -12,8 +12,8 @@ test('vi.importActual() collects coverage of original module', async () => { const coverageMap = await readCoverageMap() - // v8-browser and native runner report 100% due to different coverage collection behavior - if ((isBrowser() && isV8Provider()) || isNativeRunner()) { + // the native runner reports 100% due to different coverage collection behavior + if (isNativeRunner()) { expect(coverageMap).toMatchInlineSnapshot(` { "branches": "0/0 (100%)", -- 2.54.0