Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions packages/browser/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Comment on lines +52 to +65
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.

Ideally this should live in playwright provider, but don't think that's possible now, so here it is.

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.

Actually instead of middleware level rewriting, it should be also possible to just strip ?_vitest_original from resolveId like

resolveId(source) {
  if (source.endsWith("?_vitest_original")) { 
    return source.slice(...)
  }
}

This is because the source comes here is already the resolved id + ?_vitest_original such e.g. "\0virtual:module?_vitest_original".

In fact, I used a similar idea in RSC plugin to "redirect" already resolved id back as something importable https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/src/plugins/resolved-id-proxy.ts.

server.middlewares.use(createOrchestratorMiddleware(parentServer))
server.middlewares.use(createTesterMiddleware(parentServer))

Expand Down
4 changes: 3 additions & 1 deletion packages/vitest/src/runtime/moduleRunner/moduleMocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -130,7 +131,8 @@ export class VitestMocker extends BareModuleMocker {
callstack?: string[] | null,
): Promise<T> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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,
}
Comment on lines +114 to +124
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.

The issue was that previously rawId = "virtual:module" would get short-circuit here for both initial mocked import "virtual:module" and following importOriginal calls, which means the real code never got read by module runner.

This PR adds ?_vitest_original query to importOriginal's fetchModule so that it can return the real module code.

}
}

Expand Down
21 changes: 21 additions & 0 deletions packages/vitest/src/runtime/moduleRunner/utils.ts
Original file line number Diff line number Diff line change
@@ -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(/\?$/, '')
}
146 changes: 145 additions & 1 deletion test/cli/test/mocking.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
Comment on lines +10 to +12
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 needed to avoid this silly localhost vs 127.0.0.1 errors https://github.com/vitest-dev/vitest/actions/runs/22614377230/job/65523514606#step:8:911

   FAIL  test/browser-multiple.test.ts:7:1 > automatically assigns the port
  AssertionError: expected [ 'http://127.0.0.1:63315/', 'http://127.0.0.1:63316/' ] to include 'http://localhost:63315/'
   ❯ test/browser-multiple.test.ts:31:16
       29|   expect(spy).not.toHaveBeenCalled()
       30|   expect(stderr).not.toContain('is in use, trying another one...')
       31|   expect(urls).toContain('http://localhost:63315/')
         |                ^
       32|   expect(urls).toContain('http://localhost:63316/')
       33| })


test('setting resetMocks works if restoreMocks is also set', async () => {
const { stderr, testTree } = await runInlineTests({
'vitest.config.js': {
Expand Down Expand Up @@ -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, '<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 <root>/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",
},
}
`)
})