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

cli: esm support #1589

Merged
merged 34 commits into from
Apr 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
327c8fe
add mjs wrapper
davidjgoss Feb 26, 2021
38e7282
use import instead of require for support code
davidjgoss Feb 26, 2021
964118f
support es2018 compile target
davidjgoss Feb 26, 2021
c8943bc
separate cli for esm, override importer fn
davidjgoss Feb 27, 2021
9dc49f6
include mjs wrapper in package
davidjgoss Feb 27, 2021
50da4dc
add feature for testing with esm
davidjgoss Feb 28, 2021
01de7f2
for now, bail if on windows
davidjgoss Feb 28, 2021
9e6b29d
all same exports as cjs entry point
davidjgoss Feb 28, 2021
0f30b12
pivot to single binary with flag and hack to import import
davidjgoss Mar 10, 2021
6743af8
fix feature file wording
davidjgoss Mar 10, 2021
9d10ce1
fix lint and test
davidjgoss Mar 10, 2021
b37fb13
skip esm scenario if not node 12 or higher
davidjgoss Mar 11, 2021
68096e6
make it fail with imports in cucumber.js file
davidjgoss Mar 11, 2021
0eaaa0a
make it work for cucumber.js file
davidjgoss Mar 11, 2021
0e36903
make it fail for a custom formatter
davidjgoss Mar 11, 2021
c2c7ad5
avoid collision
davidjgoss Mar 11, 2021
23af315
Merge branch 'master' into esm-maybe
davidjgoss Mar 11, 2021
eaf2b69
Merge branch 'master' into esm-maybe
davidjgoss Apr 7, 2021
7d34fe3
make importing formatters work
davidjgoss Apr 7, 2021
4a5c7ca
make custom snippets work
davidjgoss Apr 7, 2021
a78b63a
Include .mjs files by default if using ESM
davidjgoss Apr 7, 2021
a9a8cc3
test with and without parallel
davidjgoss Apr 7, 2021
d0ed9cc
unignore windows in esm tests
davidjgoss Apr 7, 2021
52595c0
add cli doc
davidjgoss Apr 7, 2021
ca140ce
add changelog entry
davidjgoss Apr 7, 2021
5808b39
rename this
davidjgoss Apr 7, 2021
32a8158
sometimes use `pathToFileURL` as appropriate
davidjgoss Apr 7, 2021
a1b6ba6
readd semi
davidjgoss Apr 13, 2021
c2f6bd3
rework config builder tests
davidjgoss Apr 13, 2021
5e1da47
further resimplify test
davidjgoss Apr 13, 2021
d11087b
improve doco
davidjgoss Apr 14, 2021
ad28700
include importer.js in src, copy at build time
davidjgoss Apr 17, 2021
3f2e640
link to docs from changelog entry
davidjgoss Apr 17, 2021
38febb5
put wrapper.mjs in src as well
davidjgoss Apr 17, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO

### Added

* 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))

### Changed

### Deprecated
Expand Down
1 change: 1 addition & 0 deletions dependency-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ requiredModules:
- 'dist/**/*'
- 'lib/**/*'
- 'node_modules/**/*'
- 'src/importers.js'
- 'tmp/**/*'
root: '**/*.{js,ts}'
stripLoaders: false
Expand Down
16 changes: 16 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ You can pass in format options with `--format-options <JSON>`. The JSON string m

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

## ES Modules (experimental) (Node.js 12+)

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 enable this, run with the `--esm` flag.

This will also expand the default glob for support files to include the `.mjs` file extension.
Copy link
Member

Choose a reason for hiding this comment

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

Is ESM purely additive (ie require is still supported)?

If not, would seem like using this requires custom formatters used would also need to be written in ESM

Copy link
Contributor Author

@davidjgoss davidjgoss Apr 14, 2021

Choose a reason for hiding this comment

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

ESM is additive. If enabled, it'll use import() to load all user code (steps/hooks, formatters, snippets, and config file). If any of this user code is CommonJS and has require()s in it, that will still work fine - you can import() CommonJS modules, but you can't require() ES modules.

Eventually (after dropping Node 12 support, I think) we'll be able to just always use import() and not need the flag.

