current commit
261023.2 · test_runner: add option to forbid exclusive tests
'use strict';

const {
  ArrayIsArray,
  ArrayPrototypeFilter,
  ArrayPrototypeFind,
  ArrayPrototypeForEach,
  ArrayPrototypeIncludes,
  ArrayPrototypeJoin,
  ArrayPrototypeMap,
  ArrayPrototypePush,
  ArrayPrototypePushApply,
  ArrayPrototypeShift,
  ArrayPrototypeSlice,
  ArrayPrototypeSome,
  ArrayPrototypeSort,
  ObjectAssign,
  PromisePrototypeThen,
  PromiseWithResolvers,
  RegExpPrototypeSymbolReplace,
  SafeMap,
  SafePromiseAll,
  SafePromiseAllReturnVoid,
  SafePromiseAllSettledReturnVoid,
  SafeSet,
  String,
  StringFromCharCode,
  StringPrototypeIndexOf,
  StringPrototypeSlice,
  StringPrototypeStartsWith,
  Symbol,
  TypedArrayPrototypeGetLength,
  TypedArrayPrototypeSubarray,
} = primordials;

const { spawn } = require('child_process');
const { statSync } = require('fs');
const { finished } = require('internal/streams/end-of-stream');
const { resolve, sep, isAbsolute } = require('path');
const { DefaultDeserializer, DefaultSerializer } = require('v8');
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
const { Interface } = require('internal/readline/interface');
const { deserializeError } = require('internal/error_serdes');
const { Buffer } = require('buffer');
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
const console = require('internal/console/global');
const {
  codes: {
    ERR_INVALID_ARG_TYPE,
    ERR_INVALID_ARG_VALUE,
    ERR_TEST_FAILURE,
  },
} = require('internal/errors');
const esmLoader = require('internal/modules/esm/loader');
const {
  validateArray,
  validateBoolean,
  validateFunction,
  validateObject,
  validateOneOf,
  validateInteger,
  validateUint32,
  validateString,
  validateStringArray,
} = require('internal/validators');
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
const { isRegExp } = require('internal/util/types');
const { pathToFileURL } = require('internal/url');
const {
  emitExperimentalWarning,
  kEmptyObject,
} = require('internal/util');
const { kEmitMessage } = require('internal/test_runner/tests_stream');
const {
  createTestTree,
  startSubtestAfterBootstrap,
} = require('internal/test_runner/harness');
const {
  kAborted,
  kCancelledByParent,
  kSubtestsFailed,
  kTestCodeFailure,
  kTestTimeoutFailure,
  Test,
} = require('internal/test_runner/test');
const {
  createRandomSeed,
  convertStringToRegExp,
  countCompletedTest,
  kDefaultPattern,
  parseCommandLine,
} = require('internal/test_runner/utils');
const {
  parseTagFilterExpression,
} = require('internal/test_runner/tag_filter');
const { Glob } = require('internal/fs/glob');
const { once } = require('events');
const { validatePath } = require('internal/fs/utils');
const { loadPreloadModules } = require('internal/process/pre_execution');
const {
  triggerUncaughtException,
  exitCodes: { kGenericUserError },
} = internalBinding('errors');
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
  debug = fn;
});

const kIsolatedProcessName = Symbol('kIsolatedProcessName');
const kFilterArgs = [
  '--test',
  '--experimental-test-coverage',
  '--test-randomize',
  '--watch',
  '--experimental-default-config-file',
];
const kFilterArgValues = [
  '--experimental-test-tag-filter',
  '--test-reporter',
  '--test-reporter-destination',
  '--test-random-seed',
  '--config-file',
  '--experimental-config-file',
];
const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms'];

const kCanceledTests = new SafeSet()
  .add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);

// Execution-ordered events are forwarded immediately, bypassing the
// per-file declaration-order buffer.
const kExecutionOrderedEvents = new SafeSet()
  .add('test:enqueue').add('test:dequeue').add('test:complete').add('test:log');

let kResistStopPropagation;

// Worker ID pool management for concurrent test execution
class WorkerIdPool {
  #acquiredIds = new SafeSet();

  acquire() {
    let id = 1;

    while (this.#acquiredIds.has(id)) {
      id++;
    }

    this.#acquiredIds.add(id);
    return id;
  }

  release(id) {
    this.#acquiredIds.delete(id);
  }
}

