diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx
index c469b6f..46c100d 100644
--- a/src/__tests__/app.test.tsx
+++ b/src/__tests__/app.test.tsx
@@ -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))
@@ -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 () => {
@@ -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()
const user = userEvent.setup()
@@ -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()
+ 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()
+ 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)',
+ ),
)
})
})
diff --git a/src/__tests__/fixtures/regular/index.ts b/src/__tests__/fixtures/regular/index.ts
index b129f0b..0e13c74 100644
--- a/src/__tests__/fixtures/regular/index.ts
+++ b/src/__tests__/fixtures/regular/index.ts
@@ -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)
diff --git a/src/lib.ts b/src/lib.ts
index 3bab27b..789300c 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -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(
diff --git a/src/stack-trace.ts b/src/stack-trace.ts
index 35d37c4..7c2bb72 100644
--- a/src/stack-trace.ts
+++ b/src/stack-trace.ts
@@ -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) {
@@ -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()
- frames.forEach(f => f.file && files.add(f.file))
+ lines.forEach(line => line.frame?.file && files.add(line.frame.file))
return Array.from(files)
}