diff --git a/README.md b/README.md index 9b23018..0407239 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,41 @@ test('this test is not run', () => { }) ``` +## Filtering tests by name + +The [`--test-name-pattern`][] command-line option can be used to only run tests +whose name matches the provided pattern. Test name patterns are interpreted as +JavaScript regular expressions. The `--test-name-pattern` option can be +specified multiple times in order to run nested tests. For each test that is +executed, any corresponding test hooks, such as `beforeEach()`, are also +run. + +Given the following test file, starting Node.js with the +`--test-name-pattern="test [1-3]"` option would cause the test runner to execute +`test 1`, `test 2`, and `test 3`. If `test 1` did not match the test name +pattern, then its subtests would not execute, despite matching the pattern. The +same set of tests could also be executed by passing `--test-name-pattern` +multiple times (e.g. `--test-name-pattern="test 1"`, +`--test-name-pattern="test 2"`, etc.). + +```js +test('test 1', async (t) => { + await t.test('test 2'); + await t.test('test 3'); +}); +test('Test 4', async (t) => { + await t.test('Test 5'); + await t.test('test 6'); +}); +``` + +Test name patterns can also be specified using regular expression literals. This +allows regular expression flags to be used. In the previous example, starting +Node.js with `--test-name-pattern="/test [4-5]/i"` would match `Test 4` and +`Test 5` because the pattern is case-insensitive. + +Test name patterns do not change the set of files that the test runner executes. + ## Extraneous asynchronous activity Once a test function finishes executing, the TAP results are output as quickly diff --git a/bin/node--test-name-pattern.js b/bin/node--test-name-pattern.js new file mode 100644 index 0000000..128755a --- /dev/null +++ b/bin/node--test-name-pattern.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +const { argv } = require('#internal/options') + +argv['test-name-pattern'] = true + +require('./node-core-test.js') diff --git a/bin/node-core-test.js b/bin/node-core-test.js index 23a1445..4bc6e89 100755 --- a/bin/node-core-test.js +++ b/bin/node-core-test.js @@ -10,9 +10,14 @@ const { argv } = require('#internal/options') Object.assign(argv, minimist(process.argv.slice(2), { boolean: ['test', 'test-only'], + string: ['test-name-pattern'], default: Object.prototype.hasOwnProperty.call(argv, 'test') ? { test: argv.test } : undefined })) +if (typeof argv['test-name-pattern'] === 'string') { + argv['test-name-pattern'] = [argv['test-name-pattern']] +} + process.argv.splice(1, Infinity, ...argv._) if (argv.test) { require('#internal/main/test_runner') diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index e8549ca..bf09690 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1,12 +1,14 @@ -// https://github.com/nodejs/node/blob/cb7e0c59df10a42cd6930ca7f99d3acee1ce7627/lib/internal/test_runner/test.js +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/lib/internal/test_runner/test.js 'use strict' const { + ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypeReduce, ArrayPrototypeShift, ArrayPrototypeSlice, + ArrayPrototypeSome, ArrayPrototypeUnshift, FunctionPrototype, MathMax, @@ -15,6 +17,7 @@ const { PromisePrototypeThen, PromiseResolve, ReflectApply, + RegExpPrototypeExec, SafeMap, SafeSet, SafePromiseAll, @@ -33,7 +36,11 @@ const { } = require('#internal/errors') const { getOptionValue } = require('#internal/options') const { TapStream } = require('#internal/test_runner/tap_stream') -const { createDeferredCallback, isTestFailureError } = require('#internal/test_runner/utils') +const { + convertStringToRegExp, + createDeferredCallback, + isTestFailureError +} = require('#internal/test_runner/utils') const { createDeferredPromise, kEmptyObject @@ -61,6 +68,15 @@ const kDefaultTimeout = null const noop = FunctionPrototype const isTestRunner = getOptionValue('--test') const testOnlyFlag = !isTestRunner && getOptionValue('--test-only') +const testNamePatternFlag = isTestRunner + ? null + : getOptionValue('--test-name-pattern') +const testNamePatterns = testNamePatternFlag?.length > 0 + ? ArrayPrototypeMap( + testNamePatternFlag, + (re) => convertStringToRegExp(re, '--test-name-pattern') + ) + : null const kShouldAbort = Symbol('kShouldAbort') const kRunHook = Symbol('kRunHook') const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']) @@ -196,6 +212,18 @@ class Test extends AsyncResource { this.timeout = timeout } + if (testNamePatterns !== null) { + // eslint-disable-next-line no-use-before-define + const match = this instanceof TestHook || ArrayPrototypeSome( + testNamePatterns, + (re) => RegExpPrototypeExec(re, name) !== null + ) + + if (!match) { + skip = 'test name does not match pattern' + } + } + if (testOnlyFlag && !this.only) { skip = '\'only\' option not set' } @@ -673,6 +701,7 @@ class ItTest extends Test { return { ctx: { signal: this.signal, name: this.name }, args: [] } } } + class Suite extends Test { constructor (options) { super(options) diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 595431e..c2919d8 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -1,16 +1,18 @@ -// https://github.com/nodejs/node/blob/659dc126932f986fc33c7f1c878cb2b57a1e2fac/lib/internal/test_runner/utils.js +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/lib/internal/test_runner/utils.js 'use strict' const { RegExpPrototypeExec } = require('#internal/per_context/primordials') const { basename } = require('path') const { createDeferredPromise } = require('#internal/util') const { codes: { + ERR_INVALID_ARG_VALUE, ERR_TEST_FAILURE }, kIsNodeError } = require('#internal/errors') const kMultipleCallbackInvocations = 'multipleCallbackInvocations' +const kRegExpPattern = /^\/(.*)\/([a-z]*)$/ const kSupportedFileExtensions = /\.[cm]?js$/ const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/ @@ -55,7 +57,26 @@ function isTestFailureError (err) { return err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err } +function convertStringToRegExp (str, name) { + const match = RegExpPrototypeExec(kRegExpPattern, str) + const pattern = match?.[1] ?? str + const flags = match?.[2] || '' + + try { + return new RegExp(pattern, flags) + } catch (err) { + const msg = err?.message + + throw new ERR_INVALID_ARG_VALUE( + name, + str, + `is an invalid regular expression.${msg ? ` ${msg}` : ''}` + ) + } +} + module.exports = { + convertStringToRegExp, createDeferredCallback, doesPathMatchFilter, isSupportedFileType, diff --git a/package.json b/package.json index 8d787e1..b1e00a7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "bin": { "node--test": "./bin/node--test.js", "node--test-only": "./bin/node--test-only.js", + "node--test-name-pattern": "./bin/node--test-name-pattern.js", "test": "./bin/node-core-test.js" }, "imports": { diff --git a/test/message.js b/test/message.js index 4520448..b68d0d0 100755 --- a/test/message.js +++ b/test/message.js @@ -13,7 +13,7 @@ const binPath = resolve(__dirname, '..', bin.test) const MESSAGE_FOLDER = join(__dirname, './message/') const WAIT_FOR_ELLIPSIS = Symbol('wait for ellispis') -const TEST_RUNNER_FLAGS = ['--test', '--test-only'] +const TEST_RUNNER_FLAGS = ['--test', '--test-only', '--test-name-pattern'] function readLines (file) { return createInterface({ @@ -104,8 +104,8 @@ const main = async () => { ) .toString().split(' ') - const nodeFlags = flags.filter(flag => !TEST_RUNNER_FLAGS.includes(flag)).join(' ') - const testRunnerFlags = flags.filter(flag => TEST_RUNNER_FLAGS.includes(flag)).join(' ') + const nodeFlags = flags.filter(flag => !TEST_RUNNER_FLAGS.find(f => flag.startsWith(f))).join(' ') + const testRunnerFlags = flags.filter(flag => TEST_RUNNER_FLAGS.find(f => flag.startsWith(f))).join(' ') const command = testRunnerFlags.length ? `${process.execPath} ${nodeFlags} ${binPath} ${testRunnerFlags} ${filePath}` diff --git a/test/message/test_runner_test_name_pattern.js b/test/message/test_runner_test_name_pattern.js new file mode 100644 index 0000000..f0b44aa --- /dev/null +++ b/test/message/test_runner_test_name_pattern.js @@ -0,0 +1,48 @@ +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/test/message/test_runner_test_name_pattern.js +// Flags: --no-warnings --test-name-pattern=enabled --test-name-pattern=/pattern/i +'use strict' +const common = require('../common') +const { + after, + afterEach, + before, + beforeEach, + describe, + it, + test +} = require('#node:test') + +test('top level test disabled', common.mustNotCall()) +test('top level skipped test disabled', { skip: true }, common.mustNotCall()) +test('top level skipped test enabled', { skip: true }, common.mustNotCall()) +it('top level it enabled', common.mustCall()) +it('top level it disabled', common.mustNotCall()) +it.skip('top level skipped it disabled', common.mustNotCall()) +it.skip('top level skipped it enabled', common.mustNotCall()) +describe('top level describe disabled', common.mustNotCall()) +describe.skip('top level skipped describe disabled', common.mustNotCall()) +describe.skip('top level skipped describe enabled', common.mustNotCall()) +test('top level runs because name includes PaTtErN', common.mustCall()) + +test('top level test enabled', common.mustCall(async (t) => { + t.beforeEach(common.mustCall()) + t.afterEach(common.mustCall()) + await t.test( + 'nested test runs because name includes PATTERN', + common.mustCall() + ) +})) + +describe('top level describe enabled', () => { + before(common.mustCall()) + beforeEach(common.mustCall(2)) + afterEach(common.mustCall(2)) + after(common.mustCall()) + + it('nested it disabled', common.mustNotCall()) + it('nested it enabled', common.mustCall()) + describe('nested describe disabled', common.mustNotCall()) + describe('nested describe enabled', common.mustCall(() => { + it('is enabled', common.mustCall()) + })) +}) diff --git a/test/message/test_runner_test_name_pattern.out b/test/message/test_runner_test_name_pattern.out new file mode 100644 index 0000000..be548ad --- /dev/null +++ b/test/message/test_runner_test_name_pattern.out @@ -0,0 +1,107 @@ +TAP version 13 +# Subtest: top level test disabled +ok 1 - top level test disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped test disabled +ok 2 - top level skipped test disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped test enabled +ok 3 - top level skipped test enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level it enabled +ok 4 - top level it enabled + --- + duration_ms: * + ... +# Subtest: top level it disabled +ok 5 - top level it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped it disabled +ok 6 - top level skipped it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped it enabled +ok 7 - top level skipped it enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level describe disabled +ok 8 - top level describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped describe disabled +ok 9 - top level skipped describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped describe enabled +ok 10 - top level skipped describe enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level runs because name includes PaTtErN +ok 11 - top level runs because name includes PaTtErN + --- + duration_ms: * + ... +# Subtest: top level test enabled + # Subtest: nested test runs because name includes PATTERN + ok 1 - nested test runs because name includes PATTERN + --- + duration_ms: * + ... + 1..1 +ok 12 - top level test enabled + --- + duration_ms: * + ... +# Subtest: top level describe enabled + # Subtest: nested it disabled + ok 1 - nested it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + # Subtest: nested it enabled + ok 2 - nested it enabled + --- + duration_ms: * + ... + # Subtest: nested describe disabled + ok 3 - nested describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + # Subtest: nested describe enabled + # Subtest: is enabled + ok 1 - is enabled + --- + duration_ms: * + ... + 1..1 + ok 4 - nested describe enabled + --- + duration_ms: * + ... + 1..4 +ok 13 - top level describe enabled + --- + duration_ms: * + ... +1..13 +# tests 13 +# pass 4 +# fail 0 +# cancelled 0 +# skipped 9 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_test_name_pattern_with_only.js b/test/message/test_runner_test_name_pattern_with_only.js new file mode 100644 index 0000000..4f2d6fb --- /dev/null +++ b/test/message/test_runner_test_name_pattern_with_only.js @@ -0,0 +1,14 @@ +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/test/message/test_runner_test_name_pattern_with_only.js +// Flags: --no-warnings --test-only --test-name-pattern=enabled +'use strict' +const common = require('../common') +const { test } = require('#node:test') + +test('enabled and only', { only: true }, common.mustCall(async (t) => { + await t.test('enabled', common.mustCall()) + await t.test('disabled', common.mustNotCall()) +})) + +test('enabled but not only', common.mustNotCall()) +test('only does not match pattern', { only: true }, common.mustNotCall()) +test('not only and does not match pattern', common.mustNotCall()) diff --git a/test/message/test_runner_test_name_pattern_with_only.out b/test/message/test_runner_test_name_pattern_with_only.out new file mode 100644 index 0000000..2e10064 --- /dev/null +++ b/test/message/test_runner_test_name_pattern_with_only.out @@ -0,0 +1,40 @@ +TAP version 13 +# Subtest: enabled and only + # Subtest: enabled + ok 1 - enabled + --- + duration_ms: * + ... + # Subtest: disabled + ok 2 - disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + 1..2 +ok 1 - enabled and only + --- + duration_ms: * + ... +# Subtest: enabled but not only +ok 2 - enabled but not only # SKIP 'only' option not set + --- + duration_ms: * + ... +# Subtest: only does not match pattern +ok 3 - only does not match pattern # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: not only and does not match pattern +ok 4 - not only and does not match pattern # SKIP 'only' option not set + --- + duration_ms: * + ... +1..4 +# tests 4 +# pass 1 +# fail 0 +# cancelled 0 +# skipped 3 +# todo 0 +# duration_ms * diff --git a/test/parallel/test-runner-string-to-regexp.js b/test/parallel/test-runner-string-to-regexp.js new file mode 100644 index 0000000..7b6fc0e --- /dev/null +++ b/test/parallel/test-runner-string-to-regexp.js @@ -0,0 +1,21 @@ +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/test/parallel/test-runner-string-to-regexp.js + +'use strict' +const common = require('../common') +const { deepStrictEqual, throws } = require('node:assert') +const { convertStringToRegExp } = require('#internal/test_runner/utils') + +deepStrictEqual(convertStringToRegExp('foo', 'x'), /foo/) +deepStrictEqual(convertStringToRegExp('/bar/', 'x'), /bar/) +deepStrictEqual(convertStringToRegExp('/baz/gi', 'x'), /baz/gi) +deepStrictEqual(convertStringToRegExp('/foo/9', 'x'), /\/foo\/9/) + +throws( + () => convertStringToRegExp('/foo/abcdefghijk', 'x'), + common.expectsError({ + code: 'ERR_INVALID_ARG_VALUE', + message: "The argument 'x' is an invalid regular expression. " + + "Invalid flags supplied to RegExp constructor 'abcdefghijk'. " + + 'Received /foo/abcdefghijk' + }) +)