From e1746a9e19f5c02d6d40ddad5e290a9c4e2950cb Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Sun, 22 Mar 2026 02:33:49 +0300 Subject: [PATCH 1/2] test_runner: avoid reading process state directly in run() Refs: https://github.com/nodejs/node/issues/53867 The run() function is exposed as a public API via node:test module. Previously, it accessed process.argv, process.execArgv, process.cwd(), and process.env directly, which meant programmatic API users could not fully control the test runner behavior. This change: - Captures process.argv, process.cwd(), process.env, and process.execArgv in the CLI entry point (main/test_runner.js) and passes them explicitly to run() as options - Adds a processExecArgv option for V8 flag propagation - Replaces process.env fallback in runTestFile() with opts.env - Replaces process.argv usage in getRunArgs() with globPatterns - Replaces process.env.NODE_TEST_CONTEXT check with env option - Maintains backwards compatibility: when options are not provided, run() falls back to process state as before --- lib/internal/main/test_runner.js | 4 ++++ lib/internal/test_runner/runner.js | 32 ++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/internal/main/test_runner.js b/lib/internal/main/test_runner.js index fda47897da9f06..205ac8a052397f 100644 --- a/lib/internal/main/test_runner.js +++ b/lib/internal/main/test_runner.js @@ -30,7 +30,11 @@ if (isUsingInspector() && options.isolation === 'process') { options.inspectPort = process.debugPort; } +// Capture process state explicitly so run() does not need to access it. options.globPatterns = ArrayPrototypeSlice(process.argv, 1); +options.cwd = process.cwd(); +options.env = process.env; +options.processExecArgv = process.execArgv; debug('test runner configuration:', options); run(options).on('test:summary', (data) => { diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index f90c7dcad10346..831c63ea296c90 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -167,6 +167,8 @@ function getRunArgs(path, { forceExit, only, argv: suppliedArgs, execArgv, + processExecArgv, + globPatterns, rerunFailuresFilePath, root: { timeout }, cwd }) { @@ -183,7 +185,7 @@ function getRunArgs(path, { forceExit, */ const nodeOptionsSet = new SafeSet(processNodeOptions); const unknownProcessExecArgv = ArrayPrototypeFilter( - process.execArgv, + processExecArgv, (arg) => !nodeOptionsSet.has(arg), ); ArrayPrototypePushApply(runArgs, unknownProcessExecArgv); @@ -214,7 +216,9 @@ function getRunArgs(path, { forceExit, if (path === kIsolatedProcessName) { ArrayPrototypePush(runArgs, '--test'); - ArrayPrototypePushApply(runArgs, ArrayPrototypeSlice(process.argv, 1)); + if (globPatterns != null) { + ArrayPrototypePushApply(runArgs, globPatterns); + } } else { ArrayPrototypePush(runArgs, path); } @@ -421,7 +425,7 @@ function runTestFile(path, filesWatcher, opts) { const subtest = opts.root.createSubtest(FileTest, testPath, testOpts, async (t) => { const args = getRunArgs(path, opts); const stdio = ['pipe', 'pipe', 'pipe']; - const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...(opts.env || process.env) }; + const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...opts.env }; // Acquire a worker ID from the pool for process isolation mode let workerId; @@ -648,11 +652,26 @@ function run(options = kEmptyObject) { functionCoverage = 0, execArgv = [], argv = [], - cwd = process.cwd(), + cwd, + processExecArgv, rerunFailuresFilePath, env, } = options; + // Default to process state when not explicitly provided, maintaining + // backwards compatibility for public API users while allowing the + // internal CLI entry point to pass these values explicitly. + const userProvidedEnv = env !== undefined; + if (cwd === undefined) { + cwd = process.cwd(); + } + if (env === undefined) { + env = process.env; + } + if (processExecArgv === undefined) { + processExecArgv = process.execArgv; + } + if (files != null) { validateArray(files, 'options.files'); } @@ -759,7 +778,7 @@ function run(options = kEmptyObject) { validatePath(globalSetupPath, 'options.globalSetupPath'); } - if (env != null) { + if (userProvidedEnv) { validateObject(env); if (isolation === 'none') { @@ -830,13 +849,14 @@ function run(options = kEmptyObject) { isolation, argv, execArgv, + processExecArgv, rerunFailuresFilePath, env, workerIdPool: isolation === 'process' ? workerIdPool : null, }; if (isolation === 'process') { - if (process.env.NODE_TEST_CONTEXT !== undefined) { + if (env.NODE_TEST_CONTEXT !== undefined) { process.emitWarning('node:test run() is being called recursively within a test file. skipping running files.'); root.postRun(); return root.reporter; From acf2faa7b8fb9607ad02af8084ebbb8e1160cf0a Mon Sep 17 00:00:00 2001 From: Murat Aslan Date: Sun, 22 Mar 2026 10:57:26 +0300 Subject: [PATCH 2/2] test_runner: address review feedback - Remove processExecArgv option: process.execArgv is runtime state that should not be a public API option. Keep reading it directly in getRunArgs() as before. - Fix env with isolation=none: only set options.env from CLI entry point when isolation is not 'none', preventing ERR_INVALID_ARG_VALUE when using --test-isolation=none. - Add documentation for the env option in doc/api/test.md. - Add tests for env option acceptance and env+isolation=none rejection. --- doc/api/test.md | 3 +++ lib/internal/main/test_runner.js | 5 +++-- lib/internal/test_runner/runner.js | 8 +------- test/parallel/test-runner-run.mjs | 21 +++++++++++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/doc/api/test.md b/doc/api/test.md index 3d245c2303b185..40c0653555f230 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -1546,6 +1546,9 @@ changes: * `setup` {Function} A function that accepts the `TestsStream` instance and can be used to setup listeners before any tests are run. **Default:** `undefined`. + * `env` {Object} Environment variables to pass to test child processes. + This option has no effect when `isolation` is `'none'`. + **Default:** `process.env`. * `execArgv` {Array} An array of CLI flags to pass to the `node` executable when spawning the subprocesses. This option has no effect when `isolation` is `'none`'. **Default:** `[]` diff --git a/lib/internal/main/test_runner.js b/lib/internal/main/test_runner.js index 205ac8a052397f..19b62fc62a4bb3 100644 --- a/lib/internal/main/test_runner.js +++ b/lib/internal/main/test_runner.js @@ -33,8 +33,9 @@ if (isUsingInspector() && options.isolation === 'process') { // Capture process state explicitly so run() does not need to access it. options.globPatterns = ArrayPrototypeSlice(process.argv, 1); options.cwd = process.cwd(); -options.env = process.env; -options.processExecArgv = process.execArgv; +if (options.isolation !== 'none') { + options.env = process.env; +} debug('test runner configuration:', options); run(options).on('test:summary', (data) => { diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 831c63ea296c90..3b1a10005ad604 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -167,7 +167,6 @@ function getRunArgs(path, { forceExit, only, argv: suppliedArgs, execArgv, - processExecArgv, globPatterns, rerunFailuresFilePath, root: { timeout }, @@ -185,7 +184,7 @@ function getRunArgs(path, { forceExit, */ const nodeOptionsSet = new SafeSet(processNodeOptions); const unknownProcessExecArgv = ArrayPrototypeFilter( - processExecArgv, + process.execArgv, (arg) => !nodeOptionsSet.has(arg), ); ArrayPrototypePushApply(runArgs, unknownProcessExecArgv); @@ -653,7 +652,6 @@ function run(options = kEmptyObject) { execArgv = [], argv = [], cwd, - processExecArgv, rerunFailuresFilePath, env, } = options; @@ -668,9 +666,6 @@ function run(options = kEmptyObject) { if (env === undefined) { env = process.env; } - if (processExecArgv === undefined) { - processExecArgv = process.execArgv; - } if (files != null) { validateArray(files, 'options.files'); @@ -849,7 +844,6 @@ function run(options = kEmptyObject) { isolation, argv, execArgv, - processExecArgv, rerunFailuresFilePath, env, workerIdPool: isolation === 'process' ? workerIdPool : null, diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index e9bb6c4a260160..96575ba824a0b2 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -687,6 +687,27 @@ describe('forceExit', () => { message: /The property 'options\.forceExit' is not supported with watch mode\./ }); }); + + it('should accept env option and pass it to child processes', async () => { + const stream = run({ + files: [join(testFixtures, 'default-behavior/test/random.cjs')], + env: { ...process.env, NODE_TEST_CUSTOM_ENV_VAR: '1' }, + }); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustCall(1)); + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); + + it('should reject env option with isolation none', () => { + assert.throws(() => run({ + files: [join(testFixtures, 'default-behavior/test/random.cjs')], + isolation: 'none', + env: { FOO: 'bar' }, + }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); });