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) }