Skip to content

Commit 05f4e39

Browse files
committed
test_runner: add coverage support to run function
1 parent ea837a0 commit 05f4e39

File tree

4 files changed

+165
-9
lines changed

4 files changed

+165
-9
lines changed

doc/api/test.md

+11
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,9 @@ added:
12391239
- v18.9.0
12401240
- v16.19.0
12411241
changes:
1242+
- version: REPLACEME
1243+
pr-url: https://github.com/nodejs/node/pull/53937
1244+
description: Added coverage options.
12421245
- version:
12431246
- v22.0.0
12441247
- v20.14.0
@@ -1298,6 +1301,14 @@ changes:
12981301
that specifies the index of the shard to run. This option is _required_.
12991302
* `total` {number} is a positive integer that specifies the total number
13001303
of shards to split the test files to. This option is _required_.
1304+
* `coverage` {boolean} Whether to collect code coverage or not.
1305+
**Default:** `false`.
1306+
* `coverageIncludeGlobs` {string|Array} Includes specific files in code coverage using a
1307+
glob pattern, which can match both absolute and relative file paths.
1308+
**Default:** `undefined`.
1309+
* `coverageExcludeGlobs` {string|Array} Excludes specific files from code coverage using
1310+
a glob pattern, which can match both absolute and relative file paths.
1311+
**Default:** `undefined`.
13011312
* Returns: {TestsStream}
13021313

13031314
**Note:** `shard` is used to horizontally parallelize test running across

lib/internal/test_runner/harness.js

+8-7
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ const testResources = new SafeMap();
3333

3434
testResources.set(reporterScope.asyncId(), reporterScope);
3535

