Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9cc0b8e
fix: fix `vi.importActual()` for virtual modules (#9771)
hi-ogawa Mar 3, 2026
71fbf91
refactor: move injectQuery/removeQuery to moduleRunner/utils.ts
hi-ogawa Mar 3, 2026
d4c4de5
refactor: consolidate virtual module mock tests in test/cli
hi-ogawa Mar 3, 2026
919b4c2
chore: lint
hi-ogawa Mar 3, 2026
1aedd3b
fix: add _vitest_original back to fetchModule result id/url
hi-ogawa Mar 3, 2026
b621937
revert: remove _vitest_original inject-back (not provably needed)
hi-ogawa Mar 3, 2026
fc50d52
chore: comment
hi-ogawa Mar 3, 2026
1e21b99
fix: fix importActual for virtual modules in browser mode
hi-ogawa Mar 3, 2026
158c1e6
chore: comment
hi-ogawa Mar 3, 2026
caf3289
fix: don't touch wdio
hi-ogawa Mar 3, 2026
1099aec
test: wdio
hi-ogawa Mar 3, 2026
42539a0
test: fix wdio
hi-ogawa Mar 3, 2026
4a8fb50
Merge branch 'main' into fix/import-actual-virtual-modules
hi-ogawa Mar 3, 2026
bba414c
test: update snapshot
hi-ogawa Mar 3, 2026
864f2c5
chore: cleanup
hi-ogawa Mar 3, 2026
21ab1ac
fix: manual mock shouldn't load/transform original module
hi-ogawa Mar 3, 2026
bc5a94f
fix: auto detect mock self-import and treat as importActual
hi-ogawa Mar 4, 2026
c4e8935
fix: fix `doMock -> doUnmock -> import`
hi-ogawa Mar 4, 2026
0efc6b2
chore: comment
hi-ogawa Mar 4, 2026
1509b55
test: redirect mock doesn't transform original
hi-ogawa Mar 4, 2026
d7b0216
Merge branch 'main' into fix-9622-manual-mock-no-fetch
hi-ogawa Mar 4, 2026
835d14b
test: more
hi-ogawa Mar 4, 2026
b31de20
test: more
hi-ogawa Mar 4, 2026
50d59c3
chore: cleanup
hi-ogawa Mar 4, 2026
0dc1876
chore: remove debug comments and stale commented-out code
hi-ogawa Mar 4, 2026
b9bdb25
refactor: extract replaceRoot helper in mocking tests
hi-ogawa Mar 4, 2026
9a9523e
merge: resolve conflicts after #9772 squash-merge into main
hi-ogawa Mar 5, 2026
90e0779
chore: cleanup
hi-ogawa Mar 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export class BareModuleMocker implements TestModuleMocker {
return registry.getById(fixLeadingSlashes(id))
}

public getDependencyMockByUrl(url: string): MockedModule | undefined {
const registry = this.getMockerRegistry()
return registry.get(url)
}

public findMockRedirect(mockPath: string, external: string | null): string | null {
return findMockRedirect(this.root, mockPath, external)
}
Expand Down
16 changes: 15 additions & 1 deletion packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as viteModuleRunner from 'vite/module-runner'
import { Traces } from '../../utils/traces'
import { VitestMocker } from './moduleMocker'
import { VitestTransport } from './moduleTransport'
import { injectQuery } from './utils'

export type CreateImportMeta = (modulePath: string) => viteModuleRunner.ModuleRunnerImportMeta | Promise<viteModuleRunner.ModuleRunnerImportMeta>
export const createNodeImportMeta: CreateImportMeta = (modulePath: string) => {
Expand Down Expand Up @@ -165,11 +166,24 @@ export class VitestModuleRunner

let mocked: any
if (mod.meta && 'mockedModule' in mod.meta) {
const mockedModule = mod.meta.mockedModule as MockedModule
const mockId = this.mocker.getMockPath(mod.id)
// bypass mock and force "importActual" behavior when:
// - mock was removed by doUnmock (stale mockedModule in meta)
// - self-import: mock factory/file is importing the module it's mocking
const isStale = !this.mocker.getDependencyMock(mod.id)
const isSelfImport = callstack.includes(mockId)
|| callstack.includes(url)
|| ('redirect' in mockedModule && callstack.includes(mockedModule.redirect))
if (isStale || isSelfImport) {
const node = await this.fetchModule(injectQuery(url, '_vitest_original'))
return this._cachedRequest(node.url, node, callstack, metadata)
}
Comment on lines +169 to +181
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is added to support existing use cases where "import original module" fallback was happening magically. As the previous PR #9772 consolidated "import original module" to be only possible through _vitest_original query, this adjustment became necessary.

mocked = await this.mocker.requestWithMockedModule(
url,
mod,
callstack,
mod.meta.mockedModule as MockedModule,
mockedModule,
)
}
else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi
}

if (!isImportActual) {
const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId)
const resolvedMock = moduleRunner.mocker.getDependencyMockByUrl(id)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDependencyMock(rawId) doesn't work for actual file since getDependencyMock expects full module graph id but rawid/id here is a normalized path from project root. Also here id over rawId is preferred since id is an "url" passed to fetchModule, e.g.

  • rawId = \0virtual:module vs id = /@id/__x00__virtual:module
  • rawId = /src/foo.js vs id = /src/foo.js

if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') {
return {
code: '',
Expand Down
1 change: 0 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

186 changes: 150 additions & 36 deletions test/cli/test/mocking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,18 @@ test('invalid packages', async () => {
],
},
"mock-wrapper.test.ts": {
"__module_errors__": [
"Failed to resolve entry for package "test-dep-invalid". The package may have incorrect main/module/exports specified in its package.json.",
],
"basic": "passed",
},
}
`)
}
})

test('mocking modules with syntax error', async () => {
// TODO: manual mocked module still gets transformed so this is not supported yet.
const { errorTree } = await runInlineTests({
'./syntax-error.js': `syntax error`,
'./basic.test.js': /* ts */ `
import { test, expect, vi } from 'vitest'
import * as dep from './syntax-error.js'

vi.mock('./syntax-error.js', () => {
Expand All @@ -113,34 +111,49 @@ test('can mock invalid module', () => {
`,
})

if (rolldownVersion) {
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"__module_errors__": [
"Parse failure: Parse failed with 1 error:
Expected a semicolon or an implicit semicolon after a statement, but found none
1: syntax error
^
At file: /syntax-error.js:1:6",
],
},
}
`)
}
else {
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"__module_errors__": [
"Parse failure: Expected ';', '}' or <eof>
At file: /syntax-error.js:1:7",
],
},
}
`)
}
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"can mock invalid module": "passed",
},
}
`)
})

