plugin-loader.js

/**
 * 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');

/**
 * 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;

/**
 * Options for {@link PluginLoader}
 * @typedef {Object} PluginLoaderOptions
 * @property {PluginDefinition[]} [pluginDefs] - Plugin definitions
 * @property {string[]} [ignore] - A list of plugins to ignore when loading
 */