plugin-loader.js

/**
 * @typedef {import('./types.d.ts').PluginDefinition} PluginDefinition
 */

/**
 * Provides a way to load "plugins" as provided by the user.
 *
 * Currently supports:
 *
 * - Root hooks
 * - Global fixtures (setup/teardown)
 * @private
 * @module plugin
 */

"use strict";

const debug = require("debug")("mocha:plugin-loader");
const {
  createInvalidPluginDefinitionError,
  createInvalidPluginImplementationError,
} = require("./errors");
const { castArray } = require("./utils");

/**
 * @typedef {import('./types.d.ts').PluginLoaderOptions} PluginLoaderOptions
 */

/**
 * Built-in plugin definitions.
 */
const MochaPlugins = [
  /**
   * Root hook plugin definition
   * @type {PluginDefinition}
   */
  {
    exportName: "mochaHooks",
    optionName: "rootHooks",
    validate(value) {
      if (
        Array.isArray(value) ||
        (typeof value !== "function" && typeof value !== "object")
      ) {
        throw createInvalidPluginImplementationError(
          `mochaHooks must be an object or a function returning (or fulfilling with) an object`,
        );
      }
    },
    async finalize(rootHooks) {
      if (rootHooks.length) {
        const rootHookObjects = await Promise.all(
          rootHooks.map(async (hook) =>
            typeof hook === "function" ? hook() : hook,
          ),
        );

        return rootHookObjects.reduce(
          (acc, hook) => {
            hook = {
              beforeAll: [],
              beforeEach: [],
              afterAll: [],
              afterEach: [],
              ...hook,
            };
            return {
              beforeAll: [...acc.beforeAll, ...castArray(hook.beforeAll)],
              beforeEach: [...acc.beforeEach, ...castArray(hook.beforeEach)],
              afterAll: [...acc.afterAll, ...castArray(hook.afterAll)],
              afterEach: [...acc.afterEach, ...castArray(hook.afterEach)],
            };
          },
          { beforeAll: [], beforeEach: [], afterAll: [], afterEach: [] },
        );
      }
    },
  },
  /**
   * Global setup fixture plugin definition
   * @type {PluginDefinition}
   */
  {
    exportName: "mochaGlobalSetup",
    optionName: "globalSetup",
    validate(value) {
      let isValid = true;
      if (Array.isArray(value)) {
        if (value.some((item) => typeof item !== "function")) {
          isValid = false;
        }
      } else if (typeof value !== "function") {
        isValid = false;
      }
      if (!isValid) {
        throw createInvalidPluginImplementationError(
          `mochaGlobalSetup must be a function or an array of functions`,
          { pluginDef: this, pluginImpl: value },
        );
      }
    },
  },
  /**
   * Global teardown fixture plugin definition
   * @type {PluginDefinition}
   */
  {
    exportName: "mochaGlobalTeardown",
    optionName: "globalTeardown",
    validate(value) {
      let isValid = true;
      if (Array.isArray(value)) {
        if (value.some((item) => typeof item !== "function")) {
          isValid = false;
        }
      } else if (typeof value !== "function") {
        isValid = false;
      }
      if (!isValid) {
        throw createInvalidPluginImplementationError(
          `mochaGlobalTeardown must be a function or an array of functions`,
          { pluginDef: this, pluginImpl: value },
        );
      }
    },
  },
];

/**
 * Contains a registry of [plugin definitions]{@link PluginDefinition} and discovers plugin implementations in user-supplied code.
 *
 * - [load()]{@link #load} should be called for all required modules
 * - The result of [finalize()]{@link #finalize} should be merged into the options for the [Mocha]{@link Mocha} constructor.
 * @private
 */
