Skip to content

Commit c35f001

Browse files
add ESM support (take 2) (#1649)
* Revert "temporarily revert ESM change (#1647)" This reverts commit 084c1f2. * add failing scenario for deep imports * define entry point with dot * make deep imports work via export patterns * move doc to own file * link to doc from readme * add changelog entry * add example to doc * remove confusing comment * remove cli option, use import by default * update documentation * remove redundant describe * fix ordering * Update features/esm.feature Co-authored-by: Aurélien Reeves <[email protected]> * Update features/esm.feature Co-authored-by: Aurélien Reeves <[email protected]> * simplify tagging * use import only if a javascript file * add note about no transpilers * inline to avoid confusing reassignment * whoops, re-add try/catch * use require with transpilers; import otherwise * remove pointless return * support .cjs config file * type and import the importer * actually dont import - causes issues Co-authored-by: Aurélien Reeves <[email protected]>
1 parent 6e958f1 commit c35f001

24 files changed

+313
-75
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ See the [migration guide](./docs/migration.md) for details of how to migrate fro
1919

2020
### Added
2121

22+
* Add support for user code as native ES modules
2223
* `BeforeStep` and `AfterStep` hook functions now have access to the `pickleStep` in their argument object.
2324
* `--config` option to the CLI. It allows you to specify a configuration file other than `cucumber.js`.
2425
See [docs/profiles.md](./docs/profiles.md#using-another-file-than-cucumberjs) for more info.

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ The following documentation is for master. See below the documentation for older
6767
* [Attachments](/docs/support_files/attachments.md)
6868
* [API Reference](/docs/support_files/api_reference.md)
6969
* Guides
70+
* [ES Modules](./docs/esm.md)
7071
* [Running in parallel](./docs/parallel.md)
7172
* [Retrying failing scenarios](./docs/retry.md)
7273
* [Profiles](./docs/profiles.md)

dependency-lint.yml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ requiredModules:
4444
- 'dist/**/*'
4545
- 'lib/**/*'
4646
- 'node_modules/**/*'
47+
- 'src/importer.js'
4748
- 'tmp/**/*'
4849
root: '**/*.{js,ts}'
4950
stripLoaders: false

docs/esm.md

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ES Modules (experimental)
2+
3+
You can optionally write your support code (steps, hooks, etc) with native ES modules syntax - i.e. using `import` and `export` statements without transpiling. This is enabled without any additional configuration, and you can use either of the `.js` or `.mjs` file extensions.
4+
5+
Example (adapted from [our original example](./nodejs_example.md)):
6+
7+
```javascript
8+
// features/support/steps.mjs
9+
import { Given, When, Then } from '@cucumber/cucumber'
10+
import { strict as assert } from 'assert'
11+
12+
Given('a variable set to {int}', function (number) {
13+
this.setTo(number)
14+
})
15+
16+
When('I increment the variable by {int}', function (number) {
17+
this.incrementBy(number)
18+
})
19+
20+
Then('the variable should contain {int}', function (number) {
21+
assert.equal(this.variable, number)
22+
})
23+
```
24+
25+
As well as support code, these things can also be in ES modules syntax:
26+
27+
- Custom formatters
28+
- Custom snippets
29+
30+
You can use ES modules selectively/incrementally - so you can have a mixture of CommonJS and ESM in the same project.
31+
32+
When using a transpiler for e.g. TypeScript, ESM isn't supported - you'll need to configure your transpiler to output modules in CommonJS syntax (for now).
33+
34+
The config file referenced for [Profiles](./profiles.md) can only be in CommonJS syntax. In a project with `type=module`, you can name the file `cucumber.cjs`, since Node expects `.js` files to be in ESM syntax in such projects.

features/direct_imports.feature

+17
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,20 @@ Feature: Core feature elements execution using direct imports
4444
"""
4545
features/step_definitions/cucumber_steps.js:3
4646
"""
47+
48+
Scenario: deep imports don't break everything
49+
Given a file named "features/a.feature" with:
50+
"""
51+
Feature: some feature
52+
Scenario: some scenario
53+
Given a step passes
54+
"""
55+
And a file named "features/step_definitions/cucumber_steps.js" with:
56+
"""
57+
const {Given} = require('@cucumber/cucumber')
58+
const TestCaseHookDefinition = require('@cucumber/cucumber/lib/models/test_case_hook_definition')
59+
60+
Given(/^a step passes$/, function() {});
61+
"""
62+
When I run cucumber-js
63+
Then it passes

features/esm.feature

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
@esm
2+
Feature: ES modules support
3+
4+
cucumber-js works with native ES modules
5+
6+
Scenario Outline: native module syntax works in support code, formatters and snippets
7+
Given a file named "features/a.feature" with:
8+
"""
9+
Feature:
10+
Scenario: one
11+
Given a step passes
12+
13+
Scenario: two
14+
Given a step passes
15+
"""
16+
And a file named "features/step_definitions/cucumber_steps.js" with:
17+
"""
18+
import {Given} from '@cucumber/cucumber'
19+
20+
Given(/^a step passes$/, function() {});
21+
"""
22+
And a file named "custom-formatter.js" with:
23+
"""
24+
import {SummaryFormatter} from '@cucumber/cucumber'
25+
26+
export default class CustomFormatter extends SummaryFormatter {}
27+
"""
28+
And a file named "custom-snippet-syntax.js" with:
29+
"""
30+
export default class CustomSnippetSyntax {
31+
build(opts) {
32+
return 'hello world'
33+
}
34+
}
35+
"""
36+
And a file named "cucumber.cjs" with:
37+
"""
38+
module.exports = {
39+
'default': '--format summary'
40+
}
41+
"""
42+
When I run cucumber-js with `<options> --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}' <args>`
43+
Then it passes
44+
Examples:
45+
| args |
46+
| |
47+
| --parallel 2 |
48+
49+
Scenario: .mjs support code files are matched by default
50+
Given a file named "features/a.feature" with:
51+
"""
52+
Feature:
53+
Scenario:
54+
Given a step passes
55+
"""
56+
And a file named "features/step_definitions/cucumber_steps.mjs" with:
57+
"""
58+
import {Given} from '@cucumber/cucumber'
59+
60+
Given(/^a step passes$/, function() {});
61+
"""
62+
When I run cucumber-js
63+
Then it passes

features/profiles.feature

+4
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,7 @@ Feature: default command line arguments
9191
| OPT |
9292
| -c |
9393
| --config |
94+
95+
Scenario: specifying a configuration file that doesn't exist
96+
When I run cucumber-js with `--config doesntexist.js`
97+
Then it fails

features/support/hooks.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Before('@debug', function (this: World) {
1313
this.debug = true
1414
})
1515

16-
Before('@spawn', function (this: World) {
16+
Before('@spawn or @esm', function (this: World) {
1717
this.spawn = true
1818
})
1919

@@ -43,6 +43,13 @@ Before(function (
4343
this.localExecutablePath = path.join(projectPath, 'bin', 'cucumber-js')
4444
})
4545

46+
Before('@esm', function (this: World) {
47+
fsExtra.writeJSONSync(path.join(this.tmpDir, 'package.json'), {
48+
name: 'feature-test-pickle',
49+
type: 'module',
50+
})
51+
})
52+
4653
Before('@global-install', function (this: World) {
4754
const tmpObject = tmp.dirSync({ unsafeCleanup: true })
4855

package-lock.json

+4-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+10-2
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@
163163
"lib": "./lib"
164164
},
165165
"main": "./lib/index.js",
166+
"exports": {
167+
".": {
168+
"import": "./lib/wrapper.mjs",
169+
"require": "./lib/index.js"
170+
},
171+
"./lib/*": {
172+
"require": "./lib/*.js"
173+
}
174+
},
166175
"types": "./lib/index.d.ts",
167176
"engines": {
168177
"node": ">=12"
@@ -180,7 +189,6 @@
180189
"cli-table3": "^0.6.0",
181190
"colors": "^1.4.0",
182191
"commander": "^8.0.0",
183-
"create-require": "^1.1.1",
184192
"duration": "^0.2.2",
185193
"durations": "^3.4.2",
186194
"figures": "^3.2.0",
@@ -252,7 +260,7 @@
252260
"typescript": "4.4.2"
253261
},
254262
"scripts": {
255-
"build-local": "tsc --build tsconfig.node.json",
263+
"build-local": "tsc --build tsconfig.node.json && cp src/importer.js lib/ && cp src/wrapper.mjs lib/",
256264
"cck-test": "mocha 'compatibility/**/*_spec.ts'",
257265
"feature-test": "node ./bin/cucumber-js",
258266
"html-formatter": "node ./bin/cucumber-js --profile htmlFormatter",

src/cli/argv_parser.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,7 @@ const ArgvParser = {
107107
.usage('[options] [<GLOB|DIR|FILE[:LINE]>...]')
108108
.version(version, '-v, --version')
109109
.option('-b, --backtrace', 'show full backtrace for errors')
110-
.option(
111-
'-c, --config <TYPE[:PATH]>',
112-
'specify configuration file',
113-
'cucumber.js'
114-
)
110+
.option('-c, --config <TYPE[:PATH]>', 'specify configuration file')
115111
.option(
116112
'-d, --dry-run',
117113
'invoke formatters without executing steps',

src/cli/configuration_builder.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export default class ConfigurationBuilder {
7676
}
7777
supportCodePaths = await this.expandPaths(
7878
unexpandedSupportCodePaths,
79-
'.js'
79+
'.@(js|mjs)'
8080
)
8181
}
8282
return {

src/cli/configuration_builder_spec.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ describe('Configuration', () => {
7171
const relativeFeaturePath = path.join('features', 'a.feature')
7272
const featurePath = path.join(cwd, relativeFeaturePath)
7373
await fsExtra.outputFile(featurePath, '')
74-
const supportCodePath = path.join(cwd, 'features', 'a.js')
75-
await fsExtra.outputFile(supportCodePath, '')
74+
const jsSupportCodePath = path.join(cwd, 'features', 'a.js')
75+
await fsExtra.outputFile(jsSupportCodePath, '')
76+
const esmSupportCodePath = path.join(cwd, 'features', 'a.mjs')
77+
await fsExtra.outputFile(esmSupportCodePath, '')
7678
const argv = baseArgv.concat([relativeFeaturePath])
7779

7880
// Act
@@ -82,7 +84,7 @@ describe('Configuration', () => {
8284
// Assert
8385
expect(featurePaths).to.eql([featurePath])
8486
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
85-
expect(supportCodePaths).to.eql([supportCodePath])
87+
expect(supportCodePaths).to.eql([jsSupportCodePath, esmSupportCodePath])
8688
})
8789

8890
it('deduplicates the .feature files before returning', async function () {

src/cli/index.ts

+12-10
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import { IParsedArgvFormatOptions } from './argv_parser'
2929
import HttpStream from '../formatter/http_stream'
3030
import { promisify } from 'util'
3131
import { Writable } from 'stream'
32+
import { pathToFileURL } from 'url'
3233

34+
// eslint-disable-next-line @typescript-eslint/no-var-requires
35+
const { importer } = require('../importer')
3336
const { incrementing, uuid } = IdGenerator
3437

3538
export interface ICliRunResult {
@@ -143,29 +146,28 @@ export default class Cli {
143146
)
144147
type = 'progress'
145148
}
146-
return FormatterBuilder.build(type, typeOptions)
149+
return await FormatterBuilder.build(type, typeOptions)
147150
})
148151
)
149152
return async function () {
150153
await Promise.all(formatters.map(async (f) => await f.finished()))
151154
}
152155
}
153156

154-
getSupportCodeLibrary({
157+
async getSupportCodeLibrary({
155158
newId,
156159
supportCodeRequiredModules,
157160
supportCodePaths,
158-
}: IGetSupportCodeLibraryRequest): ISupportCodeLibrary {
161+
}: IGetSupportCodeLibraryRequest): Promise<ISupportCodeLibrary> {
159162
supportCodeRequiredModules.map((module) => require(module))
160163
supportCodeLibraryBuilder.reset(this.cwd, newId)
161-
supportCodePaths.forEach((codePath) => {
162-
try {
164+
for (const codePath of supportCodePaths) {
165+
if (supportCodeRequiredModules.length) {
163166
require(codePath)
164-
} catch (e) {
165-
console.error(e.stack)
166-
console.error('codepath: ' + codePath)
167+
} else {
168+
await importer(pathToFileURL(codePath))
167169
}
168-
})
170+
}
169171
return supportCodeLibraryBuilder.finalize()
170172
}
171173

@@ -184,7 +186,7 @@ export default class Cli {
184186
configuration.predictableIds && configuration.parallel <= 1
185187
? incrementing()
186188
: uuid()
187-
const supportCodeLibrary = this.getSupportCodeLibrary({
189+
const supportCodeLibrary = await this.getSupportCodeLibrary({
188190
newId,
189191
supportCodePaths: configuration.supportCodePaths,
190192
supportCodeRequiredModules: configuration.supportCodeRequiredModules,

src/cli/profile_loader.ts

+19-12
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,31 @@ import path from 'path'
33
import stringArgv from 'string-argv'
44
import { doesHaveValue, doesNotHaveValue } from '../value_checker'
55

6-
export default class ProfileLoader {
7-
private readonly directory: string
6+
const DEFAULT_FILENAMES = ['cucumber.cjs', 'cucumber.js']
87

9-
constructor(directory: string) {
10-
this.directory = directory
11-
}
8+
export default class ProfileLoader {
9+
constructor(private readonly directory: string) {}
1210

1311
async getDefinitions(configFile?: string): Promise<Record<string, string>> {
14-
const definitionsFilePath: string = path.join(
15-
this.directory,
16-
configFile || 'cucumber.js'
12+
if (configFile) {
13+
return this.loadFile(configFile)
14+
}
15+
16+
const defaultFile = DEFAULT_FILENAMES.find((filename) =>
17+
fs.existsSync(path.join(this.directory, filename))
1718
)
1819

19-
const exists = await fs.exists(definitionsFilePath)
20-
if (!exists) {
21-
return {}
20+
if (defaultFile) {
21+
return this.loadFile(defaultFile)
2222
}
23-
const definitions = require(definitionsFilePath) // eslint-disable-line @typescript-eslint/no-var-requires
23+
24+
return {}
25+
}
26+
27+
loadFile(configFile: string): Record<string, string> {
28+
const definitionsFilePath: string = path.join(this.directory, configFile)
29+
// eslint-disable-next-line @typescript-eslint/no-var-requires
30+
const definitions = require(definitionsFilePath)
2431
if (typeof definitions !== 'object') {
2532
throw new Error(`${definitionsFilePath} does not export an object`)
2633
}

0 commit comments

Comments
 (0)