function createTestFileList(patterns, cwd) {
  const hasUserSuppliedPattern = patterns != null;
  if (!patterns || patterns.length === 0) {
    patterns = [kDefaultPattern];
  } else {
    patterns = ArrayPrototypeMap(patterns, (pattern) => {
      // A directory argument is expanded to search for the default test files
      // within it, matching the behavior from before glob patterns were
      // supported. Glob patterns always use forward slashes, so a trailing
      // path separator (of either kind) is stripped before appending.
      const resolved = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
      try {
        if (statSync(resolved).isDirectory()) {
          return `${RegExpPrototypeSymbolReplace(/[\\/]+$/, pattern, '')}/${kDefaultPattern}`;
        }
      } catch {
        // Not a directory or not accessible; leave the pattern as-is so the
        // existing "Could not find" handling still applies.
      }
      return pattern;
    });
  }
  const glob = new Glob(patterns, {
    __proto__: null,
    cwd,
    exclude: (name) => name === 'node_modules',
  });
  const results = glob.globSync();

  if (hasUserSuppliedPattern && results.length === 0 && !glob.hasMagic) {
    console.error(`Could not find '${ArrayPrototypeJoin(patterns, ', ')}'`);
    process.exit(kGenericUserError);
  }

  return ArrayPrototypeSort(results);
}

function filterExecArgv(arg, i, arr) {
  return !ArrayPrototypeIncludes(kFilterArgs, arg) &&
  !ArrayPrototypeSome(kFilterArgValues, (p) => {
    return arg === p ||
      StringPrototypeStartsWith(arg, `${p}=`) ||
      (p !== '--config-file' && p !== '--experimental-config-file' &&
       i > 0 && arr[i - 1] === p);
  });
}

function getRunArgs(path, { forceExit,
                            inspectPort,
                            testNamePatterns,
                            testSkipPatterns,
                            testTagFilterExpressions,
                            only,
                            forbidOnly,
                            hasFiles,
                            testFiles,
                            globPatterns,
                            argv: suppliedArgs,
                            execArgv,
                            rerunFailuresFilePath,
                            randomize,
                            randomSeed,
                            root: { timeout },
                            cwd }) {
  const processNodeOptions = getOptionsAsFlagsFromBinding();
  const runArgs = ArrayPrototypeFilter(processNodeOptions, filterExecArgv);

  /**
   * Node supports V8 options passed via cli.
   * These options are being consumed by V8 and are not stored in nodeOptions.
   *
   * We need to propagate these options to the child processes manually.
   *
   * An example of such option are --allow-natives-syntax and --expose-gc
   */
  const nodeOptionsSet = new SafeSet(processNodeOptions);
  const unknownProcessExecArgv = ArrayPrototypeFilter(
    process.execArgv,
    (arg, i, arr) => !nodeOptionsSet.has(arg) && filterExecArgv(arg, i, arr),
  );
  ArrayPrototypePushApply(runArgs, unknownProcessExecArgv);

  if (forceExit === true) {
    ArrayPrototypePush(runArgs, '--test-force-exit');
  }
  if (isUsingInspector()) {
    ArrayPrototypePush(runArgs, `--inspect-port=${getInspectPort(inspectPort)}`);
  }
  if (testNamePatterns != null) {
    ArrayPrototypeForEach(testNamePatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-name-pattern=${pattern}`));
  }
  if (testSkipPatterns != null) {
    ArrayPrototypeForEach(testSkipPatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-skip-pattern=${pattern}`));
  }
  if (testTagFilterExpressions != null) {
    ArrayPrototypeForEach(testTagFilterExpressions, (expr) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${expr}`));
  }
  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}`);
  }
  if (rerunFailuresFilePath) {
    ArrayPrototypePush(runArgs, `--test-rerun-failures=${rerunFailuresFilePath}`);
  }
  if (randomize) {
    ArrayPrototypePush(runArgs, '--test-randomize');
  }
  if (randomSeed != null) {
    ArrayPrototypePush(runArgs, `--test-random-seed=${randomSeed}`);
  }

  ArrayPrototypePushApply(runArgs, execArgv);

  if (path === kIsolatedProcessName) {
    ArrayPrototypePush(runArgs, '--test');
    ArrayPrototypePush(runArgs, '--test-isolation=none');
    ArrayPrototypePushApply(runArgs, hasFiles ? testFiles : globPatterns ?? []);
  } else {
    ArrayPrototypePush(runArgs, path);
  }

  ArrayPrototypePushApply(runArgs, suppliedArgs);

  return runArgs;
}

const serializer = new DefaultSerializer();
serializer.writeHeader();
const v8Header = serializer.releaseBuffer();
const kV8HeaderLength = TypedArrayPrototypeGetLength(v8Header);
const kSerializedSizeHeader = 4 + kV8HeaderLength;

class FileTest extends Test {
  // This class maintains two buffers:
  #reportBuffer = []; // Parsed items waiting for this.isClearToSend()
  #rawBuffer = []; // Raw data waiting to be parsed
  #rawBufferSize = 0;
  #reportedChildren = 0;
  #pendingPartialV8Header = false;
  failedSubtests = false;

  constructor(options) {
    super(options);
    this.loc ??= {
      __proto__: null,
      line: 1,
      column: 1,
      file: resolve(this.name),
    };
    this.timeout = null;
  }

  willBeFilteredByTags() {
    // File wrappers have no tags of their own. Tag filtering applies to the
    // tests inside the file, which run in a child process (or in-process
    // import); filtering the wrapper would prevent the file from running at
    // all.
    return false;
  }

  #skipReporting() {
    return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed);
  }
  #checkNestedComment(comment) {
    const firstSpaceIndex = StringPrototypeIndexOf(comment, ' ');
    if (firstSpaceIndex === -1) return false;
    const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1);
    return secondSpaceIndex === -1 &&
          ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex));
  }
  #handleReportItem(item) {
    // The name is empty when a single child process runs all test files.
    if (this.name !== '') {
      item.data.entryFile = this.loc.file;
    }
    const isTopLevel = item.data.nesting === 0;
    if (isTopLevel) {
      if (item.type === 'test:plan' && this.#skipReporting()) {
        return;
      }
      if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) {
        return;
      }
    }
    if (item.data.details?.error) {
      item.data.details.error = deserializeError(item.data.details.error);
    }
    if (item.type === 'test:pass' || item.type === 'test:fail') {
      item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
      countCompletedTest({
        __proto__: null,
        name: item.data.name,
        finished: true,
        skipped: item.data.skip !== undefined,
        isTodo: item.data.todo !== undefined,
        passed: item.type === 'test:pass',
        cancelled: kCanceledTests.has(item.data.details?.error?.failureType),
        nesting: item.data.nesting,
        reportedType: item.data.details?.type,
      }, this.root.harness);
    }
    this.reporter[kEmitMessage](item.type, item.data);
  }
  #accumulateReportItem(item) {
    if (item.type !== 'test:pass' && item.type !== 'test:fail') {
      return;
    }
    this.#reportedChildren++;
    if (item.data.nesting === 0 && item.type === 'test:fail') {
      this.failedSubtests = true;
    }
  }
  #drainReportBuffer() {
    if (this.#reportBuffer.length > 0) {
      ArrayPrototypeForEach(this.#reportBuffer, (ast) => this.#handleReportItem(ast));
      this.#reportBuffer = [];
    }
  }
  addToReport(item) {
    if (kExecutionOrderedEvents.has(item.type)) {
      this.#handleReportItem(item);
      return;
    }
    this.#accumulateReportItem(item);
    if (!this.isClearToSend()) {
      ArrayPrototypePush(this.#reportBuffer, item);
      return;
    }
    this.#drainReportBuffer();
    this.#handleReportItem(item);
  }
  reportStarted() {}
  drain() {
    this.#drainRawBuffer();
    this.#drainReportBuffer();
  }
  report() {
    this.drain();
    const skipReporting = this.#skipReporting();
    if (!skipReporting) {
      super.reportStarted();
      super.report();
    }
  }
  parseMessage(readData) {
    let dataLength = TypedArrayPrototypeGetLength(readData);
    if (this.#pendingPartialV8Header) {
      readData = Buffer.concat([TypedArrayPrototypeSubarray(v8Header, 0, 1), readData]);
      dataLength = TypedArrayPrototypeGetLength(readData);
      this.#pendingPartialV8Header = false;
    }

    if (dataLength === 0) return;
    const partialV8Header = readData[dataLength - 1] === v8Header[0];

    if (partialV8Header) {
      // This will break if v8Header length (2 bytes) is changed.
      // However it is covered by tests.
      readData = TypedArrayPrototypeSubarray(readData, 0, dataLength - 1);
      dataLength--;
    }

    if (dataLength > 0) {
      if (this.#rawBuffer[0] && TypedArrayPrototypeGetLength(this.#rawBuffer[0]) < kSerializedSizeHeader) {
        this.#rawBuffer[0] = Buffer.concat([this.#rawBuffer[0], readData]);
      } else {
        ArrayPrototypePush(this.#rawBuffer, readData);
      }
      this.#rawBufferSize += dataLength;
      this.#processRawBuffer();
    }

    if (partialV8Header) {
      this.#pendingPartialV8Header = true;
    }
  }
  #drainRawBuffer() {
    if (this.#pendingPartialV8Header) {
      ArrayPrototypePush(this.#rawBuffer, TypedArrayPrototypeSubarray(v8Header, 0, 1));
      this.#rawBufferSize++;
      this.#pendingPartialV8Header = false;
    }

    while (this.#rawBuffer.length > 0) {
      const prevBufferLength = this.#rawBuffer.length;
      const prevBufferSize = this.#rawBufferSize;
      this.#processRawBuffer();

      if (this.#rawBuffer.length === prevBufferLength &&
          this.#rawBufferSize === prevBufferSize) {
        const bufferHead = this.#rawBuffer[0];
        this.addToReport({
          __proto__: null,
          type: 'test:stdout',
          data: {
            __proto__: null,
            file: this.name,
            message: StringFromCharCode(bufferHead[0]),
          },
        });

        if (TypedArrayPrototypeGetLength(bufferHead) === 1) {
          ArrayPrototypeShift(this.#rawBuffer);
        } else {
          this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, 1);
        }
        this.#rawBufferSize--;
      }
    }
  }
  #processRawBuffer() {
    // This method is called when it is known that there is at least one message.
    // Each pass looks at the head of the buffer and handles exactly one of four
    // cases, then re-checks the new head. Recovery from stray stdout that only
    // mimics a frame happens inline here, so no separate resync pass is needed.
    while (this.#rawBuffer.length > 0) {
      const bufferHead = this.#rawBuffer[0];
      const headerIndex = bufferHead.indexOf(v8Header);

      // 1. The head does not start with a frame header. Emit the bytes before
      //    the next header, or the whole head when there is none, as stdout and
      //    advance to the next header.
      if (headerIndex !== 0) {
        const nonSerialized = headerIndex === -1 ?
          bufferHead : TypedArrayPrototypeSubarray(bufferHead, 0, headerIndex);
        this.addToReport({
          __proto__: null,
          type: 'test:stdout',
          data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') },
        });
        this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerialized);
        if (headerIndex === -1) {
          ArrayPrototypeShift(this.#rawBuffer);
        } else {
          this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex);
        }
        continue;
      }

      // 2. The head starts with a header but the whole frame has not arrived
      //    yet. Stop and wait for more data.
      if (TypedArrayPrototypeGetLength(bufferHead) < kSerializedSizeHeader) {
        break;
      }
      // We call `readUInt32BE` manually here, because this is faster than first
      // converting it to a buffer and using `readUInt32BE` on that.
      const fullMessageSize = ((
        bufferHead[kV8HeaderLength] << 24 |
        bufferHead[kV8HeaderLength + 1] << 16 |
        bufferHead[kV8HeaderLength + 2] << 8 |
        bufferHead[kV8HeaderLength + 3]
      ) >>> 0) + kSerializedSizeHeader;
      if (this.#rawBufferSize < fullMessageSize) {
        break;
      }

      const concatenatedBuffer = this.#rawBuffer.length === 1 ?
        bufferHead : Buffer.concat(this.#rawBuffer, this.#rawBufferSize);

      // 3. The head only mimics a frame. A real frame repeats the v8 header at
      //    the start of its payload, right before the serialized value, so a
      //    payload too short for that header, or one that does not start with
      //    it, is stray stdout. Emit one byte and let the next pass resync on
      //    the following header. A genuine frame that fails to deserialize is
      //    left to throw, so real report-protocol regressions are not hidden.
      if (fullMessageSize - kSerializedSizeHeader < kV8HeaderLength ||
          concatenatedBuffer.indexOf(v8Header, kSerializedSizeHeader) !== kSerializedSizeHeader) {
        this.addToReport({
          __proto__: null,
          type: 'test:stdout',
          data: { __proto__: null, file: this.name, message: StringFromCharCode(bufferHead[0]) },
        });
        if (TypedArrayPrototypeGetLength(bufferHead) === 1) {
          ArrayPrototypeShift(this.#rawBuffer);
        } else {
          this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, 1);
        }
        this.#rawBufferSize--;
        continue;
      }

      // 4. A real frame. Deserialize it and continue from the remaining bytes.
      const deserializer = new DefaultDeserializer(
        TypedArrayPrototypeSubarray(concatenatedBuffer, kSerializedSizeHeader, fullMessageSize),
      );
      deserializer.readHeader();
      const item = deserializer.readValue();

      const remaining = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
      this.#rawBufferSize = TypedArrayPrototypeGetLength(remaining);
      this.#rawBuffer = this.#rawBufferSize !== 0 ? [remaining] : [];

      this.addToReport(item);
    }
  }
}