class PluginLoader {
  /**
   * Initializes plugin names, plugin map, etc.
   * @param {PluginLoaderOptions} [opts] - Options
   */
  constructor({ pluginDefs = MochaPlugins, ignore = [] } = {}) {
    /**
     * Map of registered plugin defs
     * @type {Map<string,PluginDefinition>}
     */
    this.registered = new Map();

    /**
     * Cache of known `optionName` values for checking conflicts
     * @type {Set<string>}
     */
    this.knownOptionNames = new Set();

    /**
     * Cache of known `exportName` values for checking conflicts
     * @type {Set<string>}
     */
    this.knownExportNames = new Set();

    /**
     * Map of user-supplied plugin implementations
     * @type {Map<string,Array<*>>}
     */
    this.loaded = new Map();

    /**
     * Set of ignored plugins by export name
     * @type {Set<string>}
     */
    this.ignoredExportNames = new Set(castArray(ignore));

    castArray(pluginDefs).forEach((pluginDef) => {
      this.register(pluginDef);
    });

    debug(
      "registered %d plugin defs (%d ignored)",
      this.registered.size,
      this.ignoredExportNames.size,
    );
  }

  /**
   * Register a plugin
   * @param {PluginDefinition} pluginDef - Plugin definition
   */
  register(pluginDef) {
    if (!pluginDef || typeof pluginDef !== "object") {
      throw createInvalidPluginDefinitionError(
        "pluginDef is non-object or falsy",
        pluginDef,
      );
    }
    if (!pluginDef.exportName) {
      throw createInvalidPluginDefinitionError(
        `exportName is expected to be a non-empty string`,
        pluginDef,
      );
    }
    let { exportName } = pluginDef;
    if (this.ignoredExportNames.has(exportName)) {
      debug(
        'refusing to register ignored plugin with export name "%s"',
        exportName,
      );
      return;
    }
    exportName = String(exportName);
    pluginDef.optionName = String(pluginDef.optionName || exportName);
    if (this.knownExportNames.has(exportName)) {
      throw createInvalidPluginDefinitionError(
        `Plugin definition conflict: ${exportName}; exportName must be unique`,
        pluginDef,
      );
    }
    this.loaded.set(exportName, []);
    this.registered.set(exportName, pluginDef);
    this.knownExportNames.add(exportName);
    this.knownOptionNames.add(pluginDef.optionName);
    debug('registered plugin def "%s"', exportName);
  }

  /**
   * Inspects a module's exports for known plugins and keeps them in memory.
   *
   * @param {*} requiredModule - The exports of a module loaded via `--require`
   * @returns {boolean} If one or more plugins was found, return `true`.
   */
  load(requiredModule) {
    // we should explicitly NOT fail if other stuff is exported.
    // we only care about the plugins we know about.
    if (requiredModule && typeof requiredModule === "object") {
      return Array.from(this.knownExportNames).reduce(
        (pluginImplFound, pluginName) => {
          const pluginImpl = requiredModule[pluginName];
          if (pluginImpl) {
            const plugin = this.registered.get(pluginName);
            if (typeof plugin.validate === "function") {
              plugin.validate(pluginImpl);
            }
            this.loaded.set(pluginName, [
              ...this.loaded.get(pluginName),
              ...castArray(pluginImpl),
            ]);
            return true;
          }
          return pluginImplFound;
        },
        false,
      );
    }
    return false;
  }

  /**
   * Call the `finalize()` function of each known plugin definition on the plugins found by [load()]{@link PluginLoader#load}.
   *
   * Output suitable for passing as input into {@link Mocha} constructor.
   * @returns {Promise<object>} Object having keys corresponding to registered plugin definitions' `optionName` prop (or `exportName`, if none), and the values are the implementations as provided by a user.
   */
  async finalize() {
    const finalizedPlugins = Object.create(null);

    for await (const [exportName, pluginImpls] of this.loaded.entries()) {
      if (pluginImpls.length) {
        const plugin = this.registered.get(exportName);
        finalizedPlugins[plugin.optionName] =
          typeof plugin.finalize === "function"
            ? await plugin.finalize(pluginImpls)
            : pluginImpls;
      }
    }

    debug("finalized plugins: %O", finalizedPlugins);
    return finalizedPlugins;
  }

  /**
   * Constructs a {@link PluginLoader}
   * @param {PluginLoaderOptions} [opts] - Plugin loader options
   */
  static create({ pluginDefs = MochaPlugins, ignore = [] } = {}) {
    return new PluginLoader({ pluginDefs, ignore });
  }
}

module.exports = PluginLoader;