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
113 changes: 81 additions & 32 deletions src/__tests__/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ describe('source maps', () => {
const resultTextArea = screen.getByRole('textbox', { name: /original stack trace/i })

await user.type(stacktraceTextarea, regular.stacktrace)
expect(resultTextArea).toHaveValue(regular.reconstructed)
// Without matching source maps the stack trace passes through verbatim.
expect(resultTextArea).toHaveValue(regular.stacktrace)

const sourceMapFileInput = screen.getByLabelText(/choose files/i)
const files = regular.sourcemaps.map(sm => new File([sm.content], sm.fileName))
Expand Down Expand Up @@ -137,7 +138,7 @@ describe('source maps', () => {
const deleteButtons = await screen.findAllByRole('button', { name: 'delete' })
await Promise.all(deleteButtons.map(btn => user.click(btn)))

await waitFor(() => expect(resultTextArea).toHaveValue(regular.reconstructed))
await waitFor(() => expect(resultTextArea).toHaveValue(regular.stacktrace))
})

test('ignores empty files list', async () => {
Expand Down Expand Up @@ -493,35 +494,35 @@ describe('source map input race', () => {
})
})

describe('column numbers', () => {
// Generated line 1 has mappings at 0-based columns 0, 10, and 11 which lead
// to different original lines, so an off-by-one in the column conversion
// resolves to a wrong original position.
function createCraftedSourceMap() {
const generator = new SourceMapGenerator({ file: 'crafted.min.js' })

generator.addMapping({
generated: { column: 0, line: 1 },
name: 'start',
original: { column: 0, line: 1 },
source: 'original.ts',
})
generator.addMapping({
generated: { column: 10, line: 1 },
name: 'first',
original: { column: 2, line: 5 },
source: 'original.ts',
})
generator.addMapping({
generated: { column: 11, line: 1 },
name: 'second',
original: { column: 4, line: 9 },
source: 'original.ts',
})
// Generated line 1 has mappings at 0-based columns 0, 10, and 11 which lead
// to different original lines, so an off-by-one in the column conversion
// resolves to a wrong original position.
function createCraftedSourceMap() {
const generator = new SourceMapGenerator({ file: 'crafted.min.js' })

return generator.toString()
}
generator.addMapping({
generated: { column: 0, line: 1 },
name: 'start',
original: { column: 0, line: 1 },
source: 'original.ts',
})
generator.addMapping({
generated: { column: 10, line: 1 },
name: 'first',
original: { column: 2, line: 5 },
source: 'original.ts',
})
generator.addMapping({
generated: { column: 11, line: 1 },
name: 'second',
original: { column: 4, line: 9 },
source: 'original.ts',
})

return generator.toString()
}

describe('column numbers', () => {
test('converts 1-based stack trace columns to the 0-based source map convention and back', async () => {
render(<App />)
const user = userEvent.setup()
Expand Down Expand Up @@ -567,16 +568,64 @@ describe('column numbers', () => {
const stacktraceTextarea = screen.getByRole('textbox', { name: /minified stack trace/i })
const resultTextArea = screen.getByRole('textbox', { name: /original stack trace/i })

// Line 2 has no mappings, so the frame must stay as is instead of
// becoming `null:null:null`.
// Line 2 has no mappings, so the raw frame line must stay as is instead
// of becoming `null:null:null`.
await user.type(stacktraceTextarea, 'Error: boom\n at crafted.min.js:2:1')

const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
sourcemapTextarea.focus()
await user.paste(createCraftedSourceMap())

await waitFor(() => expect(resultTextArea).toHaveValue('Error: boom\n at crafted.min.js:2:1'))
})
})

describe('unparsed lines', () => {
test('preserves multi-line error messages', async () => {
render(<App />)
const user = userEvent.setup()

const stacktraceTextarea = screen.getByRole('textbox', { name: /minified stack trace/i })
const resultTextArea = screen.getByRole('textbox', { name: /original stack trace/i })

await user.type(
stacktraceTextarea,
'Error: boom\nsecond line of the message\n at crafted.min.js:1:11',
)

const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
sourcemapTextarea.focus()
await user.paste(createCraftedSourceMap())

await waitFor(() =>
expect(resultTextArea).toHaveValue(
'Error: boom\nsecond line of the message\n at first (original.ts:5:3)',
),
)
})

test('keeps unparsed lines in their original position', async () => {
render(<App />)
const user = userEvent.setup()

const stacktraceTextarea = screen.getByRole('textbox', { name: /minified stack trace/i })
const resultTextArea = screen.getByRole('textbox', { name: /original stack trace/i })

// The async marker and the cause chain are not recognized by the parser
// and must pass through in place, between the mapped frames.
await user.type(
stacktraceTextarea,
'Error: outer\n at crafted.min.js:1:11\n at async run\nCaused by: Error: inner\n at crafted.min.js:1:1',
)

const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
sourcemapTextarea.focus()
await user.paste(createCraftedSourceMap())

await waitFor(() =>
expect(resultTextArea).toHaveValue('Error: boom\n at (crafted.min.js:2:1)'),
expect(resultTextArea).toHaveValue(
'Error: outer\n at first (original.ts:5:3)\n at async run\nCaused by: Error: inner\n at start (original.ts:1:1)',
),
)
})
})
Expand Down
9 changes: 0 additions & 9 deletions src/__tests__/fixtures/regular/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,6 @@ export const regular = {
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:12455:3)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15306:7)
at (../../node_modules/react-dom/cjs/react-dom-client.production.js:15274:7)`,
reconstructed: `Uncaught Error: Error!
at e.throwError (index-F7qoIhl0.js:2:1251)
at onClick (index-F7qoIhl0.js:3:5483)
at yd (vendor-B_FE3Fnm.js:8:125915)
at (vendor-B_FE3Fnm.js:8:130908)
at gn (vendor-B_FE3Fnm.js:8:15080)
at wd (vendor-B_FE3Fnm.js:8:127142)
at up (vendor-B_FE3Fnm.js:9:28431)
at cp (vendor-B_FE3Fnm.js:9:28253)`,
result: `Uncaught Error: Error!
at (../../src/source-map.ts:60:15)
at (../../src/app.tsx:164:67)
Expand Down
16 changes: 8 additions & 8 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ export function transform(sourceMaps: SourceMap[], stackTrace: null | StackTrace
}

const bindings = calculateBindings(sourceMaps, stackTrace)
const result = [stackTrace.message]

const transformed = stackTrace.frames.map(stackFrame =>
generateStackTraceLine(
toUnifiedPosition(tryGetOriginalPosition(stackFrame, bindings) ?? stackFrame),
),
)

return result.concat(transformed).join('\n')
// Only replace lines whose frames were successfully mapped; everything else
// (error messages, unparsed lines, unmapped frames) keeps its raw text.
return stackTrace.lines
.map(({ frame, raw }) => {
const original = frame && tryGetOriginalPosition(frame, bindings)
return original ? generateStackTraceLine(toUnifiedPosition(original)) : raw
})
.join('\n')
}

function tryGetOriginalPosition(
Expand Down
31 changes: 17 additions & 14 deletions src/stack-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ import type { StackFrame } from 'stacktrace-parser'

import { parse } from 'stacktrace-parser'

export interface StackTraceLine {
frame: null | StackFrame
raw: string
}

export class StackTrace {
fileNames: string[]
frames: StackFrame[]
message: string
lines: StackTraceLine[]

constructor(rawStackTrace: string) {
const frames = parse(rawStackTrace)

this.frames = frames
this.message = this.#extractErrorMessage(rawStackTrace)
this.fileNames = this.#extractFileNames(frames)
// Parse line by line so that lines the parser does not recognize
// (error messages, async markers, cause chains) keep their raw text.
this.lines = rawStackTrace.split('\n').map(raw => ({
frame: parse(raw)[0] ?? null,
raw,
}))

this.fileNames = this.#extractFileNames(this.lines)
}

static create(rawStackTrace: string) {
Expand All @@ -24,21 +31,17 @@ export class StackTrace {

const stackTrace = new StackTrace(rawStackTrace)

if (stackTrace.frames.length === 0) {
if (stackTrace.lines.every(line => !line.frame)) {
return null
}

return stackTrace
}

#extractErrorMessage(stackTrace: string) {
return stackTrace.split('\n')[0]
}

#extractFileNames(frames: StackFrame[]) {
#extractFileNames(lines: StackTraceLine[]) {
const files = new Set<string>()

frames.forEach(f => f.file && files.add(f.file))
lines.forEach(line => line.frame?.file && files.add(line.frame.file))

return Array.from(files)
}
Expand Down
Loading