function runTestFile(path, filesWatcher, opts) {
  const watchMode = filesWatcher != null;
  const testPath = path === kIsolatedProcessName ? '' : path;
  const testOpts = { __proto__: null, signal: opts.signal };
  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) };

    // Acquire a worker ID from the pool for process isolation mode
    let workerId;
    if (opts.workerIdPool) {
      workerId = opts.workerIdPool.acquire();
      env.NODE_TEST_WORKER_ID = String(workerId);
      debug('Assigned worker ID %d to test file: %s', workerId, path);
    }

    try {
      if (watchMode) {
        stdio.push('ipc');
        env.WATCH_REPORT_DEPENDENCIES = '1';
      }
      if (opts.root.harness.shouldColorizeTestFiles) {
        env.FORCE_COLOR = '1';
      }

      const child = spawn(
        process.execPath, args,
        {
          __proto__: null,
          signal: t.signal,
          encoding: 'utf8',
          env,
          stdio,
          cwd: opts.cwd,
        },
      );
      if (watchMode) {
        filesWatcher.runningProcesses.set(path, child);
        filesWatcher.watcher.watchChildProcessModules(child, path);
      }

      let err;

      child.on('error', (error) => {
        err = error;
      });

      child.stdout.on('data', (data) => {
        subtest.parseMessage(data);
      });

      const rl = new Interface({ __proto__: null, input: child.stderr });
      rl.on('line', (line) => {
        if (isInspectorMessage(line)) {
          process.stderr.write(line + '\n');
          return;
        }

        // stderr cannot be treated as TAP, per the spec. However, we want to
        // surface stderr lines to improve the DX. Inject each line into the
        // test output as an unknown token as if it came from the TAP parser.
        subtest.addToReport({
          __proto__: null,
          type: 'test:stderr',
          data: { __proto__: null, file: path, message: line + '\n' },
        });
      });

      const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
        once(child, 'exit', { __proto__: null, signal: t.signal }),
        finished(child.stdout, { __proto__: null, signal: t.signal }),
      ]);

      // Close readline interface to prevent memory leak
      rl.close();

      if (watchMode) {
        filesWatcher.runningProcesses.delete(path);
        filesWatcher.runningSubtests.delete(path);
        (async () => {
          try {
            await subTestEnded;
          } finally {
            if (filesWatcher.runningSubtests.size === 0) {
              opts.root.reporter[kEmitMessage]('test:watch:drained');
              opts.root.postRun();
            }
          }
        })();
      }

      if (code !== 0 || signal !== null) {
        if (!err) {
          const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
          err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
            __proto__: null,
            exitCode: code,
            signal: signal,
            // The stack will not be useful since the failures came from tests
            // in a child process.
            stack: undefined,
          });
        }

        throw err;
      }
    } finally {
      // Every exit path must return the ID, including abort and spawn failure.
      if (opts.workerIdPool && workerId !== undefined) {
        opts.workerIdPool.release(workerId);
        debug('Released worker ID %d from test file: %s', workerId, path);
      }
    }
  });
  const subTestEnded = subtest.start();
  return subTestEnded;
}