(Hope I've understood the question right!)

Copy link
Contributor Author

@davidjgoss davidjgoss Apr 14, 2021

Choose a reason for hiding this comment

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

And I've pushed some documentation improvements to this effect.

Copy link
Member

Choose a reason for hiding this comment

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

Looks good. Thanks!


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

- Custom formatters
- Custom snippets
- Your `cucumber.js` config file

You can use ES modules selectively/incrementally - the module loading strategy that the `--esm` flag activates supports both ES modules and CommonJS.

## Colors

Colors can be disabled with `--format-options '{"colorsEnabled": false}'`
Expand Down
80 changes: 80 additions & 0 deletions features/esm.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
Feature: ES modules support

cucumber-js works with native ES modules, via a Cli flag `--esm`

@esm
Scenario Outline: native module syntax works when using --esm
Given a file named "features/a.feature" with:
"""
Feature:
Scenario: one
Given a step passes

Scenario: two
Given a step passes
"""
And a file named "features/step_definitions/cucumber_steps.js" with:
"""
import {Given} from '@cucumber/cucumber'

Given(/^a step passes$/, function() {});
"""
And a file named "cucumber.js" with:
"""
export default {
'default': '--format message:messages.ndjson',
}
"""
And a file named "custom-formatter.js" with:
"""
import {SummaryFormatter} from '@cucumber/cucumber'

export default class CustomFormatter extends SummaryFormatter {}
"""
And a file named "custom-snippet-syntax.js" with:
"""
export default class CustomSnippetSyntax {
build(opts) {
return 'hello world'
}
}
"""
When I run cucumber-js with `<options> --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}'`
Then it passes
Examples:
| options |
| --esm |
| --esm --parallel 2 |

@esm
Scenario: .mjs support code files are matched by default when using --esm
Given a file named "features/a.feature" with:
"""
Feature:
Scenario:
Given a step passes
"""
And a file named "features/step_definitions/cucumber_steps.mjs" with:
"""
import {Given} from '@cucumber/cucumber'

Given(/^a step passes$/, function() {});
"""
When I run cucumber-js with `--esm`
Then it passes

Scenario: native module syntax doesn't work without --esm
Given a file named "features/a.feature" with:
"""
Feature:
Scenario:
Given a step passes
"""
And a file named "features/step_definitions/cucumber_steps.js" with:
"""
import {Given} from '@cucumber/cucumber'

Given(/^a step passes$/, function() {});
"""
When I run cucumber-js
Then it fails
14 changes: 13 additions & 1 deletion features/support/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Before('@debug', function (this: World) {
this.debug = true
})

Before('@spawn', function (this: World) {
Before('@spawn or @esm', function (this: World) {
this.spawn = true
})

Expand Down Expand Up @@ -43,6 +43,18 @@ Before(function (
this.localExecutablePath = path.join(projectPath, 'bin', 'cucumber-js')
})

Before('@esm', function (this: World) {
const [majorVersion] = process.versions.node.split('.')
if (Number(majorVersion) < 12) {
return 'skipped'
}
fsExtra.writeJSONSync(path.join(this.tmpDir, 'package.json'), {
name: 'feature-test-pickle',
type: 'module',
})
return undefined
})

Before('@global-install', function (this: World) {
const tmpObject = tmp.dirSync({ unsafeCleanup: true })

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@
"lib": "./lib"
},
"main": "./lib/index.js",
"exports": {
"import": "./lib/wrapper.mjs",
"require": "./lib/index.js"
},
"types": "./lib/index.d.ts",
"engines": {
"node": ">=10"
Expand All @@ -182,7 +186,6 @@
"cli-table3": "^0.6.0",
"colors": "^1.4.0",
"commander": "^7.0.0",
"create-require": "^1.1.1",
"duration": "^0.2.2",
"durations": "^3.4.2",
"figures": "^3.2.0",
Expand Down Expand Up @@ -257,7 +260,7 @@
"typescript": "4.2.3"
},
"scripts": {
"build-local": "tsc -p tsconfig.node.json",
"build-local": "tsc -p tsconfig.node.json && cp src/importers.js lib/ && cp src/wrapper.mjs lib/",
"cck-test": "mocha 'compatibility/**/*_spec.ts'",
"feature-test": "node ./bin/cucumber-js",
"html-formatter": "node ./bin/cucumber-js --profile htmlFormatter",
Expand Down
2 changes: 2 additions & 0 deletions src/cli/argv_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface IParsedArgvFormatOptions {
export interface IParsedArgvOptions {
backtrace: boolean
dryRun: boolean
esm: boolean
exit: boolean
failFast: boolean
format: string[]
Expand Down Expand Up @@ -112,6 +113,7 @@ const ArgvParser = {
'invoke formatters without executing steps',
false
)
.option('--esm', 'import support code via ES module imports', false)
.option(
'--exit',
'force shutdown of the event loop when the test run has finished: cucumber will call process.exit',
Expand Down
4 changes: 3 additions & 1 deletion src/cli/configuration_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface IConfigurationFormat {
}

export interface IConfiguration {
esm: boolean
featureDefaultLanguage: string
featurePaths: string[]
formats: IConfigurationFormat[]
Expand Down Expand Up @@ -80,10 +81,11 @@ export default class ConfigurationBuilder {
}
supportCodePaths = await this.expandPaths(
unexpandedSupportCodePaths,
'.js'
this.options.esm ? '.@(js|mjs)' : '.js'
)
}
return {
esm: this.options.esm,
featureDefaultLanguage: this.options.language,
featurePaths,
formats: this.getFormats(),
Expand Down
91 changes: 72 additions & 19 deletions src/cli/configuration_builder_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('Configuration', () => {

// Assert
expect(result).to.eql({
esm: false,
featureDefaultLanguage: 'en',
featurePaths: [],
formatOptions: {},
Expand Down Expand Up @@ -65,27 +66,79 @@ describe('Configuration', () => {
})

describe('path to a feature', () => {
it('returns the appropriate feature and support code paths', async function () {
// Arrange
const cwd = await buildTestWorkingDirectory()
const relativeFeaturePath = path.join('features', 'a.feature')
const featurePath = path.join(cwd, relativeFeaturePath)
await fsExtra.outputFile(featurePath, '')
const supportCodePath = path.join(cwd, 'features', 'a.js')
await fsExtra.outputFile(supportCodePath, '')
const argv = baseArgv.concat([relativeFeaturePath])
describe('without esm', () => {
it('returns the appropriate feature and support code paths', async function () {
// Arrange
const cwd = await buildTestWorkingDirectory()
const relativeFeaturePath = path.join('features', 'a.feature')
const featurePath = path.join(cwd, relativeFeaturePath)
await fsExtra.outputFile(featurePath, '')
const supportCodePath = path.join(cwd, 'features', 'a.js')
await fsExtra.outputFile(supportCodePath, '')
const argv = baseArgv.concat([relativeFeaturePath])

// Act
const {
featurePaths,
pickleFilterOptions,
supportCodePaths,
} = await ConfigurationBuilder.build({ argv, cwd })

// Assert
expect(featurePaths).to.eql([featurePath])
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
expect(supportCodePaths).to.eql([supportCodePath])
})
})

// Act
const {
featurePaths,
pickleFilterOptions,
supportCodePaths,
} = await ConfigurationBuilder.build({ argv, cwd })
describe('with esm and js support files', () => {
it('returns the appropriate feature and support code paths', async function () {
// Arrange
const cwd = await buildTestWorkingDirectory()
const relativeFeaturePath = path.join('features', 'a.feature')
const featurePath = path.join(cwd, relativeFeaturePath)
await fsExtra.outputFile(featurePath, '')
const supportCodePath = path.join(cwd, 'features', 'a.js')
await fsExtra.outputFile(supportCodePath, '')
const argv = baseArgv.concat([relativeFeaturePath, '--esm'])

// Act
const {
featurePaths,
pickleFilterOptions,
supportCodePaths,
} = await ConfigurationBuilder.build({ argv, cwd })

// Assert
expect(featurePaths).to.eql([featurePath])
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
expect(supportCodePaths).to.eql([supportCodePath])
})
})

// Assert
expect(featurePaths).to.eql([featurePath])
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
expect(supportCodePaths).to.eql([supportCodePath])
describe('with esm and mjs support files', () => {
it('returns the appropriate feature and support code paths', async function () {
// Arrange
const cwd = await buildTestWorkingDirectory()
const relativeFeaturePath = path.join('features', 'a.feature')
const featurePath = path.join(cwd, relativeFeaturePath)
await fsExtra.outputFile(featurePath, '')
const supportCodePath = path.join(cwd, 'features', 'a.mjs')
await fsExtra.outputFile(supportCodePath, '')
const argv = baseArgv.concat([relativeFeaturePath, '--esm'])

// Act
const {
featurePaths,
pickleFilterOptions,
supportCodePaths,
} = await ConfigurationBuilder.build({ argv, cwd })

// Assert
expect(featurePaths).to.eql([featurePath])
expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath])
expect(supportCodePaths).to.eql([supportCodePath])
})
})
})

Expand Down
8 changes: 7 additions & 1 deletion src/cli/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import TestCaseHookDefinition from '../models/test_case_hook_definition'
import TestRunHookDefinition from '../models/test_run_hook_definition'
import { builtinParameterTypes } from '../support_code_library_builder'

// eslint-disable-next-line @typescript-eslint/no-var-requires
const importers = require('../importers')

const StepDefinitionPatternType =
messages.StepDefinition.StepDefinitionPattern.StepDefinitionPatternType

Expand All @@ -29,8 +32,11 @@ export async function getExpandedArgv({
cwd,
}: IGetExpandedArgvRequest): Promise<string[]> {
const { options } = ArgvParser.parse(argv)
const importer = options.esm ? importers.esm : importers.legacy
let fullArgv = argv
const profileArgv = await new ProfileLoader(cwd).getArgv(options.profile)
const profileArgv = await new ProfileLoader(cwd, importer).getArgv(
options.profile
)
if (profileArgv.length > 0) {
fullArgv = _.concat(argv.slice(0, 2), profileArgv, argv.slice(2))
}
Expand Down
Loading