36-
function createTestTree(options = kEmptyObject) {
37-
return setup(new Test({ __proto__: null, ...options, name: '<root>' }));
36+
function createTestTree(options = kEmptyObject, config) {
37+
return setup(new Test({ __proto__: null, ...options, name: '<root>' }), config);
3838
}
3939

4040
function createProcessEventHandler(eventName, rootTest) {
@@ -87,15 +87,15 @@ function createProcessEventHandler(eventName, rootTest) {
8787
};
8888
}
8989

90-
function configureCoverage(rootTest, globalOptions) {
91-
if (!globalOptions.coverage) {
90+
function configureCoverage(rootTest, options) {
91+
if (!options.coverage) {
9292
return null;
9393
}
9494

9595
const { setupCoverage } = require('internal/test_runner/coverage');
9696

9797
try {
98-
return setupCoverage(globalOptions);
98+
return setupCoverage(options);
9999
} catch (err) {
100100
const msg = `Warning: Code coverage could not be enabled. ${err}`;
101101

@@ -125,14 +125,14 @@ function collectCoverage(rootTest, coverage) {
125125
return summary;
126126
}
127127

128-
function setup(root) {
128+
function setup(root, config) {
129129
if (root.startTime !== null) {
130130
return root;
131131
}
132132

133133
// Parse the command line options before the hook is enabled. We don't want
134134
// global input validation errors to end up in the uncaughtException handler.
135-
const globalOptions = parseCommandLine();
135+
const globalOptions = config ?? parseCommandLine();
136136

137137
const hook = createHook({
138138
__proto__: null,
@@ -211,6 +211,7 @@ function setup(root) {
211211
shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations),
212212
teardown: exitHandler,
213213
snapshotManager: null,
214+
config,
214215
};
215216
root.harness.resetCounters();
216217
root.startTime = hrtime();

lib/internal/test_runner/runner.js

+57-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const {
5151
validateFunction,
5252
validateObject,
5353
validateInteger,
54+
validateStringArray,
5455
} = require('internal/validators');
5556
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
5657
const { isRegExp } = require('internal/util/types');
@@ -471,7 +472,13 @@ function watchFiles(testFiles, opts) {
471472
function run(options = kEmptyObject) {
472473
validateObject(options, 'options');
473474

474-
let { testNamePatterns, testSkipPatterns, shard } = options;
475+
let {
476+
testNamePatterns,
477+
testSkipPatterns,
478+
shard,
479+
coverageExcludeGlobs,
480+
coverageIncludeGlobs,
481+
} = options;
475482
const {
476483
concurrency,
477484
timeout,
@@ -482,6 +489,7 @@ function run(options = kEmptyObject) {
482489
watch,
483490
setup,
484491
only,
492+
coverage,
485493
} = options;
486494

487495
if (files != null) {
@@ -549,8 +557,55 @@ function run(options = kEmptyObject) {
549557
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
550558
});
551559
}
560+
if (coverage != null) {
561+
validateBoolean(coverage, 'options.coverage');
562+
}
563+
if (coverageExcludeGlobs != null) {
564+
if (!coverage) {
565+
throw new ERR_INVALID_ARG_VALUE(
566+
'options.coverageExcludeGlobs',
567+
coverageExcludeGlobs,
568+
'is only supported when coverage is enabled',
569+
);
570+
}
571+
if (!ArrayIsArray(coverageExcludeGlobs)) {
572+
coverageExcludeGlobs = [coverageExcludeGlobs];
573+
}
574+
validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
575+
}
576+
if (coverageIncludeGlobs != null) {
577+
if (!coverage) {
578+
throw new ERR_INVALID_ARG_VALUE(
579+
'options.coverageIncludeGlobs',
580+
coverageIncludeGlobs,
581+
'is only supported when coverage is enabled',
582+
);
583+
}
584+
if (!ArrayIsArray(coverageIncludeGlobs)) {
585+
coverageIncludeGlobs = [coverageIncludeGlobs];
586+
}
587+
validateStringArray(coverageIncludeGlobs, 'options.coverageIncludeGlobs');
588+
}
552589

553-
const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
590+
const root = createTestTree(
591+
{ __proto__: null, concurrency, timeout, signal },
592+
{
593+
__proto__: null,
594+
coverage,
595+
coverageExcludeGlobs,
596+
coverageIncludeGlobs,
597+
forceExit,
598+
perFileTimeout: timeout || Infinity,
599+
runnerConcurrency: concurrency,
600+
shard,
601+
sourceMaps: options.sourceMaps,
602+
testOnlyFlag: only,
603+
testNamePatterns,
604+
testSkipPatterns,
605+
updateSnapshots: options.updateSnapshots,
606+
watchMode: watch,
607+
},
608+
);
554609

555610
if (process.env.NODE_TEST_CONTEXT !== undefined) {
556611
process.emitWarning('node:test run() is being called recursively within a test file. skipping running files.');

test/parallel/test-runner-run.mjs

+89
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { dot, spec, tap } from 'node:test/reporters';
66
import assert from 'node:assert';
77

88
const testFixtures = fixtures.path('test-runner');
9+
const skipIfNoInspector = {
10+
skip: !process.features.inspector ? 'inspector disabled' : false
11+
};
912

1013
describe('require(\'node:test\').run', { concurrency: true }, () => {
1114
it('should run with no tests', async () => {
@@ -488,6 +491,92 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
488491
});
489492
});
490493

494+
describe('coverage', () => {
495+
describe('validation', () => {
496+
497+
it('should only allow boolean in options.coverage', async () => {
498+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve(true), []]
499+
.forEach((coverage) => assert.throws(() => run({ coverage }), {
500+
code: 'ERR_INVALID_ARG_TYPE'
501+
}));
502+
});
503+
504+
it('should only allow coverageExcludeGlobs and coverageIncludeGlobs when coverage is true', async () => {
505+
assert.throws(
506+
() => run({ coverage: false, coverageIncludeGlobs: [] }),
507+
{ code: 'ERR_INVALID_ARG_VALUE' },
508+
);
509+
assert.throws(
510+
() => run({ coverage: false, coverageExcludeGlobs: [] }),
511+
{ code: 'ERR_INVALID_ARG_VALUE' },
512+
);
513+
});
514+
515+
it('should only allow string|string[] in options.coverageExcludeGlobs', async () => {
516+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
517+
.forEach((coverageExcludeGlobs) => {
518+
assert.throws(() => run({ coverage: true, coverageExcludeGlobs }), {
519+
code: 'ERR_INVALID_ARG_TYPE'
520+
});
521+
assert.throws(() => run({ coverage: true, coverageExcludeGlobs: [coverageExcludeGlobs] }), {
522+
code: 'ERR_INVALID_ARG_TYPE'
523+
});
524+
});
525+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: [''] });
526+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: '' });
527+
});
528+
529+
it('should only allow string|string[] in options.coverageIncludeGlobs', async () => {
530+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
531+
.forEach((coverageIncludeGlobs) => {
532+
assert.throws(() => run({ coverage: true, coverageIncludeGlobs }), {
533+
code: 'ERR_INVALID_ARG_TYPE'
534+
});
535+
assert.throws(() => run({ coverage: true, coverageIncludeGlobs: [coverageIncludeGlobs] }), {
536+
code: 'ERR_INVALID_ARG_TYPE'
537+
});
538+
});
539+
540+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: [''] });
541+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: '' });
542+
});
543+
});
544+
545+
const files = [fixtures.path('test-runner', 'coverage.js')];
546+
it('should run with coverage', skipIfNoInspector, async () => {
547+
const stream = run({ files, coverage: true });
548+
stream.on('test:fail', common.mustNotCall());
549+
stream.on('test:pass', common.mustCall(1));
550+
stream.on('test:coverage', common.mustCall());
551+
// eslint-disable-next-line no-unused-vars
552+
for await (const _ of stream);
553+
});
554+
555+
it('should run with coverage and exclude by glob', skipIfNoInspector, async () => {
556+
const stream = run({ files, coverage: true, coverageExcludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
557+
stream.on('test:fail', common.mustNotCall());
558+
stream.on('test:pass', common.mustCall(1));
559+
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
560+
const filesPaths = files.map(({ path }) => path);
561+
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), false);
562+
}));
563+
// eslint-disable-next-line no-unused-vars
564+
for await (const _ of stream);
565+
});
566+
567+
it('should run with coverage and include by glob', skipIfNoInspector, async () => {
568+
const stream = run({ files, coverage: true, coverageIncludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
569+
stream.on('test:fail', common.mustNotCall());
570+
stream.on('test:pass', common.mustCall(1));
571+
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
572+
const filesPaths = files.map(({ path }) => path);
573+
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), true);
574+
}));
575+
// eslint-disable-next-line no-unused-vars
576+
for await (const _ of stream);
577+
});
578+
});
579+
491580
it('should run with no files', async () => {
492581
const stream = run({
493582
files: undefined

0 commit comments

Comments
 (0)