diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 8fa9c872568d1e..a0f8b7f4b94c81 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -193,14 +193,24 @@ class TestCoverage { ObjectAssign(range, mapRangeToLines(range, lines)); if (isBlockCoverage) { + // Skip branches where all lines are ignored (similar to line handling) + if (range.ignoredLines === range.lines.length) { + continue; + } + + // If the branch is uncovered but contains ignored lines, treat it as + // covered. This matches the behavior of tools like c8 and ensures that + // ignored code doesn't penalize branch coverage. + const branchCount = (range.count === 0 && range.ignoredLines > 0) ? + 1 : range.count; + ArrayPrototypePush(branchReports, { __proto__: null, line: range.lines[0]?.line, - count: range.count, + count: branchCount, }); - if (range.count !== 0 || - range.ignoredLines === range.lines.length) { + if (branchCount !== 0) { branchesCovered++; } diff --git a/test/fixtures/test-runner/coverage-ignore-branch/source.js b/test/fixtures/test-runner/coverage-ignore-branch/source.js new file mode 100644 index 00000000000000..01f8f5052d508b --- /dev/null +++ b/test/fixtures/test-runner/coverage-ignore-branch/source.js @@ -0,0 +1,12 @@ +'use strict'; +// Source file for testing that branch coverage respects ignore comments + +function getValue(condition) { + if (condition) { + return 'truthy'; + } + /* node:coverage ignore next */ + return 'falsy'; +} + +module.exports = { getValue }; diff --git a/test/fixtures/test-runner/coverage-ignore-branch/test.js b/test/fixtures/test-runner/coverage-ignore-branch/test.js new file mode 100644 index 00000000000000..58dde458af3ed6 --- /dev/null +++ b/test/fixtures/test-runner/coverage-ignore-branch/test.js @@ -0,0 +1,9 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { getValue } = require('./source.js'); + +// Only call with true, so the false branch is "uncovered" but ignored +test('getValue returns truthy for true', () => { + assert.strictEqual(getValue(true), 'truthy'); +}); diff --git a/test/parallel/test-runner-coverage.js b/test/parallel/test-runner-coverage.js index 5a8f3d743538cb..57724353cb7255 100644 --- a/test/parallel/test-runner-coverage.js +++ b/test/parallel/test-runner-coverage.js @@ -566,3 +566,44 @@ test('coverage with directory and file named "file"', skipIfNoInspector, () => { assert.strictEqual(result.status, 0); assert(result.stdout.toString().includes('start of coverage report')); }); + +// Regression test for https://github.com/nodejs/node/issues/61586 +test('coverage ignore comments exclude branches in LCOV output', skipIfNoInspector, () => { + const fixture = fixtures.path('test-runner', 'coverage-ignore-branch', 'test.js'); + const args = [ + '--experimental-test-coverage', + '--test-reporter', 'lcov', + '--test-coverage-exclude=!test/fixtures/test-runner/coverage-ignore-branch/**', + fixture, + ]; + const result = spawnSync(process.execPath, args); + const lcov = result.stdout.toString(); + + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); + + // Extract the source.js section from LCOV output + const sourceSection = lcov.split('end_of_record') + .find((s) => s.includes('source.js')); + assert(sourceSection, 'LCOV should contain source.js coverage'); + + // Verify that all branches are reported as covered (BRH should equal BRF) + // The ignored branch should not penalize coverage + const brfMatch = sourceSection.match(/BRF:(\d+)/); + const brhMatch = sourceSection.match(/BRH:(\d+)/); + assert(brfMatch, 'LCOV should contain BRF'); + assert(brhMatch, 'LCOV should contain BRH'); + assert.strictEqual(brfMatch[1], brhMatch[1], + `All branches should be covered when ignored code is not executed. ` + + `BRF=${brfMatch[1]}, BRH=${brhMatch[1]}`); + + // Verify no BRDA entries show 0 (uncovered) for the ignored branch + // The branch at the if statement should be covered, not penalized by the ignored return + const brdaEntries = sourceSection.match(/BRDA:\d+,\d+,\d+,(\d+)/g) || []; + for (const entry of brdaEntries) { + const count = entry.match(/BRDA:\d+,\d+,\d+,(\d+)/)[1]; + assert.notStrictEqual(count, '0', + `No branch should show 0 coverage when the ` + + `uncovered path is ignored: ${entry}`); + } +});