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

Node esm support #4038

Merged
merged 1 commit into from Feb 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 8 additions & 1 deletion .eslintrc.yml
Expand Up @@ -31,7 +31,14 @@ overrides:
ecmaVersion: 2017
env:
browser: false

- files:
- esm-utils.js
parserOptions:
ecmaVersion: 2018
sourceType: module
parser: babel-eslint
juergba marked this conversation as resolved.
Show resolved Hide resolved
env:
browser: false
- files:
- test/**/*.{js,mjs}
env:
Expand Down
54 changes: 47 additions & 7 deletions docs/index.md
Expand Up @@ -39,6 +39,7 @@ Mocha is a feature-rich JavaScript test framework running on [Node.js][] and in
- [mocha.opts file support](#-opts-path)
- clickable suite titles to filter test execution
- [node debugger support](#-inspect-inspect-brk-inspect)
- [node native ES modules support](#nodejs-native-esm-support)
- [detects multiple calls to `done()`](#detects-multiple-calls-to-done)
- [use any assertion library you want](#assertions)
- [extensible reporting, bundled with 9+ reporters](#reporters)
Expand Down Expand Up @@ -70,6 +71,7 @@ Mocha is a feature-rich JavaScript test framework running on [Node.js][] and in
- [Command-Line Usage](#command-line-usage)
- [Interfaces](#interfaces)
- [Reporters](#reporters)
- [Node.JS native ESM support](#nodejs-native-esm-support)
- [Running Mocha in the Browser](#running-mocha-in-the-browser)
- [Desktop Notification Support](#desktop-notification-support)
- [Configuring Mocha (Node.js)](#configuring-mocha-nodejs)
Expand Down Expand Up @@ -354,11 +356,11 @@ With its default "BDD"-style interface, Mocha provides the hooks `before()`, `af
```js
describe('hooks', function() {
before(function() {
// runs before all tests in this block
// runs once before the first test in this block
});

after(function() {
// runs after all tests in this block
// runs once after the last test in this block
});

beforeEach(function() {
Expand Down Expand Up @@ -868,7 +870,8 @@ Configuration
--package Path to package.json for config [string]

File Handling
--extension File extension(s) to load [array] [default: js]
--extension File extension(s) to load
[array] [default: ["js","cjs","mjs"]]
--file Specify file(s) to be loaded prior to root suite
execution [array] [default: (none)]
--ignore, --exclude Ignore file(s) or glob pattern(s)
Expand Down Expand Up @@ -1538,6 +1541,42 @@ Alias: `HTML`, `html`

**The HTML reporter is not intended for use on the command-line.**

## Node.JS native ESM support

> _New in v7.1.0_

Mocha supports writing your tests as ES modules, and not just using CommonJS. For example:

```js
// test.mjs
import {add} from './add.mjs';
import assert from 'assert';

it('should add to numbers from an es module', () => {
assert.equal(add(3, 5), 8);
});
```

To enable this you don't need to do anything special. Write your test file as an ES module. In Node.js
this means either ending the file with a `.mjs` extension, or, if you want to use the regular `.js` extension, by
adding `"type": "module"` to your `package.json`.
More information can be found in the [Node.js documentation](https://nodejs.org/api/esm.html).

> Mocha supports ES modules only from Node.js v12.11.0 and above. To enable this in versions smaller than 13.2.0, you need to add `--experimental-modules` when running
> Mocha. From version 13.2.0 of Node.js, you can use ES modules without any flags.

juergba marked this conversation as resolved.
Show resolved Hide resolved
### Current Limitations

Node.JS native ESM support still has status: **Stability: 1 - Experimental**

juergba marked this conversation as resolved.
Show resolved Hide resolved
- [Watch mode](#-watch-w) does not support ES Module test files
- [Custom reporters](#third-party-reporters) and [custom interfaces](#interfaces)
can only be CommonJS files
- [Required modules](#-require-module-r-module) can only be CommonJS files
- [Configuration file](#configuring-mocha-nodejs) can only be a CommonJS file (`mocharc.js` or `mocharc.cjs`)
- When using module-level mocks via libs like `proxyquire`, `rewiremock` or `rewire`, hold off on using ES modules for your test files
- Node.JS native ESM support does not work with [esm][npm-esm] module

## Running Mocha in the Browser

Mocha runs in the browser. Every release of Mocha will have new builds of `./mocha.js` and `./mocha.css` for use in the browser.
Expand Down Expand Up @@ -1609,17 +1648,17 @@ mocha.setup({

### Browser-specific Option(s)

Browser Mocha supports many, but not all [cli options](#command-line-usage).
Browser Mocha supports many, but not all [cli options](#command-line-usage).
To use a [cli option](#command-line-usage) that contains a "-", please convert the option to camel-case, (eg. `check-leaks` to `checkLeaks`).

#### Options that differ slightly from [cli options](#command-line-usage):

`reporter` _{string|constructor}_
`reporter` _{string|constructor}_
You can pass a reporter's name or a custom reporter's constructor. You can find **recommended** reporters for the browser [here](#reporting). It is possible to use [built-in reporters](#reporters) as well. Their employment in browsers is neither recommended nor supported, open the console to see the test results.

#### Options that _only_ function in browser context:

`noHighlighting` _{boolean}_
`noHighlighting` _{boolean}_
If set to `true`, do not attempt to use syntax highlighting on output test code.

### Reporting
Expand Down Expand Up @@ -1701,7 +1740,8 @@ tests as shown below:

In addition to supporting the deprecated [`mocha.opts`](#mochaopts) run-control format, Mocha now supports configuration files, typical of modern command-line tools, in several formats:

- **JavaScript**: Create a `.mocharc.js` in your project's root directory, and export an object (`module.exports = {/* ... */}`) containing your configuration.
- **JavaScript**: Create a `.mocharc.js` (or `mocharc.cjs` when using [`"type"="module"`](#nodejs-native-esm-support) in your `package.json`)
in your project's root directory, and export an object (`module.exports = {/* ... */}`) containing your configuration.
- **YAML**: Create a `.mocharc.yaml` (or `.mocharc.yml`) in your project's root directory.
- **JSON**: Create a `.mocharc.json` (or `.mocharc.jsonc`) in your project's root directory. Comments — while not valid JSON — are allowed in this file, and will be ignored by Mocha.
- **package.json**: Create a `mocha` property in your project's `package.json`.
Expand Down
1 change: 1 addition & 0 deletions karma.conf.js
Expand Up @@ -35,6 +35,7 @@ module.exports = config => {
.ignore('chokidar')
.ignore('fs')
.ignore('glob')
.ignore('./lib/esm-utils.js')
.ignore('path')
.ignore('supports-color')
.on('bundled', (err, content) => {
Expand Down
3 changes: 2 additions & 1 deletion lib/cli/config.js
Expand Up @@ -21,6 +21,7 @@ const findUp = require('find-up');
* @private
*/
exports.CONFIG_FILES = [
'.mocharc.cjs',
'.mocharc.js',
'.mocharc.yaml',
'.mocharc.yml',
Expand Down Expand Up @@ -75,7 +76,7 @@ exports.loadConfig = filepath => {
try {
if (ext === '.yml' || ext === '.yaml') {
config = parsers.yaml(filepath);
} else if (ext === '.js') {
} else if (ext === '.js' || ext === '.cjs') {
config = parsers.js(filepath);
} else {
config = parsers.json(filepath);
Expand Down
2 changes: 1 addition & 1 deletion lib/cli/options.js
Expand Up @@ -265,7 +265,7 @@ module.exports.loadPkgRc = loadPkgRc;
* Priority list:
*
* 1. Command-line args
* 2. RC file (`.mocharc.js`, `.mocharc.ya?ml`, `mocharc.json`)
* 2. RC file (`.mocharc.c?js`, `.mocharc.ya?ml`, `mocharc.json`)
* 3. `mocha` prop of `package.json`
* 4. `mocha.opts`
* 5. default configuration (`lib/mocharc.json`)
Expand Down
15 changes: 8 additions & 7 deletions lib/cli/run-helpers.js
Expand Up @@ -15,8 +15,6 @@ const collectFiles = require('./collect-files');

const cwd = (exports.cwd = process.cwd());

exports.watchRun = watchRun;

/**
* Exits Mocha when tests + code under test has finished execution (default)
* @param {number} code - Exit code; typically # of failures
Expand Down Expand Up @@ -92,19 +90,21 @@ exports.handleRequires = (requires = []) => {
};

/**
* Collect test files and run mocha instance.
* Collect and load test files, then run mocha instance.
* @param {Mocha} mocha - Mocha instance
* @param {Options} [opts] - Command line options
* @param {boolean} [opts.exit] - Whether or not to force-exit after tests are complete
* @param {Object} fileCollectParams - Parameters that control test
* file collection. See `lib/cli/collect-files.js`.
* @returns {Runner}
* @returns {Promise<Runner>}
juergba marked this conversation as resolved.
Show resolved Hide resolved
* @private
*/
exports.singleRun = (mocha, {exit}, fileCollectParams) => {
const singleRun = async (mocha, {exit}, fileCollectParams) => {
const files = collectFiles(fileCollectParams);
debug('running tests with files', files);
mocha.files = files;

juergba marked this conversation as resolved.
Show resolved Hide resolved
await mocha.loadFilesAsync();
return mocha.run(exit ? exitMocha : exitMochaLater);
};

Expand All @@ -113,8 +113,9 @@ exports.singleRun = (mocha, {exit}, fileCollectParams) => {
* @param {Mocha} mocha - Mocha instance
* @param {Object} opts - Command line options
* @private
* @returns {Promise}
*/
exports.runMocha = (mocha, options) => {
exports.runMocha = async (mocha, options) => {
const {
juergba marked this conversation as resolved.
Show resolved Hide resolved
watch = false,
extension = [],
Expand All @@ -140,7 +141,7 @@ exports.runMocha = (mocha, options) => {
if (watch) {
watchRun(mocha, {watchFiles, watchIgnore}, fileCollectParams);
} else {
exports.singleRun(mocha, {exit}, fileCollectParams);
await singleRun(mocha, {exit}, fileCollectParams);
}
};

Expand Down
11 changes: 8 additions & 3 deletions lib/cli/run.js
Expand Up @@ -87,7 +87,6 @@ exports.builder = yargs =>
},
extension: {
default: defaults.extension,
defaultDescription: 'js',
description: 'File extension(s) to load',
group: GROUPS.FILES,
requiresArg: true,
Expand Down Expand Up @@ -299,8 +298,14 @@ exports.builder = yargs =>
.number(types.number)
.alias(aliases);

juergba marked this conversation as resolved.
Show resolved Hide resolved
exports.handler = argv => {
exports.handler = async function(argv) {
debug('post-yargs config', argv);
const mocha = new Mocha(argv);
runMocha(mocha, argv);

try {
await runMocha(mocha, argv);
} catch (err) {
console.error('\n' + (err.stack || `Error: ${err.message || err}`));
process.exit(1);
}
Copy link
Member

Choose a reason for hiding this comment

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

I'm unsure wether this will be sufficient to avoid "unhandled promise rejections". When a promise further down the call stack is failing and not catched. We will see ....

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we're dealing with all the promises down the call stack correctly, then there shouldn't be a problem.

};
31 changes: 31 additions & 0 deletions lib/esm-utils.js
@@ -0,0 +1,31 @@
const url = require('url');
const path = require('path');

const requireOrImport = async file => {
file = path.resolve(file);

if (path.extname(file) === '.mjs') {
return import(url.pathToFileURL(file));
}
// This is currently the only known way of figuring out whether a file is CJS or ESM.
// If Node.js or the community establish a better procedure for that, we can fix this code.
// Another option here would be to always use `import()`, as this also supports CJS, but I would be
// wary of using it for _all_ existing test files, till ESM is fully stable.
try {
return require(file);
} catch (err) {
juergba marked this conversation as resolved.
Show resolved Hide resolved
if (err.code === 'ERR_REQUIRE_ESM') {
return import(url.pathToFileURL(file));
Copy link

Choose a reason for hiding this comment

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

Node doesn't throw ERR_REQUIRE_ESM for .jsx files, instead throws SyntaxError: Cannot use import statement outside a module:
https://github.com/nodejs/node/blob/v13.8.0/lib/internal/modules/cjs/loader.js#L1161

I found a hacky workaround (link also provides context on how I ran into this problem).

Could you check package.json and import by default if the requiring package has set type: "module"? That wouldn't solve this problem for everyone but would for me, and seems correct regardless.

Copy link
Member

Choose a reason for hiding this comment

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

@emma-borhanian this issue is outside of this PR's scope, I would like to get it merged as is.
Afterwards the ESM implementation will grow and we are open to suggestions. But IMO checking for the nearest package.json (for each test file) is too expensive and actually Node's job.

} else {
throw err;
}
}
};

exports.loadFilesAsync = async (files, preLoadFunc, postLoadFunc) => {
for (const file of files) {
preLoadFunc(file);
const result = await requireOrImport(file);
postLoadFunc(file, result);
}
};
59 changes: 55 additions & 4 deletions lib/mocha.js
Expand Up @@ -14,6 +14,7 @@ var utils = require('./utils');
var mocharc = require('./mocharc.json');
var errors = require('./errors');
var Suite = require('./suite');
var esmUtils = utils.supportsEsModules() ? require('./esm-utils') : undefined;
var createStatsCollector = require('./stats-collector');
var createInvalidReporterError = errors.createInvalidReporterError;
var createInvalidInterfaceError = errors.createInvalidInterfaceError;
Expand Down Expand Up @@ -290,16 +291,18 @@ Mocha.prototype.ui = function(ui) {
};

/**
* Loads `files` prior to execution.
* Loads `files` prior to execution. Does not support ES Modules.
*
* @description
* The implementation relies on Node's `require` to execute
* the test interface functions and will be subject to its cache.
* Supports only CommonJS modules. To load ES modules, use Mocha#loadFilesAsync.
*
* @private
* @see {@link Mocha#addFile}
* @see {@link Mocha#run}
* @see {@link Mocha#unloadFiles}
* @see {@link Mocha#loadFilesAsync}
* @param {Function} [fn] - Callback invoked upon completion.
*/
Mocha.prototype.loadFiles = function(fn) {
Expand All @@ -314,6 +317,49 @@ Mocha.prototype.loadFiles = function(fn) {
fn && fn();
};

/**
* Loads `files` prior to execution. Supports Node ES Modules.
*
* @description
* The implementation relies on Node's `require` and `import` to execute
* the test interface functions and will be subject to its cache.
* Supports both CJS and ESM modules.
*
* @public
* @see {@link Mocha#addFile}
* @see {@link Mocha#run}
* @see {@link Mocha#unloadFiles}
* @returns {Promise}
* @example
*
* // loads ESM (and CJS) test files asynchronously, then runs root suite
* mocha.loadFilesAsync()
* .then(() => mocha.run(failures => process.exitCode = failures ? 1 : 0))
* .catch(() => process.exitCode = 1);
*/
Mocha.prototype.loadFilesAsync = function() {
var self = this;
var suite = this.suite;
this.loadAsync = true;

if (!esmUtils) {
juergba marked this conversation as resolved.
Show resolved Hide resolved
return new Promise(function(resolve) {
self.loadFiles(resolve);
});
Copy link
Member

Choose a reason for hiding this comment

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

BTW why does our eslinter - set to ES5 - not complain about that Promise?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No idea. The ways of eslint are mysterious. :-)

}

return esmUtils.loadFilesAsync(
this.files,
function(file) {
suite.emit(EVENT_FILE_PRE_REQUIRE, global, file, self);
},
function(file, resultModule) {
suite.emit(EVENT_FILE_REQUIRE, resultModule, file, self);
suite.emit(EVENT_FILE_POST_REQUIRE, global, file, self);
}
);
};

/**
* Removes a previously loaded file from Node's `require` cache.
*
Expand All @@ -330,8 +376,9 @@ Mocha.unloadFile = function(file) {
* Unloads `files` from Node's `require` cache.
*
* @description
* This allows files to be "freshly" reloaded, providing the ability
* This allows required files to be "freshly" reloaded, providing the ability
* to reuse a Mocha instance programmatically.
* Note: does not clear ESM module files from the cache
*
* <strong>Intended for consumers &mdash; not used internally</strong>
*
Expand Down Expand Up @@ -842,10 +889,14 @@ Object.defineProperty(Mocha.prototype, 'version', {
* @see {@link Mocha#unloadFiles}
* @see {@link Runner#run}
* @param {DoneCB} [fn] - Callback invoked when test execution completed.
* @return {Runner} runner instance
* @returns {Runner} runner instance
* @example
*
* // exit with non-zero status if there were test failures
* mocha.run(failures => process.exitCode = failures ? 1 : 0);
*/
Mocha.prototype.run = function(fn) {
if (this.files.length) {
if (this.files.length && !this.loadAsync) {
this.loadFiles();
}
var suite = this.suite;
Expand Down
2 changes: 1 addition & 1 deletion lib/mocharc.json
@@ -1,6 +1,6 @@
{
"diff": true,
"extension": ["js"],
"extension": ["js", "cjs", "mjs"],
"opts": "./test/mocha.opts",
"package": "./package.json",
"reporter": "spec",
Expand Down