diff --git a/doc/api/test.md b/doc/api/test.md index e2edeb86b725679..8cb9827dc9866af 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -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: REPLACEME pr-url: https://github.com/nodejs/node/pull/53866 description: Added the `globPatterns` option. @@ -1304,6 +1307,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`. + * `coverageIncludePatterns` {string|Array} Includes specific files in code coverage using a + glob expression, which can match both absolute and relative file paths. + **Default:** `undefined`. + * `coverageExcludePatterns` {string|Array} Excludes specific files from code coverage using + a glob expression, which can match both absolute and relative file paths. + **Default:** `undefined`. * Returns: {TestsStream} **Note:** `shard` is used to horizontally parallelize test running across diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index b97965235e7d472..f931a2f1ffa3699 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -495,8 +495,8 @@ function setupCoverage(options) { coverageDirectory, originalCoverageDirectory, cwd, - options.coverageExcludeGlobs, - options.coverageIncludeGlobs, + options.coverageExcludePatterns, + options.coverageIncludePatterns, ); } diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a3dc6c00630fbf8..79e6c39046b975e 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -50,6 +50,7 @@ const { validateFunction, validateObject, validateInteger, + validateStringArray, } = require('internal/validators'); const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); const { isRegExp } = require('internal/util/types'); @@ -471,7 +472,13 @@ function watchFiles(testFiles, opts) { function run(options = kEmptyObject) { validateObject(options, 'options'); - let { testNamePatterns, testSkipPatterns, shard } = options; + let { + testNamePatterns, + testSkipPatterns, + shard, + coverageExcludePatterns, + coverageIncludePatterns, + } = options; const { concurrency, timeout, @@ -483,6 +490,7 @@ function run(options = kEmptyObject) { setup, only, globPatterns, + coverage, } = options; if (files != null) { @@ -560,10 +568,43 @@ function run(options = kEmptyObject) { throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value); }); } + if (coverage != null) { + validateBoolean(coverage, 'options.coverage'); + } + if (coverageExcludePatterns != null) { + if (!coverage) { + throw new ERR_INVALID_ARG_VALUE( + 'options.coverageExcludePatterns', + coverageExcludePatterns, + 'is only supported when coverage is enabled', + ); + } + if (!ArrayIsArray(coverageExcludePatterns)) { + coverageExcludePatterns = [coverageExcludePatterns]; + } + validateStringArray(coverageExcludePatterns, 'options.coverageExcludePatterns'); + } + if (coverageIncludePatterns != null) { + if (!coverage) { + throw new ERR_INVALID_ARG_VALUE( + 'options.coverageIncludePatterns', + coverageIncludePatterns, + 'is only supported when coverage is enabled', + ); + } + if (!ArrayIsArray(coverageIncludePatterns)) { + coverageIncludePatterns = [coverageIncludePatterns]; + } + validateStringArray(coverageIncludePatterns, 'options.coverageIncludePatterns'); + } + const root = createTestTree( { __proto__: null, concurrency, timeout, signal }, { __proto__: null, + coverage, + coverageExcludePatterns, + coverageIncludePatterns, forceExit, perFileTimeout: timeout || Infinity, runnerConcurrency: concurrency, diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index aae2a756800a0fa..a5f9cc8913a2950 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -200,8 +200,8 @@ function parseCommandLine() { const watchMode = getOptionValue('--watch'); const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child'; const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8'; - let coverageExcludeGlobs; - let coverageIncludeGlobs; + let coverageExcludePatterns; + let coverageIncludePatterns; let destinations; let perFileTimeout; let reporters; @@ -277,16 +277,16 @@ function parseCommandLine() { } if (coverage) { - coverageExcludeGlobs = getOptionValue('--test-coverage-exclude'); - coverageIncludeGlobs = getOptionValue('--test-coverage-include'); + coverageExcludePatterns = getOptionValue('--test-coverage-exclude'); + coverageIncludePatterns = getOptionValue('--test-coverage-include'); } globalTestOptions = { __proto__: null, isTestRunner, coverage, - coverageExcludeGlobs, - coverageIncludeGlobs, + coverageExcludePatterns, + coverageIncludePatterns, forceExit, perFileTimeout, runnerConcurrency, diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 7a575da9c95275e..cf317d1e173d10e 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -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 () => { @@ -502,6 +505,125 @@ 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 coverageExcludePatterns and coverageIncludePatterns when coverage is true', async () => { + assert.throws( + () => run({ coverage: false, coverageIncludePatterns: [] }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + assert.throws( + () => run({ coverage: false, coverageExcludePatterns: [] }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + }); + + it('should only allow string|string[] in options.coverageExcludePatterns', async () => { + [Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false] + .forEach((coverageExcludePatterns) => { + assert.throws(() => run({ coverage: true, coverageExcludePatterns }), { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => run({ coverage: true, coverageExcludePatterns: [coverageExcludePatterns] }), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludePatterns: [''] }); + run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludePatterns: '' }); + }); + + it('should only allow string|string[] in options.coverageIncludePatterns', async () => { + [Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false] + .forEach((coverageIncludePatterns) => { + assert.throws(() => run({ coverage: true, coverageIncludePatterns }), { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => run({ coverage: true, coverageIncludePatterns: [coverageIncludePatterns] }), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludePatterns: [''] }); + run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludePatterns: '' }); + }); + }); + + 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, coverageExcludePatterns: ['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, coverageIncludePatterns: ['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 + }).compose(tap); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustNotCall()); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); + + it('should run with no files and use spec reporter', async () => { + const stream = run({ + files: undefined + }).compose(spec); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustNotCall()); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); + + it('should run with no files and use dot reporter', async () => { + const stream = run({ + files: undefined + }).compose(dot); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustNotCall()); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); + it('should avoid running recursively', async () => { const stream = run({ files: [join(testFixtures, 'recursive_run.js')] }); let stderr = '';