From de93638759b0b6a80fc186f1f715ba0cd877c0b1 Mon Sep 17 00:00:00 2001 From: dhruv Date: Tue, 21 Jul 2026 22:53:46 +0530 Subject: [PATCH] test_runner: support directory arguments to --test Since test file discovery moved to the internal glob, a directory passed to `--test` (for example `node --test test/`) matched the directory itself and was then spawned as a test file, failing with MODULE_NOT_FOUND. Directory arguments were documented and supported before that change. Expand a user-supplied pattern that contains no glob magic and resolves to an existing directory into a recursive search for the default test file patterns within it, restoring the previous behavior. Glob patterns and explicit file arguments are unchanged, and `node_modules` remains excluded. Fixes: https://github.com/nodejs/node/issues/64555 Signed-off-by: dhruv --- lib/internal/test_runner/runner.js | 35 ++++++++++++++++++++++++++++-- test/parallel/test-runner-cli.js | 28 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index dc6529c220539a..e1ffdc921d0090 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -34,9 +34,10 @@ const { } = primordials; const { spawn } = require('child_process'); +const { statSync } = require('fs'); const { finished } = require('internal/streams/end-of-stream'); const { availableParallelism } = require('os'); -const { resolve, sep, isAbsolute } = require('path'); +const { resolve, sep, isAbsolute, join } = require('path'); const { DefaultDeserializer, DefaultSerializer } = require('v8'); const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options'); const { Interface } = require('internal/readline/interface'); @@ -154,11 +155,41 @@ function createTestFileList(patterns, cwd) { if (!patterns || patterns.length === 0) { patterns = [kDefaultPattern]; } - const glob = new Glob(patterns, { + const globOptions = { __proto__: null, cwd, exclude: (name) => name === 'node_modules', + }; + let glob = new Glob(patterns, globOptions); + + // Expand plain directory arguments into a recursive search for test files + // within them. A pattern with no glob magic that resolves to an existing + // directory (e.g. `node --test test/`) would otherwise match the directory + // itself and then be spawned as a test file, failing with MODULE_NOT_FOUND. + // This restores the directory-argument behavior that was documented and + // supported before test discovery moved to globbing. + // Refs: https://github.com/nodejs/node/issues/64555 + let didExpandDirectory = false; + const expandedPatterns = ArrayPrototypeMap(patterns, (pattern, index) => { + if (glob.matchers[index].hasMagic()) { + return pattern; + } + let stats; + try { + stats = statSync(resolve(cwd, pattern)); + } catch { + return pattern; + } + if (stats.isDirectory()) { + didExpandDirectory = true; + return join(pattern, kDefaultPattern); + } + return pattern; }); + if (didExpandDirectory) { + glob = new Glob(expandedPatterns, globOptions); + } + const results = glob.globSync(); if (hasUserSuppliedPattern && results.length === 0 && ArrayPrototypeEvery(glob.matchers, (m) => !m.hasMagic())) { diff --git a/test/parallel/test-runner-cli.js b/test/parallel/test-runner-cli.js index f563d67d1a9a41..d1d61f0799cd1c 100644 --- a/test/parallel/test-runner-cli.js +++ b/test/parallel/test-runner-cli.js @@ -161,6 +161,34 @@ for (const isolation of ['none', 'process']) { assert.strictEqual(child.signal, null); } + { + // A directory argument recursively runs the test files inside it, the same + // as running from that directory. Both the bare and trailing-slash forms + // are supported. Refs: https://github.com/nodejs/node/issues/64555 + for (const dir of ['default-behavior', 'default-behavior/']) { + const args = [ + '--test', + '--test-reporter=tap', + `--test-isolation=${isolation}`, + dir, + ]; + const child = spawnSync(process.execPath, args, { cwd: testFixtures }); + + assert.strictEqual(child.stderr.toString(), ''); + const stdout = child.stdout.toString(); + assert.match(stdout, /ok 1 - this should pass/); + assert.match(stdout, /not ok 2 - this should fail/); + assert.match(stdout, /ok 3 - subdir.+subdir_test\.js/); + assert.match(stdout, /ok 4 - this should pass/); + assert.match(stdout, /ok 5 - this should be skipped/); + assert.match(stdout, /ok 6 - this should be executed/); + // node_modules is still excluded when a directory is expanded. + assert.doesNotMatch(stdout, /test-nm\.js/); + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + } + } + { // Test combined stream outputs const args = [