Skip to content

Commit

Permalink
add Root Hook Plugins
Browse files Browse the repository at this point in the history
(documentation will be in another PR)

Adds "root hook plugins", a system to define root hooks via files loaded with `--require`.

This enables root hooks to work in parallel mode.  Because parallel mode runs files in a non-deterministic order, and files do not share a `Mocha` instance, it is not possible to share these hooks with other test files.  This change also works well with third-party libraries for Mocha which need the behavior; these can now be trivially consumed by adding `--require` or `require: 'some-library'` in Mocha's config file.

The way it works is:

1. When a file is loaded via `--require`, we check to see if that file exports a property named `mochaHooks` (can be multiple files).
1. If it does, we save a reference to the property.
1. After Yargs' validation phase, we use async middleware to execute root hook plugin functions--or if they are objects, just collect them--and we flatten all hooks found into four buckets corresponding to the four hook types.
1. Once `Mocha` is instantiated, if it is given a `rootHooks` option, those hooks are applied to the root suite.

This works with parallel tests because we can save a reference to the flattened hooks in each worker process, and a new `Mocha` instance is created with them for each test file.

* * *

Tangential:

- Because a root hook plugin can be defined as an `async` function, I noticed that `utils.type()` does not return `function` for async functions; it returns `asyncfunction`.  I've added a (Node-specific, for now) test for this.

- `handleRequires` is now `async`, since it will need to be anyway to support ESM and calls to `import()`.

- fixed incorrect call to `fs.existsSync()`

Ref: #4198
  • Loading branch information
boneskull committed Apr 22, 2020
1 parent 38d579a commit 93f5bf9
Show file tree
Hide file tree
Showing 12 changed files with 431 additions and 15 deletions.
61 changes: 51 additions & 10 deletions lib/cli/run-helpers.js
Expand Up @@ -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)
Expand Down Expand Up @@ -73,20 +73,60 @@ exports.list = str =>
Array.isArray(str) ? exports.list(str.join(',')) : str.split(/ *, */);

/**
* `require()` the modules as required by `--require <require>`
* `require()` the modules as required by `--require <require>`.
*
* Returns array of `mochaHooks` exports, if any.
* @param {string[]} requires - Modules to require
* @returns {Promise<MochaRootHookObject|MochaRootHookFunction>} 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<MochaRootHookObject|MochaRootHookFunction>} 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: []}
);
};

/**
Expand All @@ -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);
};
Expand Down
10 changes: 8 additions & 2 deletions lib/cli/run.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions lib/mocha.js
Expand Up @@ -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 || {});
Expand Down Expand Up @@ -136,6 +138,10 @@ function Mocha(options) {
this[opt]();
}
}, this);

if (options.rootHooks) {
this.rootHooks(options.rootHooks);
}
}

/**
Expand Down Expand Up @@ -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<MochaRootHookObject>}
*/
@@ -0,0 +1,16 @@
'use strict';

exports.mochaHooks = {
beforeAll() {
console.log('beforeAll');
},
beforeEach() {
console.log('beforeEach');
},
afterAll() {
console.log('afterAll');
},
afterEach() {
console.log('afterEach');
}
};
@@ -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');
}
]
};
@@ -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');
}
});
@@ -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');
}
]
});
@@ -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
});
@@ -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
});

0 comments on commit 93f5bf9

Please sign in to comment.