current commit
261020.12 · test_runner: use AsyncLocalStorage instead of async_hooks
'use strict';
const {
  ArrayPrototypeForEach,
  ArrayPrototypePush,
  FunctionPrototypeBind,
  Promise,
  PromiseResolve,
  PromiseWithResolvers,
  SafePromiseAllReturnVoid,
} = primordials;
const { getCallerLocation } = internalBinding('util');
const { relative } = require('path');
const {
  codes: {
    ERR_TEST_FAILURE,
  },
} = require('internal/errors');
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
const { kCancelledByParent, Test, Suite } = require('internal/test_runner/test');
const {
  parseCommandLine,
  reporterScope,
  shouldColorizeTestFiles,
  testStorage,
  setupGlobalSetupTeardownFunctions,
  parsePreviousRuns,
} = require('internal/test_runner/utils');
const { PassThrough, compose } = require('stream');
const { reportReruns } = require('internal/test_runner/reporter/rerun');
const { queueMicrotask } = require('internal/process/task_queues');
const { TIMEOUT_MAX } = require('internal/timers');
const { clearInterval, setImmediate, setInterval } = require('timers');
const { bigint: hrtime } = process.hrtime;
let globalRoot;
let globalSetupExecuted = false;

function createTestTree(rootTestOptions, globalOptions) {
  const buildPhaseDeferred = PromiseWithResolvers();
  const isFilteringByName = globalOptions.testNamePatterns ||
    globalOptions.testSkipPatterns;
  const isFilteringByOnly = (globalOptions.isolation === 'process' || process.env.NODE_TEST_CONTEXT) ?
    globalOptions.only : true;
  const isFilteringByTags = globalOptions.testTagFilters != null;
  const harness = {
    __proto__: null,
    buildPromise: buildPhaseDeferred.promise,
    buildSuites: [],
    isWaitingForBuildPhase: false,
    watching: false,
    config: globalOptions,
    coverage: null,
    resetCounters() {
      harness.counters = {
        __proto__: null,
        tests: 0,
        failed: 0,
        passed: 0,
        cancelled: 0,
        skipped: 0,
        todo: 0,
        topLevel: 0,
        suites: 0,
      };
    },
    success: true,
    counters: null,
    shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations),
    teardown: null,
    snapshotManager: null,
    previousRuns: null,
    isFilteringByName,
    isFilteringByOnly,
    isFilteringByTags,
    async runBootstrap() {
      if (globalSetupExecuted) {
        return PromiseResolve();
      }
      globalSetupExecuted = true;
      const globalSetupFunctions = await setupGlobalSetupTeardownFunctions(
        globalOptions.globalSetupPath,
        globalOptions.cwd,
      );
      harness.globalTeardownFunction = globalSetupFunctions.globalTeardownFunction;
      if (typeof globalSetupFunctions.globalSetupFunction === 'function') {
        return globalSetupFunctions.globalSetupFunction();
      }
      return PromiseResolve();
    },
    async waitForBuildPhase() {
      if (harness.buildSuites.length > 0) {
        await SafePromiseAllReturnVoid(harness.buildSuites);
      }

      buildPhaseDeferred.resolve();
    },
  };

  harness.resetCounters();
  harness.bootstrapPromise = harness.runBootstrap();
  globalRoot = new Test({
    __proto__: null,
    ...rootTestOptions,
    harness,
    name: '<root>',
  });
  setupProcessState(globalRoot, globalOptions, harness);
  globalRoot.startTime = hrtime();
  return globalRoot;
}

function createProcessEventHandler(eventName, rootTest) {
  return (err) => {
    if (rootTest.harness.bootstrapPromise) {
      // Something went wrong during the asynchronous portion of bootstrapping
      // the test runner. Since the test runner is not setup properly, we can't
      // do anything but throw the error.
      throw err;
    }

    const test = testStorage.getStore();

    // Check if this error is coming from a reporter. If it is, throw it.
    if (test === reporterScope) {
      throw err;
    }

    // Check if this error is coming from a test or test hook. If it is, fail the test.
    if (!test || test.finished || test.hookType) {
      // If the test is already finished or the resource that created the error
      // is not mapped to a Test, report this as a top level diagnostic.
      let msg;

      if (test) {
        const name = test.hookType ? `Test hook "${test.hookType}"` : `Test "${test.name}"`;
        let locInfo = '';
        if (test.loc) {
          const relPath = relative(rootTest.config.cwd, test.loc.file);
          locInfo = ` at ${relPath}:${test.loc.line}:${test.loc.column}`;
        }

        msg = `Error: ${name}${locInfo} generated asynchronous ` +
          'activity after the test ended. This activity created the error ' +
          `"${err}" and would have caused the test to fail, but instead ` +
          `triggered an ${eventName} event.`;
      } else {
        msg = 'Error: A resource generated asynchronous activity after ' +
          `the test ended. This activity created the error "${err}" which ` +
          `triggered an ${eventName} event, caught by the test runner.`;
      }

      rootTest.diagnostic(msg);
      rootTest.harness.success = false;
      process.exitCode = kGenericUserError;
      return;
    }

    test.fail(new ERR_TEST_FAILURE(err, eventName));
    test.abortController.abort();
  };
}