test('redirect mock with syntax error in original does not load original', async () => {
const { errorTree, stderr } = await runInlineTests({
'./broken.js': `syntax error`,
'./__mocks__/broken.js': `export const value = 'mocked'`,
'./basic.test.js': `
import { test, expect, vi } from 'vitest'
import { value } from './broken.js'

vi.mock('./broken.js')

test('redirect mock works without loading broken original', () => {
expect(value).toBe('mocked')
})
`,
})

expect(stderr).toMatchInlineSnapshot(`""`)
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"redirect mock works without loading broken original": "passed",
},
}
`)
})

function replaceRoot(tree: any, root: string): any {
for (const child of Object.values(tree) as any[]) {
if (child?.__module_errors__) {
child.__module_errors__ = child.__module_errors__.map((e: string) => e.replace(root, '<root>'))
}
}
return tree
}

function modeToConfig(mode: string): RunVitestConfig {
if (mode === 'playwright') {
Expand Down Expand Up @@ -205,11 +218,7 @@ test('importOriginal returns original virtual module exports', () => {
// 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, '<root>'),
)
expect(tree).toMatchInlineSnapshot(`
expect(replaceRoot(errorTree(), root)).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",
Expand Down Expand Up @@ -277,3 +286,108 @@ test('mock works without loading original', () => {
}
`)
})

test.for(['node', 'playwright', 'webdriverio'])('mocking actual module with factory skips loading original (%s)', async (mode) => {
const { stderr, errorTree, root } = await runInlineTests({
'vitest.config.js': `
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [{
name: 'guard-load',
transform(code, id) {
if (id.includes('do-not-load')) {
throw new Error('original module should not be transformed')
}
},
}],
})
`,
'./do-not-load.js': `export const value = 'original'`,
'./basic.test.js': `
import { test, expect, vi } from 'vitest'
import * as dep from './do-not-load.js'

vi.mock('./do-not-load.js', () => {
return { value: 'mocked' }
})

test('mock works without loading original', () => {
expect(dep).toMatchObject({ value: 'mocked' })
})
`,
}, modeToConfig(mode))

if (mode === 'webdriverio') {
expect(replaceRoot(errorTree(), root)).toMatchInlineSnapshot(`
{
"basic.test.js": {
"__module_errors__": [
"Failed to import test file <root>/basic.test.js",
],
},
}
`)
return
}

expect(stderr).toBe('')
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"mock works without loading original": "passed",
},
}
`)
})

test.for(['node', 'playwright', 'webdriverio'])('mocking actual module via __mocks__ skips loading original (%s)', async (mode) => {
const { stderr, errorTree, root } = await runInlineTests({
'vitest.config.js': `
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [{
name: 'guard-load',
transform(code, id) {
if (id.includes('do-not-load') && !id.includes('__mocks__')) {
throw new Error('original module should not be transformed')
}
},
}],
})
`,
'./do-not-load.js': `export const value = 'original'`,
'./__mocks__/do-not-load.js': `export const value = 'mocked'`,
'./basic.test.js': `
import { test, expect, vi } from 'vitest'
import { value } from './do-not-load.js'

vi.mock('./do-not-load.js')

test('mock works without loading original', () => {
expect(value).toBe('mocked')
})
`,
}, modeToConfig(mode))

if (mode === 'webdriverio') {
expect(replaceRoot(errorTree(), root)).toMatchInlineSnapshot(`
{
"basic.test.js": {
"__module_errors__": [
"Failed to import test file <root>/basic.test.js",
],
},
}
`)
return
}

expect(stderr).toBe('')
expect(errorTree()).toMatchInlineSnapshot(`
{
"basic.test.js": {
"mock works without loading original": "passed",
},
}
`)
})
Loading