diff --git a/lib/internal/test_runner/reporter/spec.js b/lib/internal/test_runner/reporter/spec.js index 0df89a90efa554..16cbdf1d5aa901 100644 --- a/lib/internal/test_runner/reporter/spec.js +++ b/lib/internal/test_runner/reporter/spec.js @@ -123,7 +123,7 @@ class SpecReporter extends Transform { case 'test:diagnostic': return `${colors[type]}${this.#indent(data.nesting)}${symbols[type]}${data.message}${white}\n`; case 'test:coverage': - return getCoverageReport(this.#indent(data.nesting), data.summary, symbols['test:coverage'], blue); + return getCoverageReport(this.#indent(data.nesting), data.summary, symbols['test:coverage'], blue, true); } } _transform({ type, data }, encoding, callback) { diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 94a62a44d11d69..da429b5421a45a 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -3,12 +3,20 @@ const { ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePush, + ArrayPrototypeReduce, ObjectGetOwnPropertyDescriptor, + MathFloor, + MathMax, + MathMin, NumberPrototypeToFixed, SafePromiseAllReturnArrayLike, RegExp, RegExpPrototypeExec, SafeMap, + StringPrototypePadStart, + StringPrototypePadEnd, + StringPrototypeRepeat, + StringPrototypeSlice, } = primordials; const { basename, relative } = require('path'); @@ -16,7 +24,7 @@ const { createWriteStream } = require('fs'); const { pathToFileURL } = require('internal/url'); const { createDeferredPromise } = require('internal/util'); const { getOptionValue } = require('internal/options'); -const { green, red, white, shouldColorize } = require('internal/util/colors'); +const { green, yellow, red, white, shouldColorize } = require('internal/util/colors'); const { codes: { @@ -27,6 +35,13 @@ const { } = require('internal/errors'); const { compose } = require('stream'); +const coverageColors = { + __proto__: null, + high: green, + medium: yellow, + low: red, +}; + const kMultipleCallbackInvocations = 'multipleCallbackInvocations'; const kRegExpPattern = /^\/(.*)\/([a-z]*)$/; const kSupportedFileExtensions = /\.[cm]?js$/; @@ -256,45 +271,139 @@ function countCompletedTest(test, harness = test.root.harness) { } -function coverageThreshold(coverage, color) { - coverage = NumberPrototypeToFixed(coverage, 2); - if (color) { - if (coverage > 90) return `${green}${coverage}${color}`; - if (coverage < 50) return `${red}${coverage}${color}`; +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 coverage; + + 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 formatUncoveredLines(lines, table) { + if (table) return ArrayPrototypeJoin(formatLinesToRanges(lines), ' '); + return ArrayPrototypeJoin(lines, ', '); } -function getCoverageReport(pad, summary, symbol, color) { - let report = `${color}${pad}${symbol}start of coverage report\n`; +const kColumns = ['line %', 'branch %', 'funcs %']; +const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent']; +const kSeparator = ' | '; + +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; + + if (table) { + // Get expected column sizes + filePadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) => + MathMax(acc, relative(summary.workingDirectory, file.path).length), 0); + filePadLength = MathMax(filePadLength, 'file'.length); + 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(file.uncoveredLineNumbers, table).length), 0); + uncoveredLinesPadLength = MathMax(uncoveredLinesPadLength, 'uncovered lines'.length); + const uncoveredLinesWidth = uncoveredLinesPadLength + 2; + + tableWidth = fileWidth + columnsWidth + uncoveredLinesWidth; + + // Fit with sensible defaults + const availableWidth = (process.stdout.columns || Infinity) - prefix.length; + const columnsExtras = tableWidth - availableWidth; + if (table && columnsExtras > 0) { + // Ensure file name is sufficiently visible + const minFilePad = MathMin(8, filePadLength); + filePadLength -= MathFloor(columnsExtras * 0.2); + filePadLength = MathMax(filePadLength, minFilePad); + + // Get rest of available space, subtracting margins + uncoveredLinesPadLength = MathMax(availableWidth - columnsWidth - (filePadLength + 2) - 2, 1); + + // Update table width + 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; + } - report += `${pad}${symbol}file | line % | branch % | funcs % | uncovered lines\n`; + // Head + if (table) report += addTableLine(prefix, tableWidth); + report += `${prefix}${getCell('file', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` + + `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumns, (column, i) => getCell(column, columnPadLengths[i], StringPrototypePadStart)), kSeparator)}${kSeparator}` + + `${getCell('uncovered lines', uncoveredLinesPadLength, false, truncateEnd)}\n`; + if (table) report += addTableLine(prefix, tableWidth); + // Body for (let i = 0; i < summary.files.length; ++i) { - const { - path, - coveredLinePercent, - coveredBranchPercent, - coveredFunctionPercent, - uncoveredLineNumbers, - } = summary.files[i]; - const relativePath = relative(summary.workingDirectory, path); - const lines = coverageThreshold(coveredLinePercent, color); - const branches = coverageThreshold(coveredBranchPercent, color); - const functions = coverageThreshold(coveredFunctionPercent, color); - const uncovered = ArrayPrototypeJoin(uncoveredLineNumbers, ', '); - - report += `${pad}${symbol}${relativePath} | ${lines} | ${branches} | ` + - `${functions} | ${uncovered}\n`; + const file = summary.files[i]; + const relativePath = relative(summary.workingDirectory, file.path); + + let fileCoverage = 0; + const coverages = ArrayPrototypeMap(kColumnsKeys, (columnKey) => { + const percent = file[columnKey]; + fileCoverage += percent; + return percent; + }); + fileCoverage /= kColumnsKeys.length; + + report += `${prefix}${getCell(relativePath, filePadLength, StringPrototypePadEnd, truncateStart, fileCoverage)}${kSeparator}` + + `${ArrayPrototypeJoin(ArrayPrototypeMap(coverages, (coverage, j) => getCell(NumberPrototypeToFixed(coverage, 2), columnPadLengths[j], StringPrototypePadStart, false, coverage)), kSeparator)}${kSeparator}` + + `${getCell(formatUncoveredLines(file.uncoveredLineNumbers, table), uncoveredLinesPadLength, false, truncateEnd)}\n`; } - const { totals } = summary; - report += `${pad}${symbol}all files | ` + - `${coverageThreshold(totals.coveredLinePercent, color)} | ` + - `${coverageThreshold(totals.coveredBranchPercent, color)} | ` + - `${coverageThreshold(totals.coveredFunctionPercent, color)} |\n`; + // Foot + if (table) report += addTableLine(prefix, tableWidth); + report += `${prefix}${getCell('all files', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` + + `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`; + if (table) report += addTableLine(prefix, tableWidth); - report += `${pad}${symbol}end of coverage report\n`; + report += `${prefix}end of coverage report\n`; if (color) { report += white; } diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index 9e1ed05d88130e..31e3e9b22585c5 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -28,6 +28,7 @@ module.exports = { module.exports.blue = hasColors ? '\u001b[34m' : ''; module.exports.green = hasColors ? '\u001b[32m' : ''; module.exports.white = hasColors ? '\u001b[39m' : ''; + module.exports.yellow = hasColors ? '\u001b[33m' : ''; module.exports.red = hasColors ? '\u001b[31m' : ''; module.exports.gray = hasColors ? '\u001b[90m' : ''; module.exports.clear = hasColors ? '\u001bc' : ''; diff --git a/test/parallel/test-runner-coverage.js b/test/parallel/test-runner-coverage.js index 0eba610288261b..dcce8c1730ea84 100644 --- a/test/parallel/test-runner-coverage.js +++ b/test/parallel/test-runner-coverage.js @@ -41,16 +41,21 @@ function getTapCoverageFixtureReport() { } function getSpecCoverageFixtureReport() { + /* eslint-disable max-len */ const report = [ '\u2139 start of coverage report', - '\u2139 file | line % | branch % | funcs % | uncovered lines', - '\u2139 test/fixtures/test-runner/coverage.js | 78.65 | 38.46 | 60.00 | 12, ' + - '13, 16, 17, 18, 19, 20, 21, 22, 27, 39, 43, 44, 61, 62, 66, 67, 71, 72', - '\u2139 test/fixtures/test-runner/invalid-tap.js | 100.00 | 100.00 | 100.00 | ', - '\u2139 test/fixtures/v8-coverage/throw.js | 71.43 | 50.00 | 100.00 | 5, 6', - '\u2139 all files | 78.35 | 43.75 | 60.00 |', + '\u2139 -------------------------------------------------------------------------------------------------------------------', + '\u2139 file | line % | branch % | funcs % | uncovered lines', + '\u2139 -------------------------------------------------------------------------------------------------------------------', + '\u2139 test/fixtures/test-runner/coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72', + '\u2139 test/fixtures/test-runner/invalid-tap.js | 100.00 | 100.00 | 100.00 | ', + '\u2139 test/fixtures/v8-coverage/throw.js | 71.43 | 50.00 | 100.00 | 5-6', + '\u2139 -------------------------------------------------------------------------------------------------------------------', + '\u2139 all files | 78.35 | 43.75 | 60.00 |', + '\u2139 -------------------------------------------------------------------------------------------------------------------', '\u2139 end of coverage report', ].join('\n'); + /* eslint-enable max-len */ if (common.isWindows) { return report.replaceAll('/', '\\');