From 05f4e39cc27277686f9b1f45e5963aa4a5efd56d Mon Sep 17 00:00:00 2001 From: atlowChemi Date: Fri, 19 Jul 2024 10:57:40 +0300 Subject: [PATCH] test_runner: add coverage support to run function --- doc/api/test.md | 11 ++++ lib/internal/test_runner/harness.js | 15 ++--- lib/internal/test_runner/runner.js | 59 ++++++++++++++++++- test/parallel/test-runner-run.mjs | 89 +++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/doc/api/test.md b/doc/api/test.md index e0d4bee17a7483..ef13664156fa25 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: - v22.0.0 - v20.14.0 @@ -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 diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 02b3f9933d02d6..ef920fe51b84a3 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -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: '' })); +function createTestTree(options = kEmptyObject, config) { + return setup(new Test({ __proto__: null, ...options, name: '' }), config); } function createProcessEventHandler(eventName, rootTest) { @@ -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}`; @@ -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, @@ -211,6 +211,7 @@ function setup(root) { shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations), teardown: exitHandler, snapshotManager: null, + config, }; root.harness.resetCounters(); root.startTime = hrtime(); diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 556bf475d68691..dfdf967de05b21 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -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'); @@ -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, @@ -482,6 +489,7 @@ function run(options = kEmptyObject) { watch, setup, only, + coverage, } = options; if (files != null) { @@ -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.'); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 54e882c4ecabe3..4c069320ae2909 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 () => { @@ -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