diff --git a/lib/internal/source_map/prepare_stack_trace.js b/lib/internal/source_map/prepare_stack_trace.js index 1b12bb89f084ca..60c9d1ed3316ff 100644 --- a/lib/internal/source_map/prepare_stack_trace.js +++ b/lib/internal/source_map/prepare_stack_trace.js @@ -24,6 +24,8 @@ const { const { fileURLToPath } = require('internal/url'); const { setGetSourceMapErrorSource } = internalBinding('errors'); +const kStackLineAt = '\n at '; + // Create a prettified stacktrace, inserting context from source maps // if possible. function prepareStackTraceWithSourceMaps(error, trace) { @@ -40,14 +42,13 @@ function prepareStackTraceWithSourceMaps(error, trace) { let lastSourceMap; let lastFileName; - const preparedTrace = ArrayPrototypeJoin(ArrayPrototypeMap(trace, (t, i) => { - const str = '\n at '; + const preparedTrace = ArrayPrototypeJoin(ArrayPrototypeMap(trace, (callSite, i) => { try { // A stack trace will often have several call sites in a row within the // same file, cache the source map and file content accordingly: - let fileName = t.getFileName(); + let fileName = callSite.getFileName(); if (fileName === undefined) { - fileName = t.getEvalOrigin(); + fileName = callSite.getEvalOrigin(); } const sm = fileName === lastFileName ? lastSourceMap : @@ -55,60 +56,84 @@ function prepareStackTraceWithSourceMaps(error, trace) { lastSourceMap = sm; lastFileName = fileName; if (sm) { - // Source Map V3 lines/columns start at 0/0 whereas stack traces - // start at 1/1: - const { - originalLine, - originalColumn, - originalSource, - } = sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1); - if (originalSource && originalLine !== undefined && - originalColumn !== undefined) { - const name = getOriginalSymbolName(sm, trace, i); - // Construct call site name based on: v8.dev/docs/stack-trace-api: - const fnName = t.getFunctionName() ?? t.getMethodName(); - const typeName = t.getTypeName(); - const namePrefix = typeName !== null && typeName !== 'global' ? `${typeName}.` : ''; - const originalName = `${namePrefix}${fnName || ''}`; - // The original call site may have a different symbol name - // associated with it, use it: - const prefix = (name && name !== originalName) ? - `${name}` : - `${originalName}`; - const hasName = !!(name || originalName); - const originalSourceNoScheme = - StringPrototypeStartsWith(originalSource, 'file://') ? - fileURLToPath(originalSource) : originalSource; - // Replace the transpiled call site with the original: - return `${str}${prefix}${hasName ? ' (' : ''}` + - `${originalSourceNoScheme}:${originalLine + 1}:` + - `${originalColumn + 1}${hasName ? ')' : ''}`; - } + return `${kStackLineAt}${serializeJSStackFrame(sm, callSite, trace[i + 1])}`; } } catch (err) { debug(err); } - return `${str}${t}`; + return `${kStackLineAt}${callSite}`; }), ''); return `${errorString}${preparedTrace}`; } +/** + * Serialize a single call site in the stack trace. + * Refer to SerializeJSStackFrame in deps/v8/src/objects/call-site-info.cc for + * more details about the default ToString(CallSite). + * The CallSite API is documented at https://v8.dev/docs/stack-trace-api. + * @param {import('internal/source_map/source_map').SourceMap} sm + * @param {CallSite} callSite - the CallSite object to be serialized + * @param {CallSite} callerCallSite - caller site info + * @returns {string} - the serialized call site + */ +function serializeJSStackFrame(sm, callSite, callerCallSite) { + // Source Map V3 lines/columns start at 0/0 whereas stack traces + // start at 1/1: + const { + originalLine, + originalColumn, + originalSource, + } = sm.findEntry(callSite.getLineNumber() - 1, callSite.getColumnNumber() - 1); + if (originalSource === undefined || originalLine === undefined || + originalColumn === undefined) { + return `${callSite}`; + } + const name = getOriginalSymbolName(sm, callSite, callerCallSite); + const originalSourceNoScheme = + StringPrototypeStartsWith(originalSource, 'file://') ? + fileURLToPath(originalSource) : originalSource; + // Construct call site name based on: v8.dev/docs/stack-trace-api: + const fnName = callSite.getFunctionName() ?? callSite.getMethodName(); + + let prefix = ''; + if (callSite.isAsync()) { + // Promise aggregation operation frame has no locations. This must be an + // async stack frame. + prefix = 'async '; + } else if (callSite.isConstructor()) { + prefix = 'new '; + } + + const typeName = callSite.getTypeName(); + const namePrefix = typeName !== null && typeName !== 'global' ? `${typeName}.` : ''; + const originalName = `${namePrefix}${fnName || ''}`; + // The original call site may have a different symbol name + // associated with it, use it: + const mappedName = (name && name !== originalName) ? + `${name}` : + `${originalName}`; + const hasName = !!(name || originalName); + // Replace the transpiled call site with the original: + return `${prefix}${mappedName}${hasName ? ' (' : ''}` + + `${originalSourceNoScheme}:${originalLine + 1}:` + + `${originalColumn + 1}${hasName ? ')' : ''}`; +} + // Transpilers may have removed the original symbol name used in the stack // trace, if possible restore it from the names field of the source map: -function getOriginalSymbolName(sourceMap, trace, curIndex) { +function getOriginalSymbolName(sourceMap, callSite, callerCallSite) { // First check for a symbol name associated with the enclosing function: const enclosingEntry = sourceMap.findEntry( - trace[curIndex].getEnclosingLineNumber() - 1, - trace[curIndex].getEnclosingColumnNumber() - 1, + callSite.getEnclosingLineNumber() - 1, + callSite.getEnclosingColumnNumber() - 1, ); if (enclosingEntry.name) return enclosingEntry.name; - // Fallback to using the symbol name attached to the next stack frame: - const currentFileName = trace[curIndex].getFileName(); - const nextCallSite = trace[curIndex + 1]; - if (nextCallSite && currentFileName === nextCallSite.getFileName()) { + // Fallback to using the symbol name attached to the caller site: + const currentFileName = callSite.getFileName(); + if (callerCallSite && currentFileName === callerCallSite.getFileName()) { const { name } = sourceMap.findEntry( - nextCallSite.getLineNumber() - 1, - nextCallSite.getColumnNumber() - 1, + callerCallSite.getLineNumber() - 1, + callerCallSite.getColumnNumber() - 1, ); return name; } diff --git a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs new file mode 100644 index 00000000000000..8e3fefbebe4d5d --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs @@ -0,0 +1,13 @@ +// Flags: --enable-source-maps +import '../../../common/index.mjs'; +async function Throw() { + await 0; + throw new Error('message'); +} +(async function main() { + await Promise.all([0, 1, 2, Throw()]); +})(); +// To recreate: +// +// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts +//# sourceMappingURL=source_map_throw_async_stack_trace.mjs.map \ No newline at end of file diff --git a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs.map b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs.map new file mode 100644 index 00000000000000..728e8c20291a9b --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"source_map_throw_async_stack_trace.mjs","sourceRoot":"","sources":["source_map_throw_async_stack_trace.mts"],"names":[],"mappings":"AAAA,+BAA+B;AAE/B,OAAO,2BAA2B,CAAC;AAQnC,KAAK,UAAU,KAAK;IAClB,MAAM,CAAC,CAAC;IACR,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,CAAA;AAC5B,CAAC;AAED,CAAC,KAAK,UAAU,IAAI;IAClB,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AACxC,CAAC,CAAC,EAAE,CAAA;AAEJ,eAAe;AACf,EAAE;AACF,6LAA6L"} \ No newline at end of file diff --git a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts new file mode 100644 index 00000000000000..718f617928d5ce --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts @@ -0,0 +1,22 @@ +// Flags: --enable-source-maps + +import '../../../common/index.mjs'; + +interface Foo { + /** line + * + * blocks */ +} + +async function Throw() { + await 0; + throw new Error('message') +} + +(async function main() { + await Promise.all([0, 1, 2, Throw()]); +})() + +// To recreate: +// +// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts diff --git a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot new file mode 100644 index 00000000000000..8f7f0490587585 --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot @@ -0,0 +1,11 @@ +*output*source_map_throw_async_stack_trace.mts:13 + throw new Error('message') + ^ + + +Error: message + at Throw (*output*source_map_throw_async_stack_trace.mts:13:9) + at async Promise.all (index 3) + at async main (*output*source_map_throw_async_stack_trace.mts:17:3) + +Node.js * diff --git a/test/fixtures/source-map/output/source_map_throw_construct.mjs b/test/fixtures/source-map/output/source_map_throw_construct.mjs new file mode 100644 index 00000000000000..eab7bc2fbf2b76 --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_construct.mjs @@ -0,0 +1,11 @@ +class Foo { + constructor() { + throw new Error('message'); + } +} +new Foo(); +export {}; +// To recreate: +// +// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_construct.mts +//# sourceMappingURL=source_map_throw_construct.mjs.map \ No newline at end of file diff --git a/test/fixtures/source-map/output/source_map_throw_construct.mjs.map b/test/fixtures/source-map/output/source_map_throw_construct.mjs.map new file mode 100644 index 00000000000000..992250fe228329 --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_construct.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"source_map_throw_construct.mjs","sourceRoot":"","sources":["source_map_throw_construct.mts"],"names":[],"mappings":"AAMA,MAAM,GAAG;IACP;QACE,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC;IAC7B,CAAC;CACF;AAED,IAAI,GAAG,EAAE,CAAC;;AAEV,eAAe;AACf,EAAE;AACF,qLAAqL"} \ No newline at end of file diff --git a/test/fixtures/source-map/output/source_map_throw_construct.mts b/test/fixtures/source-map/output/source_map_throw_construct.mts new file mode 100644 index 00000000000000..cd163c3392370e --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_construct.mts @@ -0,0 +1,17 @@ +interface Block { + /** line + * + * blocks */ +} + +class Foo { + constructor() { + throw new Error('message'); + } +} + +new Foo(); + +// To recreate: +// +// npx --package typescript tsc --module nodenext --target esnext --outDir test/fixtures/source-map/output --sourceMap test/fixtures/source-map/output/source_map_throw_construct.mts diff --git a/test/fixtures/source-map/output/source_map_throw_construct.snapshot b/test/fixtures/source-map/output/source_map_throw_construct.snapshot new file mode 100644 index 00000000000000..5aca3027ab9669 --- /dev/null +++ b/test/fixtures/source-map/output/source_map_throw_construct.snapshot @@ -0,0 +1,12 @@ +file:**output*source_map_throw_construct.mjs:3 + throw new Error('message'); + ^ + +Error: message + at new Foo (file:**output*source_map_throw_construct.mjs:3:15) + at file:**output*source_map_throw_construct.mjs:6:1 + * + * + * + +Node.js * diff --git a/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot b/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot index ee3294a2871f6e..ec9f1346ca5e0c 100644 --- a/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot @@ -6,6 +6,6 @@ Error: goodbye at Hello (*uglify-throw-original.js:5:9) at Immediate. (*uglify-throw-original.js:9:3) - at process.processImmediate (node:internal*timers:503:21) + * Node.js * diff --git a/test/parallel/test-node-output-sourcemaps.mjs b/test/parallel/test-node-output-sourcemaps.mjs index d82f4a249cd1d9..e9104db220867f 100644 --- a/test/parallel/test-node-output-sourcemaps.mjs +++ b/test/parallel/test-node-output-sourcemaps.mjs @@ -13,7 +13,7 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () => .replaceAll(/\/(\w)/g, '*$1') .replaceAll('*test*', '*') .replaceAll('*fixtures*source-map*', '*') - .replaceAll(/(\W+).*node:internal\*modules.*/g, '$1*'); + .replaceAll(/(\W+).*node:.*/g, '$1*'); if (common.isWindows) { const currentDeviceLetter = path.parse(process.cwd()).root.substring(0, 1).toLowerCase(); const regex = new RegExp(`${currentDeviceLetter}:/?`, 'gi'); @@ -34,7 +34,9 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () => { name: 'source-map/output/source_map_prepare_stack_trace.js' }, { name: 'source-map/output/source_map_reference_error_tabs.js' }, { name: 'source-map/output/source_map_sourcemapping_url_string.js' }, + { name: 'source-map/output/source_map_throw_async_stack_trace.mjs' }, { name: 'source-map/output/source_map_throw_catch.js' }, + { name: 'source-map/output/source_map_throw_construct.mjs' }, { name: 'source-map/output/source_map_throw_first_tick.js' }, { name: 'source-map/output/source_map_throw_icu.js' }, { name: 'source-map/output/source_map_throw_set_immediate.js' },