'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, resolve } = 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, validateFunction, } = require('internal/validators'); const { validatePath } = require('internal/fs/utils'); const { kEmptyObject } = require('internal/util'); 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']; const kDefaultPattern = [`**/{${ArrayPrototypeJoin(kPatterns, ',')}}.{${ArrayPrototypeJoin(kFileExtensions, ',')}}`]; if (getOptionValue('--experimental-strip-types')) { ArrayPrototypePush(kDefaultPattern, '**/test/**/*{-,.,_}test.{cts,mts,ts}'); } 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 timeout = getOptionValue('--test-timeout') || Infinity; const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child'; const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8'; let globalSetupPath; 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'); 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'); globalSetupPath = getOptionValue('--test-global-setup'); 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('--test-isolation'); 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 /', ); } const indexAndTotal = StringPrototypeSplit(shardOption, '/', 2); shard = { __proto__: null, index: NumberParseInt(indexAndTotal[0], 10), total: NumberParseInt(indexAndTotal[1], 10), }; } } else { 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'); if (!coverageExcludeGlobs || coverageExcludeGlobs.length === 0) { // TODO(pmarchini): this default should follow something similar to c8 defaults // Default exclusions should be also exported to be used by other tools / users coverageExcludeGlobs = kDefaultPattern; } 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, globalSetupPath, 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, 'all files'.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; } async function setupGlobalSetupTeardownFunctions(globalSetupPath, cwd) { let globalSetupFunction; let globalTeardownFunction; if (globalSetupPath) { validatePath(globalSetupPath, 'options.globalSetupPath'); const fileURL = pathToFileURL(resolve(cwd, globalSetupPath)); const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); const globalSetupModule = await cascadedLoader .import(fileURL, pathToFileURL(cwd + sep).href, kEmptyObject); if (globalSetupModule.globalSetup) { validateFunction(globalSetupModule.globalSetup, 'globalSetupModule.globalSetup'); globalSetupFunction = globalSetupModule.globalSetup; } if (globalSetupModule.globalTeardown) { validateFunction(globalSetupModule.globalTeardown, 'globalSetupModule.globalTeardown'); globalTeardownFunction = globalSetupModule.globalTeardown; } } return { __proto__: null, globalSetupFunction, globalTeardownFunction }; } module.exports = { convertStringToRegExp, countCompletedTest, createDeferredCallback, isTestFailureError, kDefaultPattern, parseCommandLine, reporterScope, shouldColorizeTestFiles, getCoverageReport, setupGlobalSetupTeardownFunctions, };