diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 8fa9c872568d1e..4f064d077c4775 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -37,7 +37,7 @@ const { }, } = require('internal/errors'); const { matchGlobPattern } = require('internal/fs/glob'); -const { constants: { kMockSearchParam } } = require('internal/test_runner/mock/loader'); +const { constants: { kMockSearchParam }, mocks } = require('internal/test_runner/mock/loader'); const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/; const kIgnoreRegex = /\/\* node:coverage ignore next (?\d+ )?\*\//; @@ -498,8 +498,18 @@ class TestCoverage { return true; } - const searchParams = new URL(url).searchParams; - if (searchParams.get(kMockSearchParam)) { + const parsedURL = new URL(url); + if (parsedURL.searchParams.get(kMockSearchParam)) { + return true; + } + + // Check if the base URL (without query params) is registered as a mock. + // This handles the case where a CJS module is mocked and imported into + // an ESM module - V8 coverage reports the original file URL without the + // mock search parameter. + parsedURL.search = ''; + const mock = mocks.get(parsedURL.href); + if (mock?.active === true) { return true; } diff --git a/test/fixtures/test-runner/coverage-with-mock-cjs/dependency.cjs b/test/fixtures/test-runner/coverage-with-mock-cjs/dependency.cjs new file mode 100644 index 00000000000000..362067b06b2110 --- /dev/null +++ b/test/fixtures/test-runner/coverage-with-mock-cjs/dependency.cjs @@ -0,0 +1,13 @@ +'use strict'; + +const data = { type: 'cjs-object' }; + +function sum(a, b) { + return a + b; +} + +function getData() { + return data; +} + +module.exports = { sum, getData }; diff --git a/test/fixtures/test-runner/coverage-with-mock-cjs/subject.mjs b/test/fixtures/test-runner/coverage-with-mock-cjs/subject.mjs new file mode 100644 index 00000000000000..1a5ded9a3da1b7 --- /dev/null +++ b/test/fixtures/test-runner/coverage-with-mock-cjs/subject.mjs @@ -0,0 +1,5 @@ +import { sum, getData } from './dependency.cjs'; + +export const theModuleSum = (a, b) => sum(a, b); + +export const theModuleGetData = () => getData(); diff --git a/test/fixtures/test-runner/output/coverage-with-mock-cjs.mjs b/test/fixtures/test-runner/output/coverage-with-mock-cjs.mjs new file mode 100644 index 00000000000000..003304b7224a76 --- /dev/null +++ b/test/fixtures/test-runner/output/coverage-with-mock-cjs.mjs @@ -0,0 +1,17 @@ +import { describe, it, mock } from 'node:test'; + +describe('coverage with mocked CJS module in ESM', async () => { + mock.module('../coverage-with-mock-cjs/dependency.cjs', { + namedExports: { + sum: (a, b) => 42, + getData: () => ({ mocked: true }), + }, + }); + + const { theModuleSum, theModuleGetData } = await import('../coverage-with-mock-cjs/subject.mjs'); + + it('uses mocked CJS exports', (t) => { + t.assert.strictEqual(theModuleSum(1, 2), 42); + t.assert.deepStrictEqual(theModuleGetData(), { mocked: true }); + }); +}); diff --git a/test/fixtures/test-runner/output/coverage-with-mock-cjs.snapshot b/test/fixtures/test-runner/output/coverage-with-mock-cjs.snapshot new file mode 100644 index 00000000000000..83267c46cbf004 --- /dev/null +++ b/test/fixtures/test-runner/output/coverage-with-mock-cjs.snapshot @@ -0,0 +1,38 @@ +TAP version 13 +# Subtest: coverage with mocked CJS module in ESM + # Subtest: uses mocked CJS exports + ok 1 - uses mocked CJS exports + --- + duration_ms: * + type: 'test' + ... + 1..1 +ok 1 - coverage with mocked CJS module in ESM + --- + duration_ms: * + type: 'suite' + ... +1..1 +# tests 1 +# suites 1 +# pass 1 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms * +# start of coverage report +# ------------------------------------------------------------------------------- +# file | line % | branch % | funcs % | uncovered lines +# ------------------------------------------------------------------------------- +# test | | | | +# fixtures | | | | +# test-runner | | | | +# coverage-with-mock-cjs | | | | +# subject.mjs | 100.00 | 100.00 | 100.00 | +# output | | | | +# coverage-with-mock-cjs.mjs | 100.00 | 100.00 | 100.00 | +# ------------------------------------------------------------------------------- +# all files | 100.00 | 100.00 | 100.00 | +# ------------------------------------------------------------------------------- +# end of coverage report diff --git a/test/test-runner/test-output-coverage-with-mock-cjs.mjs b/test/test-runner/test-output-coverage-with-mock-cjs.mjs new file mode 100644 index 00000000000000..d751d20bc5341b --- /dev/null +++ b/test/test-runner/test-output-coverage-with-mock-cjs.mjs @@ -0,0 +1,25 @@ +// Test that mocked CJS modules imported into ESM are excluded from coverage. +// This tests the fix for https://github.com/nodejs/node/issues/61709 +import * as common from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { spawnAndAssert, defaultTransform, ensureCwdIsProjectRoot } from '../common/assertSnapshot.js'; + +if (!process.features.inspector) { + common.skip('inspector support required'); +} + +ensureCwdIsProjectRoot(); +await spawnAndAssert( + fixtures.path('test-runner/output/coverage-with-mock-cjs.mjs'), + defaultTransform, + { + flags: [ + '--disable-warning=ExperimentalWarning', + '--test-reporter=tap', + '--experimental-transform-types', + '--experimental-test-module-mocks', + '--experimental-test-coverage', + '--test-coverage-exclude=!test/**', + ], + }, +);