Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test_runner: report covered lines, functions and branches to reporters #49320

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, {
philnash marked this conversation as resolved.
Show resolved Hide resolved
__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,
philnash marked this conversation as resolved.
Show resolved Hide resolved
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) {
Comment on lines +350 to +351
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
if (count > 0 && startOffset <= line.startOffset &&
endOffset >= line.endOffset) {
if (
count > 0
&& startOffset <= line.startOffset
&& endOffset >= line.endOffset
) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the existing style, in particular the conditional directly above it. I've tried to stay away from changing formatting too much.

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);
});
});
});