Skip to content

Commit

Permalink
test_runner: add coverage support to run function
Browse files Browse the repository at this point in the history
  • Loading branch information
atlowChemi committed Jul 22, 2024
1 parent ea837a0 commit 05f4e39
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 9 deletions.
11 changes: 11 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,9 @@ added:
- v18.9.0
- v16.19.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/53937
description: Added coverage options.
- version:
- v22.0.0
- v20.14.0
Expand Down Expand Up @@ -1298,6 +1301,14 @@ changes:
that specifies the index of the shard to run. This option is _required_.
* `total` {number} is a positive integer that specifies the total number
of shards to split the test files to. This option is _required_.
* `coverage` {boolean} Whether to collect code coverage or not.
**Default:** `false`.
* `coverageIncludeGlobs` {string|Array} Includes specific files in code coverage using a
glob pattern, which can match both absolute and relative file paths.
**Default:** `undefined`.
* `coverageExcludeGlobs` {string|Array} Excludes specific files from code coverage using
a glob pattern, which can match both absolute and relative file paths.
**Default:** `undefined`.
* Returns: {TestsStream}

**Note:** `shard` is used to horizontally parallelize test running across
Expand Down
15 changes: 8 additions & 7 deletions lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const testResources = new SafeMap();

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

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

function createProcessEventHandler(eventName, rootTest) {
Expand Down Expand Up @@ -87,15 +87,15 @@ function createProcessEventHandler(eventName, rootTest) {
};
}

function configureCoverage(rootTest, globalOptions) {
if (!globalOptions.coverage) {
function configureCoverage(rootTest, options) {
if (!options.coverage) {
return null;
}

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

try {
return setupCoverage(globalOptions);
return setupCoverage(options);
} catch (err) {
const msg = `Warning: Code coverage could not be enabled. ${err}`;

Expand Down Expand Up @@ -125,14 +125,14 @@ function collectCoverage(rootTest, coverage) {
return summary;
}

function setup(root) {
function setup(root, config) {
if (root.startTime !== null) {
return root;
}

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

const hook = createHook({
__proto__: null,
Expand Down Expand Up @@ -211,6 +211,7 @@ function setup(root) {
shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations),
teardown: exitHandler,
snapshotManager: null,
config,
};
root.harness.resetCounters();
root.startTime = hrtime();
Expand Down
59 changes: 57 additions & 2 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const {
validateFunction,
validateObject,
validateInteger,
validateStringArray,
} = require('internal/validators');
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
const { isRegExp } = require('internal/util/types');
Expand Down Expand Up @@ -471,7 +472,13 @@ function watchFiles(testFiles, opts) {
function run(options = kEmptyObject) {
validateObject(options, 'options');

let { testNamePatterns, testSkipPatterns, shard } = options;
let {
testNamePatterns,
testSkipPatterns,
shard,
coverageExcludeGlobs,
coverageIncludeGlobs,
} = options;
const {
concurrency,
timeout,
Expand All @@ -482,6 +489,7 @@ function run(options = kEmptyObject) {
watch,
setup,
only,
coverage,
} = options;

if (files != null) {
Expand Down Expand Up @@ -549,8 +557,55 @@ function run(options = kEmptyObject) {
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
});
}
if (coverage != null) {
validateBoolean(coverage, 'options.coverage');
}
if (coverageExcludeGlobs != null) {
if (!coverage) {
throw new ERR_INVALID_ARG_VALUE(
'options.coverageExcludeGlobs',
coverageExcludeGlobs,
'is only supported when coverage is enabled',
);
}
if (!ArrayIsArray(coverageExcludeGlobs)) {
coverageExcludeGlobs = [coverageExcludeGlobs];
}
validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
}
if (coverageIncludeGlobs != null) {
if (!coverage) {
throw new ERR_INVALID_ARG_VALUE(
'options.coverageIncludeGlobs',
coverageIncludeGlobs,
'is only supported when coverage is enabled',
);
}
if (!ArrayIsArray(coverageIncludeGlobs)) {
coverageIncludeGlobs = [coverageIncludeGlobs];
}
validateStringArray(coverageIncludeGlobs, 'options.coverageIncludeGlobs');
}

const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
const root = createTestTree(
{ __proto__: null, concurrency, timeout, signal },
{
__proto__: null,
coverage,
coverageExcludeGlobs,
coverageIncludeGlobs,
forceExit,
perFileTimeout: timeout || Infinity,
runnerConcurrency: concurrency,
shard,
sourceMaps: options.sourceMaps,
testOnlyFlag: only,
testNamePatterns,
testSkipPatterns,
updateSnapshots: options.updateSnapshots,
watchMode: watch,
},
);

if (process.env.NODE_TEST_CONTEXT !== undefined) {
process.emitWarning('node:test run() is being called recursively within a test file. skipping running files.');
Expand Down
89 changes: 89 additions & 0 deletions test/parallel/test-runner-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { dot, spec, tap } from 'node:test/reporters';
import assert from 'node:assert';

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

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

describe('coverage', () => {
describe('validation', () => {

it('should only allow boolean in options.coverage', async () => {
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve(true), []]
.forEach((coverage) => assert.throws(() => run({ coverage }), {
code: 'ERR_INVALID_ARG_TYPE'
}));
});

it('should only allow coverageExcludeGlobs and coverageIncludeGlobs when coverage is true', async () => {
assert.throws(
() => run({ coverage: false, coverageIncludeGlobs: [] }),
{ code: 'ERR_INVALID_ARG_VALUE' },
);
assert.throws(
() => run({ coverage: false, coverageExcludeGlobs: [] }),
{ code: 'ERR_INVALID_ARG_VALUE' },
);
});

it('should only allow string|string[] in options.coverageExcludeGlobs', async () => {
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
.forEach((coverageExcludeGlobs) => {
assert.throws(() => run({ coverage: true, coverageExcludeGlobs }), {
code: 'ERR_INVALID_ARG_TYPE'
});
assert.throws(() => run({ coverage: true, coverageExcludeGlobs: [coverageExcludeGlobs] }), {
code: 'ERR_INVALID_ARG_TYPE'
});
});
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: [''] });
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: '' });
});

it('should only allow string|string[] in options.coverageIncludeGlobs', async () => {
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
.forEach((coverageIncludeGlobs) => {
assert.throws(() => run({ coverage: true, coverageIncludeGlobs }), {
code: 'ERR_INVALID_ARG_TYPE'
});
assert.throws(() => run({ coverage: true, coverageIncludeGlobs: [coverageIncludeGlobs] }), {
code: 'ERR_INVALID_ARG_TYPE'
});
});

run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: [''] });
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: '' });
});
});

const files = [fixtures.path('test-runner', 'coverage.js')];
it('should run with coverage', skipIfNoInspector, async () => {
const stream = run({ files, coverage: true });
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustCall(1));
stream.on('test:coverage', common.mustCall());
// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});

it('should run with coverage and exclude by glob', skipIfNoInspector, async () => {
const stream = run({ files, coverage: true, coverageExcludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustCall(1));
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
const filesPaths = files.map(({ path }) => path);
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), false);
}));
// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});

it('should run with coverage and include by glob', skipIfNoInspector, async () => {
const stream = run({ files, coverage: true, coverageIncludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
stream.on('test:fail', common.mustNotCall());
stream.on('test:pass', common.mustCall(1));
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
const filesPaths = files.map(({ path }) => path);
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), true);
}));
// eslint-disable-next-line no-unused-vars
for await (const _ of stream);
});
});

it('should run with no files', async () => {
const stream = run({
files: undefined
Expand Down

0 comments on commit 05f4e39

Please sign in to comment.