diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 8fa9c872568d1e..2e20ff84e09663 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -43,6 +43,7 @@ const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/; const kIgnoreRegex = /\/\* node:coverage ignore next (?\d+ )?\*\//; const kLineEndingRegex = /\r?\n$/u; const kLineSplitRegex = /(?<=\r?\n)/u; +const kNonExecutableLineRegex = /^\s*(?:$|[{}()[\];,]+|\/\/.*|\/\*.*\*\/)\s*$/; const kStatusRegex = /\/\* node:coverage (?enable|disable) \*\//; class CoverageLine { @@ -193,13 +194,15 @@ class TestCoverage { ObjectAssign(range, mapRangeToLines(range, lines)); if (isBlockCoverage) { + const branchCount = isIgnoredBranchRange(range) ? 1 : range.count; + ArrayPrototypePush(branchReports, { __proto__: null, line: range.lines[0]?.line, - count: range.count, + count: branchCount, }); - if (range.count !== 0 || + if (branchCount !== 0 || range.ignoredLines === range.lines.length) { branchesCovered++; } @@ -585,6 +588,26 @@ function mapRangeToLines(range, lines) { return { __proto__: null, lines: mappedLines, ignoredLines }; } +function isIgnoredBranchRange(range) { + if (range.count !== 0 || range.ignoredLines === 0) { + return false; + } + + for (let i = 0; i < range.lines.length; ++i) { + const line = range.lines[i]; + + if (line.ignore || + line.count > 0 || + RegExpPrototypeExec(kNonExecutableLineRegex, line.src) !== null) { + continue; + } + + return false; + } + + return true; +} + function mergeCoverageScripts(oldScript, newScript) { // Merge the functions from the new coverage into the functions from the // existing (merged) coverage. diff --git a/test/fixtures/test-runner/coverage-ignored-branch.js b/test/fixtures/test-runner/coverage-ignored-branch.js new file mode 100644 index 00000000000000..ef92b397dbf0a0 --- /dev/null +++ b/test/fixtures/test-runner/coverage-ignored-branch.js @@ -0,0 +1,11 @@ +'use strict'; + +function getValue(condition) { + if (condition) { + return 'truthy'; + } + /* node:coverage ignore next */ + return 'falsy'; +} + +module.exports = { getValue }; diff --git a/test/fixtures/test-runner/coverage-ignored-branch.test.js b/test/fixtures/test-runner/coverage-ignored-branch.test.js new file mode 100644 index 00000000000000..e6efb19715f065 --- /dev/null +++ b/test/fixtures/test-runner/coverage-ignored-branch.test.js @@ -0,0 +1,9 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const { getValue } = require('./coverage-ignored-branch'); + +test('returns truthy', () => { + assert.strictEqual(getValue(true), 'truthy'); +}); diff --git a/test/parallel/test-runner-coverage.js b/test/parallel/test-runner-coverage.js index 5a8f3d743538cb..ffb2ac7fa45371 100644 --- a/test/parallel/test-runner-coverage.js +++ b/test/parallel/test-runner-coverage.js @@ -2,7 +2,7 @@ const common = require('../common'); const assert = require('node:assert'); const { spawnSync } = require('node:child_process'); -const { readdirSync } = require('node:fs'); +const { readFileSync, readdirSync } = require('node:fs'); const { test } = require('node:test'); const fixtures = require('../common/fixtures'); const tmpdir = require('../common/tmpdir'); @@ -333,6 +333,36 @@ test('coverage reports on lines, functions, and branches', skipIfNoInspector, as }); }); +test('lcov reporter excludes ignored branch ranges', skipIfNoInspector, () => { + const fixture = fixtures.path('test-runner', 'coverage-ignored-branch.test.js'); + const destination = tmpdir.resolve('coverage-ignored-branch.lcov'); + const env = { ...process.env }; + delete env.NODE_TEST_CONTEXT; + const args = [ + '--test', + '--experimental-test-coverage', + '--test-coverage-include=coverage-ignored-branch.js', + '--test-reporter', + 'lcov', + '--test-reporter-destination', + destination, + fixture, + ]; + const result = spawnSync(process.execPath, args, { + cwd: fixtures.path('test-runner'), + env, + }); + + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); + + const report = readFileSync(destination, 'utf8'); + assert.match(report, /^BRF:3$/m); + assert.match(report, /^BRH:3$/m); + assert.doesNotMatch(report, /^BRDA:.*,0$/m); + assert.doesNotMatch(report, /^DA:8,/m); +}); + test('coverage with ESM hook - source irrelevant', skipIfNoInspector, () => { let report = [ '# start of coverage report',