Skip to content

Commit 1a8bc79

Browse files
authored
cli: esm support (cucumber#1589)
* add mjs wrapper * use import instead of require for support code * support es2018 compile target * separate cli for esm, override importer fn * include mjs wrapper in package * add feature for testing with esm * for now, bail if on windows * all same exports as cjs entry point * pivot to single binary with flag and hack to import import * fix feature file wording * fix lint and test * skip esm scenario if not node 12 or higher * make it fail with imports in cucumber.js file * make it work for cucumber.js file * make it fail for a custom formatter * avoid collision * make importing formatters work * make custom snippets work * Include .mjs files by default if using ESM * test with and without parallel * unignore windows in esm tests * add cli doc * add changelog entry * rename this * sometimes use `pathToFileURL` as appropriate * readd semi * rework config builder tests * further resimplify test * improve doco * include importer.js in src, copy at build time * link to docs from changelog entry * put wrapper.mjs in src as well
1 parent e2fd32a commit 1a8bc79

21 files changed

+348
-68
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO
1111

1212
### Added
1313

14+
* Experimental support for native ES modules via the [`--esm` flag](./docs/cli.md#es-modules-experimental-nodejs-12) ([#1589](https://github.com/cucumber/cucumber-js/pull/1589))
15+
1416
### Changed
1517

1618
### Deprecated

dependency-lint.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ requiredModules:
4343
- 'dist/**/*'
4444
- 'lib/**/*'
4545
- 'node_modules/**/*'
46+
- 'src/importers.js'
4647
- 'tmp/**/*'
4748
root: '**/*.{js,ts}'
4849
stripLoaders: false

docs/cli.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ You can pass in format options with `--format-options <JSON>`. The JSON string m
8181

8282
* Suggested use: add with profiles so you can define an object and use `JSON.stringify` instead of writing `JSON` manually.
8383

84+
## ES Modules (experimental) (Node.js 12+)
85+
86+
You can optionally write your support code (steps, hooks, etc) with native ES modules syntax - i.e. using `import` and `export` statements without transpiling.
87+
88+
To enable this, run with the `--esm` flag.
89+
90+
This will also expand the default glob for support files to include the `.mjs` file extension.
91+
92+
As well as support code, these things can also be in ES modules syntax:
93+
94+
- Custom formatters
95+
- Custom snippets
96+
- Your `cucumber.js` config file
97+
98+
You can use ES modules selectively/incrementally - the module loading strategy that the `--esm` flag activates supports both ES modules and CommonJS.
99+
84100
## Colors
85101

86102
Colors can be disabled with `--format-options '{"colorsEnabled": false}'`

features/esm.feature

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
Feature: ES modules support
2+
3+
cucumber-js works with native ES modules, via a Cli flag `--esm`
4+
5+
@esm
6+
Scenario Outline: native module syntax works when using --esm
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 "cucumber.js" with:
23+
"""
24+
export default {
25+
'default': '--format message:messages.ndjson',
26+
}
27+
"""
28+
And a file named "custom-formatter.js" with:
29+
"""
30+
import {SummaryFormatter} from '@cucumber/cucumber'
31+
32+
export default class CustomFormatter extends SummaryFormatter {}
33+
"""
34+
And a file named "custom-snippet-syntax.js" with:
35+
"""
36+
export default class CustomSnippetSyntax {
37+
build(opts) {
38+
return 'hello world'
39+
}
40+
}
41+
"""
42+
When I run cucumber-js with `<options> --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}'`
43+
Then it passes
44+
Examples:
45+
| options |
46+
| --esm |
47+
| --esm --parallel 2 |
48+
49+
@esm
50+
Scenario: .mjs support code files are matched by default when using --esm
51+
Given a file named "features/a.feature" with:
52+
"""
53+
Feature:
54+
Scenario:
55+
Given a step passes
56+
"""
57+
And a file named "features/step_definitions/cucumber_steps.mjs" with:
58+
"""
59+
import {Given} from '@cucumber/cucumber'
60+
61+
Given(/^a step passes$/, function() {});
62+
"""
63+
When I run cucumber-js with `--esm`
64+
Then it passes
65+
66+
Scenario: native module syntax doesn't work without --esm
67+
Given a file named "features/a.feature" with:
68+
"""
69+
Feature:
70+
Scenario:
71+
Given a step passes
72+
"""
73+
And a file named "features/step_definitions/cucumber_steps.js" with:
74+
"""
75+
import {Given} from '@cucumber/cucumber'
76+
77+
Given(/^a step passes$/, function() {});
78+
"""
79+
When I run cucumber-js
80+
Then it fails

features/support/hooks.ts

Lines changed: 13 additions & 1 deletion
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,18 @@ Before(function (
4343
this.localExecutablePath = path.join(projectPath, 'bin', 'cucumber-js')
4444
})
4545

46+
Before('@esm', function (this: World) {
47+
const [majorVersion] = process.versions.node.split('.')
48+
if (Number(majorVersion) < 12) {
49+
return 'skipped'
50+
}
51+
fsExtra.writeJSONSync(path.join(this.tmpDir, 'package.json'), {
52+
name: 'feature-test-pickle',
53+
type: 'module',
54+
})
55+
return undefined
56+
})
57+
4658
Before('@global-install', function (this: World) {
4759
const tmpObject = tmp.dirSync({ unsafeCleanup: true })
4860

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@
163163
"lib": "./lib"
164164
},
165165
"main": "./lib/index.js",
166+
"exports": {
167+
"import": "./lib/wrapper.mjs",
168+
"require": "./lib/index.js"
169+
},
166170
"types": "./lib/index.d.ts",
167171
"engines": {
168172
"node": ">=10"
@@ -182,7 +186,6 @@
182186
"cli-table3": "^0.6.0",
183187
"colors": "^1.4.0",
184188
"commander": "^7.0.0",
185-
"create-require": "^1.1.1",
186189
"duration": "^0.2.2",
187190
"durations": "^3.4.2",
188191
"figures": "^3.2.0",
@@ -257,7 +260,7 @@
257260
"typescript": "4.2.3"
258261
},
259262
"scripts": {
260-
"build-local": "tsc -p tsconfig.node.json",
263+
"build-local": "tsc -p tsconfig.node.json && cp src/importers.js lib/ && cp src/wrapper.mjs lib/",
261264
"cck-test": "mocha 'compatibility/**/*_spec.ts'",
262265
"feature-test": "node ./bin/cucumber-js",
263266
"html-formatter": "node ./bin/cucumber-js --profile htmlFormatter",

src/cli/argv_parser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface IParsedArgvFormatOptions {
2222
export interface IParsedArgvOptions {
2323
backtrace: boolean
2424
dryRun: boolean
25+
esm: boolean
2526
exit: boolean
2627
failFast: boolean
2728
format: string[]
@@ -112,6 +113,7 @@ const ArgvParser = {
112113
'invoke formatters without executing steps',
113114
false
114115
)
116+
.option('--esm', 'import support code via ES module imports', false)
115117
.option(
116118
'--exit',
117119
'force shutdown of the event loop when the test run has finished: cucumber will call process.exit',

src/cli/configuration_builder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface IConfigurationFormat {
1919
}
2020

2121
export interface IConfiguration {
22+
esm: boolean
2223
featureDefaultLanguage: string
2324
featurePaths: string[]
2425
formats: IConfigurationFormat[]
@@ -80,10 +81,11 @@ export default class ConfigurationBuilder {
8081
}
8182
supportCodePaths = await this.expandPaths(
8283
unexpandedSupportCodePaths,
83-
'.js'
84+
this.options.esm ? '.@(js|mjs)' : '.js'
8485
)
8586
}
8687
return {
88+
esm: this.options.esm,
8789
featureDefaultLanguage: this.options.language,
8890
featurePaths,
8991
formats: this.getFormats(),

src/cli/configuration_builder_spec.ts

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('Configuration', () => {
2929

3030
// Assert
3131
expect(result).to.eql({
32+
esm: false,
3233
featureDefaultLanguage: 'en',
3334
featurePaths: [],
3435
formatOptions: {},
@@ -65,27 +66,79 @@ describe('Configuration', () => {
6566
})
6667

6768
describe('path to a feature', () => {
68-
it('returns the appropriate feature and support code paths', async function () {
69-
// Arrange
70-
const cwd = await buildTestWorkingDirectory()
71-
const relativeFeaturePath = path.join('features', 'a.feature')
72-
const featurePath = path.join(cwd, relativeFeaturePath)
73-
await fsExtra.outputFile(featurePath, '')
74-
const supportCodePath = path.join(cwd, 'features', 'a.js')
75-
await fsExtra.outputFile(supportCodePath, '')
76-
const argv = baseArgv.concat([relativeFeaturePath])
69+
describe('without esm', () => {
70+
it('returns the appropriate feature and support code paths', async function () {
71+
// Arrange
72+
const cwd = await buildTestWorkingDirectory()
73+
const relativeFeaturePath = path.join('features', 'a.feature')
74+
const featurePath = path.join(cwd, relativeFeaturePath)
75+
await fsExtra.outputFile(featurePath, '')
76+
const supportCodePath = path.join(cwd, 'features', 'a.js')
77+
await fsExtra.outputFile(supportCodePath, '')
78+
const argv = baseArgv.concat([relativeFeaturePath])
79+
80+
// Act
81+
const {
82+
featurePaths,
83+
pickleFilterOptions,
84+
supportCodePaths,
85+
} = await ConfigurationBuilder.build({ argv, cwd })
86+
87+
// Assert
88+
expect(featurePaths).to.eql([featurePath])
89+
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
90+
expect(supportCodePaths).to.eql([supportCodePath])
91+
})
92+
})
7793

78-
// Act
79-
const {
80-
featurePaths,
81-
pickleFilterOptions,
82-
supportCodePaths,
83-
} = await ConfigurationBuilder.build({ argv, cwd })
94+
describe('with esm and js support files', () => {
95+
it('returns the appropriate feature and support code paths', async function () {
96+
// Arrange
97+
const cwd = await buildTestWorkingDirectory()
98+
const relativeFeaturePath = path.join('features', 'a.feature')
99+
const featurePath = path.join(cwd, relativeFeaturePath)
100+
await fsExtra.outputFile(featurePath, '')
101+
const supportCodePath = path.join(cwd, 'features', 'a.js')
102+
await fsExtra.outputFile(supportCodePath, '')
103+
const argv = baseArgv.concat([relativeFeaturePath, '--esm'])
104+
105+
// Act
106+
const {
107+
featurePaths,
108+
pickleFilterOptions,
109+
supportCodePaths,
110+
} = await ConfigurationBuilder.build({ argv, cwd })
111+
112+
// Assert
113+
expect(featurePaths).to.eql([featurePath])
114+
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
115+
expect(supportCodePaths).to.eql([supportCodePath])
116+
})
117+
})
84118

85-
// Assert
86-
expect(featurePaths).to.eql([featurePath])
87-
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
88-
expect(supportCodePaths).to.eql([supportCodePath])
119+
describe('with esm and mjs support files', () => {
120+
it('returns the appropriate feature and support code paths', async function () {
121+
// Arrange
122+
const cwd = await buildTestWorkingDirectory()
123+
const relativeFeaturePath = path.join('features', 'a.feature')
124+
const featurePath = path.join(cwd, relativeFeaturePath)
125+
await fsExtra.outputFile(featurePath, '')
126+
const supportCodePath = path.join(cwd, 'features', 'a.mjs')
127+
await fsExtra.outputFile(supportCodePath, '')
128+
const argv = baseArgv.concat([relativeFeaturePath, '--esm'])
129+
130+
// Act
131+
const {
132+
featurePaths,
133+
pickleFilterOptions,
134+
supportCodePaths,
135+
} = await ConfigurationBuilder.build({ argv, cwd })
136+
137+
// Assert
138+
expect(featurePaths).to.eql([featurePath])
139+
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
140+
expect(supportCodePaths).to.eql([supportCodePath])
141+
})
89142
})
90143
})
91144

src/cli/helpers.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import TestCaseHookDefinition from '../models/test_case_hook_definition'
1616
import TestRunHookDefinition from '../models/test_run_hook_definition'
1717
import { builtinParameterTypes } from '../support_code_library_builder'
1818

19+
// eslint-disable-next-line @typescript-eslint/no-var-requires
20+
const importers = require('../importers')
21+
1922
const StepDefinitionPatternType =
2023
messages.StepDefinition.StepDefinitionPattern.StepDefinitionPatternType
2124

@@ -29,8 +32,11 @@ export async function getExpandedArgv({
2932
cwd,
3033
}: IGetExpandedArgvRequest): Promise<string[]> {
3134
const { options } = ArgvParser.parse(argv)
35+
const importer = options.esm ? importers.esm : importers.legacy
3236
let fullArgv = argv
33-
const profileArgv = await new ProfileLoader(cwd).getArgv(options.profile)
37+
const profileArgv = await new ProfileLoader(cwd, importer).getArgv(
38+
options.profile
39+
)
3440
if (profileArgv.length > 0) {
3541
fullArgv = _.concat(argv.slice(0, 2), profileArgv, argv.slice(2))
3642
}

0 commit comments

Comments
 (0)