Skip to content

Commit

Permalink
test_runner: report covered lines, functions and branches to reporters
Browse files Browse the repository at this point in the history
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 nodejs#49303

PR-URL: nodejs#49320
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
  • Loading branch information
philnash authored and alexfernandez committed Nov 1, 2023
1 parent 2951b2e commit ed13849
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 9 deletions.
14 changes: 12 additions & 2 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 35 additions & 5 deletions lib/internal/test_runner/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
StringPrototypeIncludes,
StringPrototypeLocaleCompare,
StringPrototypeStartsWith,
MathMax,
} = primordials;
const {
copyFileSync,
Expand Down Expand Up @@ -43,6 +44,7 @@ class CoverageLine {
this.startOffset = startOffset;
this.endOffset = startOffset + src.length - newlineLength;
this.ignore = false;
this.count = 0;
this.#covered = true;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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++;
Expand All @@ -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++;
}
Expand All @@ -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);
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions lib/internal/test_runner/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypeFlatMap,
ArrayPrototypePush,
ArrayPrototypeReduce,
ObjectGetOwnPropertyDescriptor,
Expand Down Expand Up @@ -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, ', ');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions test/fixtures/test-runner/custom_reporters/coverage.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 56 additions & 0 deletions test/parallel/test-runner-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

0 comments on commit ed13849

Please sign in to comment.