/** * 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} */ this.registered = new Map(); /** * Cache of known `optionName` values for checking conflicts * @type {Set} */ this.knownOptionNames = new Set(); /** * Cache of known `exportName` values for checking conflicts * @type {Set} */ this.knownExportNames = new Set(); /** * Map of user-supplied plugin implementations * @type {Map>} */ this.loaded = new Map(); /** * Set of ignored plugins by export name * @type {Set} */ 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 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 */