From 9cc0b8ea8f6f0713662e535569698094441fb1bc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 14:35:47 +0900 Subject: [PATCH 01/13] fix: fix `vi.importActual()` for virtual modules (#9771) The transport short-circuits `manual`/`redirect` mocks with `code: ''`, which prevents `importActual` from fetching real module code. Fix by appending `_vitest_original` query param in `importActual` so the transport skips the mock check and fetches via `rpc().fetch()` with the clean URL. The module is cached under the flagged URL, keeping it separate from the mocked entry. Co-Authored-By: Claude Opus 4.6 --- .../runtime/moduleRunner/moduleEvaluator.ts | 22 ++++++++++ .../src/runtime/moduleRunner/moduleMocker.ts | 4 +- .../moduleRunner/startVitestModuleRunner.ts | 29 ++++++++----- test/cli/test/mocking.test.ts | 42 +++++++++++++++++++ .../mock-virtual-import-original.test.ts | 14 +++++++ test/core/vite.config.ts | 7 +++- 6 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 test/core/test/mocking/mock-virtual-import-original.test.ts diff --git a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts index 47d331647b9c..29d8a20fc32e 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts @@ -555,3 +555,25 @@ export function unwrapId(id: string): string { ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') : id } + +// 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) } +} + +// copied from vite/src/node/utils.ts +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/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index 056aceb85a50..a1d3cee09e5d 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 './moduleEvaluator' 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..f8bfbce1228b 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -9,7 +9,7 @@ import { isBareImport } from '@vitest/utils/helpers' import { isBrowserExternal, isBuiltin, toBuiltin } from '../../utils/modules' import { getSafeWorkerState } from '../utils' import { getCachedVitestImport } from './cachedResolver' -import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' +import { removeQuery, unwrapId, VitestModuleEvaluator } from './moduleEvaluator' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' @@ -95,6 +95,13 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi return vitest } + // Strip _vitest_original query — importActual uses this to bypass + // the mock short-circuit and fetch real module code. + const isImportActual = id.includes('_vitest_original') + if (isImportActual) { + id = removeQuery(id, '_vitest_original') + } + const rawId = unwrapId(id) resolvingModules.add(rawId) @@ -103,15 +110,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/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 8c0afda75423..00c029e0cc04 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -133,3 +133,45 @@ test('can mock invalid module', () => { `) } }) + +test('mocking virtual module without importOriginal skips loading original', async () => { + 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 source + }, + load(id) { + if (id === 'virtual: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') +}) + `, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "mock works without loading original": "passed", + }, + } + `) +}) diff --git a/test/core/test/mocking/mock-virtual-import-original.test.ts b/test/core/test/mocking/mock-virtual-import-original.test.ts new file mode 100644 index 000000000000..27a9e76a20d1 --- /dev/null +++ b/test/core/test/mocking/mock-virtual-import-original.test.ts @@ -0,0 +1,14 @@ +// @ts-expect-error virtual module +import { value } from 'virtual-module-importoriginal' +import { expect, test, vi } from 'vitest' + +vi.mock('virtual-module-importoriginal', async (importOriginal) => { + const original = await importOriginal<{ value: string }>() + return { + value: `${original.value}-modified`, + } +}) + +test('importOriginal returns original virtual module exports', () => { + expect(value).toBe('original-importoriginal-modified') +}) diff --git a/test/core/vite.config.ts b/test/core/vite.config.ts index adbb8a6803ea..786a05cb232a 100644 --- a/test/core/vite.config.ts +++ b/test/core/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ { name: 'example', resolveId(source) { - if (source === 'virtual-module' || source === 'virtual-module-direct' || source === 'virtual-module-indirect') { + if (source === 'virtual-module' || source === 'virtual-module-direct' || source === 'virtual-module-indirect' || source === 'virtual-module-importoriginal') { return source } }, @@ -31,6 +31,11 @@ export default defineConfig({ export const value = 'original-indirect'; ` } + if (id === 'virtual-module-importoriginal') { + return ` + export const value = 'original-importoriginal'; + ` + } }, }, ], From 71fbf91865e48350deac0b3cb90734601275a6b1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 14:45:01 +0900 Subject: [PATCH 02/13] refactor: move injectQuery/removeQuery to moduleRunner/utils.ts Co-Authored-By: Claude Opus 4.6 --- .../runtime/moduleRunner/moduleEvaluator.ts | 21 ------------------- .../src/runtime/moduleRunner/moduleMocker.ts | 2 +- .../moduleRunner/startVitestModuleRunner.ts | 3 ++- .../vitest/src/runtime/moduleRunner/utils.ts | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 packages/vitest/src/runtime/moduleRunner/utils.ts diff --git a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts index 29d8a20fc32e..14d16b51d761 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts @@ -556,24 +556,3 @@ export function unwrapId(id: string): string { : id } -// 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) } -} - -// copied from vite/src/node/utils.ts -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/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts index a1d3cee09e5d..18459332a0b8 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleMocker.ts @@ -7,7 +7,7 @@ import vm from 'node:vm' import { AutomockedModule, RedirectedModule } from '@vitest/mocker' import { distDir } from '../../paths' import { BareModuleMocker } from './bareModuleMocker' -import { injectQuery } from './moduleEvaluator' +import { injectQuery } from './utils' const spyModulePath = resolve(distDir, 'spy.js') diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index f8bfbce1228b..98a7db9706b0 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -9,7 +9,8 @@ import { isBareImport } from '@vitest/utils/helpers' import { isBrowserExternal, isBuiltin, toBuiltin } from '../../utils/modules' import { getSafeWorkerState } from '../utils' import { getCachedVitestImport } from './cachedResolver' -import { removeQuery, unwrapId, VitestModuleEvaluator } from './moduleEvaluator' +import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' +import { removeQuery } from './utils' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' diff --git a/packages/vitest/src/runtime/moduleRunner/utils.ts b/packages/vitest/src/runtime/moduleRunner/utils.ts new file mode 100644 index 000000000000..1646857a898a --- /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) } +} + +// copied from vite/src/node/utils.ts +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(/\?$/, '') +} From d4c4de5e7e265b8d64e783e30cd87fec476a5569 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 14:48:31 +0900 Subject: [PATCH 03/13] refactor: consolidate virtual module mock tests in test/cli Co-Authored-By: Claude Opus 4.6 --- test/cli/test/mocking.test.ts | 51 ++++++++++++++++++- .../mock-virtual-import-original.test.ts | 14 ----- test/core/vite.config.ts | 7 +-- 3 files changed, 50 insertions(+), 22 deletions(-) delete mode 100644 test/core/test/mocking/mock-virtual-import-original.test.ts diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 00c029e0cc04..5da39ce7e5ef 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -134,6 +134,51 @@ test('can mock invalid module', () => { } }) +test('importOriginal works for virtual modules', async () => { + 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') { + 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') +}) + `, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "importOriginal returns original virtual module exports": "passed", + }, + } + `) +}) + test('mocking virtual module without importOriginal skips loading original', async () => { const { stderr, testTree } = await runInlineTests({ 'vitest.config.js': ` @@ -142,10 +187,12 @@ export default defineConfig({ plugins: [{ name: 'virtual-test', resolveId(source) { - if (source === 'virtual:my-module') return source + if (source === 'virtual:my-module') { + return "\\0" + source + } }, load(id) { - if (id === 'virtual:my-module') { + if (id === '\\0virtual:my-module') { throw new Error('virtual module load should not be called') } }, diff --git a/test/core/test/mocking/mock-virtual-import-original.test.ts b/test/core/test/mocking/mock-virtual-import-original.test.ts deleted file mode 100644 index 27a9e76a20d1..000000000000 --- a/test/core/test/mocking/mock-virtual-import-original.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-expect-error virtual module -import { value } from 'virtual-module-importoriginal' -import { expect, test, vi } from 'vitest' - -vi.mock('virtual-module-importoriginal', async (importOriginal) => { - const original = await importOriginal<{ value: string }>() - return { - value: `${original.value}-modified`, - } -}) - -test('importOriginal returns original virtual module exports', () => { - expect(value).toBe('original-importoriginal-modified') -}) diff --git a/test/core/vite.config.ts b/test/core/vite.config.ts index 786a05cb232a..adbb8a6803ea 100644 --- a/test/core/vite.config.ts +++ b/test/core/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ { name: 'example', resolveId(source) { - if (source === 'virtual-module' || source === 'virtual-module-direct' || source === 'virtual-module-indirect' || source === 'virtual-module-importoriginal') { + if (source === 'virtual-module' || source === 'virtual-module-direct' || source === 'virtual-module-indirect') { return source } }, @@ -31,11 +31,6 @@ export default defineConfig({ export const value = 'original-indirect'; ` } - if (id === 'virtual-module-importoriginal') { - return ` - export const value = 'original-importoriginal'; - ` - } }, }, ], From 919b4c2066d2df957b4238aec3b39c9934c50125 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 14:49:24 +0900 Subject: [PATCH 04/13] chore: lint --- packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts | 1 - .../vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts | 2 +- packages/vitest/src/runtime/moduleRunner/utils.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts index 14d16b51d761..47d331647b9c 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts @@ -555,4 +555,3 @@ export function unwrapId(id: string): string { ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') : id } - diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 98a7db9706b0..8fea2dec89c5 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -10,9 +10,9 @@ import { isBrowserExternal, isBuiltin, toBuiltin } from '../../utils/modules' import { getSafeWorkerState } from '../utils' import { getCachedVitestImport } from './cachedResolver' import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' -import { removeQuery } from './utils' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' +import { removeQuery } from './utils' const { readFileSync } = fs diff --git a/packages/vitest/src/runtime/moduleRunner/utils.ts b/packages/vitest/src/runtime/moduleRunner/utils.ts index 1646857a898a..83c58a4a1485 100644 --- a/packages/vitest/src/runtime/moduleRunner/utils.ts +++ b/packages/vitest/src/runtime/moduleRunner/utils.ts @@ -1,5 +1,6 @@ // copied from vite/src/shared/utils.ts const postfixRE = /[?#].*$/ + function cleanUrl(url: string): string { return url.replace(postfixRE, '') } @@ -8,7 +9,6 @@ function splitFileAndPostfix(path: string): { file: string; postfix: string } { return { file, postfix: path.slice(file.length) } } -// copied from vite/src/node/utils.ts export function injectQuery(url: string, queryToInject: string): string { const { file, postfix } = splitFileAndPostfix(url) return `${file}?${queryToInject}${postfix[0] === '?' ? `&${postfix.slice(1)}` : /* hash only */ postfix}` From 1aedd3b4adbfac819f59442ae6082957830e8ef8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 14:53:43 +0900 Subject: [PATCH 05/13] fix: add _vitest_original back to fetchModule result id/url Ensures Vite caches the actual module under a separate key from the mocked one, preventing cache collisions between mocked and real modules. Co-Authored-By: Claude Opus 4.6 --- .../moduleRunner/startVitestModuleRunner.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 8fea2dec89c5..0778a5514eba 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -12,7 +12,7 @@ import { getCachedVitestImport } from './cachedResolver' import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' -import { removeQuery } from './utils' +import { injectQuery, removeQuery } from './utils' const { readFileSync } = fs @@ -149,7 +149,18 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi ) if ('cached' in result) { const code = readFileSync(result.tmp, 'utf-8') - return { code, ...result } + const fetched = { code, ...result } + if (isImportActual) { + fetched.id = injectQuery(fetched.id, '_vitest_original') + fetched.url = injectQuery(fetched.url, '_vitest_original') + } + return fetched + } + // Add back _vitest_original to result id/url so Vite caches the + // actual module under a separate key from the mocked one. + if (isImportActual && 'id' in result && 'url' in result) { + result.id = injectQuery(result.id, '_vitest_original') + result.url = injectQuery(result.url, '_vitest_original') } return result } From b62193724e39e5aa778535aef763df777de6bee9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 15:03:35 +0900 Subject: [PATCH 06/13] revert: remove _vitest_original inject-back (not provably needed) Co-Authored-By: Claude Opus 4.6 --- .../moduleRunner/startVitestModuleRunner.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 0778a5514eba..8fea2dec89c5 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -12,7 +12,7 @@ import { getCachedVitestImport } from './cachedResolver' import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator' import { VitestMocker } from './moduleMocker' import { VitestModuleRunner } from './moduleRunner' -import { injectQuery, removeQuery } from './utils' +import { removeQuery } from './utils' const { readFileSync } = fs @@ -149,18 +149,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi ) if ('cached' in result) { const code = readFileSync(result.tmp, 'utf-8') - const fetched = { code, ...result } - if (isImportActual) { - fetched.id = injectQuery(fetched.id, '_vitest_original') - fetched.url = injectQuery(fetched.url, '_vitest_original') - } - return fetched - } - // Add back _vitest_original to result id/url so Vite caches the - // actual module under a separate key from the mocked one. - if (isImportActual && 'id' in result && 'url' in result) { - result.id = injectQuery(result.id, '_vitest_original') - result.url = injectQuery(result.url, '_vitest_original') + return { code, ...result } } return result } From fc50d527f44c13848c32339cdfce68464502e9d9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 15:05:11 +0900 Subject: [PATCH 07/13] chore: comment --- .../src/runtime/moduleRunner/startVitestModuleRunner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 8fea2dec89c5..5f440fc61448 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -96,8 +96,8 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi return vitest } - // Strip _vitest_original query — importActual uses this to bypass - // the mock short-circuit and fetch real module code. + // strip "_vitest_original" query from `importActual` to ensure + // plugin pipeline sees only the original import id. const isImportActual = id.includes('_vitest_original') if (isImportActual) { id = removeQuery(id, '_vitest_original') From 1e21b99dccafcbc8f40955c72743c7ec46390c97 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 15:31:20 +0900 Subject: [PATCH 08/13] fix: fix importActual for virtual modules in browser mode Strip _vitest_original query in server middleware before Vite's transform pipeline so virtual module plugins can match clean IDs in load(). Remove unused ext query param from browser mocker's importActual. Add browser mode variants to virtual module mock tests via test.for. Co-Authored-By: Claude Opus 4.6 --- packages/browser/src/node/plugin.ts | 10 ++++++++++ packages/mocker/src/browser/mocker.ts | 5 ++--- test/cli/test/mocking.test.ts | 24 ++++++++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 91ed5783166f..a275fb387da5 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -49,6 +49,16 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { } next() }) + // strip _vitest_original query added by importActual so that + // virtual module plugins can match the clean id in load() + server.middlewares.use((req, _res, next) => { + if (req.url?.includes('_vitest_original')) { + req.url = req.url + .replace(/[?&]_vitest_original(?=[&#]|$)/, '') + .replace(/\?$/, '') + } + next() + }) server.middlewares.use(createOrchestratorMiddleware(parentServer)) server.middlewares.use(createTesterMiddleware(parentServer)) diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 1cf237c55b6b..972eba0b03c5 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -2,7 +2,7 @@ import type { CreateMockInstanceProcedure } from '../automocker' import type { MockedModule, MockedModuleType } from '../registry' import type { ModuleMockContext, ModuleMockOptions, TestModuleMocker } from '../types' import type { ModuleMockerInterceptor } from './interceptor' -import { extname, join } from 'pathe' +import { join } from 'pathe' import { mockObject } from '../automocker' import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry' @@ -65,9 +65,8 @@ export class ModuleMocker implements TestModuleMocker { `[vitest] Cannot resolve "${id}" imported from "${importer}"`, ) } - const ext = extname(resolved.id) const url = new URL(resolved.url, this.getBaseUrl()) - const query = `_vitest_original&ext${ext}` + const query = '_vitest_original' const actualUrl = `${url.pathname}${ url.search ? `${url.search}&${query}` : `?${query}` }${url.hash}` diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 5da39ce7e5ef..4466b64205ac 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -1,4 +1,6 @@ +import type { RunVitestConfig } from '../../test-utils' import path from 'node:path' +import { playwright } from '@vitest/browser-playwright' import { expect, test } from 'vitest' import { rolldownVersion } from 'vitest/node' import { runInlineTests, runVitest } from '../../test-utils' @@ -134,7 +136,21 @@ test('can mock invalid module', () => { } }) -test('importOriginal works for virtual modules', async () => { +function browserConfig(mode: string): RunVitestConfig { + if (mode === 'browser') { + return { + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + } + } + return {} +} + +test.for(['node', 'browser'])('importOriginal works for virtual modules (%s)', async (mode) => { const { stderr, testTree } = await runInlineTests({ 'vitest.config.js': ` import { defineConfig } from 'vitest/config' @@ -167,7 +183,7 @@ test('importOriginal returns original virtual module exports', () => { expect(value).toBe('original-modified') }) `, - }) + }, browserConfig(mode)) expect(stderr).toBe('') expect(testTree()).toMatchInlineSnapshot(` @@ -179,7 +195,7 @@ test('importOriginal returns original virtual module exports', () => { `) }) -test('mocking virtual module without importOriginal skips loading original', async () => { +test.for(['node', 'browser'])('mocking virtual module without importOriginal skips loading original (%s)', async (mode) => { const { stderr, testTree } = await runInlineTests({ 'vitest.config.js': ` import { defineConfig } from 'vitest/config' @@ -211,7 +227,7 @@ test('mock works without loading original', () => { expect(value).toBe('mocked') }) `, - }) + }, browserConfig(mode)) expect(stderr).toBe('') expect(testTree()).toMatchInlineSnapshot(` From 158c1e6fced4a8b237238cbeb1165d5c105793a9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 15:34:02 +0900 Subject: [PATCH 09/13] chore: comment --- packages/browser/src/node/plugin.ts | 2 +- .../src/runtime/moduleRunner/startVitestModuleRunner.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index a275fb387da5..39cda4d94d3c 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -50,7 +50,7 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { next() }) // strip _vitest_original query added by importActual so that - // virtual module plugins can match the clean id in load() + // 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')) { req.url = req.url diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 5f440fc61448..44031b21df73 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -96,8 +96,8 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi return vitest } - // strip "_vitest_original" query from `importActual` to ensure - // plugin pipeline sees only the original import id. + // 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') From caf3289080c0a749d2d2de92966d5df3c35db1f6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 17:02:18 +0900 Subject: [PATCH 10/13] fix: don't touch wdio --- packages/browser/src/node/plugin.ts | 8 ++++++-- packages/mocker/src/browser/mocker.ts | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 39cda4d94d3c..6d04d71c253f 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -50,11 +50,15 @@ 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) + // 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')) { + 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() diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 972eba0b03c5..1cf237c55b6b 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -2,7 +2,7 @@ import type { CreateMockInstanceProcedure } from '../automocker' import type { MockedModule, MockedModuleType } from '../registry' import type { ModuleMockContext, ModuleMockOptions, TestModuleMocker } from '../types' import type { ModuleMockerInterceptor } from './interceptor' -import { join } from 'pathe' +import { extname, join } from 'pathe' import { mockObject } from '../automocker' import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry' @@ -65,8 +65,9 @@ export class ModuleMocker implements TestModuleMocker { `[vitest] Cannot resolve "${id}" imported from "${importer}"`, ) } + const ext = extname(resolved.id) const url = new URL(resolved.url, this.getBaseUrl()) - const query = '_vitest_original' + const query = `_vitest_original&ext${ext}` const actualUrl = `${url.pathname}${ url.search ? `${url.search}&${query}` : `?${query}` }${url.hash}` From 1099aeca8be2bcebe05e63dc89b1945e39df7a12 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 17:20:01 +0900 Subject: [PATCH 11/13] test: wdio --- test/cli/test/mocking.test.ts | 61 ++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 4466b64205ac..a510f7bebe59 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -1,6 +1,7 @@ import type { RunVitestConfig } from '../../test-utils' import path from 'node:path' import { playwright } from '@vitest/browser-playwright' +import { webdriverio } from '@vitest/browser-webdriverio' import { expect, test } from 'vitest' import { rolldownVersion } from 'vitest/node' import { runInlineTests, runVitest } from '../../test-utils' @@ -136,8 +137,8 @@ test('can mock invalid module', () => { } }) -function browserConfig(mode: string): RunVitestConfig { - if (mode === 'browser') { +function modeToConfig(mode: string): RunVitestConfig { + if (mode === 'playwright') { return { browser: { enabled: true, @@ -147,11 +148,21 @@ function browserConfig(mode: string): RunVitestConfig { }, } } + if (mode === 'webdriverio') { + return { + browser: { + enabled: true, + provider: webdriverio(), + instances: [{ browser: 'chrome' }], + headless: true, + }, + } + } return {} } -test.for(['node', 'browser'])('importOriginal works for virtual modules (%s)', async (mode) => { - const { stderr, testTree } = await runInlineTests({ +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({ @@ -183,19 +194,39 @@ test('importOriginal returns original virtual module exports', () => { expect(value).toBe('original-modified') }) `, - }, browserConfig(mode)) + }, modeToConfig(mode)) - expect(stderr).toBe('') - expect(testTree()).toMatchInlineSnapshot(` - { - "basic.test.js": { - "importOriginal returns original virtual module exports": "passed", - }, - } - `) + // 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(` + { + "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', 'browser'])('mocking virtual module without importOriginal skips loading original (%s)', async (mode) => { +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' @@ -227,7 +258,7 @@ test('mock works without loading original', () => { expect(value).toBe('mocked') }) `, - }, browserConfig(mode)) + }, modeToConfig(mode)) expect(stderr).toBe('') expect(testTree()).toMatchInlineSnapshot(` From 42539a0c2985b9198db5059ae7dfab9f61328fb3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 17:48:56 +0900 Subject: [PATCH 12/13] test: fix wdio --- test/cli/test/mocking.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index a510f7bebe59..8f7def32c0fc 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -1,11 +1,16 @@ import type { RunVitestConfig } from '../../test-utils' +import { setDefaultResultOrder } from 'node:dns' import path from 'node:path' import { playwright } from '@vitest/browser-playwright' import { webdriverio } from '@vitest/browser-webdriverio' -import { expect, test } from 'vitest' +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': { From bba414c71d810242bcf052f3242448a277ae2f78 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 17:57:27 +0900 Subject: [PATCH 13/13] test: update snapshot --- test/cli/test/mocking.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 8f7def32c0fc..0642ac9c462a 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -211,6 +211,9 @@ test('importOriginal returns original virtual module exports', () => { ) 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",