diff --git a/doc/api/test.md b/doc/api/test.md index 5b694a4dc74668..b29fd7928f2ef1 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -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. diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 70f88984c82150..1ed45028f61575 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -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); diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index fba2c3132339aa..7923732f04dfcb 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -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 diff --git a/test/fixtures/test-runner/custom_reporters/coverage.mjs b/test/fixtures/test-runner/custom_reporters/coverage.mjs new file mode 100644 index 00000000000000..c1b8848799030b --- /dev/null +++ b/test/fixtures/test-runner/custom_reporters/coverage.mjs @@ -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); + } + } +} diff --git a/test/parallel/test-runner-coverage.js b/test/parallel/test-runner-coverage.js index 9377f1bb509328..2532f9971c213d 100644 --- a/test/parallel/test-runner-coverage.js +++ b/test/parallel/test-runner-coverage.js @@ -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); + }); + }); +});