Skip to content

Commit 7050d59

Browse files
committed
add Root Hook Plugins
(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
1 parent 1a4646d commit 7050d59

File tree

12 files changed

+423
-14
lines changed

12 files changed

+423
-14
lines changed

lib/cli/run-helpers.js

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ const path = require('path');
1212
const debug = require('debug')('mocha:cli:run:helpers');
1313
const watchRun = require('./watch-run');
1414
const collectFiles = require('./collect-files');
15+
const {type} = require('../utils');
1516
const {format} = require('util');
16-
17-
const cwd = (exports.cwd = process.cwd());
18-
const {createInvalidPluginError} = require('../errors');
17+
const {createInvalidPluginError, createUnsupportedError} = require('../errors');
1918

2019
/**
2120
* Exits Mocha when tests + code under test has finished execution (default)
@@ -75,20 +74,60 @@ exports.list = str =>
7574
Array.isArray(str) ? exports.list(str.join(',')) : str.split(/ *, */);
7675

7776
/**
78-
* `require()` the modules as required by `--require <require>`
77+
* `require()` the modules as required by `--require <require>`.
78+
*
79+
* Returns array of `mochaHooks` exports, if any.
7980
* @param {string[]} requires - Modules to require
81+
* @returns {Promise<MochaRootHookObject|MochaRootHookFunction>} Any root hooks
8082
* @private
8183
*/
82-
exports.handleRequires = (requires = []) => {
83-
requires.forEach(mod => {
84+
exports.handleRequires = async (requires = []) =>
85+
requires.reduce((acc, mod) => {
8486
let modpath = mod;
85-
if (fs.existsSync(mod, {cwd}) || fs.existsSync(`${mod}.js`, {cwd})) {
87+
// this is relative to cwd
88+
if (fs.existsSync(mod) || fs.existsSync(`${mod}.js`)) {
8689
modpath = path.resolve(mod);
87-
debug('resolved %s to %s', mod, modpath);
90+
debug('resolved required file %s to %s', mod, modpath);
91+
}
92+
const requiredModule = require(modpath);
93+
if (type(requiredModule) === 'object' && requiredModule.mochaHooks) {
94+
const mochaHooksType = type(requiredModule.mochaHooks);
95+
if (/function$/.test(mochaHooksType) || mochaHooksType === 'object') {
96+
debug('found root hooks in required file %s', mod);
97+
acc.push(requiredModule.mochaHooks);
98+
} else {
99+
throw createUnsupportedError(
100+
'mochaHooks must be an object or a function returning (or fulfilling with) an object'
101+
);
102+
}
88103
}
89-
require(modpath);
90104
debug('loaded required module "%s"', mod);
91-
});
105+
return acc;
106+
}, []);
107+
108+
/**
109+
* Loads root hooks as exported via `mochaHooks` from required files.
110+
* These can be sync/async functions returning objects, or just objects.
111+
* Flattens to a single object.
112+
* @param {Array<MochaRootHookObject|MochaRootHookFunction>} rootHooks - Array of root hooks
113+
* @private
114+
* @returns {MochaRootHookObject}
115+
*/
116+
exports.loadRootHooks = async rootHooks => {
117+
const rootHookObjects = await Promise.all(
118+
rootHooks.map(async hook => (/function$/.test(type(hook)) ? hook() : hook))
119+
);
120+
121+
return rootHookObjects.reduce(
122+
(acc, hook) => {
123+
acc.beforeAll = acc.beforeAll.concat(hook.beforeAll || []);
124+
acc.beforeEach = acc.beforeEach.concat(hook.beforeEach || []);
125+
acc.afterAll = acc.afterAll.concat(hook.afterAll || []);
126+
acc.afterEach = acc.afterEach.concat(hook.afterEach || []);
127+
return acc;
128+
},
129+
{beforeAll: [], beforeEach: [], afterAll: [], afterEach: []}
130+
);
92131
};
93132

94133
/**
@@ -106,6 +145,7 @@ const singleRun = async (mocha, {exit}, fileCollectParams) => {
106145
debug('single run with %d file(s)', files.length);
107146
mocha.files = files;
108147

148+
// handles ESM modules
109149
await mocha.loadFilesAsync();
110150
return mocha.run(exit ? exitMocha : exitMochaLater);
111151
};

lib/cli/run.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
list,
1919
handleRequires,
2020
validatePlugin,
21+
loadRootHooks,
2122
runMocha
2223
} = require('./run-helpers');
2324
const {ONE_AND_DONES, ONE_AND_DONE_ARGS} = require('./one-and-dones');
@@ -285,12 +286,17 @@ exports.builder = yargs =>
285286
);
286287
}
287288

289+
return true;
290+
})
291+
.middleware(async argv => {
288292
// load requires first, because it can impact "plugin" validation
289-
handleRequires(argv.require);
293+
const rawRootHooks = await handleRequires(argv.require);
290294
validatePlugin(argv, 'reporter', Mocha.reporters);
291295
validatePlugin(argv, 'ui', Mocha.interfaces);
292296

293-
return true;
297+
if (rawRootHooks.length) {
298+
argv.rootHooks = await loadRootHooks(rawRootHooks);
299+
}
294300
})
295301
.array(types.array)
296302
.boolean(types.boolean)

lib/mocha.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ exports.Test = require('./test');
118118
* @param {number} [options.slow] - Slow threshold value.
119119
* @param {number|string} [options.timeout] - Timeout threshold value.
120120
* @param {string} [options.ui] - Interface name.
121+
* @param {MochaRootHookObject} [options.rootHooks] - Hooks to bootstrap the root
122+
* suite with
121123
*/
122124
function Mocha(options) {
123125
options = utils.assign({}, mocharc, options || {});
@@ -165,6 +167,10 @@ function Mocha(options) {
165167
this[opt]();
166168
}
167169
}, this);
170+
171+
if (options.rootHooks) {
172+
this.rootHooks(options.rootHooks);
173+
}
168174
}
169175

