Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add Root Hook Plugins #4244

Merged
merged 1 commit into from May 18, 2020
Merged

Conversation

boneskull
Copy link
Member

@boneskull boneskull commented Apr 21, 2020

(documentation will be in another PR; this depends on #4237)

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).
  2. If it does, we save a reference to the property.
  3. 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.
  4. 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

@boneskull boneskull added type: feature enhancement proposal semver-minor implementation requires increase of "minor" version number; "features" area: node.js command-line-or-Node.js-specific labels Apr 21, 2020
@boneskull boneskull self-assigned this Apr 21, 2020
lib/cli/run.js Outdated Show resolved Hide resolved
lib/cli/run-helpers.js Outdated Show resolved Hide resolved
@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from 371a70f to 93f5bf9 Compare April 22, 2020 19:07
@boneskull boneskull requested a review from juergba April 22, 2020 19:08
@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from 93f5bf9 to 50e9a7a Compare April 22, 2020 21:53
@coveralls
Copy link

coveralls commented Apr 22, 2020

Coverage Status

Coverage increased (+0.05%) to 93.275% when pulling 0cdb978 on boneskull/issue/2839-root-hook-plugins into 68eec9e on master.

@juergba
Copy link
Member

juergba commented Apr 23, 2020

  1. Once Mocha is instantiated, if it is given a rootHooks option, those hooks are applied to the root suite.

We have one Mocha instance and one root suite per each child-process, right?
Isn't it possible to use one mocha over all child-processes?

Copy link
Member

@juergba juergba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users may want to run root hooks only once for all child-processes (not for each). It has to be in the first file running. Is there a way to achieve this?

Does it work for async hooks with callbacks? Because of the hook.async property.


/**
* An alternative way to define root hooks that works with parallel runs.
* @typedef {Object} MochaRootHookObject
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this typedef for typescript? Because this file is part of our bundled browser mocha.js.

Copy link
Member Author

@boneskull boneskull Apr 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can move it elsewhere. It's not intended to be in the public API at this point anyway. VSCode's TS language server understands it (somewhat). I just did this instead of doing it inline elsewhere, as describing more than the most trivial values in a @param is awkward.

Given that this functionality is intended to be specific to Node.js, maybe I should move it out? That would be poor encapsulation, though. I really dislike the points in the codebase where object A is mucking with the internals of object B, and that's exactly what would have to happen if Mocha#rootHooks() were moved elsewhere; we'd have a Mocha instance, then some other method would need to reach into Mocha#suite and call methods there.

While this is intended to be used with Node.js, it could be used in the browser. There's no reason why a browser can't call Mocha#rootHooks(), but the consumer would need to pass an object adhering to the MochaRootHookObject shape, which is awkward.

Another way may be to do away with MochaRootHookObject entirely, and instead expose four new chainable methods: Mocha#rootBeforeAll(), Mocha#rootBeforeEach(), Mocha#rootAfterAll(), and Mocha#rootAfterEach(). Each would accept a single hook function. This would probably be a more ergonomic API for a consumer (which I had not originally considered).

What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, there's no reason why the browser couldn't consume rootHooks(). I think it's probably the right place for it for now.

var beforeAll = [].concat(hooks.beforeAll || []);
var beforeEach = [].concat(hooks.beforeEach || []);
var afterAll = [].concat(hooks.afterAll || []);
var afterEach = [].concat(hooks.afterEach || []);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't understood this part. Why you pass an object, not a function.
Suite.prototype.beforeAll = function(title, fn) expects a function, but here it gets an object.
If you run those hooks with Runnable#run, how can this work? The hook has already been executed and contains the return value, not the function anymore?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hooks is a collection of all four types of hook, and has properties beforeAll, beforeEach, afterAll, afterEach. At this point in the code, if any of these property is truthy, they are expected to be arrays of functions. I think I could be more specific about the shape of the object; before we reach this point, loadHooks() (in run-helpers.js, iirc) does some pre-processing.

Below, we loop thru the properties (again, which are arrays of functions) and assign them to the root suite via the appropriate method in Suite.

lib/mocha.js Show resolved Hide resolved
@boneskull
Copy link
Member Author

boneskull commented Apr 23, 2020

@juergba

Once Mocha is instantiated, if it is given a rootHooks option, those hooks are applied to the root suite.

We have one Mocha instance and one root suite per each child-process, right?

This is correct.

Isn't it possible to use one mocha over all child-processes?

It isn't possible to share a single mocha instance across multiple child processes, no. There's no way to share an object reference via IPC (afaik).

It's not impossible for a single child process to reuse a Mocha instance. In fact, PR #4245 could take advantage of @nicojs' PR #4234 to do so. I haven't looked closely at that PR, so I'm unsure if the root suite would be reset or not. If we were to use that, root beforeAll and afterAll hooks would only be called once per child process. In the current state of #4245, these are called for each file.

I realize this doesn't work for people who need to run, e.g., beforeAll once and only once. If beforeAll is modifying the test context, there's no way to share object references between child
processes, so it would have to be run for each child process anyway. If it's doing some sort of I/O and must be run once, then presently users should not use parallel mode for now. Bikeshedding, the way forward for this use case would be support of async ESM via --require and/or adding another convention, e.g., exports.mochaSetup = async () => { /* .. */ }, which would be called in the main process, but would necessarily run before the instantiation of Mocha.

