| /** |
| * @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 |
| */ |
| |
| import debugModule from "debug"; |
| import { |
| createInvalidPluginDefinitionError, |
| createInvalidPluginImplementationError, |
| } from "./errors.js"; |
| import { castArray } from "./utils.cjs"; |
| const debug = debugModule("mocha:plugin-loader"); |
| |
| /** |
| * @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 }); |
| } |
| } |
| |
| export { PluginLoader }; |