Skip to content

Commit

Permalink
test_runner: refactor coverage report output for readability
Browse files Browse the repository at this point in the history
Add a "table" parameter to getCoverageReport.
Keep the tap coverage output intact.
Change the output by adding padding and truncating the tables' cells.
Add separation lines for table head/body/foot.
Group uncovered lines as ranges.
Add yellow color for coverage between 50 and 90.

Refs: nodejs#46674
PR-URL: nodejs#47791
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
  • Loading branch information
dmnsgn authored and Ceres6 committed Aug 14, 2023
1 parent 7465fda commit 32ed06b
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 38 deletions.
2 changes: 1 addition & 1 deletion lib/internal/test_runner/reporter/spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
171 changes: 140 additions & 31 deletions lib/internal/test_runner/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeReduce,
ObjectGetOwnPropertyDescriptor,
MathFloor,
MathMax,
MathMin,
NumberPrototypeToFixed,
SafePromiseAllReturnArrayLike,
RegExp,
RegExpPrototypeExec,
SafeMap,
StringPrototypePadStart,
StringPrototypePadEnd,
StringPrototypeRepeat,
StringPrototypeSlice,
} = primordials;

const { basename, relative } = require('path');
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: {
Expand All @@ -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$/;
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions lib/internal/util/colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' : '';
Expand Down
17 changes: 11 additions & 6 deletions test/parallel/test-runner-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('/', '\\');
Expand Down

0 comments on commit 32ed06b

Please sign in to comment.