Propagate failures from the nested LCOV reporter fixture process and make missing LCOV records fail with a targeted assertion instead of comparing against blank transformed output. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5 PR-URL: https://github.com/nodejs/node/pull/64049 Refs: https://github.com/nodejs/reliability/issues?q=%22test-output-lcov-reporter%22 Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
331 lines
11 KiB
JavaScript
331 lines
11 KiB
JavaScript
'use strict';
|
|
const common = require('.');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const fs = require('node:fs/promises');
|
|
const { realpathSync } = require('node:fs');
|
|
const assert = require('node:assert/strict');
|
|
const { pathToFileURL } = require('node:url');
|
|
const { hostname } = require('node:os');
|
|
|
|
/* eslint-disable @stylistic/js/max-len,no-control-regex */
|
|
/**
|
|
* Group 1: Line start (including color codes and escapes)
|
|
* Group 2: Function name
|
|
* Group 3: Filename
|
|
* Group 4: Line number
|
|
* Group 5: Column number
|
|
* Group 6: Line end (including color codes and `{` which indicates the start of an error object details)
|
|
*/
|
|
// Mappings: (g1 ) (g2 ) (g3 ) (g4 ) (g5 ) (g6 )
|
|
const internalStackFramesRegexp = /(?<=\n)(\s*(?:\x1b?\[\d+m\s+)?(?:at\s+)?)(?:async\s+)?(?:(.+?)\s+\()?(?:(node:.+?):(\d+)(?::(\d+))?)\)?((?:\x1b?\[\d+m)?\s*{?\n|$)/g;
|
|
/**
|
|
* Group 1: Filename
|
|
* Group 2: Line number
|
|
* Group 3: Line end and source code line
|
|
*/
|
|
const internalErrorSourceLines = /(?<=\n|^)(node:.+?):(\d+)(\n.*\n\s*\^(?:\n|$))/g;
|
|
/* eslint-enable @stylistic/js/max-len,no-control-regex */
|
|
|
|
const windowNewlineRegexp = /\r/g;
|
|
|
|
// Replaces the current Node.js executable version strings with a
|
|
// placeholder. This could commonly present in an unhandled exception
|
|
// output.
|
|
function replaceNodeVersion(str) {
|
|
return str.replaceAll(`Node.js ${process.version}`, 'Node.js <node-version>');
|
|
}
|
|
|
|
// Collapse consecutive identical lines containing the keyword into
|
|
// one single line. The `str` should have been processed by `replaceWindowsLineEndings`.
|
|
function foldIdenticalLines(str, keyword) {
|
|
const lines = str.split('\n');
|
|
const folded = lines.filter((line, idx) => {
|
|
if (idx === 0) {
|
|
return true;
|
|
}
|
|
if (line.includes(keyword) && line === lines[idx - 1]) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return folded.join('\n');
|
|
}
|
|
|
|
const kInternalFrame = '<node-internal-frames>';
|
|
// Replace non-internal frame `at TracingChannel.traceSync (node:diagnostics_channel:328:14)`
|
|
// as well as `at node:internal/main/run_main_module:33:47` with `at <node-internal-frames>`.
|
|
// Also replaces error source line like:
|
|
// node:internal/mod.js:44
|
|
// throw err;
|
|
// ^
|
|
function replaceInternalStackTrace(str) {
|
|
const result = str.replaceAll(internalErrorSourceLines, `$1:<line>$3`)
|
|
.replaceAll(internalStackFramesRegexp, `$1${kInternalFrame}$6`);
|
|
return foldIdenticalLines(result, kInternalFrame);
|
|
}
|
|
|
|
// Replaces Windows line endings with posix line endings for unified snapshots
|
|
// across platforms.
|
|
function replaceWindowsLineEndings(str) {
|
|
return str.replace(windowNewlineRegexp, '');
|
|
}
|
|
|
|
// Replaces all Windows path separators with posix separators for unified snapshots
|
|
// across platforms.
|
|
function replaceWindowsPaths(str) {
|
|
if (!common.isWindows) {
|
|
return str;
|
|
}
|
|
// Only replace `\` and `\\` with a leading letter, colon, or a `.`.
|
|
// Avoid replacing escaping patterns like ` \#`, `\ `, or `\\`.
|
|
return str.replaceAll(/(?<=(\w:|\.|\w+)(?:\S|\\ )*)\\\\?/g, '/');
|
|
}
|
|
|
|
// Removes line trailing white spaces.
|
|
function replaceTrailingSpaces(str) {
|
|
return str.replaceAll(/[\t ]+\n/g, '\n');
|
|
}
|
|
|
|
// Replaces customized or platform specific executable names to be `<node-exe>`.
|
|
function generalizeExeName(str) {
|
|
const baseName = path.basename(process.argv0 || 'node', '.exe');
|
|
return str.replaceAll(`${baseName} --`, '<node-exe> --');
|
|
}
|
|
|
|
// Replaces the pids in warning messages with a placeholder.
|
|
function replaceWarningPid(str) {
|
|
return str.replaceAll(/\(node:\d+\)/g, '(node:<pid>)');
|
|
}
|
|
|
|
// Replaces a path with a placeholder. The path can be a platform specific path
|
|
// or a file URL.
|
|
function transformPath(dirname, replacement) {
|
|
// Handles output already processed by `replaceWindowsPaths`.
|
|
const winPath = replaceWindowsPaths(dirname);
|
|
// Handles URL encoded path in file URL strings as well.
|
|
const urlEncoded = pathToFileURL(dirname).pathname;
|
|
// On Windows, paths are case-insensitive, so we need to use case-insensitive
|
|
// regex replacement to handle cases where the drive letter case differs.
|
|
const flags = common.isWindows ? 'gi' : 'g';
|
|
const urlEncodedRegex = new RegExp(RegExp.escape(urlEncoded), flags);
|
|
const dirnameRegex = new RegExp(RegExp.escape(dirname), flags);
|
|
const winPathRegex = new RegExp(RegExp.escape(winPath), flags);
|
|
return (str) => {
|
|
return str.replaceAll('\\\'', "'")
|
|
// Replace fileUrl first as `winPath` could be a substring of the fileUrl.
|
|
.replaceAll(urlEncodedRegex, replacement)
|
|
.replaceAll(dirnameRegex, replacement)
|
|
.replaceAll(winPathRegex, replacement);
|
|
};
|
|
}
|
|
|
|
// Replaces path strings representing the nodejs/node repo full project root with
|
|
// `<project-root>`. Also replaces file URLs containing the full project root path.
|
|
// The project root path may contain unicode characters.
|
|
const kProjectRoot = '<project-root>';
|
|
function transformProjectRoot() {
|
|
const projectRoot = path.resolve(__dirname, '../..');
|
|
if (process.env.NODE_TEST_DIR) {
|
|
const testDir = realpathSync(process.env.NODE_TEST_DIR);
|
|
// On Jenkins CI, the test dir may be overridden by `NODE_TEST_DIR`.
|
|
return transform(
|
|
transformPath(projectRoot, kProjectRoot),
|
|
transformPath(testDir, `${kProjectRoot}/test`),
|
|
// TODO(legendecas): test-runner may print relative paths to the test relative to cwd.
|
|
// It will be better if we could distinguish them from the project root.
|
|
transformPath(path.relative(projectRoot, testDir), 'test'),
|
|
);
|
|
}
|
|
return transformPath(projectRoot, kProjectRoot);
|
|
}
|
|
|
|
// Replaces tmpdirs created by `test/common/tmpdir.js`.
|
|
function transformTmpDir(str) {
|
|
return str.replaceAll(/\/\.tmp\.\d+\//g, '/<tmpdir>/');
|
|
}
|
|
|
|
function transform(...args) {
|
|
return (str) => args.reduce((acc, fn) => fn(acc), str);
|
|
}
|
|
|
|
function getSnapshotPath(filename) {
|
|
const { name, dir } = path.parse(filename);
|
|
return path.resolve(dir, `${name}.snapshot`);
|
|
}
|
|
|
|
async function assertSnapshot(actual, filename = process.argv[1]) {
|
|
const snapshot = getSnapshotPath(filename);
|
|
if (process.env.NODE_REGENERATE_SNAPSHOTS) {
|
|
await fs.writeFile(snapshot, actual);
|
|
} else {
|
|
let expected;
|
|
try {
|
|
expected = await fs.readFile(snapshot, 'utf8');
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') {
|
|
console.log(
|
|
'Snapshot file does not exist. You can create a new one by running the test with NODE_REGENERATE_SNAPSHOTS=1',
|
|
);
|
|
}
|
|
throw e;
|
|
}
|
|
assert.strictEqual(actual, replaceWindowsLineEndings(expected));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawn a process and assert its output against a snapshot.
|
|
* if you want to automatically update the snapshot, run tests with NODE_REGENERATE_SNAPSHOTS=1
|
|
* transform is a function that takes the output and returns a string that will be compared against the snapshot
|
|
* this is useful for normalizing output such as stack traces
|
|
* there are some predefined transforms in this file such as replaceStackTrace and replaceWindowsLineEndings
|
|
* both of which can be used as an example for writing your own
|
|
* compose multiple transforms by passing them as arguments to the transform function:
|
|
* assertSnapshot.transform(assertSnapshot.replaceStackTrace, assertSnapshot.replaceWindowsLineEndings)
|
|
* @param {string} filename
|
|
* @param {function(string): string} [transform]
|
|
* @param {object} [options] - control how the child process is spawned
|
|
* @param {boolean} [options.tty] - whether to spawn the process in a pseudo-tty
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function spawnAndAssert(filename, transform = (x) => x, { tty = false, ...options } = {}) {
|
|
if (tty && common.isWindows) {
|
|
test({ skip: 'Skipping pseudo-tty tests, as pseudo terminals are not available on Windows.' });
|
|
return;
|
|
}
|
|
let { flags } = common.parseTestMetadata(filename);
|
|
if (options.flags) {
|
|
flags = [...options.flags, ...flags];
|
|
}
|
|
|
|
const executable = tty ? (process.env.PYTHON || 'python3') : process.execPath;
|
|
const args =
|
|
tty ?
|
|
[path.join(__dirname, '../..', 'tools/pseudo-tty.py'), process.execPath, ...flags, filename] :
|
|
[...flags, filename];
|
|
const { stdout, stderr } = await common.spawnPromisified(executable, args, options);
|
|
await assertSnapshot(transform(`${stdout}${stderr}`), filename);
|
|
}
|
|
|
|
function replaceTestDuration(str) {
|
|
return str
|
|
.replaceAll(/duration_ms: [0-9.]+/g, 'duration_ms: *')
|
|
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *');
|
|
}
|
|
|
|
const root = path.resolve(__dirname, '..', '..');
|
|
function replaceSpecDuration(str) {
|
|
return str
|
|
.replaceAll(/[0-9.]+ms/g, '*ms')
|
|
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *');
|
|
}
|
|
|
|
function replaceJunitDuration(str) {
|
|
return str
|
|
.replaceAll(/time="[0-9.]+"/g, 'time="*"')
|
|
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *')
|
|
.replaceAll(`hostname="${hostname()}"`, 'hostname="HOSTNAME"')
|
|
.replaceAll(/timestamp="[^"]*"/g, 'timestamp="*"')
|
|
.replaceAll(/file="[^"]*"/g, 'file="*"');
|
|
}
|
|
|
|
// The Node test coverage returns results for all files called by the test. This
|
|
// will make the output file change if files like test/common/index.js change.
|
|
// This transform picks only the first line and then the lines from the test
|
|
// file.
|
|
function pickTestFileFromLcov(str) {
|
|
const expectedFile = 'output.js';
|
|
const lines = str.split(/\n/);
|
|
const firstLineOfTestFile = lines.findIndex(
|
|
(line) => line.startsWith('SF:') && line.trim().endsWith(expectedFile),
|
|
);
|
|
|
|
if (firstLineOfTestFile === -1) {
|
|
assert.fail(
|
|
`Could not find LCOV source record ending with ${expectedFile} ` +
|
|
`in LCOV output:\n${str || '<empty>'}`,
|
|
);
|
|
}
|
|
|
|
const lastLineOfTestFile = lines.findIndex(
|
|
(line, index) => index > firstLineOfTestFile && line.trim() === 'end_of_record',
|
|
);
|
|
|
|
if (lastLineOfTestFile === -1) {
|
|
assert.fail(
|
|
`Could not find end_of_record for LCOV source record ending with ${expectedFile} ` +
|
|
`in LCOV output:\n${str}`,
|
|
);
|
|
}
|
|
|
|
return (
|
|
lines[0] + '\n' + lines.slice(firstLineOfTestFile, lastLineOfTestFile + 1).join('\n') + '\n'
|
|
);
|
|
}
|
|
|
|
// Transforms basic patterns like:
|
|
// - platform specific path and line endings
|
|
// - line trailing spaces
|
|
// - executable specific path and versions
|
|
// - project root path and tmpdir
|
|
// - node internal stack frames
|
|
const basicTransform = transform(
|
|
replaceWindowsLineEndings,
|
|
replaceTrailingSpaces,
|
|
replaceWindowsPaths,
|
|
replaceNodeVersion,
|
|
generalizeExeName,
|
|
replaceWarningPid,
|
|
transformProjectRoot(),
|
|
transformTmpDir,
|
|
replaceInternalStackTrace,
|
|
);
|
|
|
|
const defaultTransform = transform(
|
|
basicTransform,
|
|
replaceTestDuration,
|
|
);
|
|
const specTransform = transform(
|
|
replaceSpecDuration,
|
|
basicTransform,
|
|
);
|
|
const junitTransform = transform(
|
|
replaceJunitDuration,
|
|
basicTransform,
|
|
);
|
|
const lcovTransform = transform(
|
|
basicTransform,
|
|
pickTestFileFromLcov,
|
|
);
|
|
|
|
function ensureCwdIsProjectRoot() {
|
|
if (process.cwd() !== root) {
|
|
process.chdir(root);
|
|
}
|
|
}
|
|
|
|
function canColorize() {
|
|
// Loading it lazily to avoid breaking `NODE_REGENERATE_SNAPSHOTS`.
|
|
return require('internal/tty').getColorDepth() > 2;
|
|
}
|
|
|
|
module.exports = {
|
|
assertSnapshot,
|
|
getSnapshotPath,
|
|
replaceNodeVersion,
|
|
replaceInternalStackTrace,
|
|
replaceWindowsLineEndings,
|
|
replaceWindowsPaths,
|
|
spawnAndAssert,
|
|
transform,
|
|
transformProjectRoot,
|
|
replaceTestDuration,
|
|
basicTransform,
|
|
defaultTransform,
|
|
specTransform,
|
|
junitTransform,
|
|
lcovTransform,
|
|
ensureCwdIsProjectRoot,
|
|
canColorize,
|
|
};
|