Skip to content

Commit 1e89ec0

Browse files
hi-ogawaclaude
andauthored
fix: fix vi.importActual() for virtual modules (#9772)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4e6a23d commit 1e89ec0

File tree

5 files changed

+202
-11
lines changed

5 files changed

+202
-11
lines changed

packages/browser/src/node/plugin.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
4949
}
5050
next()
5151
})
52+
// strip _vitest_original query added by importActual so that
53+
// the plugin pipeline sees the original import id (e.g. virtual modules's load hook).
54+
server.middlewares.use((req, _res, next) => {
55+
if (
56+
req.url?.includes('_vitest_original')
57+
&& parentServer.project.config.browser.provider?.name === 'playwright'
58+
) {
59+
req.url = req.url
60+
.replace(/[?&]_vitest_original(?=[&#]|$)/, '')
61+
.replace(/[?&]ext\b[^&#]*/, '')
62+
.replace(/\?$/, '')
63+
}
64+
next()
65+
})
5266
server.middlewares.use(createOrchestratorMiddleware(parentServer))
5367
server.middlewares.use(createTesterMiddleware(parentServer))
5468

packages/vitest/src/runtime/moduleRunner/moduleMocker.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import vm from 'node:vm'
77
import { AutomockedModule, RedirectedModule } from '@vitest/mocker'
88
import { distDir } from '../../paths'
99
import { BareModuleMocker } from './bareModuleMocker'
10+
import { injectQuery } from './utils'
1011

1112
const spyModulePath = resolve(distDir, 'spy.js')
1213

@@ -130,7 +131,8 @@ export class VitestMocker extends BareModuleMocker {
130131
callstack?: string[] | null,
131132
): Promise<T> {
132133
const { url } = await this.resolveId(rawId, importer)
133-
const node = await this.moduleRunner.fetchModule(url, importer)
134+
const actualUrl = injectQuery(url, '_vitest_original')
135+
const node = await this.moduleRunner.fetchModule(actualUrl, importer)
134136
const result = await this.moduleRunner.cachedRequest(
135137
node.url,
136138
node,

packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getCachedVitestImport } from './cachedResolver'
1212
import { unwrapId, VitestModuleEvaluator } from './moduleEvaluator'
1313
import { VitestMocker } from './moduleMocker'
1414
import { VitestModuleRunner } from './moduleRunner'
15+
import { removeQuery } from './utils'
1516

1617
const { readFileSync } = fs
1718

@@ -95,6 +96,13 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi
9596
return vitest
9697
}
9798

99+
// strip _vitest_original query added by importActual so that
100+
// the plugin pipeline sees the original import id (e.g. virtual modules's load hook)
101+
const isImportActual = id.includes('_vitest_original')
102+
if (isImportActual) {
103+
id = removeQuery(id, '_vitest_original')
104+
}
105+
98106
const rawId = unwrapId(id)
99107
resolvingModules.add(rawId)
100108

@@ -103,15 +111,17 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi
103111
await moduleRunner.mocker.resolveMocks()
104112
}
105113

106-
const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId)
107-
if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') {
108-
return {
109-
code: '',
110-
file: null,
111-
id: resolvedMock.id,
112-
url: resolvedMock.url,
113-
invalidate: false,
114-
mockedModule: resolvedMock,
114+
if (!isImportActual) {
115+
const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId)
116+
if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') {
117+
return {
118+
code: '',
119+
file: null,
120+
id: resolvedMock.id,
121+
url: resolvedMock.url,
122+
invalidate: false,
123+
mockedModule: resolvedMock,
124+
}
115125
}
116126
}
117127

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// copied from vite/src/shared/utils.ts
2+
const postfixRE = /[?#].*$/
3+
4+
function cleanUrl(url: string): string {
5+
return url.replace(postfixRE, '')
6+
}
7+
function splitFileAndPostfix(path: string): { file: string; postfix: string } {
8+
const file = cleanUrl(path)
9+
return { file, postfix: path.slice(file.length) }
10+
}
11+
12+
export function injectQuery(url: string, queryToInject: string): string {
13+
const { file, postfix } = splitFileAndPostfix(url)
14+
return `${file}?${queryToInject}${postfix[0] === '?' ? `&${postfix.slice(1)}` : /* hash only */ postfix}`
15+
}
16+
17+
export function removeQuery(url: string, queryToRemove: string): string {
18+
return url
19+
.replace(new RegExp(`[?&]${queryToRemove}(?=[&#]|$)`), '')
20+
.replace(/\?$/, '')
21+
}

test/cli/test/mocking.test.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import type { RunVitestConfig } from '../../test-utils'
2+
import { setDefaultResultOrder } from 'node:dns'
13
import path from 'node:path'
2-
import { expect, test } from 'vitest'
4+
import { playwright } from '@vitest/browser-playwright'
5+
import { webdriverio } from '@vitest/browser-webdriverio'
6+
import { afterAll, expect, test } from 'vitest'
37
import { rolldownVersion } from 'vitest/node'
48
import { runInlineTests, runVitest } from '../../test-utils'
59

10+
// webdriver@9 sets dns.setDefaultResultOrder("ipv4first") on import,
11+
// which makes Vite resolve localhost to 127.0.0.1 and breaks other tests asserting "localhost"
12+
afterAll(() => setDefaultResultOrder('verbatim'))
13+
614
test('setting resetMocks works if restoreMocks is also set', async () => {
715
const { stderr, testTree } = await runInlineTests({
816
'vitest.config.js': {
@@ -133,3 +141,139 @@ test('can mock invalid module', () => {
133141
`)
134142
}
135143
})
144+
145+
function modeToConfig(mode: string): RunVitestConfig {
146+
if (mode === 'playwright') {
147+
return {
148+
browser: {
149+
enabled: true,
150+
provider: playwright(),
151+
instances: [{ browser: 'chromium' }],
152+
headless: true,
153+
},
154+
}
155+
}
156+
if (mode === 'webdriverio') {
157+
return {
158+
browser: {
159+
enabled: true,
160+
provider: webdriverio(),
161+
instances: [{ browser: 'chrome' }],
162+
headless: true,
163+
},
164+
}
165+
}
166+
return {}
167+
}
168+
169+
test.for(['node', 'playwright', 'webdriverio'])('importOriginal for virtual modules (%s)', async (mode) => {
170+
const { stderr, errorTree, root } = await runInlineTests({
171+
'vitest.config.js': `
172+
import { defineConfig } from 'vitest/config'
173+
export default defineConfig({
174+
plugins: [{
175+
name: 'virtual-test',
176+
resolveId(source) {
177+
if (source === 'virtual:my-module') {
178+
return "\\0" + source
179+
}
180+
},
181+
load(id) {
182+
if (id === '\\0virtual:my-module') {
183+
return 'export const value = "original"'
184+
}
185+
},
186+
}],
187+
})
188+
`,
189+
'./basic.test.js': `
190+
import { test, expect, vi } from 'vitest'
191+
import { value } from 'virtual:my-module'
192+
193+
vi.mock('virtual:my-module', async (importOriginal) => {
194+
const original = await importOriginal()
195+
return { value: original.value + '-modified' }
196+
})
197+
198+
test('importOriginal returns original virtual module exports', () => {
199+
expect(value).toBe('original-modified')
200+
})
201+
`,
202+
}, modeToConfig(mode))
203+
204+
// webdriverio uses a server-side interceptor plugin whose load hook
205+
// intercepts the clean id, so importActual returns the mock instead
206+
// of the original module. This is a known limitation.
207+
if (mode === 'webdriverio') {
208+
const tree = errorTree()
209+
tree['basic.test.js'].__module_errors__ = tree['basic.test.js'].__module_errors__.map(
210+
(e: string) => e.replace(root, '<root>'),
211+
)
212+
expect(tree).toMatchInlineSnapshot(`
213+
{
214+
"__unhandled_errors__": [
215+
"[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",
216+
],
217+
"basic.test.js": {
218+
"__module_errors__": [
219+
"Failed to import test file <root>/basic.test.js",
220+
],
221+
},
222+
}
223+
`)
224+
}
225+
else {
226+
expect(stderr).toBe('')
227+
expect(errorTree()).toMatchInlineSnapshot(`
228+
{
229+
"basic.test.js": {
230+
"importOriginal returns original virtual module exports": "passed",
231+
},
232+
}
233+
`)
234+
}
235+
})
236+
237+
test.for(['node', 'playwright', 'webdriverio'])('mocking virtual module without importOriginal skips loading original (%s)', async (mode) => {
238+
const { stderr, testTree } = await runInlineTests({
239+
'vitest.config.js': `
240+
import { defineConfig } from 'vitest/config'
241+
export default defineConfig({
242+
plugins: [{
243+
name: 'virtual-test',
244+
resolveId(source) {
245+
if (source === 'virtual:my-module') {
246+
return "\\0" + source
247+
}
248+
},
249+
load(id) {
250+
if (id === '\\0virtual:my-module') {
251+
throw new Error('virtual module load should not be called')
252+
}
253+
},
254+
}],
255+
})
256+
`,
257+
'./basic.test.js': `
258+
import { test, expect, vi } from 'vitest'
259+
import { value } from 'virtual:my-module'
260+
261+
vi.mock('virtual:my-module', () => {
262+
return { value: 'mocked' }
263+
})
264+
265+
test('mock works without loading original', () => {
266+
expect(value).toBe('mocked')
267+
})
268+
`,
269+
}, modeToConfig(mode))
270+
271+
expect(stderr).toBe('')
272+
expect(testTree()).toMatchInlineSnapshot(`
273+
{
274+
"basic.test.js": {
275+
"mock works without loading original": "passed",
276+
},
277+
}
278+
`)
279+
})

0 commit comments

Comments
 (0)