Skip to content

Commit

Permalink
fix!(browser): print correct stacktrace (#3698)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Jul 19, 2023
1 parent a90d64f commit f56de2a
Show file tree
Hide file tree
Showing 32 changed files with 296 additions and 213 deletions.
2 changes: 1 addition & 1 deletion packages/browser/package.json
Expand Up @@ -39,7 +39,7 @@
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"vitest": ">=0.32.3"
"vitest": ">=0.34.0"
},
"dependencies": {
"estree-walker": "^3.0.3",
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/vite.config.ts
Expand Up @@ -12,6 +12,7 @@ export default defineConfig({
minify: false,
outDir: '../../dist/client',
emptyOutDir: false,
assetsDir: '__vitest_browser__',
},
plugins: [
{
Expand Down
4 changes: 2 additions & 2 deletions packages/snapshot/src/port/state.ts
Expand Up @@ -6,7 +6,7 @@
*/

import type { ParsedStack } from '@vitest/utils'
import { parseErrorStacktrace } from '@vitest/utils'
import { parseErrorStacktrace } from '@vitest/utils/source-map'
import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
import type { SnapshotData, SnapshotEnvironment, SnapshotMatchOptions, SnapshotResult, SnapshotStateOptions, SnapshotUpdateState } from '../types'
import type { InlineSnapshot } from './inlineSnapshot'
Expand Down Expand Up @@ -128,7 +128,7 @@ export default class SnapshotState {
): void {
this._dirty = true
if (options.isInline) {
const stacks = parseErrorStacktrace(options.error || new Error('snapshot'), [])
const stacks = parseErrorStacktrace(options.error || new Error('snapshot'), { ignoreStackEntries: [] })
const stack = this._inferInlineSnapshotStack(stacks)
if (!stack) {
throw new Error(
Expand Down
7 changes: 7 additions & 0 deletions packages/utils/package.json
Expand Up @@ -32,6 +32,10 @@
"types": "./dist/helpers.d.ts",
"import": "./dist/helpers.js"
},
"./source-map": {
"types": "./dist/source-map.d.ts",
"import": "./dist/source-map.js"
},
"./*": "./*"
},
"main": "./dist/index.js",
Expand All @@ -50,5 +54,8 @@
"diff-sequences": "^29.4.3",
"loupe": "^2.3.6",
"pretty-format": "^29.5.0"
},
"devDependencies": {
"@jridgewell/trace-mapping": "^0.3.18"
}
}
11 changes: 6 additions & 5 deletions packages/utils/rollup.config.js
Expand Up @@ -7,11 +7,12 @@ import json from '@rollup/plugin-json'
import pkg from './package.json' assert { type: 'json' }

const entries = {
index: 'src/index.ts',
helpers: 'src/helpers.ts',
diff: 'src/diff/index.ts',
error: 'src/error.ts',
types: 'src/types.ts',
'index': 'src/index.ts',
'helpers': 'src/helpers.ts',
'diff': 'src/diff/index.ts',
'error': 'src/error.ts',
'source-map': 'src/source-map.ts',
'types': 'src/types.ts',
}

const external = [
Expand Down
1 change: 1 addition & 0 deletions packages/utils/source-map.ts
@@ -0,0 +1 @@
export * from './dist/source-map.js'
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Expand Up @@ -7,4 +7,4 @@ export * from './display'
export * from './constants'
export * from './colors'
export * from './base'
export * from './source-map'
export * from './offset'
42 changes: 42 additions & 0 deletions packages/utils/src/offset.ts
@@ -0,0 +1,42 @@
export const lineSplitRE = /\r?\n/

export function positionToOffset(
source: string,
lineNumber: number,
columnNumber: number,
): number {
const lines = source.split(lineSplitRE)
const nl = /\r\n/.test(source) ? 2 : 1
let start = 0

if (lineNumber > lines.length)
return source.length

for (let i = 0; i < lineNumber - 1; i++)
start += lines[i].length + nl

return start + columnNumber
}

export function offsetToLineNumber(
source: string,
offset: number,
): number {
if (offset > source.length) {
throw new Error(
`offset is longer than source length! offset ${offset} > length ${source.length}`,
)
}
const lines = source.split(lineSplitRE)
const nl = /\r\n/.test(source) ? 2 : 1
let counted = 0
let line = 0
for (; line < lines.length; line++) {
const lineLength = lines[line].length + nl
if (counted + lineLength >= offset)
break

counted += lineLength
}
return line + 1
}
150 changes: 93 additions & 57 deletions packages/utils/src/source-map.ts
@@ -1,8 +1,19 @@
import { resolve } from 'pathe'
import type { SourceMapInput } from '@jridgewell/trace-mapping'
import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'
import type { ErrorWithDiff, ParsedStack } from './types'
import { isPrimitive, notNullish } from './helpers'

export const lineSplitRE = /\r?\n/
export { TraceMap, originalPositionFor, generatedPositionFor } from '@jridgewell/trace-mapping'
export type { SourceMapInput } from '@jridgewell/trace-mapping'

export interface StackTraceParserOptions {
ignoreStackEntries?: (RegExp | string)[]
getSourceMap?: (file: string) => unknown
}

const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m
const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code])?$/

const stackIgnorePatterns = [
'node:internal',
Expand All @@ -15,6 +26,8 @@ const stackIgnorePatterns = [
'/node_modules/chai/',
'/node_modules/tinypool/',
'/node_modules/tinyspy/',
'/deps/chai.js',
/__vitest_browser__/,
]

function extractLocation(urlLike: string) {
Expand All @@ -26,14 +39,61 @@ function extractLocation(urlLike: string) {
const parts = regExp.exec(urlLike.replace(/^\(|\)$/g, ''))
if (!parts)
return [urlLike]
return [parts[1], parts[2] || undefined, parts[3] || undefined]
let url = parts[1]
if (url.startsWith('http:') || url.startsWith('https:')) {
const urlObj = new URL(url)
url = urlObj.pathname
}
if (url.startsWith('/@fs/')) {
url
= url.slice(typeof process !== 'undefined' && process.platform === 'win32' ? 5 : 4)
}
return [url, parts[2] || undefined, parts[3] || undefined]
}

export function parseSingleFFOrSafariStack(raw: string): ParsedStack | null {
let line = raw.trim()

if (SAFARI_NATIVE_CODE_REGEXP.test(line))
return null

if (line.includes(' > eval'))
line = line.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ':$1')

if (!line.includes('@') && !line.includes(':'))
return null

const functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/
const matches = line.match(functionNameRegex)
const functionName = matches && matches[1] ? matches[1] : undefined
const [url, lineNumber, columnNumber] = extractLocation(line.replace(functionNameRegex, ''))

if (!url || !lineNumber || !columnNumber)
return null

return {
file: url,
method: functionName || '',
line: Number.parseInt(lineNumber),
column: Number.parseInt(columnNumber),
}
}

export function parseSingleStack(raw: string) {
const line = raw.trim()
if (!CHROME_IE_STACK_REGEXP.test(line))
return parseSingleFFOrSafariStack(line)
return parseSingleV8Stack(line)
}

// Based on https://github.com/stacktracejs/error-stack-parser
// Credit to stacktracejs
export function parseSingleStack(raw: string): ParsedStack | null {
export function parseSingleV8Stack(raw: string): ParsedStack | null {
let line = raw.trim()

if (!CHROME_IE_STACK_REGEXP.test(line))
return null

if (line.includes('(eval '))
line = line.replace(/eval code/g, 'eval').replace(/(\(eval at [^()]*)|(,.*$)/g, '')

Expand Down Expand Up @@ -75,73 +135,49 @@ export function parseSingleStack(raw: string): ParsedStack | null {
}
}

export function parseStacktrace(stack: string, ignore = stackIgnorePatterns): ParsedStack[] {
const stackFrames = stack
.split('\n')
.map((raw): ParsedStack | null => {
const stack = parseSingleStack(raw)

if (!stack || (ignore.length && ignore.some(p => stack.file.match(p))))
return null

export function parseStacktrace(stack: string, options: StackTraceParserOptions = {}): ParsedStack[] {
const { ignoreStackEntries = stackIgnorePatterns } = options
let stacks = !CHROME_IE_STACK_REGEXP.test(stack)
? parseFFOrSafariStackTrace(stack)
: parseV8Stacktrace(stack)
if (ignoreStackEntries.length)
stacks = stacks.filter(stack => !ignoreStackEntries.some(p => stack.file.match(p)))
return stacks.map((stack) => {
const map = options.getSourceMap?.(stack.file) as SourceMapInput | null | undefined
if (!map || typeof map !== 'object' || !map.version)
return stack
})
const traceMap = new TraceMap(map)
const { line, column } = originalPositionFor(traceMap, stack)
if (line != null && column != null)
return { ...stack, line, column }
return stack
})
}

function parseFFOrSafariStackTrace(stack: string): ParsedStack[] {
return stack
.split('\n')
.map(line => parseSingleFFOrSafariStack(line))
.filter(notNullish)
}

return stackFrames
function parseV8Stacktrace(stack: string): ParsedStack[] {
return stack
.split('\n')
.map(line => parseSingleV8Stack(line))
.filter(notNullish)
}

export function parseErrorStacktrace(e: ErrorWithDiff, ignore = stackIgnorePatterns): ParsedStack[] {
export function parseErrorStacktrace(e: ErrorWithDiff, options: StackTraceParserOptions = {}): ParsedStack[] {
if (!e || isPrimitive(e))
return []

if (e.stacks)
return e.stacks

const stackStr = e.stack || e.stackStr || ''
const stackFrames = parseStacktrace(stackStr, ignore)
const stackFrames = parseStacktrace(stackStr, options)

e.stacks = stackFrames
return stackFrames
}

export function positionToOffset(
source: string,
lineNumber: number,
columnNumber: number,
): number {
const lines = source.split(lineSplitRE)
const nl = /\r\n/.test(source) ? 2 : 1
let start = 0

if (lineNumber > lines.length)
return source.length

for (let i = 0; i < lineNumber - 1; i++)
start += lines[i].length + nl

return start + columnNumber
}

export function offsetToLineNumber(
source: string,
offset: number,
): number {
if (offset > source.length) {
throw new Error(
`offset is longer than source length! offset ${offset} > length ${source.length}`,
)
}
const lines = source.split(lineSplitRE)
const nl = /\r\n/.test(source) ? 2 : 1
let counted = 0
let line = 0
for (; line < lines.length; line++) {
const lineLength = lines[line].length + nl
if (counted + lineLength >= offset)
break

counted += lineLength
}
return line + 1
}

0 comments on commit f56de2a

Please sign in to comment.