diff --git a/lib/cli/run-helpers.js b/lib/cli/run-helpers.js index 2bb674dcaf..984faf53a5 100644 --- a/lib/cli/run-helpers.js +++ b/lib/cli/run-helpers.js @@ -85,7 +85,7 @@ exports.list = str => */ exports.handleRequires = async (requires = []) => { const pluginLoader = PluginLoader.create(); - for (const mod of requires) { + for await (const mod of requires) { let modpath = mod; // this is relative to cwd if (fs.existsSync(mod) || fs.existsSync(`${mod}.js`)) { diff --git a/lib/nodejs/parallel-buffered-runner.js b/lib/nodejs/parallel-buffered-runner.js index ee8635ab98..9e7375e96c 100644 --- a/lib/nodejs/parallel-buffered-runner.js +++ b/lib/nodejs/parallel-buffered-runner.js @@ -235,6 +235,8 @@ class ParallelBufferedRunner extends Runner { this.emit(EVENT_RUN_BEGIN); + await this.runGlobalSetup(); + const results = await allSettled( files.map(this._createFileRunner(pool, options)) ); @@ -257,6 +259,9 @@ class ParallelBufferedRunner extends Runner { if (this._state === ABORTING) { return; } + + await this.runGlobalTeardown(); + this.emit(EVENT_RUN_END); debug('run(): completing with failure count %d', this.failures); callback(this.failures); diff --git a/lib/nodejs/worker.js b/lib/nodejs/worker.js index 9cd5139bc5..6db9540cc3 100644 --- a/lib/nodejs/worker.js +++ b/lib/nodejs/worker.js @@ -12,7 +12,7 @@ const { } = require('../errors'); const workerpool = require('workerpool'); const Mocha = require('../mocha'); -const {handleRequires, validatePlugin} = require('../cli/run-helpers'); +const {handleRequires, validateLegacyPlugin} = require('../cli/run-helpers'); const d = require('debug'); const debug = d.debug(`mocha:parallel:worker:${process.pid}`); const isDebugEnabled = d.enabled(`mocha:parallel:worker:${process.pid}`); @@ -42,7 +42,7 @@ if (workerpool.isMainThread) { */ let bootstrap = async argv => { const plugins = await handleRequires(argv.require); - validatePlugin(argv, 'ui', Mocha.interfaces); + validateLegacyPlugin(argv, 'ui', Mocha.interfaces); // globalSetup and globalTeardown do not run in workers argv.rootHooks = plugins.rootHooks; diff --git a/lib/plugin.js b/lib/plugin.js index 5e6ab9e53f..1832e1f5c0 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -8,31 +8,45 @@ const constants = (exports.constants = defineConstants({ PLUGIN_GLOBAL_SETUP: 'mochaGlobalSetup', PLUGIN_GLOBAL_TEARDOWN: 'mochaGlobalTeardown' })); +const { + PLUGIN_ROOT_HOOKS, + PLUGIN_GLOBAL_SETUP, + PLUGIN_GLOBAL_TEARDOWN +} = constants; +const PLUGIN_NAMES = new Set(Object.values(constants)); exports.PluginLoader = class PluginLoader { - constructor() { - this.pluginMap = new Map([ - [constants.PLUGIN_ROOT_HOOKS, []], - [constants.PLUGIN_GLOBAL_SETUP, []], - [constants.PLUGIN_GLOBAL_TEARDOWN, []] - ]); + constructor({ + pluginNames = PLUGIN_NAMES, + pluginValidators = PluginValidators + } = {}) { + this.pluginNames = Array.from(pluginNames); + this.pluginMap = new Map( + this.pluginNames.map(pluginName => [pluginName, []]) + ); + this.pluginValidators = pluginValidators; } 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') { - PLUGIN_TYPES.forEach(pluginType => { - const plugin = requiredModule[pluginType]; + return this.pluginNames.reduce((isFound, pluginName) => { + const plugin = requiredModule[pluginName]; if (plugin) { - PluginValidators[pluginType](plugin); - this.pluginMap.set(pluginType, [ - ...this.pluginMap.get(pluginType), + if (this.pluginValidators[pluginName]) { + this.pluginValidators[pluginName](plugin); + } + this.pluginMap.set(pluginName, [ + ...this.pluginMap.get(pluginName), ...castArray(plugin) ]); + return true; } - }); + return isFound; + }, false); } + return false; } async finalize() { @@ -55,18 +69,11 @@ exports.PluginLoader = class PluginLoader { return finalizedPlugins; } - static create() { - return new PluginLoader(); + static create(...args) { + return new PluginLoader(...args); } }; -const PLUGIN_TYPES = new Set(Object.values(constants)); -const { - PLUGIN_ROOT_HOOKS, - PLUGIN_GLOBAL_SETUP, - PLUGIN_GLOBAL_TEARDOWN -} = constants; - const createFunctionArrayValidator = pluginType => value => { let isValid = true; if (Array.isArray(value)) { @@ -102,22 +109,34 @@ const PluginValidators = { * Loads root hooks as exported via `mochaHooks` from required files. * These can be sync/async functions returning objects, or just objects. * Flattens to a single object. - * @param {Array} rootHooks - Array of root hooks + * @param {MochaRootHookObject[]|MochaRootHookFunction[]} rootHooks - Array of root hooks * @private * @returns {MochaRootHookObject} */ -const aggregateRootHooks = async rootHooks => { +const aggregateRootHooks = (exports.aggregateRootHooks = async ( + rootHooks = [] +) => { const rootHookObjects = await Promise.all( rootHooks.map(async hook => (typeof hook === 'function' ? hook() : hook)) ); + console.dir(rootHookObjects); return rootHookObjects.reduce( - (acc, hook) => ({ - beforeAll: [...acc.beforeAll, ...(hook.beforeAll || [])], - beforeEach: [...acc.beforeEach, ...(hook.beforeEach || [])], - afterAll: [...acc.afterAll, ...(hook.afterAll || [])], - afterEach: [...acc.afterEach, ...(hook.afterEach || [])] - }), + (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: []} ); -}; +}); diff --git a/lib/runner.js b/lib/runner.js index f4d70f9da0..d98f7e21a5 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -982,18 +982,48 @@ Runner.prototype._uncaught = function(err) { } }; -Runner.prototype.runGlobal = function runGlobal(globalFns, events) { - var context = (this._globalContext = this._globalContext || {}); - var self = this; - return globalFns.reduce(async (promise, globalFn) => { +Runner.prototype.runGlobalSetup = async function runGlobalSetup() { + this.emit(constants.EVENT_PRE_GLOBAL_SETUP); + const {globalSetup} = this._opts; + if (globalSetup) { + debug('run(): global setup starting'); + await this.runGlobalFixtures(globalSetup, { + begin: constants.EVENT_GLOBAL_SETUP_BEGIN, + end: constants.EVENT_GLOBAL_SETUP_END + }); + } + debug('run(): global setup complete'); + this.emit(constants.EVENT_POST_GLOBAL_SETUP); +}; + +Runner.prototype.runGlobalTeardown = async function runGlobalTeardown() { + this.emit(constants.EVENT_PRE_GLOBAL_TEARDOWN); + const {globalTeardown} = this._opts; + if (globalTeardown) { + debug('run(): global teardown starting'); + await this.runGlobalFixtures(globalTeardown, { + begin: constants.EVENT_GLOBAL_TEARDOWN_BEGIN, + end: constants.EVENT_GLOBAL_TEARDOWN_END + }); + } + debug('run(): global teardown complete'); + this.emit(constants.EVENT_POST_GLOBAL_TEARDOWN); +}; + +Runner.prototype.runGlobalFixtures = function runGlobalFixtures( + fixtureFns, + events +) { + const context = (this._globalContext = this._globalContext || {}); + return fixtureFns.reduce(async (promise, fixtureFn) => { await promise; - self.emit(events.begin, { - value: globalFn, + this.emit(events.begin, { + value: fixtureFn, context: context }); - const retVal = await globalFn.call(context); - self.emit(events.end, { - value: globalFn, + const retVal = await fixtureFn.call(context); + this.emit(events.end, { + value: fixtureFn, context: context, fulfilled: retVal }); @@ -1010,7 +1040,7 @@ Runner.prototype.runGlobal = function runGlobal(globalFns, events) { * @param {{files: string[], options: Options}} [opts] - For subclasses * @return {Runner} Runner instance. */ -Runner.prototype.run = function(fn, opts) { +Runner.prototype.run = function(fn, opts = {}) { var rootSuite = this.suite; var options = opts.options || {}; @@ -1029,17 +1059,7 @@ Runner.prototype.run = function(fn, opts) { debug('run(): emitted %s', constants.EVENT_RUN_BEGIN); this.runSuite(rootSuite, async () => { - if (options.globalTeardown) { - debug('run(): global teardown starting'); - this.emit(constants.EVENT_PRE_GLOBAL_TEARDOWN); - await this.runGlobal(options.globalTeardown, { - begin: constants.EVENT_GLOBAL_TEARDOWN_BEGIN, - end: constants.EVENT_GLOBAL_TEARDOWN_END - }); - - debug('run(): global teardown complete'); - this.emit(constants.EVENT_POST_GLOBAL_TEARDOWN); - } + await this.runGlobalTeardown(); end(); }); }; @@ -1057,17 +1077,8 @@ Runner.prototype.run = function(fn, opts) { debug('run(): "delay" ended'); } - if (options.globalSetup) { - this.emit(constants.EVENT_PRE_GLOBAL_SETUP); - debug('run(): global setup starting'); - await this.runGlobal(options.globalSetup, { - begin: constants.EVENT_GLOBAL_SETUP_BEGIN, - end: constants.EVENT_GLOBAL_SETUP_END - }); + await this.runGlobalSetup(); - this.emit(constants.EVENT_POST_GLOBAL_SETUP); - debug('run(): global setup complete'); - } return begin(); }; diff --git a/test/node-unit/cli/run-helpers.spec.js b/test/node-unit/cli/run-helpers.spec.js index 79b831b1e8..71122bc403 100644 --- a/test/node-unit/cli/run-helpers.spec.js +++ b/test/node-unit/cli/run-helpers.spec.js @@ -1,76 +1,17 @@ 'use strict'; const { - validatePlugin, + validateLegacyPlugin, list, aggregateRootHooks } = require('../../../lib/cli/run-helpers'); describe('helpers', function() { - describe('aggregateRootHooks()', function() { - describe('when passed nothing', function() { - it('should reject', async function() { - return expect(aggregateRootHooks(), 'to be rejected'); - }); - }); - - describe('when passed empty array of hooks', function() { - it('should return an empty MochaRootHooks object', async function() { - return expect(aggregateRootHooks([]), 'to be fulfilled with', { - beforeAll: [], - beforeEach: [], - afterAll: [], - afterEach: [] - }); - }); - }); - - describe('when passed an array containing hook objects and sync functions and async functions', function() { - it('should flatten them into a single object', async function() { - function a() {} - function b() {} - function d() {} - function g() {} - async function f() {} - function c() { - return { - beforeAll: d, - beforeEach: g - }; - } - async function e() { - return { - afterEach: f - }; - } - return expect( - aggregateRootHooks([ - { - beforeEach: a - }, - { - afterAll: b - }, - c, - e - ]), - 'to be fulfilled with', - { - beforeAll: [d], - beforeEach: [a, g], - afterAll: [b], - afterEach: [f] - } - ); - }); - }); - }); - - describe('validatePlugin()', function() { + describe('validateLegacyPlugin()', function() { describe('when used with "reporter" key', function() { it('should disallow an array of names', function() { expect( - () => validatePlugin({reporter: ['bar']}, 'reporter'), + () => validateLegacyPlugin({reporter: ['bar']}, 'reporter'), 'to throw', { code: 'ERR_MOCHA_INVALID_REPORTER', @@ -81,7 +22,7 @@ describe('helpers', function() { it('should fail to recognize an unknown reporter', function() { expect( - () => validatePlugin({reporter: 'bar'}, 'reporter'), + () => validateLegacyPlugin({reporter: 'bar'}, 'reporter'), 'to throw', {code: 'ERR_MOCHA_INVALID_REPORTER', message: /cannot find module/i} ); @@ -91,7 +32,7 @@ describe('helpers', function() { describe('when used with an "interfaces" key', function() { it('should disallow an array of names', function() { expect( - () => validatePlugin({interface: ['bar']}, 'interface'), + () => validateLegacyPlugin({interface: ['bar']}, 'interface'), 'to throw', { code: 'ERR_MOCHA_INVALID_INTERFACE', @@ -102,7 +43,7 @@ describe('helpers', function() { it('should fail to recognize an unknown interface', function() { expect( - () => validatePlugin({interface: 'bar'}, 'interface'), + () => validateLegacyPlugin({interface: 'bar'}, 'interface'), 'to throw', {code: 'ERR_MOCHA_INVALID_INTERFACE', message: /cannot find module/i} ); @@ -112,7 +53,7 @@ describe('helpers', function() { describe('when used with an unknown plugin type', function() { it('should fail', function() { expect( - () => validatePlugin({frog: 'bar'}, 'frog'), + () => validateLegacyPlugin({frog: 'bar'}, 'frog'), 'to throw', /unknown plugin/i ); @@ -123,7 +64,7 @@ describe('helpers', function() { it('should fail and report the original error', function() { expect( () => - validatePlugin( + validateLegacyPlugin( { reporter: require.resolve('./fixtures/bad-module.fixture.js') }, diff --git a/test/node-unit/worker.spec.js b/test/node-unit/worker.spec.js index 8d46ef4973..715e2e2ffb 100644 --- a/test/node-unit/worker.spec.js +++ b/test/node-unit/worker.spec.js @@ -54,8 +54,8 @@ describe('worker', function() { }; stubs.runHelpers = { - handleRequires: sinon.stub(), - validatePlugin: sinon.stub(), + handleRequires: sinon.stub().resolves({}), + validateLegacyPlugin: sinon.stub(), loadRootHooks: sinon.stub().resolves() }; @@ -155,7 +155,7 @@ describe('worker', function() { await worker.run('some-file.js', serializeJavascript(argv)); expect( - stubs.runHelpers.validatePlugin, + stubs.runHelpers.validateLegacyPlugin, 'to have a call satisfying', [argv, 'ui', stubs.Mocha.interfaces] ).and('was called once'); @@ -204,7 +204,7 @@ describe('worker', function() { expect(stubs.runHelpers, 'to satisfy', { handleRequires: expect.it('was called once'), - validatePlugin: expect.it('was called once') + validateLegacyPlugin: expect.it('was called once') }); }); }); diff --git a/test/unit/plugin.spec.js b/test/unit/plugin.spec.js new file mode 100644 index 0000000000..a249ee7a00 --- /dev/null +++ b/test/unit/plugin.spec.js @@ -0,0 +1,166 @@ +'use strict'; + +const rewiremock = require('rewiremock/node'); +const sinon = require('sinon'); + +describe('plugin module', function() { + describe('PluginLoader', function() { + let PluginLoader; + beforeEach(function() { + PluginLoader = rewiremock.proxy('../../lib/plugin', {}).PluginLoader; + }); + + describe('constructor', function() { + it('should create an empty mapping of active plugins using defaults', function() { + expect(new PluginLoader().pluginMap.get('mochaHooks'), 'to equal', []); + }); + + it('should accept an explicit list of plugin named exports', function() { + expect( + new PluginLoader({pluginNames: ['mochaBananaPhone']}).pluginMap.get( + 'mochaBananaPhone' + ), + 'to equal', + [] + ); + }); + + it('should accept an explicit object of plugin validators', function() { + const pluginValidators = {mochaBananaPhone: () => {}}; + expect( + new PluginLoader({pluginValidators}).pluginValidators, + 'to be', + pluginValidators + ); + }); + }); + + describe('static method', function() { + describe('create()', function() { + it('should return a PluginLoader instance', function() { + expect(PluginLoader.create(), 'to be a', PluginLoader); + }); + }); + }); + + describe('instance method', function() { + describe('load()', function() { + let pluginLoader; + + beforeEach(function() { + pluginLoader = PluginLoader.create(); + }); + describe('when called with a falsy value', function() { + it('should return false', function() { + expect(pluginLoader.load(), 'to be false'); + }); + }); + + describe('when called with an object containing no recognized plugin', function() { + it('should return false', function() { + expect( + pluginLoader.load({mochaBananaPhone: () => {}}), + 'to be false' + ); + }); + }); + + describe('when called with an object containing a recognized plugin', function() { + let pluginLoader; + let pluginValidators; + beforeEach(function() { + pluginValidators = {mochaBananaPhone: sinon.spy()}; + pluginLoader = PluginLoader.create({ + pluginNames: ['mochaBananaPhone'], + pluginValidators + }); + }); + + it('should return true', function() { + const func = () => {}; + expect(pluginLoader.load({mochaBananaPhone: func}), 'to be true'); + }); + + it('should retain the value of any matching property in its mapping', function() { + const func = () => {}; + pluginLoader.load({mochaBananaPhone: func}); + expect(pluginLoader.pluginMap.get('mochaBananaPhone'), 'to equal', [ + func + ]); + }); + + it('should call the associated validator, if present', function() { + const func = () => {}; + pluginLoader.load({mochaBananaPhone: func}); + expect(pluginValidators.mochaBananaPhone, 'was called once'); + }); + }); + }); + }); + }); + + describe('aggregateRootHooks()', function() { + let aggregateRootHooks; + beforeEach(function() { + aggregateRootHooks = rewiremock.proxy('../../lib/plugin', {}) + .aggregateRootHooks; + }); + + describe('when passed nothing', function() { + it('should not reject', async function() { + return expect(aggregateRootHooks(), 'to be fulfilled'); + }); + }); + + describe('when passed empty array of hooks', function() { + it('should return an empty MochaRootHooks object', async function() { + return expect(aggregateRootHooks([]), 'to be fulfilled with', { + beforeAll: [], + beforeEach: [], + afterAll: [], + afterEach: [] + }); + }); + }); + + describe('when passed an array containing hook objects and sync functions and async functions', function() { + it('should flatten them into a single object', async function() { + function a() {} + function b() {} + function d() {} + function g() {} + async function f() {} + function c() { + return { + beforeAll: d, + beforeEach: g + }; + } + async function e() { + return { + afterEach: f + }; + } + return expect( + aggregateRootHooks([ + { + beforeEach: a + }, + { + afterAll: b + }, + c, + e + ]), + 'to be fulfilled with', + { + beforeAll: [d], + beforeEach: [a, g], + afterAll: [b], + afterEach: [f] + } + ); + }); + }); + }); +}); diff --git a/test/unit/runner.spec.js b/test/unit/runner.spec.js index 28a152fc1e..bed0c1f4e5 100644 --- a/test/unit/runner.spec.js +++ b/test/unit/runner.spec.js @@ -21,6 +21,16 @@ var STATE_FAILED = Runnable.constants.STATE_FAILED; var STATE_IDLE = Runner.constants.STATE_IDLE; var STATE_RUNNING = Runner.constants.STATE_RUNNING; var STATE_STOPPED = Runner.constants.STATE_STOPPED; +const { + EVENT_PRE_GLOBAL_SETUP, + EVENT_POST_GLOBAL_SETUP, + EVENT_PRE_GLOBAL_TEARDOWN, + EVENT_POST_GLOBAL_TEARDOWN, + EVENT_GLOBAL_SETUP_BEGIN, + EVENT_GLOBAL_SETUP_END, + EVENT_GLOBAL_TEARDOWN_BEGIN, + EVENT_GLOBAL_TEARDOWN_END +} = Runner.constants; describe('Runner', function() { var suite; @@ -542,11 +552,122 @@ describe('Runner', function() { done(); }); + describe('global fixtures', function() { + beforeEach(function() { + sinon.stub(runner, 'runGlobalSetup'); + sinon.stub(runner, 'runGlobalTeardown'); + }); + + it('it should run global setup', function(done) { + runner.run(() => { + expect(runner.runGlobalSetup, 'was called once'); + done(); + }); + }); + + it('should run global teardown', function(done) { + runner.run(() => { + expect(runner.runGlobalTeardown, 'was called once'); + done(); + }); + }); + }); + afterEach(function() { runner.dispose(); }); }); + describe('runGlobalSetup()', function() { + beforeEach(function() { + sinon.stub(runner, 'runGlobalFixtures').resolves(); + }); + + describe('when a fixture is present', function() { + beforeEach(function() { + runner._opts.globalSetup = sinon.spy(); + }); + + it('should call runGlobalFixtures()', async function() { + await runner.runGlobalSetup(); + expect(runner.runGlobalFixtures, 'to have a call satisfying', [ + runner._opts.globalSetup, + {begin: EVENT_GLOBAL_SETUP_BEGIN, end: EVENT_GLOBAL_SETUP_END} + ]); + }); + }); + + describe('when a fixture is not present', function() { + it('should not call runGlobalFixtures()', async function() { + await runner.runGlobalSetup(); + expect(runner.runGlobalFixtures, 'was not called'); + }); + }); + + it('should emit EVENT_PRE_GLOBAL_SETUP', async function() { + return expect( + async () => runner.runGlobalSetup(), + 'to emit from', + runner, + EVENT_PRE_GLOBAL_SETUP + ); + }); + + it('should emit EVENT_POST_GLOBAL_SETUP', async function() { + return expect( + async () => runner.runGlobalSetup(), + 'to emit from', + runner, + EVENT_POST_GLOBAL_SETUP + ); + }); + }); + + describe('runGlobalTeardown()', function() { + beforeEach(function() { + sinon.stub(runner, 'runGlobalFixtures').resolves(); + }); + + describe('when a fixture is present', function() { + beforeEach(function() { + runner._opts.globalTeardown = sinon.spy(); + }); + + it('should call runGlobalFixtures()', async function() { + await runner.runGlobalTeardown(); + expect(runner.runGlobalFixtures, 'to have a call satisfying', [ + runner._opts.globalTeardown, + {begin: EVENT_GLOBAL_TEARDOWN_BEGIN, end: EVENT_GLOBAL_TEARDOWN_END} + ]); + }); + }); + + describe('when a fixture is not present', function() { + it('should not call runGlobalFixtures()', async function() { + await runner.runGlobalTeardown(); + expect(runner.runGlobalFixtures, 'was not called'); + }); + }); + + it('should emit EVENT_PRE_GLOBAL_TEARDOWN', async function() { + return expect( + async () => runner.runGlobalTeardown(), + 'to emit from', + runner, + EVENT_PRE_GLOBAL_TEARDOWN + ); + }); + + it('should emit EVENT_POST_GLOBAL_TEARDOWN', async function() { + return expect( + async () => runner.runGlobalTeardown(), + 'to emit from', + runner, + EVENT_POST_GLOBAL_TEARDOWN + ); + }); + }); + describe('.dispose', function() { it('should remove all listeners from itself', function() { runner.on('disposeShouldRemoveThis', noop);