diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 91ed5783166f..6d04d71c253f 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -49,6 +49,20 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { } next() }) + // strip _vitest_original query added by importActual so that + // the plugin pipeline sees the original import id (e.g. virtual modules's load hook). + server.middlewares.use((req, _res, next) => { + if ( + req.url?.includes('_vitest_original') + && parentServer.project.config.browser.provider?.name === 'playwright' + ) { + req.url = req.url + .replace(/[?&]_vitest_original(?=[&#]|$)/, '') + .replace(/[?&]ext\b[^&#]*/, '') + .replace(/\?$/, '') + } + next() + }) server.middlewares.use(createOrchestratorMiddleware(parentServer)) server.middlewares.use(createTesterMiddleware(parentServer)) diff --git a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index 056aceb85a50..18459332a0b8 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts @@ -7,6 +7,7 @@ import vm from 'node:vm' import { AutomockedModule, RedirectedModule } from '@vitest/mocker' import { distDir } from '../../paths' import { BareModuleMocker } from './bareModuleMocker' +import { injectQuery } from './utils' const spyModulePath = resolve(distDir, 'spy.js') @@ -130,7 +131,8 @@ export class VitestMocker extends BareModuleMocker { callstack?: string[] | null, ): Promise { const { url } = await this.resolveId(rawId, importer) - const node = await this.moduleRunner.fetchModule(url, importer) + const actualUrl = injectQuery(url, '_vitest_original') + const node = await this.moduleRunner.fetchModule(actualUrl, importer) const result = await this.moduleRunner.cachedRequest( node.url, node, diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 6a818d2eea04..44031b21df73 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -12,6 +12,7 @@ import { getCachedVitestImport } from './cachedResolver' import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' +import { removeQuery } from './utils' const { readFileSync } = fs @@ -95,6 +96,13 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi return vitest } + // strip _vitest_original query added by importActual so that + // the plugin pipeline sees the original import id (e.g. virtual modules's load hook) + const isImportActual = id.includes('_vitest_original') + if (isImportActual) { + id = removeQuery(id, '_vitest_original') + } + const rawId = unwrapId(id) resolvingModules.add(rawId) @@ -103,15 +111,17 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi await moduleRunner.mocker.resolveMocks() } - const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) - if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { - return { - code: '', - file: null, - id: resolvedMock.id, - url: resolvedMock.url, - invalidate: false, - mockedModule: resolvedMock, + if (!isImportActual) { + const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) + if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { + return { + code: '', + file: null, + id: resolvedMock.id, + url: resolvedMock.url, + invalidate: false, + mockedModule: resolvedMock, + } } } diff --git a/packages/vitest/src/runtime/moduleRunner/utils.ts b/packages/vitest/src/runtime/moduleRunner/utils.ts new file mode 100644 index 000000000000..83c58a4a1485 --- /dev/null +++ b/packages/vitest/src/runtime/moduleRunner/utils.ts @@ -0,0 +1,21 @@ +// copied from vite/src/shared/utils.ts +const postfixRE = /[?#].*$/ + +function cleanUrl(url: string): string { + return url.replace(postfixRE, '') +} +function splitFileAndPostfix(path: string): { file: string; postfix: string } { + const file = cleanUrl(path) + return { file, postfix: path.slice(file.length) } +} + +export function injectQuery(url: string, queryToInject: string): string { + const { file, postfix } = splitFileAndPostfix(url) + return `${file}?${queryToInject}${postfix[0] === '?' ? `&${postfix.slice(1)}` : /* hash only */ postfix}` +} + +export function removeQuery(url: string, queryToRemove: string): string { + return url + .replace(new RegExp(`[?&]${queryToRemove}(?=[&#]|$)`), '') + .replace(/\?$/, '') +} diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 8c0afda75423..0642ac9c462a 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -1,8 +1,16 @@ +import type { RunVitestConfig } from '../../test-utils' +import { setDefaultResultOrder } from 'node:dns' import path from 'node:path' -import { expect, test } from 'vitest' +import { playwright } from '@vitest/browser-playwright' +import { webdriverio } from '@vitest/browser-webdriverio' +import { afterAll, expect, test } from 'vitest' import { rolldownVersion } from 'vitest/node' import { runInlineTests, runVitest } from '../../test-utils' +// webdriver@9 sets dns.setDefaultResultOrder("ipv4first") on import, +// which makes Vite resolve localhost to 127.0.0.1 and breaks other tests asserting "localhost" +afterAll(() => setDefaultResultOrder('verbatim')) + test('setting resetMocks works if restoreMocks is also set', async () => { const { stderr, testTree } = await runInlineTests({ 'vitest.config.js': { @@ -133,3 +141,139 @@ test('can mock invalid module', () => { `) } }) + +function modeToConfig(mode: string): RunVitestConfig { + if (mode === 'playwright') { + return { + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + } + } + if (mode === 'webdriverio') { + return { + browser: { + enabled: true, + provider: webdriverio(), + instances: [{ browser: 'chrome' }], + headless: true, + }, + } + } + return {} +} + +test.for(['node', 'playwright', 'webdriverio'])('importOriginal for virtual modules (%s)', async (mode) => { + const { stderr, errorTree, root } = await runInlineTests({ + 'vitest.config.js': ` +import { defineConfig } from 'vitest/config' +export default defineConfig({ + plugins: [{ + name: 'virtual-test', + resolveId(source) { + if (source === 'virtual:my-module') { + return "\\0" + source + } + }, + load(id) { + if (id === '\\0virtual:my-module') { + return 'export const value = "original"' + } + }, + }], +}) + `, + './basic.test.js': ` +import { test, expect, vi } from 'vitest' +import { value } from 'virtual:my-module' + +vi.mock('virtual:my-module', async (importOriginal) => { + const original = await importOriginal() + return { value: original.value + '-modified' } +}) + +test('importOriginal returns original virtual module exports', () => { + expect(value).toBe('original-modified') +}) + `, + }, modeToConfig(mode)) + + // webdriverio uses a server-side interceptor plugin whose load hook + // intercepts the clean id, so importActual returns the mock instead + // of the original module. This is a known limitation. + if (mode === 'webdriverio') { + const tree = errorTree() + tree['basic.test.js'].__module_errors__ = tree['basic.test.js'].__module_errors__.map( + (e: string) => e.replace(root, ''), + ) + expect(tree).toMatchInlineSnapshot(` + { + "__unhandled_errors__": [ + "[vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock", + ], + "basic.test.js": { + "__module_errors__": [ + "Failed to import test file /basic.test.js", + ], + }, + } + `) + } + else { + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "importOriginal returns original virtual module exports": "passed", + }, + } + `) + } +}) + +test.for(['node', 'playwright', 'webdriverio'])('mocking virtual module without importOriginal skips loading original (%s)', async (mode) => { + const { stderr, testTree } = await runInlineTests({ + 'vitest.config.js': ` +import { defineConfig } from 'vitest/config' +export default defineConfig({ + plugins: [{ + name: 'virtual-test', + resolveId(source) { + if (source === 'virtual:my-module') { + return "\\0" + source + } + }, + load(id) { + if (id === '\\0virtual:my-module') { + throw new Error('virtual module load should not be called') + } + }, + }], +}) + `, + './basic.test.js': ` +import { test, expect, vi } from 'vitest' +import { value } from 'virtual:my-module' + +vi.mock('virtual:my-module', () => { + return { value: 'mocked' } +}) + +test('mock works without loading original', () => { + expect(value).toBe('mocked') +}) + `, + }, modeToConfig(mode)) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "mock works without loading original": "passed", + }, + } + `) +})