| '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('--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'); |
| 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, |
| 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, |
| }; |