diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 5251b6c02..074ed51e9 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -576,7 +576,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider { url = request.href.slice(request.origin.length) } - const result = await this.project.browser!.vite.transformRequest(url).catch(() => null) + const result = await this.project + .browser!.vite.transformRequest(decodeURI(url)) + .catch(() => null) if (!result) { return route.continue() } diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 547bbc62c..c3a7bd1dd 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -488,7 +488,7 @@ export function setupBrowserRpc( } else { if (module.type === 'redirect') { const redirectUrl = new URL(module.redirect) - const redirect = join(vite.config.root, slash(redirectUrl.pathname)) + const redirect = join(vite.config.root, slash(decodeURI(redirectUrl.pathname))) checkFileAccess(redirect) module.redirect = redirect } diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 6a9f2f280..c49e8c878 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -66,7 +66,7 @@ function extractLocation(urlLike: string) { const urlObj = new URL(url) urlObj.searchParams.delete('import') urlObj.searchParams.delete('browserv') - url = urlObj.pathname + urlObj.hash + urlObj.search + url = tryDecode(urlObj.pathname, decodeURI) + urlObj.hash + urlObj.search } if (url.startsWith('/@fs/')) { const isWindows = /^\/@fs\/[a-zA-Z]:\//.test(url) @@ -78,6 +78,14 @@ function extractLocation(urlLike: string) { return [url, parts[2] || undefined, parts[3] || undefined] } +function tryDecode(value: string, decode: (value: string) => string): string { + try { + return decode(value) + } catch { + return value + } +} + export function parseSingleFFOrSafariStack(raw: string): ParsedStack | null { let line = raw.trim() @@ -182,7 +190,8 @@ export function parseSingleV8Stack(raw: string): ParsedStack | null { } if (file.startsWith('file://')) { - file = file.slice(7) + // pathToFileURL also encodes "#" and "?", which decodeURI keeps + file = tryDecode(file.slice(7), decodeURIComponent) } // normalize Windows path (\ -> /) diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts index 4c6bbc49c..875ec2ea0 100644 --- a/test/browser/specs/mocking.test.ts +++ b/test/browser/specs/mocking.test.ts @@ -1,6 +1,7 @@ import { expect, onTestFailed, onTestFinished, test } from 'vitest' import { editFile, runVitest } from '../../test-utils' import { instances } from '../settings' +import { runInlineBrowserTests } from './utils' // TODO: investigate `isolate: false` tests. // Doesn't seem like we can run things in parallel if there are mocks @@ -125,3 +126,57 @@ test('mocks from a setup file apply to static imports of a test file', async () expect(result.exitCode).toBe(0) }) + +// fix https://github.com/vitest-dev/vitest/issues/7788 +test('mocks modules next to a test file in a directory with spaces', async () => { + const { errorTree } = await runInlineBrowserTests({ + 'space dir/source.ts': `export function answer() { return 42 }`, + 'space dir/redirect.ts': `export function value() { return 'original' }`, + 'space dir/__mocks__/redirect.ts': `export function value() { return 'mocked' }`, + 'space dir/factory.ts': `export const name = 'original'`, + 'space dir/basic.test.ts': ` +import { expect, test, vi } from 'vitest' +import { name } from './factory' +import { value } from './redirect' +import { answer } from './source' + +vi.mock('./source', { spy: true }) +vi.mock('./redirect') +vi.mock('./factory', () => ({ name: 'factory' })) + +test('spy', () => { + expect(vi.isMockFunction(answer)).toBe(true) +}) + +test('redirect', () => { + expect(value()).toBe('mocked') +}) + +test('factory', () => { + expect(name).toBe('factory') +}) + +test('error location', () => { + expect(answer()).toBe(0) +}) +`, + }) + + const trees = errorTree({ project: true, stackTrace: true }) + expect(Object.keys(trees).sort()).toEqual(instances.map(({ browser }) => browser).sort()) + for (const tree of Object.values(trees)) { + expect(tree).toMatchInlineSnapshot(` + { + "space dir/basic.test.ts": { + "error location": [ + "expected 42 to be +0 // Object.is equality + at space dir/basic.test.ts:24:20", + ], + "factory": "passed", + "redirect": "passed", + "spy": "passed", + }, + } + `) + } +}) diff --git a/test/e2e/test/no-module-runner.test.ts b/test/e2e/test/no-module-runner.test.ts index 061e336f5..248994277 100644 --- a/test/e2e/test/no-module-runner.test.ts +++ b/test/e2e/test/no-module-runner.test.ts @@ -171,6 +171,29 @@ describe.runIf(module.registerHooks)('supported', () => { await vitest.waitForStdout('1 passed') }) + // fix https://github.com/vitest-dev/vitest/issues/7788 + test('mocks a module next to a test file in a directory with spaces', async () => { + const { stderr, testTree } = await runNoViteModuleRunnerTests({ + 'space dir/greet.js': `export function greet() { return 'hello world' }`, + 'space dir/basic.test.js': ` + import { greet } from './greet.js' + vi.mock('./greet.js', { spy: true }) + test('greet is mocked', () => { + expect(vi.isMockFunction(greet)).toBe(true) + }) + `, + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "space dir/basic.test.js": { + "greet is mocked": "passed", + }, + } + `) + }) + test('updating inline snapshots works', async () => { const { fs, stdout } = await runNoViteModuleRunnerTests( { diff --git a/test/unit/test/utils.spec.ts b/test/unit/test/utils.spec.ts index 3044d2b4f..b81e45dad 100644 --- a/test/unit/test/utils.spec.ts +++ b/test/unit/test/utils.spec.ts @@ -6,7 +6,7 @@ import { objectAttr, toArray, } from '@vitest/utils/helpers' -import { parseSingleFFOrSafariStack } from '@vitest/utils/source-map' +import { parseSingleFFOrSafariStack, parseSingleStack } from '@vitest/utils/source-map' import { EvaluatedModules } from 'vite/module-runner' import { beforeAll, describe, expect, onTestFinished, test } from 'vitest' import { deepMergeSnapshot } from '../../../packages/snapshot/src/port/utils' @@ -449,3 +449,25 @@ describe('parseSingleFFOrSafariStack', () => { parseSingleFFOrSafariStack(new PrettyError(obj).stack!) }) }) + +describe('parseSingleStack', () => { + test.for([ + [' at http://localhost:63315/space%20dir/basic.test.ts:4:1', '/space dir/basic.test.ts'], + [ + 'module code@http://localhost:63315/space%20dir/basic.test.ts:4:1', + '/space dir/basic.test.ts', + ], + [ + ' at http://localhost:63315/@fs/Users/me/my%20project/basic.test.ts:4:1', + '/Users/me/my project/basic.test.ts', + ], + [ + ' at file:///Users/me/my%20project/test%231.test.ts:4:1', + '/Users/me/my project/test#1.test.ts', + ], + [' at /Users/me/project/repro-%20.test.ts:4:1', '/Users/me/project/repro-%20.test.ts'], + [' at http://localhost:63315/100%.test.ts:4:1', '/100%.test.ts'], + ])('%s', ([line, file]) => { + expect(parseSingleStack(line)?.file).toBe(file) + }) +}) -- 2.51.2 From cd423ba045ed50662ea8e7bf7c2373e876cc0e5e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 9 Oct 2026 18:07:58 +0200 Subject: [PATCH 2/3] fix(mocker): fix native loader mocking edge cases (#11540) --- packages/mocker/src/node/hoistMocks.ts | 3 +- packages/mocker/src/node/parsers.ts | 32 +++++----- test/e2e/test/no-module-runner.test.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/packages/mocker/src/node/hoistMocks.ts b/packages/mocker/src/node/hoistMocks.ts index 13a847eeb..56896d646 100644 --- a/packages/mocker/src/node/hoistMocks.ts +++ b/packages/mocker/src/node/hoistMocks.ts @@ -290,9 +290,10 @@ export function hoistMocks( let hasMockApiCall = false esmWalker(ast, { + // called after all call expressions are visited onIdentifier(id, info, parentStack) { const binding = idToImportMap.get(id.name) - if (!binding) { + if (!binding || !hasMockApiCall) { return } diff --git a/packages/mocker/src/node/parsers.ts b/packages/mocker/src/node/parsers.ts index 1ee659d90..ea5872730 100644 --- a/packages/mocker/src/node/parsers.ts +++ b/packages/mocker/src/node/parsers.ts @@ -39,21 +39,22 @@ export function collectModuleExports( if (format === 'module') { const [imports_, exports_] = parseModuleSyntax(code, filename) const fileExports = exports_.map((p) => p.n) + // cached before re-exports are parsed, so circular re-exports stop here + cachedFileExports.set(filename, fileExports) imports_.forEach(({ ss: start, se: end, n: name }) => { const substring = code.substring(start, end).replace(/ +/g, ' ') if (name && substring.startsWith('export *') && !substring.startsWith('export * as')) { fileExports.push(...tryParseModule(name)) } }) - cachedFileExports.set(filename, fileExports) exports.push(...fileExports) } else { const { exports: exports_, reexports } = parseCjsSyntax(code, filename) const fileExports = [...exports_] + cachedFileExports.set(filename, fileExports) reexports.forEach((name) => { fileExports.push(...tryParseModule(name)) }) - cachedFileExports.set(filename, fileExports) exports.push(...fileExports) } @@ -96,27 +97,26 @@ export function collectModuleExports( return cachedFileExports.get(resolvedModulePath)! } - const fileContent = readFileSync(resolvedModulePath, 'utf-8') const ext = extname(resolvedModulePath) - const code = transformCode(fileContent, resolvedModulePath) - if (code == null) { - cachedFileExports.set(resolvedModulePath, []) - return [] - } - - const resolvedModuleFormat = resolveModuleFormat(resolvedModulePath, code) if (ext === '.json') { return ['default'] - } else { + } + + const fileContent = readFileSync(resolvedModulePath, 'utf-8') + const code = transformCode(fileContent, resolvedModulePath) + const resolvedModuleFormat = resolveModuleFormat( + pathToFileURL(resolvedModulePath).toString(), + code, + ) + if (!resolvedModuleFormat) { // can't do wasm, for example console.warn( - `Cannot process '${resolvedModuleFormat}' imported from ${filename} because of unknown file extension: ${ext}.`, + `Cannot process '${resolvedModulePath}' imported from ${filename} because of unknown file extension: ${ext}.`, ) + cachedFileExports.set(resolvedModulePath, []) + return [] } - if (resolvedModuleFormat) { - return collectModuleExports(resolvedModulePath, code, resolvedModuleFormat, exports) - } - return [] + return collectModuleExports(resolvedModulePath, code, resolvedModuleFormat, exports) } return Array.from(new Set(exports)) diff --git a/test/e2e/test/no-module-runner.test.ts b/test/e2e/test/no-module-runner.test.ts index 248994277..3e7b3a78d 100644 --- a/test/e2e/test/no-module-runner.test.ts +++ b/test/e2e/test/no-module-runner.test.ts @@ -508,6 +508,88 @@ if (import.meta.vitest) { `) }) + test('imports are kept when the file has no hoisted calls', async () => { + const { stderr, testTree } = await runNoViteModuleRunnerTests({ + 'add.js': `export const add = (a, b) => a + b`, + 'comment.test.js': /* js */ ` +import { add } from './add.js' +// vi.mock('./add.js') +test('add', () => { + expect(add(1, 2)).toBe(3) +}) + `, + 'import-actual.test.js': /* js */ ` +import { add } from './add.js' +test('add', async () => { + const actual = await vi.importActual('./add.js') + expect(add).toBe(actual.add) +}) + `, + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "comment.test.js": { + "add": "passed", + }, + "import-actual.test.js": { + "add": "passed", + }, + } + `) + }) + + test('manual mock keeps names re-exported with export *', async () => { + const { stderr, testTree } = await runNoViteModuleRunnerTests({ + 'base.js': `export function fromBase() { return 'base' }`, + 'star.js': /* js */ ` +export * from './base.js' +export function own() { return 'own' } + `, + 'star.test.js': /* js */ ` +import { fromBase, own } from './star.js' +vi.mock(import('./star.js'), () => ({ + own: () => 'mocked-own', + fromBase: () => 'mocked-base', +})) +test('re-exported names are mocked', () => { + expect(own()).toBe('mocked-own') + expect(fromBase()).toBe('mocked-base') +}) + `, + 'users.js': /* js */ ` +export * from './posts.js' +export const getUser = () => 'user' + `, + 'posts.js': /* js */ ` +export * from './users.js' +export const getPost = () => 'post' + `, + 'circular.test.js': /* js */ ` +import { getPost, getUser } from './users.js' +vi.mock(import('./users.js'), () => ({ + getUser: () => 'mocked-user', + getPost: () => 'mocked-post', +})) +test('circular re-exports are mocked', () => { + expect(getUser()).toBe('mocked-user') + expect(getPost()).toBe('mocked-post') +}) + `, + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "circular.test.js": { + "circular re-exports are mocked": "passed", + }, + "star.test.js": { + "re-exported names are mocked": "passed", + }, + } + `) + }) + test('cannot import JS file without extension in ESM', async () => { const { stderr, root } = await runNoViteModuleRunnerTests({ 'add.js': /* js */ ` -- 2.51.2 From 69e6369f64455b4d78a6338521bbfc5fdb909af0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 9 Oct 2026 18:47:02 +0200 Subject: [PATCH 3/3] fix(browser): open one cdp session per tester (#11542) --- packages/browser/src/node/projectParent.ts | 43 +++++++++++----------- test/browser/test/cdp.test.ts | 5 ++- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/browser/src/node/projectParent.ts b/packages/browser/src/node/projectParent.ts index bee7fd871..5a31705de 100644 --- a/packages/browser/src/node/projectParent.ts +++ b/packages/browser/src/node/projectParent.ts @@ -4,7 +4,6 @@ import type { ParsedStack, TestError } from 'vitest' import type { BrowserCommand, BrowserScript, - CDPSession, ResolvedConfig, TestProject, Vite, @@ -172,14 +171,26 @@ export class ParentBrowserProject { }) } - public readonly cdps: Map = new Map() - private cdpSessionsPromises = new Map>() + private cdpHandlers = new Map>() - async ensureCDPHandler(sessionId: string, rpcId: string): Promise { - const cachedHandler = this.cdps.get(rpcId) - if (cachedHandler) { - return cachedHandler + ensureCDPHandler(sessionId: string, rpcId: string): Promise { + let handler = this.cdpHandlers.get(rpcId) + if (!handler) { + handler = this.createCDPHandler(sessionId, rpcId) + this.cdpHandlers.set(rpcId, handler) + handler.catch(() => { + if (this.cdpHandlers.get(rpcId) === handler) { + this.cdpHandlers.delete(rpcId) + } + }) } + return handler + } + + private async createCDPHandler( + sessionId: string, + rpcId: string, + ): Promise { const browserSession = this.vitest._browserSessions.getSession(sessionId) if (!browserSession) { throw new Error(`Session "${sessionId}" not found.`) @@ -196,28 +207,18 @@ export class ParentBrowserProject { throw new Error(`CDP is not supported by the provider "${provider.name}".`) } - const session = - (await this.cdpSessionsPromises.get(rpcId)) ?? - (await (async () => { - const promise = provider.getCDPSession!(sessionId).finally(() => { - this.cdpSessionsPromises.delete(rpcId) - }) - this.cdpSessionsPromises.set(rpcId, promise) - return promise - })()) + const session = await provider.getCDPSession(sessionId) const rpc = (browser.state as BrowserServerState).testers.get(rpcId) if (!rpc) { throw new Error(`Tester RPC "${rpcId}" was not established.`) } - const handler = new BrowserServerCDPHandler(session, rpc) - this.cdps.set(rpcId, handler) - return handler + return new BrowserServerCDPHandler(session, rpc) } - removeCDPHandler(sessionId: string): void { - this.cdps.delete(sessionId) + removeCDPHandler(rpcId: string): void { + this.cdpHandlers.delete(rpcId) } async formatScripts(scripts: BrowserScript[] | undefined): Promise { diff --git a/test/browser/test/cdp.test.ts b/test/browser/test/cdp.test.ts index 5bfdbdbd3..6de825059 100644 --- a/test/browser/test/cdp.test.ts +++ b/test/browser/test/cdp.test.ts @@ -7,10 +7,11 @@ describe.runIf(server.provider === 'playwright' && server.browser === 'chromium' it('cdp sends events correctly', async () => { const messageAdded = vi.fn() - await cdp().send('Console.enable') - + // `on` does not wait for the server, so subscribe before the next request cdp().on('Console.messageAdded', messageAdded) + await cdp().send('Console.enable') + onTestFinished(async () => { await cdp().send('Console.disable') })