Skip to content

Commit

Permalink
Add support for ESM loaders (#2399)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidjgoss committed Apr 25, 2024
1 parent 8f96d9f commit 2e51aed
Show file tree
Hide file tree
Showing 25 changed files with 203 additions and 114 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber.

## [Unreleased]
### Added
- Add `loader` option for ESM loader hooks [#2399](https://github.com/cucumber/cucumber-js/pull/2399)

## [10.5.1] - 2024-04-21
### Fixed
Expand Down
1 change: 0 additions & 1 deletion compatibility/cck_spec.ts
Expand Up @@ -43,7 +43,6 @@ describe('Cucumber Compatibility Kit', () => {
requirePaths: [
`${CCK_IMPLEMENTATIONS_PATH}/${suiteName}/${suiteName}.ts`,
],
importPaths: [],
},
runtime: {
dryRun: false,
Expand Down
45 changes: 23 additions & 22 deletions docs/configuration.md
Expand Up @@ -78,28 +78,29 @@ These options can be used in a configuration file (see [above](#files)) or on th
- Where options are repeatable, they are appended/merged if provided more than once.
- Where options aren't repeatable, the CLI takes precedence over a configuration file.

| Name | Type | Repeatable | CLI Option | Description | Default |
|-------------------|------------|------------|---------------------------|-------------------------------------------------------------------------------------------------------------------|---------|
| `paths` | `string[]` | Yes | (as arguments) | Paths to where your feature files are - see [below](#finding-your-features) | [] |
| `backtrace` | `boolean` | No | `--backtrace`, `-b` | Show the full backtrace for errors | false |
| `dryRun` | `boolean` | No | `--dry-run`, `-d` | Prepare a test run but don't run it - see [Dry Run](./dry_run.md) | false |
| `forceExit` | `boolean` | No | `--exit`, `--force-exit` | Explicitly call `process.exit()` after the test run (when run via CLI) - see [CLI](./cli.md) | false |
| `failFast` | `boolean` | No | `--fail-fast` | Stop running tests when a test fails - see [Fail Fast](./fail_fast.md) | false |
| `format` | `string[]` | Yes | `--format`, `-f` | Name/path and (optionally) output file path of each formatter to use - see [Formatters](./formatters.md) | [] |
| `formatOptions` | `object` | Yes | `--format-options` | Options to be provided to formatters - see [Formatters](./formatters.md) | {} |
| `import` | `string[]` | Yes | `--import`, `-i` | Paths to where your support code is | [] |
| `language` | `string` | No | `--language` | Default language for your feature files | en |
| `name` | `string` | No | `--name` | Regular expressions of which scenario names should match one of to be run - see [Filtering](./filtering.md#names) | [] |
| `order` | `string` | No | `--order` | Run in the order defined, or in a random order - see [Filtering and Ordering](./filtering.md#order) | defined |
| `parallel` | `number` | No | `--parallel` | Run tests in parallel with the given number of worker processes - see [Parallel](./parallel.md) | 0 |
| `publish` | `boolean` | No | `--publish` | Publish a report of your test run to <https://reports.cucumber.io/> | false |
| `require` | `string[]` | Yes | `--require`, `-r` | Paths to where your support code is, for CommonJS - see [below](#finding-your-code) | [] |
| `requireModule` | `string[]` | Yes | `--require-module` | Names of transpilation modules to load, loaded via `require()` - see [Transpiling](./transpiling.md) | [] |
| `retry` | `number` | No | `--retry` | Retry failing tests up to the given number of times - see [Retry](./retry.md) | 0 |
| `retryTagFilter` | `string` | Yes | `--retry-tag-filter` | Tag expression to filter which scenarios can be retried - see [Retry](./retry.md) | |
| `strict` | `boolean` | No | `--strict`, `--no-strict` | Fail the test run if there are pending steps | true |
| `tags` | `string` | Yes | `--tags`, `-t` | Tag expression to filter which scenarios should be run - see [Filtering](./filtering.md#tags) | |
| `worldParameters` | `object` | Yes | `--world-parameters` | Parameters to be passed to your World - see [World](./support_files/world.md) | {} |
| Name | Type | Repeatable | CLI Option | Description | Default |
|-------------------|------------|------------|---------------------------|--------------------------------------------------------------------------------------------------------------------|---------|
| `paths` | `string[]` | Yes | (as arguments) | Paths to where your feature files are - see [below](#finding-your-features) | [] |
| `backtrace` | `boolean` | No | `--backtrace`, `-b` | Show the full backtrace for errors | false |
| `dryRun` | `boolean` | No | `--dry-run`, `-d` | Prepare a test run but don't run it - see [Dry Run](./dry_run.md) | false |
| `forceExit` | `boolean` | No | `--exit`, `--force-exit` | Explicitly call `process.exit()` after the test run (when run via CLI) - see [CLI](./cli.md) | false |
| `failFast` | `boolean` | No | `--fail-fast` | Stop running tests when a test fails - see [Fail Fast](./fail_fast.md) | false |
| `format` | `string[]` | Yes | `--format`, `-f` | Name/path and (optionally) output file path of each formatter to use - see [Formatters](./formatters.md) | [] |
| `formatOptions` | `object` | Yes | `--format-options` | Options to be provided to formatters - see [Formatters](./formatters.md) | {} |
| `import` | `string[]` | Yes | `--import`, `-i` | Paths to where your support code is | [] |
| `language` | `string` | No | `--language` | Default language for your feature files | en |
| `loader` | `string[]` | Yes | `--loader`, `-l` | Module specifiers for loaders to be registered ahead of loading support code - see [Transpiling](./transpiling.md) | [] |
| `name` | `string` | No | `--name` | Regular expressions of which scenario names should match one of to be run - see [Filtering](./filtering.md#names) | [] |
| `order` | `string` | No | `--order` | Run in the order defined, or in a random order - see [Filtering and Ordering](./filtering.md#order) | defined |
| `parallel` | `number` | No | `--parallel` | Run tests in parallel with the given number of worker processes - see [Parallel](./parallel.md) | 0 |
| `publish` | `boolean` | No | `--publish` | Publish a report of your test run to <https://reports.cucumber.io/> | false |
| `require` | `string[]` | Yes | `--require`, `-r` | Paths to where your support code is, for CommonJS - see [below](#finding-your-code) | [] |
| `requireModule` | `string[]` | Yes | `--require-module` | Names of transpilation modules to load, loaded via `require()` - see [Transpiling](./transpiling.md) | [] |
| `retry` | `number` | No | `--retry` | Retry failing tests up to the given number of times - see [Retry](./retry.md) | 0 |
| `retryTagFilter` | `string` | Yes | `--retry-tag-filter` | Tag expression to filter which scenarios can be retried - see [Retry](./retry.md) | |
| `strict` | `boolean` | No | `--strict`, `--no-strict` | Fail the test run if there are pending steps | true |
| `tags` | `string` | Yes | `--tags`, `-t` | Tag expression to filter which scenarios should be run - see [Filtering](./filtering.md#tags) | |
| `worldParameters` | `object` | Yes | `--world-parameters` | Parameters to be passed to your World - see [World](./support_files/world.md) | {} |

## Finding your features

Expand Down
38 changes: 5 additions & 33 deletions docs/esm.md
@@ -1,6 +1,6 @@
# ES Modules (experimental)

You can optionally write your support code (steps, hooks, etc) with native ES modules syntax - i.e. using `import` and `export` statements without transpiling.
You can optionally write your support code (steps, hooks, etc) with native ES modules syntax - i.e. using `import` and `export` statements without transpiling to CommonJS.

If your support code is written as ESM, you'll need to use the `import` configuration option to specify your files, rather than the `require` option, although we do automatically detect and import any `.mjs` files found within your features directory.

Expand All @@ -26,40 +26,12 @@ Then('the variable should contain {int}', function (number) {

As well as support code, these things can also be in ES modules syntax:

- Custom formatters
- Custom snippets
- [Configuration files](./configuration.md#files)
- [Custom formatters](./custom_formatters.md)
- [Custom snippets](./custom_snippet_syntaxes.md)

You can use ES modules selectively/incrementally - so you can have a mixture of CommonJS and ESM in the same project.

## Configuration file

You can write your [configuration file](./configuration.md#files) in ESM format. Here's an example adapted from our [Profiles](./profiles.md) doc:

```javascript
const common = {
requireModule: ['ts-node/register'],
require: ['support/**/*.ts'],
worldParameters: {
appUrl: process.env.MY_APP_URL || 'http://localhost:3000/'
}
}

export default {
...common,
format: ['progress-bar', 'html:cucumber-report.html'],
}

export const ci = {
...common,
format: ['html:cucumber-report.html'],
publish: true
}
```

## Transpiling

You can use [ESM loaders](https://nodejs.org/api/esm.html#loaders) to transpile your support code on the fly. The `requireModule` configuration option only works with CommonJS (i.e. `require` hooks) and is not applicable here. Cucumber doesn't have an equivalent option for ESM loaders because they currently can't be registered in-process, so you'll need to declare the loader externally, like this:

```shell
NODE_OPTIONS="--loader <loader>" npx cucumber-js
```
See [Transpiling](./transpiling.md#esm) for how to do just-in-time compilation that outputs ESM.
70 changes: 29 additions & 41 deletions docs/transpiling.md
@@ -1,64 +1,57 @@
# Transpiling

Step definitions and support files can be written in a syntax or language that compiles to JavaScript, and just-in-time compiled when you run Cucumber. The output of the transpiler must match the module format expected by Node.js, and you must use the correct Cucumber directive to import the code, "import" for ESM and "require" for CommonJS.
Step definitions and support files can be written in a syntax or language that compiles (or, "transpiles") to JavaScript, and just-in-time compiled when you run Cucumber. This requires a little extra configuration of Cucumber, so we can use the correct mechanism to compile your code on the fly.

For example, you might want to use [Babel](https://babeljs.io/):
For this doc, we'll take the example of TypeScript since it's so prevalent in the ecosystem. But you'll do similar things if you want to use e.g. Babel or CoffeeScript instead.

- In a configuration file `{ requireModule: ['@babel/register'] }`
- On the CLI `cucumber-js --require-module @babel/register`
For compiling TypeScript on the fly, you should install [ts-node](https://github.com/TypeStrong/ts-node) if it's not already a dependency of your project:

This would mean any support code loaded with the `require` option would be transpiled first then loaded into Cucumber.
```shell
npm install --save-dev ts-node
```

## TypeScript
## Module format

Your `tsconfig.json` should have these `compilerOptions`:
The first thing you need to establish is the JavaScript module format you are compiling to. It'll be either of:

```json
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
- **CommonJS** produces `require` and `module.exports` in compiled output for `import`s and `export`s respectively in the source. If you aren't sure, there's a good chance it's this one.
- [**ESM**](./esm.md) produces `import` and `export` in compiled output which should more closely match your source. This is newer than CommonJS but gaining adoption quickly as the industry transitions.

```
With TypeScript, your `tsconfig.json` should provide some clues. Specifically, if `compilerOptions.module` is not specified or `CommonJS`, then you're probably outputting CommonJS, whereas anything starting with `ES` or `Node` indicates ESM.

Typescript's output must match Cucumber's expected input, and this is controlled by the "module" option in the `tsconfig.json`. The default is "CommonJS", but if your project is setup to be an ESM project then Typescript will need to output some generation of ES code to Cucumber. If you aren't sure use "ESNext".
## CommonJS

You'll also need to specify where your support code is, since `.ts` files won't be picked up by default.
For CommonJS, you need to use the `requireModule` configuration option to register `ts-node`, and then `require` for your TypeScript support code, like this:

Other than that, a pretty standard TypeScript setup should work as expected.
- In a configuration file `{ requireModule: ['ts-node/register'], require: ['features/step-definitions/**/*.ts'] }`
- On the CLI `npx cucumber-js --require-module ts-node/register --require 'features/step-definitions/**/*.ts'`

### With ts-node
## ESM

[ts-node](https://github.com/TypeStrong/ts-node) is the one of the most popular ways to load TypeScript files.
There are two ways of doing this depending on your version of Cucumber. Given the transitional state of modules in Node.js, consider them both experimental for now.

- In a configuration file `{ requireModule: ['ts-node/register'], require: ['step-definitions/**/*.ts'] }`
- On the CLI `$ cucumber-js --require-module ts-node/register --require 'step-definitions/**/*.ts'`
### Loader option

If you are using ts-node in a CommonJS project then this configuration will work, but if you have an ESM project you should follow these steps.
ℹ️ Added in v10.6.0

- Set TypeScript to export to an ES format such as "ESNext" using the `ts-config.json` file.
- Set a NODE_OPTIONS environment flag to use the ts-node ESM loader: `NODE_OPTIONS=\"--loader ts-node/esm\"`
For ESM, you need to use the `loader` configuration option to register `ts-node`, and then `import` for your TypeScript support code, like this:

Note: One possible way to set an environment is to use the [cross-env](https://www.npmjs.com/package/cross-env) package with `npm i -D cross-env`. If you go this route the package.json script line for cucumber will read something like this:
- In a configuration file `{ loader: ['ts-node/esm'], import: ['features/step-definitions/**/*.ts'] }`
- On the CLI `npx cucumber-js --loader ts-node/esm --import 'features/step-definitions/**/*.ts'`

```json
{
"scripts": {
"test": "cross-env NODE_OPTIONS=\"--loader ts-node/esm\" cucumber-js"
}
}
```
The value of `loader` will usually be a package/module name, but if you have a loader you've authored locally, you can provide a path that's relative to your project's working directory.

### With Babel
### Environment variable

If you are using babel with [@babel/preset-typescript](https://babeljs.io/docs/en/babel-preset-typescript):
In versions earlier than v10.6.0 (without the `loader` option), you can still instruct Node.js to register the loader on the process via the `NODE_OPTIONS` environment variable, like this:

- In a configuration file `{ requireModule: ['@babel/register'], require: ['step-definitions/**/*.ts'] }`
- On the CLI `cucumber-js --require-module @babel/register --require 'step-definitions/**/*.ts'`
`NODE_OPTIONS=\"--loader ts-node/esm\"`

### ESM
You then just need to specify the `import` option as above for your support code.

See [ESM](./esm.md) for general advice on using loaders for transpilation in ESM projects.
(This approach is no longer recommended, and you might see a warning from Node.js telling you so.)

### Source maps
## Source maps

Source maps are used to ensure accurate source references and stack traces in Cucumber's reporting, by giving traceability from a transpiled piece of code back to the original source code.

Expand All @@ -68,8 +61,3 @@ If you're using step definition code that's _already_ transpiled (maybe because

1. Ensure source maps are emitted by your transpiler. You can verify by checking for a comment starting with `//# sourceMappingURL=` at the end of your transpiled file(s).
2. Ensure source maps are enabled at runtime. Node.js supports this natively via [the `--enable-source-maps` flag](https://nodejs.org/docs/latest/api/cli.html#--enable-source-maps).
3. Ensure you are using the require directive to import CommonJS formatted code and import for ESM formatted code.

## Summary
- Transpiling allows you to convert your step definitions from any language that can compile to JavaScript - most frequently Typescript.
- There are two formats for modules in JavaScript: CommonJS and ESM. You must make sure the transpiler outputs what Cucumber expects to input.

0 comments on commit 2e51aed

Please sign in to comment.