@boneskull boneskull requested a review from juergba April 27, 2020 19:33
@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch 2 times, most recently from 2642081 to 74cf0ef Compare April 30, 2020 23:47
@boneskull boneskull closed this May 1, 2020
@boneskull boneskull deleted the boneskull/issue/2839-root-hook-plugins branch May 1, 2020 21:48
@boneskull boneskull restored the boneskull/issue/2839-root-hook-plugins branch May 1, 2020 21:48
@boneskull boneskull reopened this May 1, 2020
@boneskull
Copy link
Member Author

somehow accidentally closed this

@boneskull
Copy link
Member Author

boneskull commented May 4, 2020

@juergba waiting for response from you in the comments, and also pls @mochajs/core take a look.

I want to make sure

  1. If we add an API like this, we are comfortable living with it for awhile, and
  2. We understand why it should be a thing
  3. We understand the limitations (there's currently no way, when running tests in parallel, to run a root "before all"/"after all" hook once and only once; they happen once per test file)
  4. I'm not sure if we should wait on the other PR which allows better "resetting" of Mocha; this would allow us to reduce these hooks to run once per child process instead of once per test file

@craigtaub
Copy link
Contributor

What about adding a new argument --setup or --setup-file or --global-setup?

Sounds sensible to me. Could see this adding to the user confusion around whether to use file or require. Hopefully no issues running once per child process + still use rootHooks.

Assuming waiting for more feedback before adding docs, which is fine.

@boneskull
Copy link
Member Author

@craigtaub docs are in #4246

@boneskull
Copy link
Member Author

re: --setup. I'm not entirely opposed, but we run the risk of just adding more and more flags if we want to support some other kind of plugin.

this is why I wanted to use --require + a convention. this would position mocha to define more such conventions in the future to expose more kinds of plugins.

if we use --setup, it can only really be used for this one thing, and if we want to do something else, then the precedent is to create yet another option.

things that could be supported via --require + convention include, but are not limited to:

  1. easier interface definition
  2. easier reporter definition
  3. custom Runners (e.g., a worker-thread based runner)
  4. custom stats collecting
  5. snapshot test hooks
  6. tighter integration with assertion & mocking libraries

Could see this adding to the user confusion around whether to use file or require

If we merged this, I think we would want to be explicit: do not use --file for root-level hooks. --file is intended to describe a specific order in which test files run, not for root hook definition.

@boneskull
Copy link
Member Author

I note that you could do this, with minimal changes:

// .mocharc.js

module.exports = {
  require: [
    'source-map-support',
    'something-else',
    {
      mochaHooks: {
        beforeEach() {
          this.foo()
        }
      }
    }
  ]
};

@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from e0aaff2 to f70bb78 Compare May 14, 2020 18:26
@boneskull
Copy link
Member Author

@nicojs @craigtaub What do you think of my argument above? Convinced or no?

@nicojs
Copy link
Contributor

nicojs commented May 14, 2020

Sure, that makes sense. Didn't know about other plugins yet. If you need a plugin system, this makes sense.

module​.​exports​ ​=​ ​{​
  ​require​: ​[​
    ​'source-map-support'​,​
    ​'something-else'​,​
    ​{​
      ​mochaHooks​: ​{​
        ​beforeEach​(​)​ ​{​
          ​this​.​foo​(​)​
        ​}​
      ​}​
    ​}​
  ​]​
​}​;

This would work, except not for --concurrent mode, right?

@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from f70bb78 to 61d2389 Compare May 14, 2020 21:54
@boneskull
Copy link
Member Author

boneskull commented May 14, 2020

@nicojs

module.exports​ ​=​ ​{​
  ​require​: ​[​
    ​'source-map-support',​
    ​'something-else',​
    ​{​
      ​mochaHooks​: ​{​
        ​beforeEach()​ ​{​
          ​this.foo()​
        ​}​
      ​}​
    ​}​
  ​]​
​};

I'm not sure I understand. The above would theoretically work as a .mocharc.js with parallel: true or not. I just need to modify the codebase to know what to do with a require value that isn't a string. (see below)

@boneskull
Copy link
Member Author

(rather, you're right, I'd need to re-load the config file for each child process, because even if I did serialize and eval the damn thing it'd be missing the enclosing scope)

@boneskull
Copy link
Member Author

anyway, not gonna do that right now. still: a --require foo.js where foo.js exports a mochaHooks prop will work with --parallel or without.

@nicojs
Copy link
Contributor

nicojs commented May 15, 2020

I've created a small serialization library you might want to look at for communicating between child process and main process: https://www.npmjs.com/package/surrial
We're using it in Stryker for the same reason.

Anyway, it won't solve the enclosing scope issue. You cannot simply pick up a function and serialize / deseriaze it and expect every scenario to work.

You could, however, load the config files in child processes for this reason.

That's all totally out of scope for this PR. And off topic.

@craigtaub
Copy link
Contributor

What do you think of my argument above? Convinced or no?

Yep good point, plugin system sounds useful.

@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from 61d2389 to 4d0f301 Compare May 18, 2020 18:31
@boneskull
Copy link
Member Author

@nicojs Thanks. There are a lot of serialization libs out there--I just picked one.

You're welcome to try to replace the adhoc serialization stuff with surrial, but we have to be careful about a) performance, b) transmitting exceptions, and b) self-referential objects--e.g., I'm not serializing the entirety of a Suite for performance reasons.

(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
@boneskull boneskull force-pushed the boneskull/issue/2839-root-hook-plugins branch from 4d0f301 to 0cdb978 Compare May 18, 2020 18:44
@boneskull
Copy link
Member Author

something is weird with github. the build (on travis anyway) has passed

@boneskull
Copy link
Member Author

still waiting on appveyor.

@boneskull
Copy link
Member Author

travis passed.

@boneskull boneskull merged commit 722ce01 into master May 18, 2020
@boneskull boneskull deleted the boneskull/issue/2839-root-hook-plugins branch May 18, 2020 23:57
@craigtaub craigtaub added this to the next milestone May 21, 2020
nicojs added a commit to stryker-mutator/stryker-js that referenced this pull request Jun 14, 2020
* Support mocha@<9
* Add support for [rootHooks](mochajs/mocha#4244)
* Ignore `--parallel` flag (stryker will handle concurrency).
nicojs added a commit to stryker-mutator/stryker-js that referenced this pull request Jun 16, 2020
* Support peer dependency range mocha@<9
* Add support for [rootHooks](mochajs/mocha#4244)
* Ignore `--parallel` flag (stryker will handle concurrency).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: node.js command-line-or-Node.js-specific semver-minor implementation requires increase of "minor" version number; "features" type: feature enhancement proposal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants