[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(browser): improve source map handling for bundled files (#7534)

authored by

Vladimir and committed by
GitHub
(Feb 25, 2025, 4:56 PM +0100) e2c570b6 5387a5b3

+124 -26
+3
pnpm-lock.yaml
··· 1057 1057 '@vitest/browser': 1058 1058 specifier: workspace:* 1059 1059 version: link:../../packages/browser 1060 + '@vitest/bundled-lib': 1061 + specifier: link:./bundled-lib 1062 + version: link:bundled-lib 1060 1063 '@vitest/cjs-lib': 1061 1064 specifier: link:./cjs-lib 1062 1065 version: link:cjs-lib
+1
test/browser/package.json
··· 26 26 "@types/react": "^18.2.79", 27 27 "@vitejs/plugin-basic-ssl": "^1.0.2", 28 28 "@vitest/browser": "workspace:*", 29 + "@vitest/bundled-lib": "link:./bundled-lib", 29 30 "@vitest/cjs-lib": "link:./cjs-lib", 30 31 "@vitest/injected-lib": "link:./injected-lib", 31 32 "react": "^18.3.1",
+1 -1
test/browser/vitest.config.mts
··· 42 42 }, 43 43 }, 44 44 optimizeDeps: { 45 - include: ['@vitest/cjs-lib', 'react/jsx-dev-runtime'], 45 + include: ['@vitest/cjs-lib', '@vitest/bundled-lib', 'react/jsx-dev-runtime'], 46 46 }, 47 47 test: { 48 48 include: ['test/**.test.{ts,js,tsx}'],
+38 -14
packages/utils/src/source-map.ts
··· 16 16 export interface StackTraceParserOptions { 17 17 ignoreStackEntries?: (RegExp | string)[] 18 18 getSourceMap?: (file: string) => unknown 19 - getFileName?: (id: string) => string 19 + getUrlId?: (id: string) => string 20 20 frameFilter?: (error: ErrorWithDiff, frame: ParsedStack) => boolean | void 21 21 } 22 22 ··· 62 62 } 63 63 if (url.startsWith('http:') || url.startsWith('https:')) { 64 64 const urlObj = new URL(url) 65 - url = urlObj.pathname 65 + urlObj.searchParams.delete('import') 66 + urlObj.searchParams.delete('browserv') 67 + url = urlObj.pathname + urlObj.hash + urlObj.search 66 68 } 67 69 if (url.startsWith('/@fs/')) { 68 70 const isWindows = /^\/@fs\/[a-zA-Z]:\//.test(url) ··· 198 200 options: StackTraceParserOptions = {}, 199 201 ): ParsedStack[] { 200 202 const { ignoreStackEntries = stackIgnorePatterns } = options 201 - let stacks = !CHROME_IE_STACK_REGEXP.test(stack) 203 + const stacks = !CHROME_IE_STACK_REGEXP.test(stack) 202 204 ? parseFFOrSafariStackTrace(stack) 203 205 : parseV8Stacktrace(stack) 204 - if (ignoreStackEntries.length) { 205 - stacks = stacks.filter( 206 - stack => !ignoreStackEntries.some(p => stack.file.match(p)), 207 - ) 208 - } 206 + 209 207 return stacks.map((stack) => { 210 - if (options.getFileName) { 211 - stack.file = options.getFileName(stack.file) 208 + if (options.getUrlId) { 209 + stack.file = options.getUrlId(stack.file) 212 210 } 213 211 214 212 const map = options.getSourceMap?.(stack.file) as ··· 216 214 | null 217 215 | undefined 218 216 if (!map || typeof map !== 'object' || !map.version) { 219 - return stack 217 + return shouldFilter(ignoreStackEntries, stack.file) ? null : stack 220 218 } 219 + 221 220 const traceMap = new TraceMap(map) 222 - const { line, column } = originalPositionFor(traceMap, stack) 221 + const { line, column, source, name } = originalPositionFor(traceMap, stack) 222 + 223 + let file: string = stack.file 224 + if (source) { 225 + const fileUrl = stack.file.startsWith('file://') 226 + ? stack.file 227 + : `file://${stack.file}` 228 + const sourceRootUrl = map.sourceRoot 229 + ? new URL(map.sourceRoot, fileUrl) 230 + : fileUrl 231 + file = new URL(source, sourceRootUrl).pathname 232 + } 233 + 234 + if (shouldFilter(ignoreStackEntries, file)) { 235 + return null 236 + } 237 + 223 238 if (line != null && column != null) { 224 - return { ...stack, line, column } 239 + return { 240 + line, 241 + column, 242 + file, 243 + method: name || stack.method, 244 + } 225 245 } 226 246 return stack 227 - }) 247 + }).filter(s => s != null) 248 + } 249 + 250 + function shouldFilter(ignoreStackEntries: (string | RegExp)[], file: string): boolean { 251 + return ignoreStackEntries.some(p => file.match(p)) 228 252 } 229 253 230 254 function parseFFOrSafariStackTrace(stack: string): ParsedStack[] {
+6
test/browser/bundled-lib/package.json
··· 1 + { 2 + "name": "@vitest/bundled-lib", 3 + "type": "module", 4 + "private": true, 5 + "main": "./src/index.js" 6 + }
+8 -4
test/browser/specs/runner.test.ts
··· 163 163 164 164 test(`stack trace points to correct file in every browser`, () => { 165 165 // depending on the browser it references either `.toBe()` or `expect()` 166 - expect(stderr).toMatch(/test\/failing.test.ts:10:(12|17)/) 166 + expect(stderr).toMatch(/test\/failing.test.ts:11:(12|17)/) 167 167 168 168 // column is 18 in safari, 8 in others 169 169 expect(stderr).toMatch(/throwError src\/error.ts:8:(18|8)/) 170 170 171 171 expect(stderr).toContain('The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.') 172 - expect(stderr).toMatch(/test\/failing.test.ts:18:(27|36)/) 173 - expect(stderr).toMatch(/test\/failing.test.ts:19:(27|33)/) 174 - expect(stderr).toMatch(/test\/failing.test.ts:20:(27|39)/) 172 + expect(stderr).toMatch(/test\/failing.test.ts:19:(27|36)/) 173 + expect(stderr).toMatch(/test\/failing.test.ts:20:(27|33)/) 174 + expect(stderr).toMatch(/test\/failing.test.ts:21:(27|39)/) 175 + 176 + expect(stderr).toMatch(/bundled-lib\/src\/b.js:2:(8|18)/) 177 + expect(stderr).toMatch(/bundled-lib\/src\/index.js:5:(15|17)/) 178 + expect(stderr).toMatch(/test\/failing.test.ts:25:(2|8)/) 175 179 }) 176 180 177 181 test('popup apis should log a warning', () => {
+5
test/browser/test/failing.test.ts
··· 1 1 import { page } from '@vitest/browser/context' 2 + import { index } from '@vitest/bundled-lib' 2 3 import { expect, it } from 'vitest' 3 4 import { throwError } from '../src/error' 4 5 ··· 18 19 page.getByRole('button').dblClick() 19 20 page.getByRole('button').click() 20 21 page.getByRole('button').tripleClick() 22 + }) 23 + 24 + it('correctly prints error from a bundled file', () => { 25 + index() 21 26 })
+49 -7
packages/browser/src/node/projectParent.ts
··· 10 10 Vitest, 11 11 } from 'vitest/node' 12 12 import type { BrowserServerState } from './state' 13 + import { readFileSync } from 'node:fs' 13 14 import { readFile } from 'node:fs/promises' 14 15 import { parseErrorStacktrace, parseStacktrace, type StackTraceParserOptions } from '@vitest/utils/source-map' 15 - import { join, resolve } from 'pathe' 16 + import { dirname, join, resolve } from 'pathe' 16 17 import { BrowserServerCDPHandler } from './cdp' 17 18 import builtinCommands from './commands/index' 18 19 import { distRoot } from './constants' ··· 41 42 42 43 public config: ResolvedConfig 43 44 45 + // cache for non-vite source maps 46 + private sourceMapCache = new Map<string, any>() 47 + 44 48 constructor( 45 49 public project: TestProject, 46 50 public base: string, ··· 50 54 this.stackTraceOptions = { 51 55 frameFilter: project.config.onStackTrace, 52 56 getSourceMap: (id) => { 57 + if (this.sourceMapCache.has(id)) { 58 + return this.sourceMapCache.get(id) 59 + } 53 60 const result = this.vite.moduleGraph.getModuleById(id)?.transformResult 61 + // this can happen for bundled dependencies in node_modules/.vite 62 + if (result && !result.map) { 63 + const sourceMapUrl = this.retrieveSourceMapURL(result.code) 64 + if (!sourceMapUrl) { 65 + return null 66 + } 67 + const filepathDir = dirname(id) 68 + const sourceMapPath = resolve(filepathDir, sourceMapUrl) 69 + const map = JSON.parse(readFileSync(sourceMapPath, 'utf-8')) 70 + this.sourceMapCache.set(id, map) 71 + return map 72 + } 54 73 return result?.map 55 74 }, 56 - getFileName: (id) => { 75 + getUrlId: (id) => { 57 76 const mod = this.vite.moduleGraph.getModuleById(id) 58 - if (mod?.file) { 59 - return mod.file 77 + if (mod) { 78 + return id 60 79 } 61 - const modUrl = this.vite.moduleGraph.urlToModuleMap.get(id) 62 - if (modUrl?.file) { 63 - return modUrl.file 80 + const resolvedPath = resolve(project.config.root, id.slice(1)) 81 + const modUrl = this.vite.moduleGraph.getModuleById(resolvedPath) 82 + if (modUrl) { 83 + return resolvedPath 84 + } 85 + // some browsers (looking at you, safari) don't report queries in stack traces 86 + // the next best thing is to try the first id that this file resolves to 87 + const files = this.vite.moduleGraph.getModulesByFile(resolvedPath) 88 + if (files && files.size) { 89 + return files.values().next().value!.id! 64 90 } 65 91 return id 66 92 }, ··· 234 260 .split('/') 235 261 const decodedTestFile = decodeURIComponent(testFile) 236 262 return { sessionId, testFile: decodedTestFile } 263 + } 264 + 265 + private retrieveSourceMapURL(source: string): string | null { 266 + const re 267 + = /\/\/[@#]\s*sourceMappingURL=([^\s'"]+)\s*$|\/\*[@#]\s*sourceMappingURL=[^\s*'"]+\s*\*\/\s*$/gm 268 + // Keep executing the search to find the *last* sourceMappingURL to avoid 269 + // picking up sourceMappingURLs from comments, strings, etc. 270 + let lastMatch, match 271 + // eslint-disable-next-line no-cond-assign 272 + while ((match = re.exec(source))) { 273 + lastMatch = match 274 + } 275 + if (!lastMatch) { 276 + return null 277 + } 278 + return lastMatch[1] 237 279 } 238 280 }
+3
test/browser/bundled-lib/src/a.js
··· 1 + export function a() { 2 + return 'a' 3 + }
+3
test/browser/bundled-lib/src/b.js
··· 1 + export function b() { 2 + throw new Error('error from b') 3 + }
+1
test/browser/bundled-lib/src/index.d.ts
··· 1 + export declare function index(): string
+6
test/browser/bundled-lib/src/index.js
··· 1 + import { a } from './a.js' 2 + import { b } from './b.js' 3 + 4 + export function index() { 5 + return a() + b() 6 + }