function watchFiles(testFiles, opts) {
  const runningProcesses = new SafeMap();
  const runningSubtests = new SafeMap();
  const watcherMode = opts.hasFiles ? 'filter' : 'all';
  const watcher = new FilesWatcher({ __proto__: null, debounce: 200, mode: watcherMode, signal: opts.signal });
  if (!opts.hasFiles) {
    watcher.watchPath(opts.cwd);
  }
  const filesWatcher = { __proto__: null, watcher, runningProcesses, runningSubtests };
  opts.root.harness.watching = true;

  async function restartTestFile(file) {
    const runningProcess = runningProcesses.get(file);
    if (runningProcess) {
      runningProcess.kill();
      await once(runningProcess, 'exit');
    }
    if (!runningSubtests.size) {
      // Reset the topLevel counter
      opts.root.harness.counters.topLevel = 0;
    }

    await runningSubtests.get(file);
    runningSubtests.set(file, runTestFile(file, filesWatcher, opts));
  }

  // Watch for changes in current filtered files
  const onChanged = ({ owners, eventType }) => {
    if (!opts.hasFiles && (eventType === 'rename' || eventType === 'change')) {
      const updatedTestFiles = createTestFileList(opts.globPatterns, opts.cwd);
      const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x));
      const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x));

      testFiles = updatedTestFiles;

      // When file renamed (created / deleted) we need to update the watcher
      if (newFileName) {
        owners = new SafeSet().add(newFileName);
        const resolveFileName = isAbsolute(newFileName) ? newFileName : resolve(opts.cwd, newFileName);
        watcher.filterFile(resolveFileName, owners);
      }

      if (!newFileName && previousFileName) {
        return; // Avoid rerunning files when file deleted
      }
    }
    // Reset the root start time to recalculate the duration
    // of the run
    opts.root.clearExecutionTime();
    opts.root.reporter[kEmitMessage]('test:watch:restarted');

    // Restart test files
    if (opts.isolation === 'none') {
      PromisePrototypeThen(restartTestFile(kIsolatedProcessName), undefined, (error) => {
        triggerUncaughtException(error, true /* fromPromise */);
      });
    } else {
      watcher.unfilterFilesOwnedBy(owners);
      PromisePrototypeThen(SafePromiseAllReturnVoid(testFiles, async (file) => {
        if (!owners.has(file)) {
          return;
        }

        await restartTestFile(file);
      }, undefined, (error) => {
        triggerUncaughtException(error, true /* fromPromise */);
      }));
    }
  };

  watcher.on('changed', onChanged);

  // Cleanup function to remove event listener and prevent memory leak
  const cleanup = () => {
    watcher.removeListener('changed', onChanged);
    opts.root.harness.watching = false;
    opts.root.postRun();
  };

  if (opts.signal) {
    kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
    opts.signal.addEventListener(
      'abort',
      cleanup,
      { __proto__: null, once: true, [kResistStopPropagation]: true },
    );
  }

  // Expose cleanup method for proper resource management
  filesWatcher.cleanup = cleanup;

  return filesWatcher;
}

