node/lib/internal/test_runner/utils.js
Yagiz Nizipli bb8cc65edb
lib: replace createDeferredPromise util with Promise.withResolvers
PR-URL: https://github.com/nodejs/node/pull/54836
Reviewed-By: Michaël Zasso <targos@protonmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2024-10-19 10:13:58 +02:00

608 lines
18 KiB
JavaScript

'use strict';
const {
ArrayPrototypeFlatMap,
ArrayPrototypeForEach,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypeReduce,
ArrayPrototypeSome,
MathFloor,
MathMax,
MathMin,
NumberParseInt,
NumberPrototypeToFixed,
ObjectGetOwnPropertyDescriptor,
PromiseWithResolvers,
RegExp,
RegExpPrototypeExec,
SafeMap,
SafePromiseAllReturnArrayLike,
StringPrototypePadEnd,
StringPrototypePadStart,
StringPrototypeRepeat,
StringPrototypeSlice,
StringPrototypeSplit,
} = primordials;
const { AsyncResource } = require('async_hooks');
const { relative, sep } = require('path');
const { createWriteStream } = require('fs');
const { pathToFileURL } = require('internal/url');
const { getOptionValue } = require('internal/options');
const { green, yellow, red, white, shouldColorize } = require('internal/util/colors');
const {
codes: {
ERR_INVALID_ARG_VALUE,
ERR_TEST_FAILURE,
},
kIsNodeError,
} = require('internal/errors');
const { compose } = require('stream');
const { validateInteger } = require('internal/validators');
const coverageColors = {
__proto__: null,
high: green,
medium: yellow,
low: red,
};
const kMultipleCallbackInvocations = 'multipleCallbackInvocations';
const kRegExpPattern = /^\/(.*)\/([a-z]*)$/;
const kPatterns = ['test', 'test/**/*', 'test-*', '*[._-]test'];
const kFileExtensions = ['js', 'mjs', 'cjs'];
if (getOptionValue('--experimental-strip-types')) {
ArrayPrototypePush(kFileExtensions, 'ts', 'mts', 'cts');
}
const kDefaultPattern = `**/{${ArrayPrototypeJoin(kPatterns, ',')}}.{${ArrayPrototypeJoin(kFileExtensions, ',')}}`;
function createDeferredCallback() {
let calledCount = 0;
const { promise, resolve, reject } = PromiseWithResolvers();
const cb = (err) => {
calledCount++;
// If the callback is called a second time, let the user know, but
// don't let them know more than once.
if (calledCount > 1) {
if (calledCount === 2) {
throw new ERR_TEST_FAILURE(
'callback invoked multiple times',
kMultipleCallbackInvocations,
);
}
return;
}
if (err) {
return reject(err);
}
resolve();
};
return { __proto__: null, promise, cb };
}
function isTestFailureError(err) {
return err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err;
}
function convertStringToRegExp(str, name) {
const match = RegExpPrototypeExec(kRegExpPattern, str);
const pattern = match?.[1] ?? str;
const flags = match?.[2] || '';
try {
return new RegExp(pattern, flags);
} catch (err) {
const msg = err?.message;
throw new ERR_INVALID_ARG_VALUE(
name,
str,
`is an invalid regular expression.${msg ? ` ${msg}` : ''}`,
);
}
}
const kBuiltinDestinations = new SafeMap([
['stdout', process.stdout],
['stderr', process.stderr],
]);
const kBuiltinReporters = new SafeMap([
['spec', 'internal/test_runner/reporter/spec'],
['dot', 'internal/test_runner/reporter/dot'],
['tap', 'internal/test_runner/reporter/tap'],
['junit', 'internal/test_runner/reporter/junit'],
['lcov', 'internal/test_runner/reporter/lcov'],
]);
const kDefaultReporter = 'spec';
const kDefaultDestination = 'stdout';
function tryBuiltinReporter(name) {
const builtinPath = kBuiltinReporters.get(name);
if (builtinPath === undefined) {
return;
}
return require(builtinPath);
}
function shouldColorizeTestFiles(destinations) {
// This function assumes only built-in destinations (stdout/stderr) supports coloring
return ArrayPrototypeSome(destinations, (_, index) => {
const destination = kBuiltinDestinations.get(destinations[index]);
return destination && shouldColorize(destination);
});
}
async function getReportersMap(reporters, destinations) {
return SafePromiseAllReturnArrayLike(reporters, async (name, i) => {
const destination = kBuiltinDestinations.get(destinations[i]) ??
createWriteStream(destinations[i], { __proto__: null, flush: true });
// Load the test reporter passed to --test-reporter
let reporter = tryBuiltinReporter(name);
if (reporter === undefined) {
let parentURL;
try {
parentURL = pathToFileURL(process.cwd() + '/').href;
} catch {
parentURL = 'file:///';
}
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
reporter = await cascadedLoader.import(name, parentURL, { __proto__: null });
}
if (reporter?.default) {
reporter = reporter.default;
}
if (reporter?.prototype && ObjectGetOwnPropertyDescriptor(reporter.prototype, 'constructor')) {
reporter = new reporter();
}
if (!reporter) {
throw new ERR_INVALID_ARG_VALUE('Reporter', name, 'is not a valid reporter');
}
return { __proto__: null, reporter, destination };
});
}
const reporterScope = new AsyncResource('TestReporterScope');
let globalTestOptions;
function parseCommandLine() {
if (globalTestOptions) {
return globalTestOptions;
}
const isTestRunner = getOptionValue('--test');
const coverage = getOptionValue('--experimental-test-coverage');
const forceExit = getOptionValue('--test-force-exit');
const sourceMaps = getOptionValue('--enable-source-maps');
const updateSnapshots = getOptionValue('--test-update-snapshots');
const watch = getOptionValue('--watch');
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
let concurrency;
let coverageExcludeGlobs;
let coverageIncludeGlobs;
let lineCoverage;
let branchCoverage;
let functionCoverage;
let destinations;
let isolation;
let only = getOptionValue('--test-only');
let reporters;
let shard;
let testNamePatterns = mapPatternFlagToRegExArray('--test-name-pattern');
let testSkipPatterns = mapPatternFlagToRegExArray('--test-skip-pattern');
let timeout;
if (isChildProcessV8) {
kBuiltinReporters.set('v8-serializer', 'internal/test_runner/reporter/v8-serializer');
reporters = ['v8-serializer'];
destinations = [kDefaultDestination];
} else if (isChildProcess) {
reporters = ['tap'];
destinations = [kDefaultDestination];
} else {
destinations = getOptionValue('--test-reporter-destination');
reporters = getOptionValue('--test-reporter');
if (reporters.length === 0 && destinations.length === 0) {
ArrayPrototypePush(reporters, kDefaultReporter);
}
if (reporters.length === 1 && destinations.length === 0) {
ArrayPrototypePush(destinations, kDefaultDestination);
}
if (destinations.length !== reporters.length) {
throw new ERR_INVALID_ARG_VALUE(
'--test-reporter',
reporters,
'must match the number of specified \'--test-reporter-destination\'',
);
}
}
if (isTestRunner) {
isolation = getOptionValue('--experimental-test-isolation');
timeout = getOptionValue('--test-timeout') || Infinity;
if (isolation === 'none') {
concurrency = 1;
} else {
concurrency = getOptionValue('--test-concurrency') || true;
only = false;
testNamePatterns = null;
testSkipPatterns = null;
}
const shardOption = getOptionValue('--test-shard');
if (shardOption) {
if (!RegExpPrototypeExec(/^\d+\/\d+$/, shardOption)) {
throw new ERR_INVALID_ARG_VALUE(
'--test-shard',
shardOption,
'must be in the form of <index>/<total>',
);
}
const indexAndTotal = StringPrototypeSplit(shardOption, '/');
shard = {
__proto__: null,
index: NumberParseInt(indexAndTotal[0], 10),
total: NumberParseInt(indexAndTotal[1], 10),
};
}
} else {
timeout = Infinity;
concurrency = 1;
const testNamePatternFlag = getOptionValue('--test-name-pattern');
only = getOptionValue('--test-only');
testNamePatterns = testNamePatternFlag?.length > 0 ?
ArrayPrototypeMap(
testNamePatternFlag,
(re) => convertStringToRegExp(re, '--test-name-pattern'),
) : null;
const testSkipPatternFlag = getOptionValue('--test-skip-pattern');
testSkipPatterns = testSkipPatternFlag?.length > 0 ?
ArrayPrototypeMap(testSkipPatternFlag, (re) => convertStringToRegExp(re, '--test-skip-pattern')) : null;
}
if (coverage) {
coverageExcludeGlobs = getOptionValue('--test-coverage-exclude');
coverageIncludeGlobs = getOptionValue('--test-coverage-include');
branchCoverage = getOptionValue('--test-coverage-branches');
lineCoverage = getOptionValue('--test-coverage-lines');
functionCoverage = getOptionValue('--test-coverage-functions');
validateInteger(branchCoverage, '--test-coverage-branches', 0, 100);
validateInteger(lineCoverage, '--test-coverage-lines', 0, 100);
validateInteger(functionCoverage, '--test-coverage-functions', 0, 100);
}
const setup = reporterScope.bind(async (rootReporter) => {
const reportersMap = await getReportersMap(reporters, destinations);
for (let i = 0; i < reportersMap.length; i++) {
const { reporter, destination } = reportersMap[i];
compose(rootReporter, reporter).pipe(destination);
}
reporterScope.reporters = reportersMap;
});
globalTestOptions = {
__proto__: null,
isTestRunner,
concurrency,
coverage,
coverageExcludeGlobs,
coverageIncludeGlobs,
destinations,
forceExit,
isolation,
branchCoverage,
functionCoverage,
lineCoverage,
only,
reporters,
setup,
shard,
sourceMaps,
testNamePatterns,
testSkipPatterns,
timeout,
updateSnapshots,
watch,
};
return globalTestOptions;
}
function mapPatternFlagToRegExArray(flagName) {
const patterns = getOptionValue(flagName);
if (patterns?.length > 0) {
return ArrayPrototypeMap(patterns, (re) => convertStringToRegExp(re, flagName));
}
return null;
}
function countCompletedTest(test, harness = test.root.harness) {
if (test.nesting === 0) {
harness.counters.topLevel++;
}
if (test.reportedType === 'suite') {
harness.counters.suites++;
return;
}
// Check SKIP and TODO tests first, as those should not be counted as
// failures.
if (test.skipped) {
harness.counters.skipped++;
} else if (test.isTodo) {
harness.counters.todo++;
} else if (test.cancelled) {
harness.counters.cancelled++;
harness.success = false;
} else if (!test.passed) {
harness.counters.failed++;
harness.success = false;
} else {
harness.counters.passed++;
}
harness.counters.tests++;
}
const memo = new SafeMap();
function addTableLine(prefix, width) {
const key = `${prefix}-${width}`;
let value = memo.get(key);
if (value === undefined) {
value = `${prefix}${StringPrototypeRepeat('-', width)}\n`;
memo.set(key, value);
}
return value;
}
const kHorizontalEllipsis = '\u2026';
function truncateStart(string, width) {
return string.length > width ? `${kHorizontalEllipsis}${StringPrototypeSlice(string, string.length - width + 1)}` : string;
}
function truncateEnd(string, width) {
return string.length > width ? `${StringPrototypeSlice(string, 0, width - 1)}${kHorizontalEllipsis}` : string;
}
function formatLinesToRanges(values) {
return ArrayPrototypeMap(ArrayPrototypeReduce(values, (prev, current, index, array) => {
if ((index > 0) && ((current - array[index - 1]) === 1)) {
prev[prev.length - 1][1] = current;
} else {
prev.push([current]);
}
return prev;
}, []), (range) => ArrayPrototypeJoin(range, '-'));
}
function getUncoveredLines(lines) {
return ArrayPrototypeFlatMap(lines, (line) => (line.count === 0 ? line.line : []));
}
function formatUncoveredLines(lines, table) {
if (table) return ArrayPrototypeJoin(formatLinesToRanges(lines), ' ');
return ArrayPrototypeJoin(lines, ', ');
}
const kColumns = ['line %', 'branch %', 'funcs %'];
const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
const kSeparator = ' | ';
function buildFileTree(summary) {
const tree = { __proto__: null };
let treeDepth = 1;
let longestFile = 0;
ArrayPrototypeForEach(summary.files, (file) => {
let longestPart = 0;
const parts = StringPrototypeSplit(relative(summary.workingDirectory, file.path), sep);
let current = tree;
ArrayPrototypeForEach(parts, (part, index) => {
current[part] ||= { __proto__: null };
current = current[part];
// If this is the last part, add the file to the tree
if (index === parts.length - 1) {
current.file = file;
}
// Keep track of the longest part for padding
longestPart = MathMax(longestPart, part.length);
});
treeDepth = MathMax(treeDepth, parts.length);
longestFile = MathMax(longestPart, longestFile);
});
return { __proto__: null, tree, treeDepth, longestFile };
}
function getCoverageReport(pad, summary, symbol, color, table) {
const prefix = `${pad}${symbol}`;
let report = `${color}${prefix}start of coverage report\n`;
let filePadLength;
let columnPadLengths = [];
let uncoveredLinesPadLength;
let tableWidth;
// Create a tree of file paths
const { tree, treeDepth, longestFile } = buildFileTree(summary);
if (table) {
// Calculate expected column sizes based on the tree
filePadLength = table && longestFile;
filePadLength += (treeDepth - 1);
if (color) {
filePadLength += 2;
}
filePadLength = MathMax(filePadLength, 'file'.length);
if (filePadLength > (process.stdout.columns / 2)) {
filePadLength = MathFloor(process.stdout.columns / 2);
}
const fileWidth = filePadLength + 2;
columnPadLengths = ArrayPrototypeMap(kColumns, (column) => (table ? MathMax(column.length, 6) : 0));
const columnsWidth = ArrayPrototypeReduce(columnPadLengths, (acc, columnPadLength) => acc + columnPadLength + 3, 0);
uncoveredLinesPadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) =>
MathMax(acc, formatUncoveredLines(getUncoveredLines(file.lines), table).length), 0);
uncoveredLinesPadLength = MathMax(uncoveredLinesPadLength, 'uncovered lines'.length);
const uncoveredLinesWidth = uncoveredLinesPadLength + 2;
tableWidth = fileWidth + columnsWidth + uncoveredLinesWidth;
const availableWidth = (process.stdout.columns || Infinity) - prefix.length;
const columnsExtras = tableWidth - availableWidth;
if (table && columnsExtras > 0) {
filePadLength = MathMin(availableWidth * 0.5, filePadLength);
uncoveredLinesPadLength = MathMax(availableWidth - columnsWidth - (filePadLength + 2) - 2, 1);
tableWidth = availableWidth;
} else {
uncoveredLinesPadLength = Infinity;
}
}
function getCell(string, width, pad, truncate, coverage) {
if (!table) return string;
let result = string;
if (pad) result = pad(result, width);
if (truncate) result = truncate(result, width);
if (color && coverage !== undefined) {
if (coverage > 90) return `${coverageColors.high}${result}${color}`;
if (coverage > 50) return `${coverageColors.medium}${result}${color}`;
return `${coverageColors.low}${result}${color}`;
}
return result;
}
function writeReportLine({ file, depth = 0, coveragesColumns, fileCoverage, uncoveredLines }) {
const fileColumn = `${prefix}${StringPrototypeRepeat(' ', depth)}${getCell(file, filePadLength - depth, StringPrototypePadEnd, truncateStart, fileCoverage)}`;
const coverageColumns = ArrayPrototypeJoin(ArrayPrototypeMap(coveragesColumns, (coverage, j) => {
const coverageText = typeof coverage === 'number' ? NumberPrototypeToFixed(coverage, 2) : coverage;
return getCell(coverageText, columnPadLengths[j], StringPrototypePadStart, false, coverage);
}), kSeparator);
const uncoveredLinesColumn = getCell(uncoveredLines, uncoveredLinesPadLength, false, truncateEnd);
return `${fileColumn}${kSeparator}${coverageColumns}${kSeparator}${uncoveredLinesColumn}\n`;
}
function printCoverageBodyTree(tree, depth = 0) {
for (const key in tree) {
if (tree[key].file) {
const file = tree[key].file;
const fileName = ArrayPrototypePop(StringPrototypeSplit(file.path, sep));
let fileCoverage = 0;
const coverages = ArrayPrototypeMap(kColumnsKeys, (columnKey) => {
const percent = file[columnKey];
fileCoverage += percent;
return percent;
});
fileCoverage /= kColumnsKeys.length;
const uncoveredLines = formatUncoveredLines(getUncoveredLines(file.lines), table);
report += writeReportLine({
__proto__: null,
file: fileName,
depth: depth,
coveragesColumns: coverages,
fileCoverage: fileCoverage,
uncoveredLines: uncoveredLines,
});
} else {
report += writeReportLine({
__proto__: null,
file: key,
depth: depth,
coveragesColumns: ArrayPrototypeMap(columnPadLengths, () => ''),
fileCoverage: undefined,
uncoveredLines: '',
});
printCoverageBodyTree(tree[key], depth + 1);
}
}
}
// -------------------------- Coverage Report --------------------------
if (table) report += addTableLine(prefix, tableWidth);
// Print the header
report += writeReportLine({
__proto__: null,
file: 'file',
coveragesColumns: kColumns,
fileCoverage: undefined,
uncoveredLines: 'uncovered lines',
});
if (table) report += addTableLine(prefix, tableWidth);
// Print the body
printCoverageBodyTree(tree);
if (table) report += addTableLine(prefix, tableWidth);
// Print the footer
const allFilesCoverages = ArrayPrototypeMap(kColumnsKeys, (columnKey) => summary.totals[columnKey]);
report += writeReportLine({
__proto__: null,
file: 'all files',
coveragesColumns: allFilesCoverages,
fileCoverage: undefined,
uncoveredLines: '',
});
if (table) report += addTableLine(prefix, tableWidth);
report += `${prefix}end of coverage report\n`;
if (color) {
report += white;
}
return report;
}
module.exports = {
convertStringToRegExp,
countCompletedTest,
createDeferredCallback,
isTestFailureError,
kDefaultPattern,
parseCommandLine,
reporterScope,
shouldColorizeTestFiles,
getCoverageReport,
};