170176
/**
@@ -959,3 +965,46 @@ Mocha.prototype.run = function(fn) {
959965

960966
return runner.run(done);
961967
};
968+
969+
/**
970+
* Assigns hooks to the root suite
971+
* @param {MochaRootHookObject} [hooks] - Hooks to assign to root suite
972+
* @chainable
973+
*/
974+
Mocha.prototype.rootHooks = function rootHooks(hooks) {
975+
if (utils.type(hooks) === 'object') {
976+
var beforeAll = [].concat(hooks.beforeAll || []);
977+
var beforeEach = [].concat(hooks.beforeEach || []);
978+
var afterAll = [].concat(hooks.afterAll || []);
979+
var afterEach = [].concat(hooks.afterEach || []);
980+
var rootSuite = this.suite;
981+
beforeAll.forEach(function(hook) {
982+
rootSuite.beforeAll(hook);
983+
});
984+
beforeEach.forEach(function(hook) {
985+
rootSuite.beforeEach(hook);
986+
});
987+
afterAll.forEach(function(hook) {
988+
rootSuite.afterAll(hook);
989+
});
990+
afterEach.forEach(function(hook) {
991+
rootSuite.afterEach(hook);
992+
});
993+
}
994+
return this;
995+
};
996+
997+
/**
998+
* An alternative way to define root hooks that works with parallel runs.
999+
* @typedef {Object} MochaRootHookObject
1000+
* @property {Function|Function[]} [beforeAll] - "Before all" hook(s)
1001+
* @property {Function|Function[]} [beforeEach] - "Before each" hook(s)
1002+
* @property {Function|Function[]} [afterAll] - "After all" hook(s)
1003+
* @property {Function|Function[]} [afterEach] - "After each" hook(s)
1004+
*/
1005+
1006+
/**
1007+
* An function that returns a {@link MochaRootHookObject}, either sync or async.
1008+
* @callback MochaRootHookFunction
1009+
* @returns {MochaRootHookObject|Promise<MochaRootHookObject>}
1010+
*/
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
3+
exports.mochaHooks = {
4+
beforeAll() {
5+
console.log('beforeAll');
6+
},
7+
beforeEach() {
8+
console.log('beforeEach');
9+
},
10+
afterAll() {
11+
console.log('afterAll');
12+
},
13+
afterEach() {
14+
console.log('afterEach');
15+
}
16+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
exports.mochaHooks = {
4+
beforeAll: [
5+
function() {
6+
console.log('beforeAll array 1');
7+
},
8+
function() {
9+
console.log('beforeAll array 2');
10+
}
11+
],
12+
beforeEach: [
13+
function() {
14+
console.log('beforeEach array 1');
15+
},
16+
function() {
17+
console.log('beforeEach array 2');
18+
}
19+
],
20+
afterAll: [
21+
function() {
22+
console.log('afterAll array 1');
23+
},
24+
function() {
25+
console.log('afterAll array 2');
26+
}
27+
],
28+
afterEach: [
29+
function() {
30+
console.log('afterEach array 1');
31+
},
32+
function() {
33+
console.log('afterEach array 2');
34+
}
35+
]
36+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
3+
exports.mochaHooks = async () => ({
4+
beforeAll() {
5+
console.log('beforeAll');
6+
},
7+
beforeEach() {
8+
console.log('beforeEach');
9+
},
10+
afterAll() {
11+
console.log('afterAll');
12+
},
13+
afterEach() {
14+
console.log('afterEach');
15+
}
16+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
exports.mochaHooks = async() => ({
4+
beforeAll: [
5+
function() {
6+
console.log('beforeAll array 1');
7+
},
8+
function() {
9+
console.log('beforeAll array 2');
10+
}
11+
],
12+
beforeEach: [
13+
function() {
14+
console.log('beforeEach array 1');
15+
},
16+
function() {
17+
console.log('beforeEach array 2');
18+
}
19+
],
20+
afterAll: [
21+
function() {
22+
console.log('afterAll array 1');
23+
},
24+
function() {
25+
console.log('afterAll array 2');
26+
}
27+
],
28+
afterEach: [
29+
function() {
30+
console.log('afterEach array 1');
31+
},
32+
function() {
33+
console.log('afterEach array 2');
34+
}
35+
]
36+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// run with --require root-hook-defs-a.fixture.js --require
2+
// root-hook-defs-b.fixture.js
3+
4+
it('should also have some root hooks', function() {
5+
// test
6+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// run with --require root-hook-defs-a.fixture.js --require
2+
// root-hook-defs-b.fixture.js
3+
4+
it('should have some root hooks', function() {
5+
// test
6+
});

0 commit comments

Comments
 (0)