function run(options = kEmptyObject) {
  validateObject(options, 'options');

  let {
    testNamePatterns,
    testSkipPatterns,
    testTagFilters,
    shard,
    coverageExcludeGlobs,
    coverageIncludeGlobs,
  } = options;
  const {
    concurrency,
    timeout,
    signal,
    files,
    forceExit,
    inspectPort,
    isolation = 'process',
    watch,
    setup,
    globalSetupPath,
    only,
    globPatterns,
    forbidOnly,
    coverage = false,
    coverageIncludeAll = false,
    lineCoverage = 0,
    branchCoverage = 0,
    functionCoverage = 0,
    randomize: suppliedRandomize,
    randomSeed: suppliedRandomSeed,
    execArgv = [],
    argv = [],
    cwd = process.cwd(),
    rerunFailuresFilePath,
    env,
  } = options;

  if (files != null) {
    validateArray(files, 'options.files');
  }
  if (watch != null) {
    validateBoolean(watch, 'options.watch');
  }
  if (forceExit != null) {
    validateBoolean(forceExit, 'options.forceExit');

    if (forceExit && watch) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.forceExit', watch, 'is not supported with watch mode',
      );
    }
  }
  if (only != null) {
    validateBoolean(only, 'options.only');
  }
  if (forbidOnly != null) {
    validateBoolean(forbidOnly, 'options.forbidOnly');
  }
  if (globPatterns != null) {
    validateArray(globPatterns, 'options.globPatterns');
  }
  if (suppliedRandomize != null) {
    validateBoolean(suppliedRandomize, 'options.randomize');
  }
  if (suppliedRandomSeed != null) {
    validateUint32(suppliedRandomSeed, 'options.randomSeed');
  }
  let randomize = suppliedRandomize;
  let randomSeed = suppliedRandomSeed;

  if (randomSeed != null) {
    randomize = true;
  }
  if (watch) {
    if (randomSeed != null) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.randomSeed',
        randomSeed,
        'is not supported with watch mode',
      );
    }
    if (randomize) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.randomize',
        randomize,
        'is not supported with watch mode',
      );
    }
  }
  if (rerunFailuresFilePath) {
    validatePath(rerunFailuresFilePath, 'options.rerunFailuresFilePath');
    // TODO(pmarchini): Support rerun-failures with randomization by
    // persisting the randomization seed in the rerun state file.
    if (randomSeed != null) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.randomSeed',
        randomSeed,
        'is not supported with rerun failures mode',
      );
    }
    if (randomize) {
      throw new ERR_INVALID_ARG_VALUE(
        'options.randomize',
        randomize,
        'is not supported with rerun failures mode',
      );
    }
  }
  if (randomize) {
    randomSeed ??= createRandomSeed();
  }

  validateString(cwd, 'options.cwd');

  if (globPatterns?.length > 0 && files?.length > 0) {
    throw new ERR_INVALID_ARG_VALUE(
      'options.globPatterns', globPatterns, 'is not supported when specifying \'options.files\'',
    );
  }

  if (shard != null) {
    validateObject(shard, 'options.shard');
    // Avoid re-evaluating the shard object in case it's a getter
    shard = { __proto__: null, index: shard.index, total: shard.total };

    validateInteger(shard.total, 'options.shard.total', 1);
    validateInteger(shard.index, 'options.shard.index', 1, shard.total);

    if (watch) {
      throw new ERR_INVALID_ARG_VALUE('options.shard', watch, 'shards not supported with watch mode');
    }
  }
  if (setup != null) {
    validateFunction(setup, 'options.setup');
  }
  if (testNamePatterns != null) {
    if (!ArrayIsArray(testNamePatterns)) {
      testNamePatterns = [testNamePatterns];
    }

    testNamePatterns = ArrayPrototypeMap(testNamePatterns, (value, i) => {
      if (isRegExp(value)) {
        return value;
      }
      const name = `options.testNamePatterns[${i}]`;
      if (typeof value === 'string') {
        return convertStringToRegExp(value, name);
      }
      throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
    });
  }
  if (testSkipPatterns != null) {
    if (!ArrayIsArray(testSkipPatterns)) {
      testSkipPatterns = [testSkipPatterns];
    }

    testSkipPatterns = ArrayPrototypeMap(testSkipPatterns, (value, i) => {
      if (isRegExp(value)) {
        return value;
      }
      const name = `options.testSkipPatterns[${i}]`;
      if (typeof value === 'string') {
        return convertStringToRegExp(value, name);
      }
      throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
    });
  }

  // The public contract of testTagFilters is `string | string[]`. The
  // parseCommandLine bootstrap path piggybacks the already-parsed AST array
  // on the same field, identifiable by the sibling testTagFilterExpressions
  // field which only that path sets. When that marker is present and the
  // first element isn't a string, treat the array as ASTs and skip the
  // public validation loop. Otherwise validate every element as a string,
  // so any non-string input throws ERR_INVALID_ARG_TYPE with the offending
  // index regardless of position.
  let testTagFilterExpressions = null;
  if (testTagFilters != null) {
    if (!ArrayIsArray(testTagFilters)) {
      testTagFilters = [testTagFilters];
    }
    if (testTagFilters.length === 0) {
      testTagFilters = null;
    } else if (options.testTagFilterExpressions != null &&
               typeof testTagFilters[0] !== 'string') {
      // Internal bootstrap: trust the AST array as already-parsed.
    } else {
      emitExperimentalWarning('Test tags');
      testTagFilterExpressions = ArrayPrototypeSlice(testTagFilters);
      testTagFilters = ArrayPrototypeMap(testTagFilters, (value, i) => {
        const name = `options.testTagFilters[${i}]`;
        if (typeof value !== 'string') {
          throw new ERR_INVALID_ARG_TYPE(name, 'string', value);
        }
        return parseTagFilterExpression(value, name);
      });
    }
  }
  testTagFilterExpressions ??= options.testTagFilterExpressions;

  validateOneOf(isolation, 'options.isolation', ['process', 'none']);
  validateBoolean(coverage, 'options.coverage');
  validateBoolean(coverageIncludeAll, 'options.coverageIncludeAll');
  if (coverageExcludeGlobs != null) {
    if (!ArrayIsArray(coverageExcludeGlobs)) {
      coverageExcludeGlobs = [coverageExcludeGlobs];
    }
    validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
  } else if (coverage) {
    coverageExcludeGlobs = [kDefaultPattern];
  }
  if (coverageIncludeGlobs != null) {
    if (!ArrayIsArray(coverageIncludeGlobs)) {
      coverageIncludeGlobs = [coverageIncludeGlobs];
    }
    validateStringArray(coverageIncludeGlobs, 'options.coverageIncludeGlobs');
  }
  validateInteger(lineCoverage, 'options.lineCoverage', 0, 100);
  validateInteger(branchCoverage, 'options.branchCoverage', 0, 100);
  validateInteger(functionCoverage, 'options.functionCoverage', 0, 100);

  validateStringArray(argv, 'options.argv');
  validateStringArray(execArgv, 'options.execArgv');

  if (globalSetupPath != null) {
    validatePath(globalSetupPath, 'options.globalSetupPath');
  }

  if (env != null) {
    validateObject(env, 'options.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,
    // parseCommandLine() should not be used here. However, The existing run()
    // behavior has relied on it, so removing it must be done in a semver major.
    ...parseCommandLine(),
    setup,  // This line can be removed when parseCommandLine() is removed here.
    coverage,
    coverageIncludeAll,
    coverageExcludeGlobs,
    coverageIncludeGlobs,
    rerunFailuresFilePath,
    lineCoverage: lineCoverage,
    branchCoverage: branchCoverage,
    functionCoverage: functionCoverage,
    cwd,
    globalSetupPath,
    randomize,
    randomSeed,
    testTagFilters,
  };

  if (forbidOnly != null) {
    globalOptions.forbidOnly = forbidOnly;
  }

  if (isolation === 'none') {
    if (testNamePatterns != null) {
      globalOptions.testNamePatterns = testNamePatterns;
    }
    if (testSkipPatterns != null) {
      globalOptions.testSkipPatterns = testSkipPatterns;
    }
    if (only != null) {
      globalOptions.only = only;
    }
  }

  const root = createTestTree(rootTestOptions, globalOptions);
  let testFiles = files ?? createTestFileList(globPatterns, cwd);
  const { isTestRunner } = globalOptions;

  if (randomize) {
    root.diagnostic(`Randomized test order seed: ${randomSeed}`);
  }

  if (shard) {
    testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
  }

  let teardown;
  let postRun;
  let filesWatcher;
  let runFiles;

  // The pool tracks the IDs actually in use, so they stay exclusive and never
  // exceed the number of files running concurrently.
  const workerIdPool = new WorkerIdPool();
  debug('Created worker ID pool, testFiles: %d', testFiles.length);

  const opts = {
    __proto__: null,
    root,
    signal,
    inspectPort,
    testNamePatterns,
    testSkipPatterns,
    testTagFilterExpressions,
    hasFiles: files != null,
    globPatterns,
    only,
    forceExit,
    forbidOnly,
    cwd,
    isolation,
    argv,
    execArgv,
    rerunFailuresFilePath,
    env,
    workerIdPool: isolation === 'process' ? workerIdPool : null,
    randomize,
    randomSeed,
    testFiles,
  };

  if (isolation === 'process') {
    if (process.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;
    }

    if (watch) {
      filesWatcher = watchFiles(testFiles, opts);
    } else {
      postRun = () => root.postRun();
      teardown = () => root.harness.teardown();
    }

    runFiles = () => {
      root.harness.bootstrapPromise = null;
      root.harness.buildPromise = null;
      return SafePromiseAllSettledReturnVoid(testFiles, (path) => {
        const subtest = runTestFile(path, filesWatcher, opts);
        filesWatcher?.runningSubtests.set(path, subtest);
        return subtest;
      });
    };
  } else if (isolation === 'none') {
    // For isolation=none, set worker ID to 1 in the current process
    process.env.NODE_TEST_WORKER_ID = '1';
    debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');

    if (watch) {
      const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
      filesWatcher = watchFiles(absoluteTestFiles, opts);
      runFiles = async () => {
        root.harness.bootstrapPromise = null;
        root.harness.buildPromise = null;
        const subtest = runTestFile(kIsolatedProcessName, filesWatcher, opts);
        filesWatcher?.runningSubtests.set(kIsolatedProcessName, subtest);
        return subtest;
      };
    } else {
      runFiles = async () => {
        const { promise, resolve: finishBootstrap } = PromiseWithResolvers();

        await root.runInAsyncScope(async () => {
          const parentURL = pathToFileURL(cwd + sep).href;
          const cascadedLoader = esmLoader.getOrInitializeCascadedLoader();
          let topLevelTestCount = 0;

          root.harness.bootstrapPromise = root.harness.bootstrapPromise ?
            SafePromiseAllReturnVoid([root.harness.bootstrapPromise, promise]) :
            promise;

          // We need to setup the user modules in the test runner if we are running with
          // --test-isolation=none and --test in order to avoid loading the user modules
          // BEFORE the creation of the root test (that would cause them to get lost).
          if (isTestRunner) {
            // If we are not coming from the test runner entry point, the user-required and imported
            // modules have already been loaded.
            // Since it's possible to delete modules from require.cache, a CommonJS module
            // could otherwise be executed twice.
            loadPreloadModules();
          }
          const userImports = getOptionValue('--import');
          for (let i = 0; i < userImports.length; i++) {
            await cascadedLoader.import(userImports[i], parentURL, kEmptyObject);
          }

          for (let i = 0; i < testFiles.length; ++i) {
            const testFile = testFiles[i];
            const fileURL = pathToFileURL(resolve(cwd, testFile));
            const parent = i === 0 ? undefined : parentURL;
            let threw = false;
            let importError;

            root.entryFile = resolve(testFile);
            debug('loading test file:', fileURL.href);
            try {
              await cascadedLoader.import(fileURL, parent, { __proto__: null });
            } catch (err) {
              threw = true;
              importError = err;
            }

            debug(
              'loaded "%s": top level test count before = %d and after = %d',
              testFile,
              topLevelTestCount,
              root.subtests.length,
            );
            if (threw || topLevelTestCount === root.subtests.length) {
              // This file failed to load or had no tests in it. Add the placeholder test.
              const subtest = root.createSubtest(Test, testFile, kEmptyObject, undefined, {
                __proto__: null,
                loc: [1, 1, resolve(testFile)],
              });
              if (threw) {
                subtest.fail(importError);
              }
              startSubtestAfterBootstrap(subtest);
            }

            topLevelTestCount = root.subtests.length;
          }
        });

        debug('beginning test execution');
        root.entryFile = null;
        finishBootstrap();
        return root.processPendingSubtests();
      };
    }
  }

  const runChain = async () => {
    if (root.harness?.bootstrapPromise) {
      await root.harness.bootstrapPromise;
    }
    if (typeof setup === 'function') {
      await setup(root.reporter);
    }

    await runFiles();
    postRun?.();
    teardown?.();
  };

  runChain();
  return root.reporter;
}

module.exports = {
  FileTest, // Exported for tests only
  run,
};