Repository: nodejs/node
Pull request: test_runner: add env option to run function

Support an `env` option that is passed to the
underlying child_process.
Fixes: https://github.com/nodejs/node/issues/60709

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/test.md
patch: @@ -1435,6 +1435,9 @@ added:
   - v18.9.0
   - v16.19.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/61367
+    description: Add the `env` option.
   - version: v24.7.0
     pr-url: https://github.com/nodejs/node/pull/59443
     description: Added a rerunFailuresFilePath option.
@@ -1555,6 +1558,9 @@ changes:
   * `functionCoverage` {number} Require a minimum percent of covered functions. If code
     coverage does not reach the threshold specified, the process will exit with code `1`.
     **Default:** `0`.
+  * `env` {Object} Specify environment variables to be passed along to the test process.
+    This options is not compatible with `isolation='none'`. These variables will override
+    those from the main process, and are not merged with `process.env`.
 * Returns: {TestsStream}
 
 **Note:** `shard` is used to horizontally parallelize test running across

filename: lib/internal/test_runner/runner.js
patch: @@ -403,7 +403,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, ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
+    const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...(opts.env || process.env) };
     if (watchMode) {
       stdio.push('ipc');
       env.WATCH_REPORT_DEPENDENCIES = '1';
@@ -623,6 +623,7 @@ function run(options = kEmptyObject) {
     argv = [],
     cwd = process.cwd(),
     rerunFailuresFilePath,
+    env,
   } = options;
 
   if (files != null) {
@@ -731,6 +732,14 @@ function run(options = kEmptyObject) {
     validatePath(globalSetupPath, 'options.globalSetupPath');
   }
 
+  if (env != null) {
+    validateObject(env);
+
+    if (isolation === 'none') {
+      throw new ERR_INVALID_ARG_VALUE('options.env', env, 'is not supported with isolation=\'none\'');
+    }
+  }
+
   const rootTestOptions = { __proto__: null, concurrency, timeout, signal };
   const globalOptions = {
     __proto__: null,
@@ -776,6 +785,7 @@ function run(options = kEmptyObject) {
     argv,
     execArgv,
     rerunFailuresFilePath,
+    env,
   };
 
   if (isolation === 'process') {

filename: test/fixtures/test-runner/process-env.js
patch: @@ -0,0 +1,7 @@
+const { test } = require('node:test');
+
+test('process.env is correct', (t) => {
+    t.assert.strictEqual(process.env.ABC, undefined, 'main process env var should be undefined');
+    t.assert.strictEqual(process.env.NODE_TEST_CONTEXT, 'child-v8', 'NODE_TEST_CONTEXT should be set by run()');
+    t.assert.strictEqual(process.env.FOOBAR, 'FUZZBUZZ', 'specified env var should be defined');
+});

filename: test/parallel/test-runner-run.mjs
patch: @@ -648,6 +648,29 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
   });
 });
 
+describe('env', () => {
+  it('should allow env variables to be configured', async () => {
+    // Need to inherit some process.env variables so it runs reliably across different environments.
+    const env = { ...process.env, FOOBAR: 'FUZZBUZZ' };
+    // Set a variable on main process env and test it does not exist within test env.
+    process.env.ABC = 'XYZ';
+
+    const stream = run({ files: [join(testFixtures, 'process-env.js')], env });
+    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);
+    delete process.env.ABC;
+  });
+
+  it('should throw error when env is specified with isolation=none', async () => {
+    assert.throws(() => run({ env: { foo: 'bar' }, isolation: 'none' }), {
+      code: 'ERR_INVALID_ARG_VALUE',
+      message: /The property 'options\.env' is not supported with isolation='none'\. Received { foo: 'bar' }/
+    });
+  });
+});
+
 describe('forceExit', () => {
   it('throws for non-boolean values', () => {
     [Symbol(), {}, 0, 1, '1', Promise.resolve([])].forEach((forceExit) => {