diff --git a/docs/config/browser/importmapmocks.md b/docs/config/browser/importmapmocks.md index 7498ec953..eb3e18927 100644 --- a/docs/config/browser/importmapmocks.md +++ b/docs/config/browser/importmapmocks.md @@ -13,7 +13,7 @@ Serve module mocks through an [import map](https://developer.mozilla.org/en-US/d 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. +An import map rule cannot be removed from the document. When [`isolate`](/config/isolate) is `false`, all test files run in the same document, and a rule from one file would send the imports of the next file to a mock that no longer exists, so Vitest also intercepts requests by default. Set the option to `true` to always use import maps, or to `false` to always intercept requests. diff --git a/docs/config/isolate.md b/docs/config/isolate.md index a92f055e3..42c8f6261 100644 --- a/docs/config/isolate.md +++ b/docs/config/isolate.md @@ -11,6 +11,10 @@ outline: deep Run tests in an isolated environment. This option has no effect on `vmThreads` and `vmForks` pools. +::: warning Browser Mode +In Browser Mode, a page runs its test files one after another in the same document when `isolate` is `false`. The browser keeps one instance per module URL for the lifetime of the document, so the files share every module they import, and a module that one file mocked stays mocked for the files that run after it in the same page. Vitest reports an error when a file mocks a module that the page already imported, and when a file imports a module that an earlier file mocked. See [Mocking with `isolate: false`](/guide/mocking/modules#mocking-with-isolate-false) for the patterns that work without isolation. +::: + Disabling this option might [improve performance](/guide/improving-performance) if your code doesn't rely on side effects (which is usually true for projects with `node` environment). ::: tip diff --git a/docs/guide/browser/index.md b/docs/guide/browser/index.md index ccc3061d7..c45c708e3 100644 --- a/docs/guide/browser/index.md +++ b/docs/guide/browser/index.md @@ -629,6 +629,10 @@ When using Vitest Browser, it's important to note that thread blocking dialogs l In such situations, Vitest provides default mocks with default returned values for these APIs. This ensures that if the user accidentally uses synchronous popup web APIs, the execution would not hang. However, it's still recommended for the user to mock these web APIs for a better experience. Read more in [Mocking](/guide/mocking). +### Module Mocking Without Isolation + +With [`isolate: false`](/config/isolate), the test files of a page run in one document, and the browser keeps one instance per module URL. A mock from one file stays in the page for the files that run after it, and a later file cannot mock a module that the page already imported. Vitest reports both cases as errors. See [Mocking with `isolate: false`](/guide/mocking/modules#mocking-with-isolate-false) for the patterns that work. + ### Spying on Module Exports Browser Mode uses the browser's native ESM support to serve modules. The module namespace object is sealed and can't be reconfigured, unlike in Node.js tests where Vitest can patch the Module Runner. This means you can't call `vi.spyOn` on an imported object: diff --git a/docs/guide/improving-performance.md b/docs/guide/improving-performance.md index b0708cd0d..89d00af38 100644 --- a/docs/guide/improving-performance.md +++ b/docs/guide/improving-performance.md @@ -78,6 +78,8 @@ export default defineConfig({ }) ``` +In Browser Mode, `isolate: false` runs the test files of a page in one document, which shares every imported module and every module mock between them. Files that mock a module differently, or need the original of a module that another file mocks, belong in the isolated project. See [Mocking with `isolate: false`](/guide/mocking/modules#mocking-with-isolate-false). + :::tip If you are using `vmThreads` pool, you cannot disable isolation. Use `threads` pool instead to improve your tests performance. ::: diff --git a/docs/guide/mocking/modules.md b/docs/guide/mocking/modules.md index f168f3aa7..38fbc2a4e 100644 --- a/docs/guide/mocking/modules.md +++ b/docs/guide/mocking/modules.md @@ -328,6 +328,8 @@ If module runner is [disabled](/config/experimental#experimental-vitemodulerunne 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. +The browser keeps one instance per module URL for the lifetime of the page, so a mock can only affect a module that the page did not import yet. If a module was imported before `vi.mock` registered it, for example by a setup file, Vitest fails the test file instead of using the original module. The same applies to test files that share a page when [`isolate`](/config/isolate) is `false`, see [Mocking with `isolate: false`](#mocking-with-isolate-false). + For example, if the module is automocked, Vitest can parse static exports and create a placeholder module: ::: code-group @@ -420,3 +422,26 @@ export function foobar(injectedFoo) { ``` This is the intended behavior, and we do not plan to implement a workaround. Consider refactoring your code into multiple files or use techniques such as [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection). We believe that making the application testable is not the responsibility of the test runner, but of the application architecture. + +### Mocking with `isolate: false` {#mocking-with-isolate-false} + +`vi.mock` changes how a module is evaluated. It does not change a module that is already evaluated. With [`isolate: false`](/config/isolate), the test files that run in the same worker share the module cache, so a module that one file evaluated keeps its imports for the files that run after it: + +- A file that imports a module which an earlier file evaluated gets that module as it was evaluated, with or without the mocks of the earlier file. +- `vi.mock` in a later file affects only the modules that are evaluated for the first time in that file. + +In Node.js, Vitest stores a mocked module and the original module under different keys. A test file that imports the module directly always gets the version it asked for, mocked or original. + +In Browser Mode, the page stores one instance per module URL. The first file that imports a module decides which version the page keeps, and every later file in the same page gets that instance. `vi.unmock` cannot restore a module that the page already imported. + +Vitest fails the test file instead of returning the wrong module in two cases: + +- The file calls `vi.mock` for a module that the page already imported. +- The file, or a module that is evaluated for the first time in it, imports a module that an earlier file mocked. + +These patterns work in the same page: + +- The same `vi.mock` call in a [setup file](/config/setupfiles) or in a module that every test file imports. Every file registers the same mock, and the page keeps the first instance. Use `{ spy: true }` with [`restoreMocks`](/config/restoremocks) when tests need different behavior from the same module. +- A module that one test file mocks and no other file in the page imports. + +If some files need the original module and other files need a mock, put them in different [projects](/guide/projects) and disable isolation only for the project that does not mock, as shown in [Improving Performance](/guide/improving-performance#test-isolation). diff --git a/packages/browser/src/client/tester/mocker-interceptor.ts b/packages/browser/src/client/tester/mocker-interceptor.ts index b02da66c2..50ccc96db 100644 --- a/packages/browser/src/client/tester/mocker-interceptor.ts +++ b/packages/browser/src/client/tester/mocker-interceptor.ts @@ -1,21 +1,79 @@ +import type { MockedModule } from '@vitest/mocker' import type { ModuleMockerInterceptor } from '@vitest/mocker/browser' import type { MockImportMap } from '../../types' import type { BrowserRPC } from '../client' -import { getBrowserState, getWorkerState } from '../utils' +import { relative } from 'pathe' +import { getBrowserState, getConfig, getWorkerState } from '../utils' + +interface PageMock { + importer: string + type: MockedModule['type'] + // the test file that was running when the page fetched the mock + loadedBy?: string +} + +// the page keeps one instance per module URL, so test files that share a page +// also share every module and mock that the page already evaluated +const pageMocks = new Map() +// module URL -> the test file that was running when the page fetched it +const pageLoads = new Map() +// a mock that the page fetches from another URL, like an import map target +const mockTargets = new Map() +const fileMocks = new Set() +const fileUnmocks = new Set() +let currentFile: string | undefined + +const loads = new PerformanceObserver((list) => recordLoads(list.getEntries())) +loads.observe({ type: 'resource', buffered: true }) + +export function onTestFileStart(file: string): void { + flushLoads() + // a mock that the page never fetched is gone with the registry of its file + for (const [url, mock] of pageMocks) { + if (mock.loadedBy === undefined) { + pageMocks.delete(url) + } + } + fileMocks.clear() + fileUnmocks.clear() + currentFile = file +} export function createModuleMockerInterceptor(): ModuleMockerInterceptor { return { async register(module, importer) { + flushLoads() + const url = normalizeModuleUrl(module.url) + if (url) { + assertCanMock(url, module, importer) + fileMocks.add(url) + fileUnmocks.delete(url) + } const { sessionId } = getBrowserState() const importMap = await rpc().registerMock(sessionId, module.toJSON(), { importer, testFile: getWorkerState().filepath, }) if (importMap) { + for (const [specifier, target] of Object.entries(importMap.imports)) { + const moduleUrl = normalizeModuleUrl(specifier) + const targetUrl = normalizeModuleUrl(target) + if (moduleUrl && targetUrl && targetUrl !== moduleUrl) { + mockTargets.set(targetUrl, moduleUrl) + } + } appendImportMap(importMap) } }, async delete(id) { + const url = normalizeModuleUrl(id) + if (url) { + fileMocks.delete(url) + fileUnmocks.add(url) + if (pageMocks.get(url)?.loadedBy === undefined) { + pageMocks.delete(url) + } + } const state = getBrowserState() await rpc().unregisterMock(state.sessionId, id) }, @@ -26,6 +84,143 @@ export function createModuleMockerInterceptor(): ModuleMockerInterceptor { } } +// a file that shares the page with earlier files gets their mocks from the +// modules that it evaluates, which Node.js resolves against its own mocks instead +export async function assertNoMockConflicts(importpath: string): Promise { + flushLoads() + const mocked: string[] = [] + for (const [url, mock] of pageMocks) { + if (mock.loadedBy !== undefined && !fileMocks.has(url)) { + mocked.push(url) + } + } + if (!mocked.length) { + return + } + const imports = await rpc().getMockedImports(importpath, mocked) + const entry = normalizeModuleUrl(importpath) + for (const { importer, specifier } of imports) { + const url = normalizeModuleUrl(specifier) + const mock = url && pageMocks.get(url) + if (!url || !mock || mock.loadedBy === undefined || fileMocks.has(url)) { + continue + } + const importerUrl = normalizeModuleUrl(importer) + // a module that an earlier file evaluated keeps the mock in Node.js as well + if (!importerUrl || (importerUrl !== entry && pageLoads.get(importerUrl) !== currentFile)) { + continue + } + const reason = `"${displayPath(importerUrl)}" imports "${url}", which ${describeMockLoad(mock, '')}.` + throw new Error( + `${ + fileUnmocks.has(url) ? `vi.unmock("${url}") has no effect: ${reason}` : reason + } The mocked module stays in the page when "isolate" is false. ` + + `Mock "${url}" in this file too, or run the files that need the original in an isolated project.`, + ) + } +} + +function assertCanMock(url: string, module: MockedModule, importer: string) { + const mock = pageMocks.get(url) + if (mock?.loadedBy !== undefined) { + // the same mock again, usually from a setup file: the page keeps the first instance + if (mock.importer === importer && mock.type === module.type) { + return + } + throw new Error( + `Cannot mock "${url}": ${describeMockLoad(mock, ' it')}. ${sharedPageHint(url)}`, + ) + } + const file = pageLoads.get(url) + if (file === undefined) { + pageMocks.set(url, { importer, type: module.type }) + return + } + if (file === currentFile) { + throw new Error( + `Cannot mock "${url}": the page already imported it before vi.mock was registered, ` + + `for example by a setup file or by an earlier dynamic import. ` + + `A module cannot be evaluated again in the browser.`, + ) + } + throw new Error( + `Cannot mock "${url}": the page already imported it while running "${displayPath(file)}". ${sharedPageHint(url)}`, + ) +} + +function describeMockLoad(mock: PageMock, object: '' | ' it') { + const file = displayPath(mock.loadedBy!) + const importer = displayPath(mock.importer) + return importer === file + ? `the page already imported${object} as a mock while running "${file}"` + : `the page already imported${object} as the mock from "${importer}" while running "${file}"` +} + +function sharedPageHint(url: string) { + return ( + `Test files share the page when "isolate" is false, and a module cannot be evaluated again. ` + + `Mock "${url}" the same way in a setup file, or run the files that mock it in an isolated project.` + ) +} + +function flushLoads() { + recordLoads(loads.takeRecords()) +} + +function recordLoads(entries: PerformanceEntryList) { + for (const entry of entries) { + const url = normalizeModuleUrl(entry.name) + if (!url) { + continue + } + const moduleUrl = mockTargets.get(url) ?? url + if (pageLoads.has(moduleUrl)) { + continue + } + const file = currentFile ?? '' + pageLoads.set(moduleUrl, file) + const mock = pageMocks.get(moduleUrl) + if (mock) { + mock.loadedBy = file + } + } +} + +// the mocker, the import map and the server identify a module by its URL +// without the dependency version, the HMR timestamp, the test run and the Vite base +function normalizeModuleUrl(raw: string): string | undefined { + let url: URL + try { + url = new URL(raw, location.href) + } catch { + return + } + if (url.origin !== location.origin || url.searchParams.has('_vitest_original')) { + return + } + for (const query of ['v', 't', 'import', 'browserv']) { + url.searchParams.delete(query) + } + let pathname = url.pathname + const base = getBrowserState().viteConfig.base + if (base && base !== '/' && pathname.startsWith(base)) { + pathname = pathname.slice(base.length - 1) + } + return pathname + url.search +} + +function displayPath(path: string) { + const root = getConfig().root + path = path.replace(/[?&]browserv=\w+/, '') + if (path.startsWith('/@fs/')) { + path = path.slice(/^\/@fs\/[a-zA-Z]:\//.test(path) ? 5 : 4) + } + if (path.startsWith(`${root}/`)) { + return relative(root, path) + } + return path.replace(/^\//, '') +} + // 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) { diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index fef76125f..b20260264 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -30,6 +30,7 @@ import { import { createStackString, parseStacktrace } from '../../../../utils/src/source-map' import { getTestName } from '../../../../vitest/src/utils/tasks' import { getBrowserState, getOrchestratorState, getWorkerState, moduleRunner, now } from '../utils' +import { assertNoMockConflicts } from './mocker-interceptor' import { rpc } from './rpc' import { VitestBrowserSnapshotEnvironment } from './snapshot' import { recordBrowserTraceEntry } from './trace' @@ -350,6 +351,9 @@ function createBrowserRunner( } catch (err) { throw new Error(`Failed to import test file ${filepath}`, { cause: err }) } + if (!this.config.isolate) { + await assertNoMockConflicts(importpath) + } if (mode === 'collect' && !this.sourceMapPrefetches.has(filepath)) { // the file is transformed now, so the server can hand out its map; diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index f24ac7da0..35c309ca5 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -17,7 +17,7 @@ import { getBrowserState, getConfig, getWorkerState, moduleRunner } from '../uti import { setupDialogsSpy } from './dialog' import { setupConsoleLogSpy } from './logger' import { VitestBrowserClientMocker } from './mocker' -import { createModuleMockerInterceptor } from './mocker-interceptor' +import { createModuleMockerInterceptor, onTestFileStart } from './mocker-interceptor' import { createSafeRpc } from './rpc' import { browserHashMap, initiateRunner } from './runner' import { CommandsManager } from './tester-utils' @@ -236,6 +236,7 @@ async function executeTests(method: 'run' | 'collect', specifications: FileSpeci for (const file of specifications) { state.filepath = file.filepath + onTestFileStart(file.filepath) debug?.('running test file', file.filepath) await traces.$( diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index e0942180a..d77cf1d5e 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -98,6 +98,7 @@ export interface BrowserRunnerState { runner: VitestTestRunner viteConfig: { root: string + base?: string } providedContext: string type: 'tester' | 'orchestrator' diff --git a/packages/browser/src/node/mocks.ts b/packages/browser/src/node/mocks.ts index cf794172c..82892e930 100644 --- a/packages/browser/src/node/mocks.ts +++ b/packages/browser/src/node/mocks.ts @@ -21,7 +21,13 @@ export interface FactoryExportResolver { type MockHandlers = Pick< WebSocketBrowserHandlers, - 'resolveId' | 'resolveMock' | 'invalidate' | 'registerMock' | 'unregisterMock' | 'clearMocks' + | 'resolveId' + | 'resolveMock' + | 'invalidate' + | 'registerMock' + | 'unregisterMock' + | 'clearMocks' + | 'getMockedImports' > export function createMockHandlers( @@ -159,7 +165,38 @@ export function createMockHandlers( } return mocker.delete(sessionId, id) }, + async getMockedImports(url, mocked) { + const entry = await vite.environments.client.moduleGraph.getModuleByUrl(url) + if (!entry) { + return [] + } + const mockedUrls = new Set(mocked) + const imports: { importer: string; specifier: string }[] = [] + const queue = [entry] + const visited = new Set(queue) + for (let index = 0; index < queue.length; index++) { + const node = queue[index] + for (const imported of node.importedModules) { + if (mockedUrls.has(cleanModuleUrl(imported.url))) { + imports.push({ importer: node.url, specifier: imported.url }) + } + if (!visited.has(imported)) { + visited.add(imported) + queue.push(imported) + } + } + } + return imports + }, + } +} + +function cleanModuleUrl(url: string): string { + const parsed = new URL(url, 'http://localhost') + for (const query of ['v', 't', 'import']) { + parsed.searchParams.delete(query) } + return parsed.pathname + parsed.search } function injectQuery(url: string, query: string): string { diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index 25c88cd26..3f3b6d664 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -51,6 +51,7 @@ export async function resolveTester( __VITEST_CONFIG__: JSON.stringify(browserProject.wrapSerializedConfig()), __VITEST_VITE_CONFIG__: JSON.stringify({ root: browserProject.vite.config.root, + base: browserProject.vite.config.base, }), __VITEST_TYPE__: '"tester"', __VITEST_METHOD__: JSON.stringify('none'), diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 14d2cae0d..e5fa2a30c 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -86,6 +86,11 @@ export interface WebSocketBrowserHandlers { ) => Promise unregisterMock: (sessionId: string, id: string) => void clearMocks: (sessionId: string) => void + /** the imports of the module graph of `url` that point at one of the `mocked` URLs */ + getMockedImports: ( + url: string, + mocked: string[], + ) => Promise<{ importer: string; specifier: string }[]> // cdp sendCdpEvent: (sessionId: string, event: string, payload?: Record) => unknown diff --git a/test/browser/fixtures/mocking-non-isolated/late/1-original.test.ts b/test/browser/fixtures/mocking-non-isolated/late/1-original.test.ts new file mode 100644 index 000000000..8a548a97c --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/late/1-original.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { late } from '../src/late' + +test('the original module is imported first', () => { + expect(late()).toBe('original') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/late/2-mock.test.ts b/test/browser/fixtures/mocking-non-isolated/late/2-mock.test.ts new file mode 100644 index 000000000..ec39e967c --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/late/2-mock.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { late } from '../src/late' + +vi.mock('../src/late', () => ({ late: () => 'mocked' })) + +test('a mock of a module that the page already imported', () => { + expect(late()).toBe('mocked') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/leak/1-mock.test.ts b/test/browser/fixtures/mocking-non-isolated/leak/1-mock.test.ts new file mode 100644 index 000000000..c8a730b98 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/leak/1-mock.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { leak } from '../src/leak' + +vi.mock('../src/leak', () => ({ leak: () => 'mocked' })) + +test('the mock is imported first', () => { + expect(leak()).toBe('mocked') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/leak/2-original.test.ts b/test/browser/fixtures/mocking-non-isolated/leak/2-original.test.ts new file mode 100644 index 000000000..8479e05db --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/leak/2-original.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { leak } from '../src/leak' + +test('the original is imported after the mock', () => { + expect(leak()).toBe('original') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/leak/3-consumer.test.ts b/test/browser/fixtures/mocking-non-isolated/leak/3-consumer.test.ts new file mode 100644 index 000000000..415eb9481 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/leak/3-consumer.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { consumer } from '../src/consumer' + +test('a new module links to the mock of an earlier file', () => { + expect(consumer()).toBe('original') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/own/1-mock.test.ts b/test/browser/fixtures/mocking-non-isolated/own/1-mock.test.ts new file mode 100644 index 000000000..f264af139 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/own/1-mock.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { own } from '../src/own' + +vi.mock('../src/own', () => ({ own: () => 'mocked' })) + +test('a module that no other file imports', () => { + expect(own()).toBe('mocked') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/refactory/1-a.test.ts b/test/browser/fixtures/mocking-non-isolated/refactory/1-a.test.ts new file mode 100644 index 000000000..3c8d742ea --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/refactory/1-a.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { refactory } from '../src/refactory' + +vi.mock('../src/refactory', () => ({ refactory: () => 'a' })) + +test('the first factory', () => { + expect(refactory()).toBe('a') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/refactory/2-b.test.ts b/test/browser/fixtures/mocking-non-isolated/refactory/2-b.test.ts new file mode 100644 index 000000000..624b2e276 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/refactory/2-b.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { refactory } from '../src/refactory' + +vi.mock('../src/refactory', () => ({ refactory: () => 'b' })) + +test('a second factory for the same module', () => { + expect(refactory()).toBe('b') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/setup.ts b/test/browser/fixtures/mocking-non-isolated/setup.ts new file mode 100644 index 000000000..a9d7d551f --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/setup.ts @@ -0,0 +1,3 @@ +import { vi } from 'vitest' + +vi.mock('./src/shared', () => ({ shared: () => 'setup mock' })) diff --git a/test/browser/fixtures/mocking-non-isolated/shared/1-setup-mock.test.ts b/test/browser/fixtures/mocking-non-isolated/shared/1-setup-mock.test.ts new file mode 100644 index 000000000..fa630bfaf --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/shared/1-setup-mock.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { shared } from '../src/shared' + +test('the mock from the setup file', () => { + expect(shared()).toBe('setup mock') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/shared/2-setup-mock.test.ts b/test/browser/fixtures/mocking-non-isolated/shared/2-setup-mock.test.ts new file mode 100644 index 000000000..fa630bfaf --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/shared/2-setup-mock.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { shared } from '../src/shared' + +test('the mock from the setup file', () => { + expect(shared()).toBe('setup mock') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/src/consumer.ts b/test/browser/fixtures/mocking-non-isolated/src/consumer.ts new file mode 100644 index 000000000..95e1e3642 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/consumer.ts @@ -0,0 +1,5 @@ +import { leak } from './leak' + +export function consumer() { + return leak() +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/late.ts b/test/browser/fixtures/mocking-non-isolated/src/late.ts new file mode 100644 index 000000000..65db98e98 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/late.ts @@ -0,0 +1,3 @@ +export function late() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/leak.ts b/test/browser/fixtures/mocking-non-isolated/src/leak.ts new file mode 100644 index 000000000..6aefbe52c --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/leak.ts @@ -0,0 +1,3 @@ +export function leak() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/own.ts b/test/browser/fixtures/mocking-non-isolated/src/own.ts new file mode 100644 index 000000000..ff8290f14 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/own.ts @@ -0,0 +1,3 @@ +export function own() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/refactory.ts b/test/browser/fixtures/mocking-non-isolated/src/refactory.ts new file mode 100644 index 000000000..289473bf7 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/refactory.ts @@ -0,0 +1,3 @@ +export function refactory() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/shared.ts b/test/browser/fixtures/mocking-non-isolated/src/shared.ts new file mode 100644 index 000000000..21ab73b8a --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/shared.ts @@ -0,0 +1,3 @@ +export function shared() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/src/unloaded.ts b/test/browser/fixtures/mocking-non-isolated/src/unloaded.ts new file mode 100644 index 000000000..a56de25a7 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/src/unloaded.ts @@ -0,0 +1,3 @@ +export function unloaded() { + return 'original' +} diff --git a/test/browser/fixtures/mocking-non-isolated/unloaded/1-mock-not-loaded.test.ts b/test/browser/fixtures/mocking-non-isolated/unloaded/1-mock-not-loaded.test.ts new file mode 100644 index 000000000..4741ee2ac --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/unloaded/1-mock-not-loaded.test.ts @@ -0,0 +1,7 @@ +import { expect, test, vi } from 'vitest' + +vi.mock('../src/unloaded', () => ({ unloaded: () => 'mocked' })) + +test('the mocked module is never imported in this file', () => { + expect(document.body).toBeDefined() +}) diff --git a/test/browser/fixtures/mocking-non-isolated/unloaded/2-original.test.ts b/test/browser/fixtures/mocking-non-isolated/unloaded/2-original.test.ts new file mode 100644 index 000000000..0e83c5603 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/unloaded/2-original.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { unloaded } from '../src/unloaded' + +test('a mock that no file imported does not apply', () => { + expect(unloaded()).toBe('original') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/unmock/1-unmock.test.ts b/test/browser/fixtures/mocking-non-isolated/unmock/1-unmock.test.ts new file mode 100644 index 000000000..90f1401ca --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/unmock/1-unmock.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { shared } from '../src/shared' + +vi.unmock('../src/shared') + +test('vi.unmock of a mock that the page already imported', () => { + expect(shared()).toBe('original') +}) diff --git a/test/browser/fixtures/mocking-non-isolated/vitest.config.ts b/test/browser/fixtures/mocking-non-isolated/vitest.config.ts new file mode 100644 index 000000000..d2ac110c9 --- /dev/null +++ b/test/browser/fixtures/mocking-non-isolated/vitest.config.ts @@ -0,0 +1,29 @@ +import type { TestSpecification } from 'vitest/node' +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: { + isolate: false, + fileParallelism: false, + setupFiles: ['./setup.ts'], + sequence: { + sequencer: class { + async shard(files: TestSpecification[]) { + return files + } + async sort(files: TestSpecification[]) { + return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + } + }, + }, + browser: { + enabled: true, + provider, + instances, + headless: true, + }, + }, +}) diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts index cb4d25012..dd5ce6161 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -6,8 +6,6 @@ import { editFile, runVitest } from '../../test-utils' 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([ { isolate: true, importMapMocks: false }, { isolate: true, importMapMocks: true }, @@ -142,22 +140,89 @@ test.each([false, true])( }, ) -test('import map mocks report a module that was imported before it was mocked', async () => { +test.each([false, true])( + 'a mock of a module that a setup file already imported fails - importMapMocks %s', + async (importMapMocks) => { + const result = await runVitest({ + root: 'fixtures/mocking-import-map-conflict', + browser: { importMapMocks }, + }) + + const trees = moduleResults(result) + expect(Object.keys(trees).sort()).toEqual(instances.map(({ browser }) => browser).sort()) + for (const tree of Object.values(trees)) { + expect(tree).toMatchInlineSnapshot(` + { + "already-imported.test.ts": [ + "Cannot mock "/source.ts": the page already imported it before vi.mock was registered, for example by a setup file or by an earlier dynamic import. A module cannot be evaluated again in the browser.", + ], + } + `) + } + expect(result.exitCode).toBe(1) + }, +) + +test('files that share a page report the mocks that leak between them', async () => { const result = await runVitest({ - root: 'fixtures/mocking-import-map-conflict', - browser: { importMapMocks: true }, + root: 'fixtures/mocking-non-isolated', }) - instances.forEach(({ browser }) => { - expect(result.stdout).not.toReportPassedTest('already-imported.test.ts', browser) + onTestFailed(() => { + console.error(result.stdout) + console.error(result.stderr) }) - expect(result.stderr).toContain( - 'Cannot mock "/source.ts" with an import map because the browser kept its original URL.', - ) + const trees = moduleResults(result) + expect(Object.keys(trees).sort()).toEqual(instances.map(({ browser }) => browser).sort()) + for (const tree of Object.values(trees)) { + expect(tree).toMatchInlineSnapshot(` + { + "late/1-original.test.ts": "passed", + "late/2-mock.test.ts": [ + "Cannot mock "/src/late.ts": the page already imported it while running "late/1-original.test.ts". Test files share the page when "isolate" is false, and a module cannot be evaluated again. Mock "/src/late.ts" the same way in a setup file, or run the files that mock it in an isolated project.", + ], + "leak/1-mock.test.ts": "passed", + "leak/2-original.test.ts": [ + ""leak/2-original.test.ts" imports "/src/leak.ts", which the page already imported as a mock while running "leak/1-mock.test.ts". The mocked module stays in the page when "isolate" is false. Mock "/src/leak.ts" in this file too, or run the files that need the original in an isolated project.", + ], + "leak/3-consumer.test.ts": [ + ""src/consumer.ts" imports "/src/leak.ts", which the page already imported as a mock while running "leak/1-mock.test.ts". The mocked module stays in the page when "isolate" is false. Mock "/src/leak.ts" in this file too, or run the files that need the original in an isolated project.", + ], + "own/1-mock.test.ts": "passed", + "refactory/1-a.test.ts": "passed", + "refactory/2-b.test.ts": [ + "Cannot mock "/src/refactory.ts": the page already imported it as a mock while running "refactory/1-a.test.ts". Test files share the page when "isolate" is false, and a module cannot be evaluated again. Mock "/src/refactory.ts" the same way in a setup file, or run the files that mock it in an isolated project.", + ], + "shared/1-setup-mock.test.ts": "passed", + "shared/2-setup-mock.test.ts": "passed", + "unloaded/1-mock-not-loaded.test.ts": "passed", + "unloaded/2-original.test.ts": "passed", + "unmock/1-unmock.test.ts": [ + "vi.unmock("/src/shared.ts") has no effect: "unmock/1-unmock.test.ts" imports "/src/shared.ts", which the page already imported as the mock from "setup.ts" while running "shared/1-setup-mock.test.ts". The mocked module stays in the page when "isolate" is false. Mock "/src/shared.ts" in this file too, or run the files that need the original in an isolated project.", + ], + } + `) + } expect(result.exitCode).toBe(1) }) +// a file that fails to import reports the cause of the failure +function moduleResults({ results }: Awaited>) { + const trees: Record> = {} + for (const module of results) { + const tree = (trees[module.project.name] ??= {}) + tree[module.relativeModuleId] = module.ok() + ? 'passed' + : module.errors().map(({ cause, message }) => { + return typeof cause === 'object' && cause && 'message' in cause + ? String(cause.message) + : message + }) + } + return trees +} + test.runIf(provider.name === 'playwright')( 'mocks fall back to request interception when the browser rejects a late import map', async () => {