diff --git a/lib/cli/run-helpers.js b/lib/cli/run-helpers.js index 72823c48f6..6ed57a7797 100644 --- a/lib/cli/run-helpers.js +++ b/lib/cli/run-helpers.js @@ -12,8 +12,8 @@ const path = require('path'); const debug = require('debug')('mocha:cli:run:helpers'); const watchRun = require('./watch-run'); const collectFiles = require('./collect-files'); - -const cwd = (exports.cwd = process.cwd()); +const {type} = require('../utils'); +const {createUnsupportedError} = require('../errors'); /** * Exits Mocha when tests + code under test has finished execution (default) @@ -73,20 +73,60 @@ exports.list = str => Array.isArray(str) ? exports.list(str.join(',')) : str.split(/ *, */); /** - * `require()` the modules as required by `--require ` + * `require()` the modules as required by `--require `. + * + * Returns array of `mochaHooks` exports, if any. * @param {string[]} requires - Modules to require + * @returns {Promise} Any root hooks * @private */ -exports.handleRequires = (requires = []) => { - requires.forEach(mod => { +exports.handleRequires = async (requires = []) => + requires.reduce((acc, mod) => { let modpath = mod; - if (fs.existsSync(mod, {cwd}) || fs.existsSync(`${mod}.js`, {cwd})) { + // this is relative to cwd + if (fs.existsSync(mod) || fs.existsSync(`${mod}.js`)) { modpath = path.resolve(mod); - debug(`resolved ${mod} to ${modpath}`); + debug('resolved required file %s to %s', mod, modpath); } - require(modpath); - debug(`loaded require "${mod}"`); - }); + const requiredModule = require(modpath); + if (type(requiredModule) === 'object' && requiredModule.mochaHooks) { + const mochaHooksType = type(requiredModule.mochaHooks); + if (/function$/.test(mochaHooksType) || mochaHooksType === 'object') { + debug('found root hooks in required file %s', mod); + acc.push(requiredModule.mochaHooks); + } else { + throw createUnsupportedError( + 'mochaHooks must be an object or a function returning (or fulfilling with) an object' + ); + } + } + debug('loaded required file %s', mod); + return acc; + }, []); + +/** + * 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 + * @private + * @returns {MochaRootHookObject} + */ +exports.loadRootHooks = async rootHooks => { + const rootHookObjects = await Promise.all( + rootHooks.map(async hook => (/function$/.test(type(hook)) ? hook() : hook)) + ); + + return rootHookObjects.reduce( + (acc, hook) => { + acc.beforeAll = acc.beforeAll.concat(hook.beforeAll || []); + acc.beforeEach = acc.beforeEach.concat(hook.beforeEach || []); + acc.afterAll = acc.afterAll.concat(hook.afterAll || []); + acc.afterEach = acc.afterEach.concat(hook.afterEach || []); + return acc; + }, + {beforeAll: [], beforeEach: [], afterAll: [], afterEach: []} + ); }; /** @@ -104,6 +144,7 @@ const singleRun = async (mocha, {exit}, fileCollectParams) => { debug('running tests with files', files); mocha.files = files; + // handles ESM modules await mocha.loadFilesAsync(); return mocha.run(exit ? exitMocha : exitMochaLater); }; diff --git a/lib/cli/run.js b/lib/cli/run.js index d024cbb0f2..36463a4b6f 100644 --- a/lib/cli/run.js +++ b/lib/cli/run.js @@ -18,6 +18,7 @@ const { list, handleRequires, validatePlugin, + loadRootHooks, runMocha } = require('./run-helpers'); const {ONE_AND_DONES, ONE_AND_DONE_ARGS} = require('./one-and-dones'); @@ -285,12 +286,17 @@ exports.builder = yargs => ); } + return true; + }) + .middleware(async argv => { // load requires first, because it can impact "plugin" validation - handleRequires(argv.require); + const rawRootHooks = await handleRequires(argv.require); validatePlugin(argv, 'reporter', Mocha.reporters); validatePlugin(argv, 'ui', Mocha.interfaces); - return true; + if (rawRootHooks.length) { + argv.rootHooks = await loadRootHooks(argv.rawRootHooks); + } }) .array(types.array) .boolean(types.boolean) diff --git a/lib/mocha.js b/lib/mocha.js index 017daa1e2c..56992ddf1d 100644 --- a/lib/mocha.js +++ b/lib/mocha.js @@ -90,6 +90,8 @@ exports.Test = require('./test'); * @param {number} [options.slow] - Slow threshold value. * @param {number|string} [options.timeout] - Timeout threshold value. * @param {string} [options.ui] - Interface name. + * @param {MochaRootHookObject} [options.rootHooks] - Hooks to bootstrap the root + * suite with */ function Mocha(options) { options = utils.assign({}, mocharc, options || {}); @@ -136,6 +138,10 @@ function Mocha(options) { this[opt](); } }, this); + + if (options.rootHooks) { + this.rootHooks(options.rootHooks); + } } /** @@ -866,3 +872,46 @@ Mocha.prototype.run = function(fn) { return runner.run(done); }; + +/** + * Assigns hooks to the root suite + * @param {MochaRootHookObject} [hooks] - Hooks to assign to root suite + * @chainable + */ +Mocha.prototype.rootHooks = function rootHooks(hooks) { + if (utils.type(hooks) === 'object') { + var beforeAll = [].concat(hooks.beforeAll || []); + var beforeEach = [].concat(hooks.beforeEach || []); + var afterAll = [].concat(hooks.afterAll || []); + var afterEach = [].concat(hooks.afterEach || []); + var rootSuite = this.suite; + beforeAll.forEach(function(hook) { + rootSuite.beforeAll(hook); + }); + beforeEach.forEach(function(hook) { + rootSuite.beforeEach(hook); + }); + afterAll.forEach(function(hook) { + rootSuite.afterAll(hook); + }); + afterEach.forEach(function(hook) { + rootSuite.afterEach(hook); + }); + } + return this; +}; + +/** + * An alternative way to define root hooks that works with parallel runs. + * @typedef {Object} MochaRootHookObject + * @property {Function|Function[]} [beforeAll] - "Before all" hook(s) + * @property {Function|Function[]} [beforeEach] - "Before each" hook(s) + * @property {Function|Function[]} [afterAll] - "After all" hook(s) + * @property {Function|Function[]} [afterEach] - "After each" hook(s) + */ + +/** + * An function that returns a {@link MochaRootHookObject}, either sync or async. + * @callback MochaRootHookFunction + * @returns {MochaRootHookObject|Promise} + */ diff --git a/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js new file mode 100644 index 0000000000..8938816eee --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js @@ -0,0 +1,16 @@ +'use strict'; + +exports.mochaHooks = { + beforeAll() { + console.log('beforeAll'); + }, + beforeEach() { + console.log('beforeEach'); + }, + afterAll() { + console.log('afterAll'); + }, + afterEach() { + console.log('afterEach'); + } +}; diff --git a/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js new file mode 100644 index 0000000000..6aa0ed3342 --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js @@ -0,0 +1,36 @@ +'use strict'; + +exports.mochaHooks = { + beforeAll: [ + function() { + console.log('beforeAll array 1'); + }, + function() { + console.log('beforeAll array 2'); + } + ], + beforeEach: [ + function() { + console.log('beforeEach array 1'); + }, + function() { + console.log('beforeEach array 2'); + } + ], + afterAll: [ + function() { + console.log('afterAll array 1'); + }, + function() { + console.log('afterAll array 2'); + } + ], + afterEach: [ + function() { + console.log('afterEach array 1'); + }, + function() { + console.log('afterEach array 2'); + } + ] +}; diff --git a/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js new file mode 100644 index 0000000000..624973de75 --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js @@ -0,0 +1,16 @@ +'use strict'; + +exports.mochaHooks = async () => ({ + beforeAll() { + console.log('beforeAll'); + }, + beforeEach() { + console.log('beforeEach'); + }, + afterAll() { + console.log('afterAll'); + }, + afterEach() { + console.log('afterEach'); + } +}); diff --git a/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js new file mode 100644 index 0000000000..d073a35fcf --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js @@ -0,0 +1,36 @@ +'use strict'; + +exports.mochaHooks = async() => ({ + beforeAll: [ + function() { + console.log('beforeAll array 1'); + }, + function() { + console.log('beforeAll array 2'); + } + ], + beforeEach: [ + function() { + console.log('beforeEach array 1'); + }, + function() { + console.log('beforeEach array 2'); + } + ], + afterAll: [ + function() { + console.log('afterAll array 1'); + }, + function() { + console.log('afterAll array 2'); + } + ], + afterEach: [ + function() { + console.log('afterEach array 1'); + }, + function() { + console.log('afterEach array 2'); + } + ] +}); diff --git a/test/integration/fixtures/options/require/root-hook-test-2.fixture.js b/test/integration/fixtures/options/require/root-hook-test-2.fixture.js new file mode 100644 index 0000000000..4d00018d2c --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-test-2.fixture.js @@ -0,0 +1,6 @@ +// run with --require root-hook-defs-a.fixture.js --require +// root-hook-defs-b.fixture.js + +it('should also have some root hooks', function() { + // test +}); \ No newline at end of file diff --git a/test/integration/fixtures/options/require/root-hook-test.fixture.js b/test/integration/fixtures/options/require/root-hook-test.fixture.js new file mode 100644 index 0000000000..412895c87c --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-test.fixture.js @@ -0,0 +1,6 @@ +// run with --require root-hook-defs-a.fixture.js --require +// root-hook-defs-b.fixture.js + +it('should have some root hooks', function() { + // test +}); \ No newline at end of file diff --git a/test/integration/options/require.spec.js b/test/integration/options/require.spec.js new file mode 100644 index 0000000000..7e3204143e --- /dev/null +++ b/test/integration/options/require.spec.js @@ -0,0 +1,122 @@ +'use strict'; + +var invokeMochaAsync = require('../helpers').invokeMochaAsync; + +describe('--require', function() { + describe('when mocha run in serial mode', function() { + it('should run root hooks when provided via mochaHooks object export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + it('should run root hooks when provided via mochaHooks function export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-c.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-d.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + }); + + describe('when mocha in parallel mode', function() { + it('should run root hooks when provided via mochaHooks object exports', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + it('should run root hooks when provided via mochaHooks function export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-c.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-d.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + describe('when running multiple jobs', function() { + it('should run root hooks when provided via mochaHooks object exports for each job', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test-2.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /(?:beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2[\s\S]+?){2}/ + ); + }); + }); + }); +}); diff --git a/test/node-unit/cli/run-helpers.spec.js b/test/node-unit/cli/run-helpers.spec.js index a2a63335f5..55ffc3fc73 100644 --- a/test/node-unit/cli/run-helpers.spec.js +++ b/test/node-unit/cli/run-helpers.spec.js @@ -1,6 +1,10 @@ 'use strict'; -const {validatePlugin, list} = require('../../../lib/cli/run-helpers'); +const { + validatePlugin, + list, + loadRootHooks +} = require('../../../lib/cli/run-helpers'); const {createSandbox} = require('sinon'); describe('cli "run" command', function() { @@ -15,8 +19,69 @@ describe('cli "run" command', function() { }); describe('helpers', function() { - describe('validatePlugin()', function() { - it('should disallow an array of module names', function() { + describe('loadRootHooks()', function() { + describe('when passed nothing', function() { + it('should reject', async function() { + return expect(loadRootHooks(), 'to be rejected'); + }); + }); + + describe('when passed empty array of hooks', function() { + it('should return an empty MochaRootHooks object', async function() { + return expect(loadRootHooks([]), '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() {} + function f() {} + function c() { + return { + beforeAll: d, + beforeEach: g + }; + } + async function e() { + return { + afterEach: f + }; + } + return expect( + loadRootHooks([ + { + beforeEach: a + }, + { + afterAll: b + }, + c, + e + ]), + 'to be fulfilled with', + { + beforeAll: [d], + beforeEach: [a, g], + afterAll: [b], + afterEach: [f] + } + ); + }); + }); + }); + }); + + describe('validatePlugin()', function() { + describe('when used with "reporter" key', function() { + it('should disallow an array of names', function() { expect( () => validatePlugin({foo: ['bar']}, 'foo'), 'to throw a', diff --git a/test/node-unit/utils.spec.js b/test/node-unit/utils.spec.js new file mode 100644 index 0000000000..489052cba4 --- /dev/null +++ b/test/node-unit/utils.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +const utils = require('../../lib/utils'); + +describe('utils', function() { + describe('function', function() { + describe('type', function() { + it('should return "asyncfunction" if the parameter is an async function', function() { + expect( + utils.type(async () => {}), + 'to be', + 'asyncfunction' + ); + }); + }); + }); +});