function configureCoverage(rootTest, globalOptions) {
  if (!globalOptions.coverage) {
    return null;
  }

  const { setupCoverage } = require('internal/test_runner/coverage');

  try {
    return setupCoverage(globalOptions);
  } catch (err) {
    const msg = `Warning: Code coverage could not be enabled. ${err}`;

    rootTest.diagnostic(msg);
    rootTest.harness.success = false;
    process.exitCode = kGenericUserError;
  }
}

function collectCoverage(rootTest, coverage) {
  if (!coverage) {
    return null;
  }

  let summary = null;

  try {
    summary = coverage.summary();
  } catch (err) {
    rootTest.diagnostic(`Warning: Could not report code coverage. ${err}`);
    rootTest.harness.success = false;
    process.exitCode = kGenericUserError;
  }

  try {
    coverage.cleanup();
  } catch (err) {
    rootTest.diagnostic(`Warning: Could not clean up code coverage. ${err}`);
    rootTest.harness.success = false;
    process.exitCode = kGenericUserError;
  }

  return summary;
}

function setupFailureStateFile(rootTest, globalOptions) {
  if (!globalOptions.rerunFailuresFilePath) {
    return;
  }
  rootTest.harness.previousRuns = parsePreviousRuns(globalOptions.rerunFailuresFilePath);
  if (rootTest.harness.previousRuns === null) {
    rootTest.diagnostic(`Warning: The rerun failures file at ` +
      `${globalOptions.rerunFailuresFilePath} is not a valid rerun file. ` +
      'The test runner will not be able to rerun failed tests.');
    rootTest.harness.success = false;
    process.exitCode = kGenericUserError;
    return;
  }
  if (!process.env.NODE_TEST_CONTEXT) {
    const reporter = reportReruns(rootTest.harness.previousRuns, globalOptions);
    compose(rootTest.reporter, reporter).pipe(new PassThrough());
  }
}

function setupProcessState(root, globalOptions) {
  const exceptionHandler =
    createProcessEventHandler('uncaughtException', root);
  const rejectionHandler =
    createProcessEventHandler('unhandledRejection', root);
  const coverage = configureCoverage(root, globalOptions);

  setupFailureStateFile(root, globalOptions);

  const exitHandler = async (kill) => {
    if (root.subtests.length === 0 && (root.hooks.before.length > 0 || root.hooks.after.length > 0)) {
      // Run global before/after hooks in case there are no tests
      await root.run();
    }

    if (kill !== true && root.subtestsPromise !== null) {
      // Wait for all subtests to finish, but keep the process alive in case
      // there are no ref'ed handles left.
      const keepAlive = setInterval(() => {}, TIMEOUT_MAX);
      await root.subtestsPromise.promise;
      clearInterval(keepAlive);
    }

    root.postRun(new ERR_TEST_FAILURE(
      'Promise resolution is still pending but the event loop has already resolved',
      kCancelledByParent));

    if (root.harness.globalTeardownFunction) {
      await root.harness.globalTeardownFunction();
      root.harness.globalTeardownFunction = null;
    }

    process.removeListener('uncaughtException', exceptionHandler);
    process.removeListener('unhandledRejection', rejectionHandler);
    process.removeListener('beforeExit', exitHandler);
    if (globalOptions.isTestRunner) {
      process.removeListener('SIGINT', terminationHandler);
      process.removeListener('SIGTERM', terminationHandler);
    }
  };

  const findRunningTests = (test, running = []) => {
    if (test.startTime !== null && !test.finished) {
      for (let i = 0; i < test.subtests.length; i++) {
        findRunningTests(test.subtests[i], running);
      }
      // Only add leaf tests (innermost running tests)
      if (test.activeSubtests === 0 && test.name !== '<root>') {
        ArrayPrototypePush(running, {
          __proto__: null,
          name: test.name,
          nesting: test.nesting,
          file: test.loc?.file,
          line: test.loc?.line,
          column: test.loc?.column,
        });
      }
    }
    return running;
  };

  const terminationHandler = async () => {
    const runningTests = findRunningTests(root);
    if (runningTests.length > 0) {
      root.reporter.interrupted(runningTests);
      // Allow the reporter stream to process the interrupted event
      await new Promise((resolve) => setImmediate(resolve));
    }
    await exitHandler(true);
    process.exit();
  };

  process.on('uncaughtException', exceptionHandler);
  process.on('unhandledRejection', rejectionHandler);
  process.on('beforeExit', exitHandler);
  // TODO(MoLow): Make it configurable to hook when isTestRunner === false.
  if (globalOptions.isTestRunner) {
    process.on('SIGINT', terminationHandler);
    process.on('SIGTERM', terminationHandler);
  }

  root.harness.coverage = FunctionPrototypeBind(collectCoverage, null, root, coverage);
  root.harness.teardown = exitHandler;
}

