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