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',
+    });
+  }
+});