diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 8fa9c872568d1e..577ce15f147d03 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -533,6 +533,8 @@ function setupCoverage(options) { return null; } + internalBinding('profiler').startCoverage(); + // Ensure that NODE_V8_COVERAGE is set so that coverage can propagate to // child processes. process.env.NODE_V8_COVERAGE = coverageDirectory; diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 2033ac16e8ea49..ec0677831c373a 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -805,6 +805,8 @@ function run(options = kEmptyObject) { coverageExcludeGlobs = [coverageExcludeGlobs]; } validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs'); + } else if (coverage) { + coverageExcludeGlobs = [kDefaultPattern]; } if (coverageIncludeGlobs != null) { if (!ArrayIsArray(coverageIncludeGlobs)) { diff --git a/src/inspector_profiler.cc b/src/inspector_profiler.cc index 28653a3939daef..559b4fd27d56ab 100644 --- a/src/inspector_profiler.cc +++ b/src/inspector_profiler.cc @@ -548,6 +548,30 @@ static void SetSourceMapCacheGetter(const FunctionCallbackInfo& args) { env->set_source_map_cache_getter(args[0].As()); } +static void StartCoverage(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + + Debug(env, + DebugCategory::INSPECTOR_PROFILER, + "StartCoverage, connection %s nullptr\n", + env->coverage_connection() == nullptr ? "==" : "!="); + + if (env->coverage_connection() != nullptr) { + return; + } + + // The parent of `--test --test-isolation=process` intentionally has no + // inspector (see Environment::should_create_inspector); workers handle + // coverage themselves. Without an inspector, V8CoverageConnection would + // get a null session and crash on the first DispatchMessage. + if (!env->should_create_inspector()) { + return; + } + + env->set_coverage_connection(std::make_unique(env)); + env->coverage_connection()->Start(); +} + static void TakeCoverage(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); V8CoverageConnection* connection = env->coverage_connection(); @@ -601,6 +625,7 @@ static void Initialize(Local target, SetMethod(context, target, "setCoverageDirectory", SetCoverageDirectory); SetMethod( context, target, "setSourceMapCacheGetter", SetSourceMapCacheGetter); + SetMethod(context, target, "startCoverage", StartCoverage); SetMethod(context, target, "takeCoverage", TakeCoverage); SetMethod(context, target, "stopCoverage", StopCoverage); SetMethod(context, target, "endCoverage", EndCoverage); @@ -609,6 +634,7 @@ static void Initialize(Local target, void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SetCoverageDirectory); registry->Register(SetSourceMapCacheGetter); + registry->Register(StartCoverage); registry->Register(TakeCoverage); registry->Register(StopCoverage); registry->Register(EndCoverage); diff --git a/test/fixtures/test-runner/coverage-isolation-none/src/foo.mjs b/test/fixtures/test-runner/coverage-isolation-none/src/foo.mjs new file mode 100644 index 00000000000000..f6a50e85a9d412 --- /dev/null +++ b/test/fixtures/test-runner/coverage-isolation-none/src/foo.mjs @@ -0,0 +1,11 @@ +export function add(a, b) { + return a + b; +} + +export function sub(a, b) { + return a - b; +} + +export function unused() { + return 'unused'; +} diff --git a/test/fixtures/test-runner/coverage-isolation-none/tests/foo.test.mjs b/test/fixtures/test-runner/coverage-isolation-none/tests/foo.test.mjs new file mode 100644 index 00000000000000..efbccddc87412b --- /dev/null +++ b/test/fixtures/test-runner/coverage-isolation-none/tests/foo.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { add, sub } from '../src/foo.mjs'; + +test('add', () => { + assert.strictEqual(add(2, 3), 5); +}); + +test('sub', () => { + assert.strictEqual(sub(5, 3), 2); +}); diff --git a/test/parallel/test-runner-coverage-isolation-none-api.mjs b/test/parallel/test-runner-coverage-isolation-none-api.mjs new file mode 100644 index 00000000000000..08417e5dc3a1b6 --- /dev/null +++ b/test/parallel/test-runner-coverage-isolation-none-api.mjs @@ -0,0 +1,92 @@ +import * as common from '../common/index.mjs'; +import { before, describe, it, run } from 'node:test'; +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { cp, writeFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; +import fixtures from '../common/fixtures.js'; + +const skipIfNoInspector = { + skip: !process.features.inspector ? 'inspector disabled' : false, +}; + +tmpdir.refresh(); + +async function setupFixtures() { + const fixtureDir = fixtures.path('test-runner', 'coverage-isolation-none'); + await cp(fixtureDir, tmpdir.path, { recursive: true }); +} + +describe('run() coverage with isolation: none', skipIfNoInspector, () => { + before(async () => { + await setupFixtures(); + }); + + for (const isolation of ['none', 'process']) { + it(`reports src coverage and excludes test files by default (isolation=${isolation})`, async () => { + const stream = run({ + files: [join(tmpdir.path, 'tests', 'foo.test.mjs')], + coverage: true, + isolation, + cwd: tmpdir.path, + }); + stream.on('test:fail', common.mustNotCall()); + + let summary; + stream.on('test:coverage', common.mustCall(({ summary: s }) => { + summary = s; + })); + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + + assert.ok(summary, 'test:coverage event must fire'); + const paths = summary.files.map((f) => f.path); + assert.ok( + paths.length > 0, + `coverage files must be reported (isolation=${isolation}); got ${JSON.stringify(paths)}`, + ); + assert.ok( + paths.some((p) => p.endsWith(`src${sep}foo.mjs`)), + `expected src/foo.mjs to be present (isolation=${isolation}); got ${JSON.stringify(paths)}`, + ); + assert.ok( + paths.every((p) => !p.endsWith('foo.test.mjs')), + `expected foo.test.mjs to be excluded by default (isolation=${isolation}); got ${JSON.stringify(paths)}`, + ); + }); + } + + it('is idempotent when --experimental-test-coverage is also passed', async () => { + const runnerPath = join(tmpdir.path, 'runner.mjs'); + await writeFile(runnerPath, `\ +import { run } from 'node:test'; +import { join } from 'node:path'; + +const stream = run({ + files: [join(import.meta.dirname, 'tests', 'foo.test.mjs')], + coverage: true, + isolation: 'none', + cwd: import.meta.dirname, +}); +stream.on('test:fail', () => process.exit(10)); +let summary; +stream.on('test:coverage', (event) => { summary = event.summary; }); +for await (const _ of stream); +if (!summary || summary.files.length === 0) process.exit(11); +const hasSrc = summary.files.some((f) => f.path.endsWith('foo.mjs') && !f.path.endsWith('foo.test.mjs')); +const hasTest = summary.files.some((f) => f.path.endsWith('foo.test.mjs')); +if (!hasSrc) process.exit(12); +if (hasTest) process.exit(13); +`); + const result = spawnSync(process.execPath, [ + '--experimental-test-coverage', + runnerPath, + ], { cwd: tmpdir.path }); + assert.strictEqual( + result.status, + 0, + `exited with ${result.status}\nstderr: ${result.stderr}\nstdout: ${result.stdout}`, + ); + }); +}); diff --git a/test/parallel/test-runner-run-coverage.mjs b/test/parallel/test-runner-run-coverage.mjs index 15fcfef5238843..89a9da2a179e44 100644 --- a/test/parallel/test-runner-run-coverage.mjs +++ b/test/parallel/test-runner-run-coverage.mjs @@ -123,6 +123,7 @@ describe('require(\'node:test\').run coverage settings', { concurrency: true }, const stream = run({ files, coverage: true, + coverageExcludeGlobs: '!test/**', coverageIncludeGlobs: ['test/fixtures/test-runner/coverage.js', 'test/*/v8-coverage/throw.js'], }); stream.on('test:fail', common.mustNotCall()); @@ -157,7 +158,14 @@ describe('require(\'node:test\').run coverage settings', { concurrency: true }, const thresholdErrors = []; const originalExitCode = process.exitCode; assert.notStrictEqual(originalExitCode, 1); - const stream = run({ files, coverage: true, lineCoverage: 99, branchCoverage: 99, functionCoverage: 99 }); + const stream = run({ + files, + coverage: true, + coverageExcludeGlobs: '!test/**', + lineCoverage: 99, + branchCoverage: 99, + functionCoverage: 99, + }); stream.on('test:fail', common.mustNotCall()); stream.on('test:pass', common.mustCall(1)); stream.on('test:diagnostic', ({ message }) => {