lib: add source map support for assert messages

Map source lines in assert messages with cached source maps.

PR-URL: https://github.com/nodejs/node/pull/59751
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
This commit is contained in:
Chengzhong Wu 2025-09-03 21:49:02 +01:00 committed by Node.js GitHub Bot
parent d35bd2088e
commit f1b56d6200
14 changed files with 135 additions and 59 deletions

View File

@ -8,9 +8,15 @@ const {
const {
getErrorSourcePositions,
} = internalBinding('errors');
const {
getSourceMapsSupport,
findSourceMap,
getSourceLine,
} = require('internal/source_map/source_map_cache');
/**
* Get the source location of an error.
* Get the source location of an error. If source map is enabled, resolve the source location
* based on the source map.
*
* The `error.stack` must not have been accessed. The resolution is based on the structured
* error stack data.
@ -21,10 +27,35 @@ function getErrorSourceLocation(error) {
const pos = getErrorSourcePositions(error);
const {
sourceLine,
scriptResourceName,
lineNumber,
startColumn,
} = pos;
return { sourceLine, startColumn };
// Source map is not enabled. Return the source line directly.
if (!getSourceMapsSupport().enabled) {
return { sourceLine, startColumn };
}
const sm = findSourceMap(scriptResourceName);
if (sm === undefined) {
return;
}
const {
originalLine,
originalColumn,
originalSource,
} = sm.findEntry(lineNumber - 1, startColumn);
const originalSourceLine = getSourceLine(sm, originalSource, originalLine, originalColumn);
if (!originalSourceLine) {
return;
}
return {
sourceLine: originalSourceLine,
startColumn: originalColumn,
};
}
const memberAccessTokens = [ '.', '?.', '[', ']' ];
@ -111,7 +142,8 @@ function getFirstExpression(code, startColumn) {
}
/**
* Get the source expression of an error.
* Get the source expression of an error. If source map is enabled, resolve the source location
* based on the source map.
*
* The `error.stack` must not have been accessed, or the source location may be incorrect. The
* resolution is based on the structured error stack data.

View File

@ -1,11 +1,9 @@
'use strict';
const {
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ErrorPrototypeToString,
RegExpPrototypeSymbolSplit,
SafeStringIterator,
StringPrototypeRepeat,
StringPrototypeSlice,
@ -16,8 +14,7 @@ let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => {
debug = fn;
});
const { getStringWidth } = require('internal/util/inspect');
const { readFileSync } = require('fs');
const { findSourceMap } = require('internal/source_map/source_map_cache');
const { findSourceMap, getSourceLine } = require('internal/source_map/source_map_cache');
const {
kIsNodeError,
} = require('internal/errors');
@ -155,21 +152,13 @@ function getErrorSource(
originalLine,
originalColumn,
) {
const originalSourcePathNoScheme =
StringPrototypeStartsWith(originalSourcePath, 'file://') ?
fileURLToPath(originalSourcePath) : originalSourcePath;
const source = getOriginalSource(
sourceMap.payload,
originalSourcePath,
);
if (typeof source !== 'string') {
return;
}
const lines = RegExpPrototypeSymbolSplit(/\r?\n/, source, originalLine + 1);
const line = lines[originalLine];
const line = getSourceLine(sourceMap, originalSourcePath, originalLine);
if (!line) {
return;
}
const originalSourcePathNoScheme =
StringPrototypeStartsWith(originalSourcePath, 'file://') ?
fileURLToPath(originalSourcePath) : originalSourcePath;
// Display ^ in appropriate position, regardless of whether tabs or
// spaces are used:
@ -182,39 +171,10 @@ function getErrorSource(
prefix = StringPrototypeSlice(prefix, 0, -1); // The last character is '^'.
const exceptionLine =
`${originalSourcePathNoScheme}:${originalLine + 1}\n${line}\n${prefix}^\n\n`;
`${originalSourcePathNoScheme}:${originalLine + 1}\n${line}\n${prefix}^\n`;
return exceptionLine;
}
/**
* Retrieve the original source code from the source map's `sources` list or disk.
* @param {import('internal/source_map/source_map').SourceMap.payload} payload
* @param {string} originalSourcePath - path or url of the original source
* @returns {string | undefined} - the source content or undefined if file not found
*/
function getOriginalSource(payload, originalSourcePath) {
let source;
// payload.sources has been normalized to be an array of absolute urls.
const sourceContentIndex =
ArrayPrototypeIndexOf(payload.sources, originalSourcePath);
if (payload.sourcesContent?.[sourceContentIndex]) {
// First we check if the original source content was provided in the
// source map itself:
source = payload.sourcesContent[sourceContentIndex];
} else if (StringPrototypeStartsWith(originalSourcePath, 'file://')) {
// If no sourcesContent was found, attempt to load the original source
// from disk:
debug(`read source of ${originalSourcePath} from filesystem`);
const originalSourcePathNoScheme = fileURLToPath(originalSourcePath);
try {
source = readFileSync(originalSourcePathNoScheme, 'utf8');
} catch (err) {
debug(err);
}
}
return source;
}
/**
* Retrieve exact line in the original source code from the source map's `sources` list or disk.
* @param {string} fileName - actual file name

View File

@ -1,13 +1,16 @@
'use strict';
const {
ArrayPrototypeIndexOf,
ArrayPrototypePush,
JSONParse,
ObjectFreeze,
RegExpPrototypeExec,
RegExpPrototypeSymbolSplit,
SafeMap,
StringPrototypeCodePointAt,
StringPrototypeSplit,
StringPrototypeStartsWith,
} = primordials;
// See https://tc39.es/ecma426/ for SourceMap V3 specification.
@ -16,6 +19,7 @@ let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => {
debug = fn;
});
const { readFileSync } = require('fs');
const { validateBoolean, validateObject } = require('internal/validators');
const {
setSourceMapsEnabled: setSourceMapsNative,
@ -277,8 +281,7 @@ function lineLengths(content) {
*/
function sourceMapFromFile(mapURL) {
try {
const fs = require('fs');
const content = fs.readFileSync(fileURLToPath(mapURL), 'utf8');
const content = readFileSync(fileURLToPath(mapURL), 'utf8');
const data = JSONParse(content);
return sourcesToAbsolute(mapURL, data);
} catch (err) {
@ -400,8 +403,62 @@ function findSourceMap(sourceURL) {
}
}
/**
* Retrieve the original source code from the source map's `sources` list or disk.
* @param {import('internal/source_map/source_map').SourceMap.payload} payload
* @param {string} originalSourcePath - path or url of the original source
* @returns {string | undefined} - the source content or undefined if file not found
*/
function getOriginalSource(payload, originalSourcePath) {
let source;
// payload.sources has been normalized to be an array of absolute urls.
const sourceContentIndex =
ArrayPrototypeIndexOf(payload.sources, originalSourcePath);
if (payload.sourcesContent?.[sourceContentIndex]) {
// First we check if the original source content was provided in the
// source map itself:
source = payload.sourcesContent[sourceContentIndex];
} else if (StringPrototypeStartsWith(originalSourcePath, 'file://')) {
// If no sourcesContent was found, attempt to load the original source
// from disk:
debug(`read source of ${originalSourcePath} from filesystem`);
const originalSourcePathNoScheme = fileURLToPath(originalSourcePath);
try {
source = readFileSync(originalSourcePathNoScheme, 'utf8');
} catch (err) {
debug(err);
}
}
return source;
}
/**
* Get the line of source in the source map.
* @param {import('internal/source_map/source_map').SourceMap} sourceMap
* @param {string} originalSourcePath path or url of the original source
* @param {number} originalLine line number in the original source
* @returns {string|undefined} source line if found
*/
function getSourceLine(
sourceMap,
originalSourcePath,
originalLine,
) {
const source = getOriginalSource(
sourceMap.payload,
originalSourcePath,
);
if (typeof source !== 'string') {
return;
}
const lines = RegExpPrototypeSymbolSplit(/\r?\n/, source, originalLine + 1);
const line = lines[originalLine];
return line;
}
module.exports = {
findSourceMap,
getSourceLine,
getSourceMapsSupport,
setSourceMapsSupport,
maybeCacheSourceMap,

View File

@ -0,0 +1,20 @@
AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:
assert(false)
at Object.<anonymous> (*/test/fixtures/source-map/output/source_map_assert_source_line.ts:11:3)
*
*
*
*
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
*
*
*
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}

View File

@ -0,0 +1,14 @@
// Flags: --enable-source-maps --experimental-transform-types --no-warnings
require('../../../common');
const assert = require('node:assert');
enum Bar {
makeSureTransformTypes,
}
try {
assert(false);
} catch (e) {
console.log(e);
}

View File

@ -2,7 +2,6 @@
throw err
^
Error: an error!
at functionD (*/test/fixtures/source-map/enclosing-call-site.js:16:17)
at functionC (*/test/fixtures/source-map/enclosing-call-site.js:10:3)

View File

@ -2,7 +2,6 @@
alert "I knew it!"
^
ReferenceError: alert is not defined
at Object.eval (*/synthesized/workspace/tabs-source-url.coffee:26:2)
at eval (*/synthesized/workspace/tabs-source-url.coffee:1:14)

View File

@ -2,7 +2,6 @@
alert "I knew it!"
^
ReferenceError: alert is not defined
at Object.<anonymous> (*/test/fixtures/source-map/tabs.coffee:26:2)
at Object.<anonymous> (*/test/fixtures/source-map/tabs.coffee:1:14)

View File

@ -2,7 +2,6 @@
throw new Error('message')
^
Error: message
at Throw (*/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts:13:9)
at async Promise.all (index 3)

View File

@ -2,7 +2,6 @@
throw new Error('message');
^
Error: message
at new Foo (*/test/fixtures/source-map/output/source_map_throw_construct.mts:13:11)
at <anonymous> (*/test/fixtures/source-map/output/source_map_throw_construct.mts:17:1)

View File

@ -3,7 +3,6 @@ reachable
throw Error('an exception');
^
Error: an exception
at branch (*/test/fixtures/source-map/typescript-throw.ts:18:11)
at Object.<anonymous> (*/test/fixtures/source-map/typescript-throw.ts:24:1)

View File

@ -2,7 +2,6 @@
("あ 🐕 🐕", throw Error("an error"));
^
Error: an error
at Object.createElement (*/test/fixtures/source-map/icu.jsx:3:23)
at Object.<anonymous> (*/test/fixtures/source-map/icu.jsx:9:5)

View File

@ -2,7 +2,6 @@
throw Error('goodbye');
^
Error: goodbye
at Hello (*/test/fixtures/source-map/uglify-throw-original.js:5:9)
at Immediate.<anonymous> (*/test/fixtures/source-map/uglify-throw-original.js:9:3)

View File

@ -14,6 +14,7 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () =>
);
const tests = [
{ name: 'source-map/output/source_map_assert_source_line.ts' },
{ name: 'source-map/output/source_map_disabled_by_api.js' },
{ name: 'source-map/output/source_map_disabled_by_process_api.js' },
{ name: 'source-map/output/source_map_enabled_by_api.js' },