Commit 45b6626457e for nodejs
commit 45b6626457e233fe0f907448ac21681a472b04f5
Author: joao-oliveira-softtor <joao.oliveira@softtor.com.br>
Date: Fri Oct 23 04:04:37 2026 -0300
test_runner: add option to forbid exclusive tests
Add --test-forbid-only and the forbidOnly run() option to detect
accidentally committed exclusive tests. Reject only declarations and
runOnly(true) in both isolation modes, and record a run-level failure
so TODO and expected failures cannot absorb the error.
Document the runtime-only detection and add regression coverage for
filtering, option inheritance, both isolation modes, and disabled
guards.
Refs: https://github.com/nodejs/node/issues/60917
Assisted-by: a coding agent
Signed-off-by: João Victor <joao.oliveira@softtor.com.br>
PR-URL: https://github.com/nodejs/node/pull/66578
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
diff --git a/doc/api/cli.md b/doc/api/cli.md
index 7230231decd..4c4b029cd34 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -3261,6 +3261,22 @@ added: v22.8.0
Require a minimum percent of covered lines. If code coverage does not reach
the threshold specified, the process will exit with code `1`.
+### `--test-forbid-only`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+Fails the test run if a test or suite is declared with `only: true`, including
+the `test.only()`, `it.only()`, `suite.only()`, and `describe.only()` shorthands,
+or if `testContext.runOnly(true)` is called. This is useful in CI to prevent
+accidentally committing exclusive tests that exclude the rest of a test suite.
+
+This option applies with either test isolation mode and takes precedence over
+`--test-only`. Name filtering, TODO markers, and expected failures do not suppress
+detected violations. Only declarations evaluated during the run are checked;
+code inside a skipped test callback is not evaluated.
+
### `--test-force-exit`
<!-- YAML
@@ -4384,6 +4400,7 @@ one is included in the list below.
* `--test-coverage-include-all`
* `--test-coverage-include`
* `--test-coverage-lines`
+* `--test-forbid-only`
* `--test-global-setup`
* `--test-isolation`
* `--test-name-pattern`
diff --git a/doc/api/test.md b/doc/api/test.md
index a7eedab6d9e..4df7af5f364 100644
--- a/doc/api/test.md
+++ b/doc/api/test.md
@@ -355,6 +355,14 @@ it.todo('should do the thing', { expectFailure: true }, () => {
## `only` tests
+To prevent exclusive tests from accidentally excluding tests in CI, use
+[`--test-forbid-only`][] or the `forbidOnly` option of [`run()`][]. When enabled,
+declaring a test or suite with `only: true` or calling `testContext.runOnly(true)`
+fails the run. This includes the `.only()` shorthands and applies with either
+isolation mode, even when `--test-only` is also enabled. Name filtering, TODO
+markers, and expected failures cannot suppress detected violations. Declarations
+inside callbacks that are never executed are not checked.
+
If Node.js is started with the [`--test-only`][] command-line option, or test
isolation is disabled, it is possible to skip all tests except for a selected
subset by passing the `only` option to the tests that should run. When a test
@@ -1710,6 +1718,9 @@ added:
- v18.9.0
- v16.19.0
changes:
+ - version: REPLACEME
+ pr-url: https://github.com/nodejs/node/pull/66578
+ description: Added the `forbidOnly` option.
- version:
- v26.2.0
- v24.19.0
@@ -1766,6 +1777,9 @@ changes:
* `forceExit` {boolean} Configures the test runner to exit the process once
all known tests have finished executing even if the event loop would
otherwise remain active. **Default:** `false`.
+ * `forbidOnly` {boolean} Fail the run when a test or suite is declared with
+ `only: true` or `testContext.runOnly(true)` is called. See [`only` tests](#only-tests).
+ **Default:** the value of [`--test-forbid-only`][].
* `globPatterns` {Array} An array containing the list of glob patterns to
match test files. This option cannot be used together with `files`.
**Default:** Same as [running tests from the command line][].
@@ -5194,6 +5208,7 @@ test.describe('my suite', (suite) => {
[`--test-concurrency`]: cli.md#--test-concurrency
[`--test-coverage-exclude`]: cli.md#--test-coverage-exclude
[`--test-coverage-include`]: cli.md#--test-coverage-include
+[`--test-forbid-only`]: cli.md#--test-forbid-only
[`--test-name-pattern`]: cli.md#--test-name-pattern
[`--test-only`]: cli.md#--test-only
[`--test-reporter-destination`]: cli.md#--test-reporter-destination
diff --git a/doc/node.1 b/doc/node.1
index 821fe8d8e0f..0c0fa769a48 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -1631,6 +1631,16 @@ filtering as the rest of the report.
Require a minimum percent of covered lines. If code coverage does not reach
the threshold specified, the process will exit with code \fB1\fR.
.
+.It Fl -test-forbid-only
+Fails the test run if a test or suite is declared with \fBonly: true\fR, including
+the \fBtest.only()\fR, \fBit.only()\fR, \fBsuite.only()\fR, and \fBdescribe.only()\fR shorthands,
+or if \fBtestContext.runOnly(true)\fR is called. This is useful in CI to prevent
+accidentally committing exclusive tests that exclude the rest of a test suite.
+This option applies with either test isolation mode and takes precedence over
+\fB--test-only\fR. Name filtering, TODO markers, and expected failures do not suppress
+detected violations. Only declarations evaluated during the run are checked;
+code inside a skipped test callback is not evaluated.
+.
.It Fl -test-force-exit
Configures the test runner to exit the process once all known tests have
finished executing even if the event loop would otherwise remain active.
@@ -2441,6 +2451,8 @@ one is included in the list below.
.It
\fB--test-coverage-lines\fR
.It
+\fB--test-forbid-only\fR
+.It
\fB--test-global-setup\fR
.It
\fB--test-isolation\fR
diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js
index b3d1b30b12e..b37198df6d7 100644
--- a/lib/internal/test_runner/runner.js
+++ b/lib/internal/test_runner/runner.js
@@ -206,6 +206,7 @@ function getRunArgs(path, { forceExit,
testSkipPatterns,
testTagFilterExpressions,
only,
+ forbidOnly,
hasFiles,
testFiles,
globPatterns,
@@ -252,6 +253,9 @@ function getRunArgs(path, { forceExit,
if (only === true) {
ArrayPrototypePush(runArgs, '--test-only');
}
+ if (forbidOnly != null && forbidOnly !== getOptionValue('--test-forbid-only')) {
+ ArrayPrototypePush(runArgs, forbidOnly ? '--test-forbid-only' : '--no-test-forbid-only');
+ }
if (timeout != null) {
ArrayPrototypePush(runArgs, `--test-timeout=${timeout}`);
}
@@ -788,6 +792,7 @@ function run(options = kEmptyObject) {
globalSetupPath,
only,
globPatterns,
+ forbidOnly,
coverage = false,
coverageIncludeAll = false,
lineCoverage = 0,
@@ -820,6 +825,9 @@ function run(options = kEmptyObject) {
if (only != null) {
validateBoolean(only, 'options.only');
}
+ if (forbidOnly != null) {
+ validateBoolean(forbidOnly, 'options.forbidOnly');
+ }
if (globPatterns != null) {
validateArray(globPatterns, 'options.globPatterns');
}
@@ -1020,6 +1028,10 @@ function run(options = kEmptyObject) {
testTagFilters,
};
+ if (forbidOnly != null) {
+ globalOptions.forbidOnly = forbidOnly;
+ }
+
if (isolation === 'none') {
if (testNamePatterns != null) {
globalOptions.testNamePatterns = testNamePatterns;
@@ -1066,6 +1078,7 @@ function run(options = kEmptyObject) {
globPatterns,
only,
forceExit,
+ forbidOnly,
cwd,
isolation,
argv,
diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js
index 7019ec15fce..1b0956e4253 100644
--- a/lib/internal/test_runner/test.js
+++ b/lib/internal/test_runner/test.js
@@ -377,6 +377,9 @@ class TestContext {
}
runOnly(value) {
+ if (value && this.#test.config.forbidOnly) {
+ failForbiddenOnly(this.#test.root, 'runOnly() is not allowed when forbidOnly is enabled');
+ }
this.#test.runOnlySubtests = !!value;
}
@@ -575,6 +578,14 @@ function parseExpectFailure(expectFailure) {
return { __proto__: null, label: undefined, match: expectFailure };
}
+function failForbiddenOnly(root, message) {
+ // Filtering, TODO, and expected failures must not hide forbidden exclusivity.
+ root.harness.success = false;
+ root.diagnostic(message);
+ process.exitCode = kGenericUserError;
+ throw new ERR_TEST_FAILURE(message, kTestCodeFailure);
+}
+
class Test extends AsyncResource {
reportedType = 'test';
abortController;
@@ -644,6 +655,10 @@ class Test extends AsyncResource {
parent.nesting + 1;
const { config, isFilteringByName, isFilteringByOnly, isFilteringByTags } = parent.root.harness;
+ if (only && config.forbidOnly) {
+ failForbiddenOnly(parent.root, 'The only option is not allowed when forbidOnly is enabled');
+ }
+
this.root = parent.root;
this.harness = null;
this.config = config;
diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js
index e8043a88177..4f3728fe092 100644
--- a/lib/internal/test_runner/utils.js
+++ b/lib/internal/test_runner/utils.js
@@ -270,6 +270,7 @@ function parseCommandLine() {
let destinations;
let isolation;
let only = getOptionValue('--test-only');
+ const forbidOnly = getOptionValue('--test-forbid-only');
let reporters;
let shard;
let testNamePatterns = mapPatternFlagToRegExArray('--test-name-pattern');
@@ -424,6 +425,7 @@ function parseCommandLine() {
functionCoverage,
lineCoverage,
only,
+ forbidOnly,
reporters,
setup,
globalSetupPath,
diff --git a/src/node_options.cc b/src/node_options.cc
index d066f4af772..bdc8171529b 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -1279,6 +1279,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
kAllowedInEnvvar,
false,
OptionNamespaces::kTestRunnerNamespace);
+ AddOption("--test-forbid-only",
+ "fail tests that use the 'only' option",
+ BOOL_FIELD(test_forbid_only),
+ kAllowedInEnvvar,
+ false,
+ OptionNamespaces::kTestRunnerNamespace);
AddOption("--test-shard",
"run test at specific shard",
&EnvironmentOptions::test_shard,
diff --git a/src/node_options.h b/src/node_options.h
index b5f2084ab74..6323f36dce1 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -266,6 +266,7 @@ class EnvironmentOptions : public Options {
DEFINE_BOOL_FIELD(test_randomize) = false;
DEFINE_BOOL_FIELD(has_test_random_seed) = false;
DEFINE_BOOL_FIELD(test_only) = false;
+ DEFINE_BOOL_FIELD(test_forbid_only) = false;
DEFINE_BOOL_FIELD(test_udp_no_try_send) = false;
DEFINE_BOOL_FIELD(coverage_include_all) = false;
DEFINE_BOOL_FIELD(throw_deprecation) = false;
diff --git a/test/parallel/test-runner-forbid-only.js b/test/parallel/test-runner-forbid-only.js
new file mode 100644
index 00000000000..2444e578f60
--- /dev/null
+++ b/test/parallel/test-runner-forbid-only.js
@@ -0,0 +1,153 @@
+'use strict';
+const common = require('../common');
+const assert = require('node:assert');
+const { writeFileSync } = require('node:fs');
+const { test, run } = require('node:test');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+const fixture = tmpdir.resolve('only.test.js');
+writeFileSync(fixture, `
+ const { test } = require('node:test');
+ test.only('focused test', () => {});
+ test('ordinary test', () => {});
+`);
+const env = { ...process.env, NODE_OPTIONS: '', FORCE_COLOR: '0' };
+delete env.NODE_TEST_CONTEXT;
+const onlyIfNodeOptionsSupport = { skip: process.config.variables.node_without_node_options };
+
+function createFixture(name, source) {
+ const file = tmpdir.resolve(`${name}.js`);
+ writeFileSync(file, `
+ const { test, it, suite, describe } = require('node:test');
+ ${source}
+ `);
+ return file;
+}
+
+function assertForbidden({ code, signal, stdout, stderr }) {
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 1, stdout + stderr);
+ assert.match(stdout + stderr, /(?:only|runOnly).*not allowed/);
+ assert.doesNotMatch(stdout, /BODY_EXECUTED/);
+}
+
+const cases = {
+ 'test-only': "test.only('focused test', () => { console.log('BODY_EXECUTED'); });",
+ 'it-only': "it.only('focused test', () => { console.log('BODY_EXECUTED'); });",
+ 'suite-only': "suite.only('focused suite', () => { console.log('BODY_EXECUTED'); });",
+ 'describe-only': "describe.only('focused suite', () => { console.log('BODY_EXECUTED'); });",
+ 'only-option': "test('focused test', { only: true }, () => { console.log('BODY_EXECUTED'); });",
+ 'suite-option': "suite('focused suite', { only: true }, () => { console.log('BODY_EXECUTED'); });",
+ 'subtest-option': "test('parent', async (t) => { await t.test('child', { only: true }, () => {}); });",
+ 'run-only': "test('parent', (t) => { t.runOnly(true); console.log('BODY_EXECUTED'); });",
+ 'skipped-only': "test('focused test', { only: true, skip: true }, () => {});",
+ 'todo-only': "test('focused test', { only: true, todo: true }, () => {});",
+ 'expected-failure-only': "test('focused test', { only: true, expectFailure: true }, () => {});",
+ 'todo-parent': "test.todo('parent', () => { test.only('child', () => {}); });",
+ 'expected-failure-parent': "test('parent', { expectFailure: true }, (t) => { t.runOnly(true); });",
+};
+const caseFiles = Object.entries(cases).map(([name, source]) => [name, createFixture(name, source)]);
+
+for (const isolation of ['process', 'none']) {
+ test(`run() rejects only with isolation=${isolation}`, async () => {
+ const runner = tmpdir.resolve(`runner-${isolation}.js`);
+ writeFileSync(runner, `
+ const { run } = require('node:test');
+ const { tap } = require('node:test/reporters');
+ run({
+ files: [${JSON.stringify(fixture)}],
+ isolation: ${JSON.stringify(isolation)},
+ forbidOnly: true,
+ }).on('test:fail', () => { process.exitCode = 1; }).compose(tap).pipe(process.stdout);
+ `);
+ const { code, signal, stdout, stderr } = await common.spawnPromisified(process.execPath, [runner], { env });
+ assertForbidden({ code, signal, stdout, stderr });
+ });
+
+ for (const [name, file] of caseFiles) {
+ test(`CLI rejects ${name} with isolation=${isolation}`, async () => {
+ assertForbidden(await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap', '--test-forbid-only', file,
+ ], { env }));
+ });
+ }
+
+ for (const args of [['--test-only'], ['--test-name-pattern=ordinary'], ['--test-skip-pattern=focused']]) {
+ test(`filter ${args[0]} cannot hide only with isolation=${isolation}`, async () => {
+ assertForbidden(await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap', '--test-forbid-only', ...args, fixture,
+ ], { env }));
+ });
+ }
+
+ test(`NODE_OPTIONS enables forbidOnly with isolation=${isolation}`, onlyIfNodeOptionsSupport, async () => {
+ assertForbidden(await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap', fixture,
+ ], { env: { ...env, NODE_OPTIONS: '--test-forbid-only' } }));
+ });
+
+ test(`filtered suites cannot hide only with isolation=${isolation}`, async () => {
+ const file = createFixture(`filtered-${isolation}`, `
+ suite('filtered', () => { it.only('child', () => {}); });
+ test('ordinary', () => {});
+ `);
+ assertForbidden(await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap',
+ '--test-forbid-only', '--test-name-pattern=ordinary', file,
+ ], { env }));
+ });
+
+ for (const source of ['CLI', 'NODE_OPTIONS']) {
+ const options = source === 'NODE_OPTIONS' ? onlyIfNodeOptionsSupport : {};
+ test(`run() can override ${source} with forbidOnly=false and isolation=${isolation}`, options, async () => {
+ const runner = createFixture(`override-${source}-${isolation}`, `
+ const { run } = require('node:test');
+ const { tap } = require('node:test/reporters');
+ run({ files: [${JSON.stringify(fixture)}], isolation: ${JSON.stringify(isolation)}, forbidOnly: false })
+ .on('test:fail', () => { process.exitCode = 1; }).compose(tap).pipe(process.stdout);
+ `);
+ const args = source === 'CLI' ? ['--test-forbid-only', runner] : [runner];
+ const childEnv = source === 'NODE_OPTIONS' ? { ...env, NODE_OPTIONS: '--test-forbid-only' } : env;
+ const { code, signal, stdout, stderr } = await common.spawnPromisified(process.execPath, args, { env: childEnv });
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stdout + stderr);
+ assert.match(stdout, /ok 1 - focused test/);
+ });
+ }
+
+ test(`ordinary tests still run with isolation=${isolation}`, async () => {
+ const file = createFixture(`ordinary-${isolation}`, `
+ test('ordinary', () => {});
+ test('explicit false', { only: false }, () => {});
+ test('runOnly false', (t) => { t.runOnly(false); });
+ `);
+ const { code, signal, stdout, stderr } = await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap', '--test-forbid-only', file,
+ ], { env });
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stdout + stderr);
+ // Explicit only:false is filtered out in the existing none-isolation mode.
+ assert.match(stdout, isolation === 'none' ? /# pass 2/ : /# pass 3/);
+ });
+
+ for (const flags of [[], ['--test-forbid-only', '--no-test-forbid-only']]) {
+ test(`only still filters when disabled (${flags}) with isolation=${isolation}`, async () => {
+ const { code, signal, stdout, stderr } = await common.spawnPromisified(process.execPath, [
+ '--test', `--test-isolation=${isolation}`, '--test-reporter=tap', '--test-only', ...flags, fixture,
+ ], { env });
+ assert.strictEqual(signal, null);
+ assert.strictEqual(code, 0, stdout + stderr);
+ assert.match(stdout, /# pass 1/);
+ assert.doesNotMatch(stdout, /ordinary test/);
+ });
+ }
+}
+
+test('run() validates forbidOnly', () => {
+ for (const forbidOnly of [0, 1, '', 'true', {}, []]) {
+ assert.throws(() => run({ forbidOnly }), {
+ code: 'ERR_INVALID_ARG_TYPE',
+ });
+ }
+});