test_runner: report covered lines, functions and branches to reporters

This is a breaking change for the format of test:coverage events. But
the test coverage is still experimental, so I don't believe it requires
a semver-major bump.

Fixes https://github.com/nodejs/node/issues/49303

PR-URL: https://github.com/nodejs/node/pull/49320
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
This commit is contained in:
Phil Nash authored and GitHub committed 2023-08-30 20:22:45 +00:00
1 parent fcf5de008a
commit 3a6a80a4e1
5 files changed
+125 -9

No files matched your search

+12 -2
View File
@@ -2011,8 +2011,18 @@ object, streaming a series of events representing the execution of the tests.
* `coveredLinePercent` {number} The percentage of lines covered.
* `coveredBranchPercent` {number} The percentage of branches covered.
* `coveredFunctionPercent` {number} The percentage of functions covered.
* `uncoveredLineNumbers` {Array} An array of integers representing line
numbers that are uncovered.
* `functions` {Array} An array of functions representing function
coverage.
* `name` {string} The name of the function.
* `line` {number} The line number where the function is defined.
* `count` {number} The number of times the function was called.
* `branches` {Array} An array of branches representing branch coverage.
* `line` {number} The line number where the branch is defined.
* `count` {number} The number of times the branch was taken.
* `lines` {Array} An array of lines representing line
numbers and the number of times they were covered.
* `line` {number} The line number.
* `count` {number} The number of times the line was covered.
* `totals` {Object} An object containing a summary of coverage for all
files.
* `totalLineCount` {number} The total number of lines.
+35 -5
View File
@@ -13,6 +13,7 @@ const {
StringPrototypeIncludes,
StringPrototypeLocaleCompare,
StringPrototypeStartsWith,
MathMax,
} = primordials;
const {
copyFileSync,
@@ -43,6 +44,7 @@ class CoverageLine {
this.startOffset = startOffset;
this.endOffset = startOffset + src.length - newlineLength;
this.ignore = false;
this.count = 0;
this.#covered = true;
}
@@ -118,6 +120,8 @@ class TestCoverage {
let totalFunctions = 0;
let branchesCovered = 0;
let functionsCovered = 0;
const functionReports = [];
const branchReports = [];
const lines = ArrayPrototypeMap(linesWithBreaks, (line, i) => {
const startOffset = offset;
@@ -159,12 +163,20 @@ class TestCoverage {
for (let j = 0; j < functions.length; ++j) {
const { isBlockCoverage, ranges } = functions[j];
let maxCountPerFunction = 0;
for (let k = 0; k < ranges.length; ++k) {
const range = ranges[k];
maxCountPerFunction = MathMax(maxCountPerFunction, range.count);
mapRangeToLines(range, lines);
if (isBlockCoverage) {
ArrayPrototypePush(branchReports, {
__proto__: null,
line: range.lines[0].line,
count: range.count,
});
if (range.count !== 0 ||
range.ignoredLines === range.lines.length) {
branchesCovered++;
@@ -177,6 +189,13 @@ class TestCoverage {
if (j > 0 && ranges.length > 0) {
const range = ranges[0];
ArrayPrototypePush(functionReports, {
__proto__: null,
name: functions[j].functionName,
count: maxCountPerFunction,
line: range.lines[0].line,
});
if (range.count !== 0 || range.ignoredLines === range.lines.length) {
functionsCovered++;
}
@@ -186,15 +205,19 @@ class TestCoverage {
}
let coveredCnt = 0;
const uncoveredLineNums = [];
const lineReports = [];
for (let j = 0; j < lines.length; ++j) {
const line = lines[j];
if (!line.ignore) {
ArrayPrototypePush(lineReports, {
__proto__: null,
line: line.line,
count: line.count,
});
}
if (line.covered || line.ignore) {
coveredCnt++;
} else {
ArrayPrototypePush(uncoveredLineNums, line.line);
}
}
@@ -210,7 +233,9 @@ class TestCoverage {
coveredLinePercent: toPercentage(coveredCnt, lines.length),
coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
uncoveredLineNumbers: uncoveredLineNums,
functions: functionReports,
branches: branchReports,
lines: lineReports,
});
coverageSummary.totals.totalLineCount += lines.length;
@@ -320,6 +345,11 @@ function mapRangeToLines(range, lines) {
if (count === 0 && startOffset <= line.startOffset &&
endOffset >= line.endOffset) {
line.covered = false;
line.count = 0;
}
if (count > 0 && startOffset <= line.startOffset &&
endOffset >= line.endOffset) {
line.count = count;
}
ArrayPrototypePush(mappedLines, line);
+7 -2
View File
@@ -2,6 +2,7 @@
const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypeFlatMap,
ArrayPrototypePush,
ArrayPrototypeReduce,
ObjectGetOwnPropertyDescriptor,
@@ -297,6 +298,10 @@ function formatLinesToRanges(values) {
}, []), (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, ', ');
@@ -326,7 +331,7 @@ function getCoverageReport(pad, summary, symbol, color, table) {
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);
MathMax(acc, formatUncoveredLines(getUncoveredLines(file.lines), table).length), 0);
uncoveredLinesPadLength = MathMax(uncoveredLinesPadLength, 'uncovered lines'.length);
const uncoveredLinesWidth = uncoveredLinesPadLength + 2;
@@ -388,7 +393,7 @@ function getCoverageReport(pad, summary, symbol, color, table) {
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`;
`${getCell(formatUncoveredLines(getUncoveredLines(file.lines), table), uncoveredLinesPadLength, false, truncateEnd)}\n`;
}
// Foot
+15
View File
@@ -0,0 +1,15 @@
import { Transform } from 'node:stream';
export default class CoverageReporter extends Transform {
constructor(options) {
super({ ...options, writableObjectMode: true });
}
_transform(event, _encoding, callback) {
if (event.type === 'test:coverage') {
callback(null, JSON.stringify(event.data, null, 2));
} else {
callback(null);
}
}
}
+56
View File
@@ -182,3 +182,59 @@ test('coverage is combined for multiple processes', skipIfNoInspector, () => {
assert(result.stdout.toString().includes(report));
assert.strictEqual(result.status, 0);
});
test('coverage reports on lines, functions, and branches', skipIfNoInspector, async (t) => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const child = spawnSync(process.execPath,
['--test', '--experimental-test-coverage', '--test-reporter',
fixtures.fileURL('test-runner/custom_reporters/coverage.mjs'),
fixture]);
assert.strictEqual(child.stderr.toString(), '');
const stdout = child.stdout.toString();
const coverage = JSON.parse(stdout);
await t.test('does not include node_modules', () => {
assert.strictEqual(coverage.summary.files.length, 3);
const files = ['coverage.js', 'invalid-tap.js', 'throw.js'];
coverage.summary.files.forEach((file, index) => {
assert.ok(file.path.endsWith(files[index]));
});
});
const file = coverage.summary.files[0];
await t.test('reports on function coverage', () => {
const uncalledFunction = file.functions.find((f) => f.name === 'uncalledTopLevelFunction');
assert.strictEqual(uncalledFunction.count, 0);
assert.strictEqual(uncalledFunction.line, 16);
const calledTwice = file.functions.find((f) => f.name === 'fnWithControlFlow');
assert.strictEqual(calledTwice.count, 2);
assert.strictEqual(calledTwice.line, 35);
});
await t.test('reports on branch coverage', () => {
const uncalledBranch = file.branches.find((b) => b.line === 6);
assert.strictEqual(uncalledBranch.count, 0);
const calledTwice = file.branches.find((b) => b.line === 35);
assert.strictEqual(calledTwice.count, 2);
});
await t.test('reports on line coverage', () => {
[
{ line: 36, count: 2 },
{ line: 37, count: 1 },
{ line: 38, count: 1 },
{ line: 39, count: 0 },
{ line: 40, count: 1 },
{ line: 41, count: 1 },
{ line: 42, count: 1 },
{ line: 43, count: 0 },
{ line: 44, count: 0 },
].forEach((line) => {
const testLine = file.lines.find((l) => l.line === line.line);
assert.strictEqual(testLine.count, line.count);
});
});
});