function lazyBootstrapRoot() {
  if (!globalRoot) {
    // This is where the test runner is bootstrapped when node:test is used
    // without the --test flag or the run() API.
    const entryFile = process.argv?.[1];
    const rootTestOptions = {
      __proto__: null,
      entryFile,
      loc: entryFile ? [1, 1, entryFile] : undefined,
    };
    const globalOptions = parseCommandLine();
    globalOptions.cwd = process.cwd();
    createTestTree(rootTestOptions, globalOptions);
    globalRoot.reporter.on('test:summary', (data) => {
      if (!data.success) {
        process.exitCode = kGenericUserError;
      }
    });
    globalRoot.harness.bootstrapPromise = SafePromiseAllReturnVoid([
      globalRoot.harness.bootstrapPromise,
      globalOptions.setup(globalRoot.reporter),
    ]);
  }
  return globalRoot;
}

async function startSubtestAfterBootstrap(subtest) {
  if (subtest.root.harness.buildPromise) {
    if (subtest.root.harness.bootstrapPromise) {
      await subtest.root.harness.bootstrapPromise;
      subtest.root.harness.bootstrapPromise = null;
    }

    if (subtest.buildSuite) {
      ArrayPrototypePush(subtest.root.harness.buildSuites, subtest.buildSuite);
    }

    if (!subtest.root.harness.isWaitingForBuildPhase) {
      subtest.root.harness.isWaitingForBuildPhase = true;
      queueMicrotask(() => {
        subtest.root.harness.waitForBuildPhase();
      });
    }

    await subtest.root.harness.buildPromise;
    subtest.root.harness.buildPromise = null;
  }

  await subtest.start();
}

function runInParentContext(Factory) {
  function run(name, options, fn, overrides) {
    const parent = testStorage.getStore() || lazyBootstrapRoot();
    const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
    if (parent instanceof Suite) {
      return PromiseResolve();
    }

    return startSubtestAfterBootstrap(subtest);
  }

  const test = (name, options, fn) => {
    const overrides = {
      __proto__: null,
      loc: getCallerLocation(),
    };

    return run(name, options, fn, overrides);
  };
  ArrayPrototypeForEach(['expectFailure', 'skip', 'todo', 'only'], (keyword) => {
    test[keyword] = (name, options, fn) => {
      const overrides = {
        __proto__: null,
        [keyword]: true,
        loc: getCallerLocation(),
      };

      return run(name, options, fn, overrides);
    };
  });
  return test;
}

function hook(hook) {
  return (fn, options) => {
    const parent = testStorage.getStore() || lazyBootstrapRoot();
    parent.createHook(hook, fn, {
      __proto__: null,
      ...options,
      parent,
      hookType: hook,
      loc: getCallerLocation(),
    });
  };
}

function getTestContext() {
  const test = testStorage.getStore();
  // Exclude the reporter sentinel
  if (test === undefined || test === reporterScope) {
    return undefined;
  }
  return test.getCtx();
}

module.exports = {
  createTestTree,
  getTestContext,
  test: runInParentContext(Test),
  suite: runInParentContext(Suite),
  before: hook('before'),
  after: hook('after'),
  beforeEach: hook('beforeEach'),
  afterEach: hook('afterEach'),
  startSubtestAfterBootstrap,
};