diff --git a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts index 9a54267dcc3f..513d9e8ef443 100644 --- a/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts +++ b/packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts @@ -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) } diff --git a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts index af3093baed71..d3049faec457 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts @@ -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 export const createNodeImportMeta: CreateImportMeta = (modulePath: string) => { @@ -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) + } mocked = await this.mocker.requestWithMockedModule( url, mod, callstack, - mod.meta.mockedModule as MockedModule, + mockedModule, ) } else { diff --git a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts index 44031b21df73..33ce48443729 100644 --- a/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts @@ -112,7 +112,7 @@ export function startVitestModuleRunner(options: ContextModuleRunnerOptions): Vi } if (!isImportActual) { - const resolvedMock = moduleRunner.mocker.getDependencyMock(rawId) + const resolvedMock = moduleRunner.mocker.getDependencyMockByUrl(id) if (resolvedMock?.type === 'manual' || resolvedMock?.type === 'redirect') { return { code: '', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90fd71363941..ac92a4fdad6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13186,7 +13186,6 @@ snapshots: '@types/yauzl@2.10.3': dependencies: '@types/node': 24.11.0 - optional: true '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: diff --git a/test/cli/test/mocking.test.ts b/test/cli/test/mocking.test.ts index 0642ac9c462a..0603417436c2 100644 --- a/test/cli/test/mocking.test.ts +++ b/test/cli/test/mocking.test.ts @@ -87,9 +87,7 @@ 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", }, } `) @@ -97,10 +95,10 @@ test('invalid packages', async () => { }) 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', () => { @@ -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 - 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, '')) + } + } + return tree +} function modeToConfig(mode: string): RunVitestConfig { if (mode === 'playwright') { @@ -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, ''), - ) - 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", @@ -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 /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 /basic.test.js", + ], + }, + } + `) + return + } + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "mock works without loading original": "passed", + }, + } + `) +})