diff --git a/doc/api/cli.md b/doc/api/cli.md index b4406bcec58b7e..8daba8b46eaf5b 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -624,6 +624,10 @@ Use this flag to enable [ShadowRealm][] support. added: - v19.7.0 - v18.15.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/47686 + description: This option can be used with `--test`. --> When used in conjunction with the `node:test` module, a code coverage report is diff --git a/doc/api/test.md b/doc/api/test.md index b733c1110cb188..fb43e628dbf5c9 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -423,10 +423,6 @@ if (anAlwaysFalseCondition) { The test runner's code coverage functionality has the following limitations, which will be addressed in a future Node.js release: -* Although coverage data is collected for child processes, this information is - not included in the coverage report. Because the command line test runner uses - child processes to execute test files, it cannot be used with - `--experimental-test-coverage`. * Source maps are not supported. * Excluding specific files or directories from the coverage report is not supported. diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 12dcb1da9866a4..1f5aa919a43707 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -1,13 +1,15 @@ 'use strict'; const { + ArrayFrom, ArrayPrototypeMap, ArrayPrototypePush, JSONParse, MathFloor, NumberParseInt, - RegExp, RegExpPrototypeExec, RegExpPrototypeSymbolSplit, + SafeMap, + SafeSet, StringPrototypeIncludes, StringPrototypeLocaleCompare, StringPrototypeStartsWith, @@ -23,9 +25,7 @@ const { setupCoverageHooks } = require('internal/util'); const { tmpdir } = require('os'); const { join, resolve } = require('path'); const { fileURLToPath } = require('url'); -const kCoveragePattern = - `^coverage\\-${process.pid}\\-(\\d{13})\\-(\\d+)\\.json$`; -const kCoverageFileRegex = new RegExp(kCoveragePattern); +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; @@ -95,13 +95,6 @@ class TestCoverage { for (let i = 0; i < coverage.length; ++i) { const { functions, url } = coverage[i]; - if (StringPrototypeStartsWith(url, 'node:') || - StringPrototypeIncludes(url, '/node_modules/') || - // On Windows some generated coverages are invalid. - !StringPrototypeStartsWith(url, 'file:')) { - continue; - } - // Split the file source into lines. Make sure the lines maintain their // original line endings because those characters are necessary for // determining offsets in the file. @@ -345,8 +338,7 @@ function mapRangeToLines(range, lines) { } function getCoverageFromDirectory(coverageDirectory) { - // TODO(cjihrig): Instead of only reading the coverage file for this process, - // combine all coverage files in the directory into a single data structure. + const result = new SafeMap(); let dir; try { @@ -359,8 +351,11 @@ function getCoverageFromDirectory(coverageDirectory) { const coverageFile = join(coverageDirectory, entry.name); const coverage = JSONParse(readFileSync(coverageFile, 'utf8')); - return coverage.result; + + mergeCoverage(result, coverage.result); } + + return ArrayFrom(result.values()); } finally { if (dir) { dir.closeSync(); @@ -368,4 +363,137 @@ function getCoverageFromDirectory(coverageDirectory) { } } +function mergeCoverage(merged, coverage) { + for (let i = 0; i < coverage.length; ++i) { + const newScript = coverage[i]; + const { url } = newScript; + + // Filter out core modules and the node_modules/ directory from results. + if (StringPrototypeStartsWith(url, 'node:') || + StringPrototypeIncludes(url, '/node_modules/') || + // On Windows some generated coverages are invalid. + !StringPrototypeStartsWith(url, 'file:')) { + continue; + } + + const oldScript = merged.get(url); + + if (oldScript === undefined) { + merged.set(url, newScript); + } else { + mergeCoverageScripts(oldScript, newScript); + } + } +} + +function mergeCoverageScripts(oldScript, newScript) { + // Merge the functions from the new coverage into the functions from the + // existing (merged) coverage. + for (let i = 0; i < newScript.functions.length; ++i) { + const newFn = newScript.functions[i]; + let found = false; + + for (let j = 0; j < oldScript.functions.length; ++j) { + const oldFn = oldScript.functions[j]; + + if (newFn.functionName === oldFn.functionName && + newFn.ranges?.[0].startOffset === oldFn.ranges?.[0].startOffset && + newFn.ranges?.[0].endOffset === oldFn.ranges?.[0].endOffset) { + // These are the same functions. + found = true; + + // If newFn is block level coverage, then it will: + // - Replace oldFn if oldFn is not block level coverage. + // - Merge with oldFn if it is also block level coverage. + // If newFn is not block level coverage, then it has no new data. + if (newFn.isBlockCoverage) { + if (oldFn.isBlockCoverage) { + // Merge the oldFn ranges with the newFn ranges. + mergeCoverageRanges(oldFn, newFn); + } else { + // Replace oldFn with newFn. + oldFn.isBlockCoverage = true; + oldFn.ranges = newFn.ranges; + } + } + + break; + } + } + + if (!found) { + // This is a new function to track. This is possible because V8 can + // generate a different list of functions depending on which code paths + // are executed. For example, if a code path dynamically creates a + // function, but that code path is not executed then the function does + // not show up in the coverage report. Unfortunately, this also means + // that the function counts in the coverage summary can never be + // guaranteed to be 100% accurate. + ArrayPrototypePush(oldScript.functions, newFn); + } + } +} + +function mergeCoverageRanges(oldFn, newFn) { + const mergedRanges = new SafeSet(); + + // Keep all of the existing covered ranges. + for (let i = 0; i < oldFn.ranges.length; ++i) { + const oldRange = oldFn.ranges[i]; + + if (oldRange.count > 0) { + mergedRanges.add(oldRange); + } + } + + // Merge in the new ranges where appropriate. + for (let i = 0; i < newFn.ranges.length; ++i) { + const newRange = newFn.ranges[i]; + let exactMatch = false; + + for (let j = 0; j < oldFn.ranges.length; ++j) { + const oldRange = oldFn.ranges[j]; + + if (doesRangeEqualOtherRange(newRange, oldRange)) { + // These are the same ranges, so keep the existing one. + oldRange.count += newRange.count; + mergedRanges.add(oldRange); + exactMatch = true; + break; + } + + // Look at ranges representing missing coverage and add ranges that + // represent the intersection. + if (oldRange.count === 0 && newRange.count === 0) { + if (doesRangeContainOtherRange(oldRange, newRange)) { + // The new range is completely within the old range. Discard the + // larger (old) range, and keep the smaller (new) range. + mergedRanges.add(newRange); + } else if (doesRangeContainOtherRange(newRange, oldRange)) { + // The old range is completely within the new range. Discard the + // larger (new) range, and keep the smaller (old) range. + mergedRanges.add(oldRange); + } + } + } + + // Add new ranges that do not represent missing coverage. + if (newRange.count > 0 && !exactMatch) { + mergedRanges.add(newRange); + } + } + + oldFn.ranges = ArrayFrom(mergedRanges); +} + +function doesRangeEqualOtherRange(range, otherRange) { + return range.startOffset === otherRange.startOffset && + range.endOffset === otherRange.endOffset; +} + +function doesRangeContainOtherRange(range, otherRange) { + return range.startOffset <= otherRange.startOffset && + range.endOffset >= otherRange.endOffset; +} + module.exports = { setupCoverage }; diff --git a/src/node_options.cc b/src/node_options.cc index bbe74d3f830b32..4e3633e5b5b8a6 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -141,13 +141,6 @@ void EnvironmentOptions::CheckOptions(std::vector* errors, } if (test_runner) { - if (test_runner_coverage) { - // TODO(cjihrig): This restriction can be removed once multi-process - // code coverage is supported. - errors->push_back( - "--experimental-test-coverage cannot be used with --test"); - } - if (syntax_check_only) { errors->push_back("either --test or --check can be used, not both"); } diff --git a/test/fixtures/test-runner/combined_coverage/common.js b/test/fixtures/test-runner/combined_coverage/common.js new file mode 100644 index 00000000000000..4d8bda43057a23 --- /dev/null +++ b/test/fixtures/test-runner/combined_coverage/common.js @@ -0,0 +1,69 @@ +'use strict'; +function fnA() { + let cnt = 0; + + try { + cnt++; + throw new Error('boom'); + cnt++; + } catch (err) { + cnt++; + } finally { + if (false) { + + } + + return cnt; + } + cnt++; +} + +function fnB(arr) { + for (let i = 0; i < arr.length; ++i) { + if (i === 2) { + continue; + } else { + fnE(1); + } + } +} + +function fnC(arg1, arg2) { + if (arg1 === 1) { + if (arg2 === 3) { + return -1; + } + + if (arg2 === 4) { + return 3; + } + + if (arg2 === 5) { + return 9; + } + } +} + +function fnD(arg) { + let cnt = 0; + + if (arg % 2 === 0) { + cnt++; + } else if (arg === 1) { + cnt++; + } else if (arg === 3) { + cnt++; + } else { + fnC(1, 5); + } + + return cnt; +} + +function fnE(arg) { + const a = arg ?? 5; + + return a; +} + +module.exports = { fnA, fnB, fnC, fnD, fnE }; diff --git a/test/fixtures/test-runner/combined_coverage/first.test.js b/test/fixtures/test-runner/combined_coverage/first.test.js new file mode 100644 index 00000000000000..29a98e349bd45f --- /dev/null +++ b/test/fixtures/test-runner/combined_coverage/first.test.js @@ -0,0 +1,12 @@ +'use strict'; +const { test } = require('node:test'); +const common = require('./common'); + +function notCalled() { +} + +test('first 1', () => { + common.fnA(); + common.fnD(100); + common.fnB([1, 2, 3]); +}); diff --git a/test/fixtures/test-runner/combined_coverage/second.test.js b/test/fixtures/test-runner/combined_coverage/second.test.js new file mode 100644 index 00000000000000..0b7e292ddb7605 --- /dev/null +++ b/test/fixtures/test-runner/combined_coverage/second.test.js @@ -0,0 +1,8 @@ +'use strict'; +const { test } = require('node:test'); +const common = require('./common'); + +test('second 1', () => { + common.fnB([1, 2, 3]); + common.fnD(3); +}); diff --git a/test/fixtures/test-runner/combined_coverage/third.test.js b/test/fixtures/test-runner/combined_coverage/third.test.js new file mode 100644 index 00000000000000..5416f440223907 --- /dev/null +++ b/test/fixtures/test-runner/combined_coverage/third.test.js @@ -0,0 +1,8 @@ +'use strict'; +const { test } = require('node:test'); +const common = require('./common'); + +test('third 1', () => { + common.fnC(1, 4); + common.fnD(99); +}); diff --git a/test/parallel/test-runner-coverage.js b/test/parallel/test-runner-coverage.js index 01fc8667199e50..2fc019bfd8d595 100644 --- a/test/parallel/test-runner-coverage.js +++ b/test/parallel/test-runner-coverage.js @@ -57,20 +57,6 @@ function getSpecCoverageFixtureReport() { } test('test coverage report', async (t) => { - await t.test('--experimental-test-coverage and --test cannot be combined', () => { - // TODO(cjihrig): This test can be removed once multi-process code coverage - // is supported. - const args = ['--test', '--experimental-test-coverage']; - const result = spawnSync(process.execPath, args); - - // 9 is the documented exit code for an invalid CLI argument. - assert.strictEqual(result.status, 9); - assert.match( - result.stderr.toString(), - /--experimental-test-coverage cannot be used with --test/ - ); - }); - await t.test('handles the inspector not being available', (t) => { if (process.features.inspector) { return; @@ -154,3 +140,56 @@ test('test spec coverage reporter', async (t) => { assert(!findCoverageFileForPid(result.pid)); }); }); + +test('single process coverage is the same with --test', () => { + if (!process.features.inspector) { + return; + } + + const fixture = fixtures.path('test-runner', 'coverage.js'); + const args = [ + '--test', '--experimental-test-coverage', '--test-reporter', 'tap', fixture, + ]; + const result = spawnSync(process.execPath, args); + const report = getTapCoverageFixtureReport(); + + assert(result.stdout.toString().includes(report)); + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); + assert(!findCoverageFileForPid(result.pid)); +}); + +test('coverage is combined for multiple processes', () => { + if (!process.features.inspector) { + return; + } + + let report = [ + '# start of coverage report', + '# file | line % | branch % | funcs % | uncovered lines', + '# test/fixtures/test-runner/combined_coverage/common.js | 89.86 | ' + + '62.50 | 100.00 | 8, 13, 14, 18, 34, 35, 53', + '# test/fixtures/test-runner/combined_coverage/first.test.js | 83.33 | ' + + '100.00 | 50.00 | 5, 6', + '# test/fixtures/test-runner/combined_coverage/second.test.js | 100.00 ' + + '| 100.00 | 100.00 | ', + '# test/fixtures/test-runner/combined_coverage/third.test.js | 100.00 | ' + + '100.00 | 100.00 | ', + '# all files | 90.72 | 72.73 | 88.89 |', + '# end of coverage report', + ].join('\n'); + + if (common.isWindows) { + report = report.replaceAll('/', '\\'); + } + + const fixture = fixtures.path('test-runner', 'combined_coverage'); + const args = [ + '--test', '--experimental-test-coverage', '--test-reporter', 'tap', fixture, + ]; + const result = spawnSync(process.execPath, args); + + assert(result.stdout.toString().includes(report)); + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); +});