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