Skip to content

Commit

Permalink
Add support for Node.JS native ES modules (#4038)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gil Tayar authored Feb 24, 2020
1 parent a995e33 commit 57be455
Show file tree
Hide file tree
Showing 25 changed files with 372 additions and 79 deletions.
9 changes: 8 additions & 1 deletion .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ overrides:
ecmaVersion: 2017
env:
browser: false

- files:
- esm-utils.js
parserOptions:
ecmaVersion: 2018
sourceType: module
parser: babel-eslint
env:
browser: false
- files:
- test/**/*.{js,mjs}
env:
Expand Down
54 changes: 47 additions & 7 deletions docs/index.md
Original file line number Diff line number Diff line change
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.
### Current Limitations

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

- [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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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>}
* @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;

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 {
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
Original file line number Diff line number Diff line change
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);

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);
}
};
31 changes: 31 additions & 0 deletions lib/esm-utils.js
Original file line number Diff line number Diff line change
@@ -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) {
if (err.code === 'ERR_REQUIRE_ESM') {
return import(url.pathToFileURL(file));
} 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
Original file line number Diff line number Diff line change
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) {
return new Promise(function(resolve) {
self.loadFiles(resolve);
});
}

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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"diff": true,
"extension": ["js"],
"extension": ["js", "cjs", "mjs"],
"opts": "./test/mocha.opts",
"package": "./package.json",
"reporter": "spec",
Expand Down
Loading

0 comments on commit 57be455

Please sign in to comment.