From d268a8dd373fefc12d8a1d39bcd937cf413d54e1 Mon Sep 17 00:00:00 2001 From: davidgoss Date: Wed, 21 Apr 2021 22:13:44 +0100 Subject: [PATCH 01/25] Revert "temporarily revert ESM change (#1647)" This reverts commit 084c1f258097d1e424c3ecddf8bd980d8d8f5229. --- dependency-lint.yml | 1 + docs/cli.md | 16 ++++ features/esm.feature | 80 +++++++++++++++++ features/support/hooks.ts | 14 ++- package.json | 7 +- src/cli/argv_parser.ts | 2 + src/cli/configuration_builder.ts | 4 +- src/cli/configuration_builder_spec.ts | 91 ++++++++++++++++---- src/cli/helpers.ts | 8 +- src/cli/index.ts | 28 ++++-- src/cli/profile_loader.ts | 12 +-- src/cli/profile_loader_spec.ts | 5 +- src/formatter/builder.ts | 74 ++++++++++------ src/formatter/helpers/issue_helpers_spec.ts | 3 +- src/formatter/progress_bar_formatter_spec.ts | 5 +- src/importers.js | 17 ++++ src/wrapper.mjs | 38 ++++++++ test/formatter_helpers.ts | 3 +- tsconfig.json | 4 +- yarn.lock | 2 +- 20 files changed, 346 insertions(+), 68 deletions(-) create mode 100644 features/esm.feature create mode 100644 src/importers.js create mode 100644 src/wrapper.mjs diff --git a/dependency-lint.yml b/dependency-lint.yml index d85654c33..a4c469fc7 100644 --- a/dependency-lint.yml +++ b/dependency-lint.yml @@ -43,6 +43,7 @@ requiredModules: - 'dist/**/*' - 'lib/**/*' - 'node_modules/**/*' + - 'src/importers.js' - 'tmp/**/*' root: '**/*.{js,ts}' stripLoaders: false diff --git a/docs/cli.md b/docs/cli.md index 9b8a1acd1..2f2d86258 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -81,6 +81,22 @@ You can pass in format options with `--format-options `. 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. + +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}'` diff --git a/features/esm.feature b/features/esm.feature new file mode 100644 index 000000000..96397709c --- /dev/null +++ b/features/esm.feature @@ -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 ` --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 \ No newline at end of file diff --git a/features/support/hooks.ts b/features/support/hooks.ts index 04d5bee06..a5f056d55 100644 --- a/features/support/hooks.ts +++ b/features/support/hooks.ts @@ -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 }) @@ -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 }) diff --git a/package.json b/package.json index 53a558fa4..71607bed0 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", @@ -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", diff --git a/src/cli/argv_parser.ts b/src/cli/argv_parser.ts index 1a80381fc..94e828009 100644 --- a/src/cli/argv_parser.ts +++ b/src/cli/argv_parser.ts @@ -22,6 +22,7 @@ export interface IParsedArgvFormatOptions { export interface IParsedArgvOptions { backtrace: boolean dryRun: boolean + esm: boolean exit: boolean failFast: boolean format: string[] @@ -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', diff --git a/src/cli/configuration_builder.ts b/src/cli/configuration_builder.ts index e830b11c5..7c86484af 100644 --- a/src/cli/configuration_builder.ts +++ b/src/cli/configuration_builder.ts @@ -19,6 +19,7 @@ export interface IConfigurationFormat { } export interface IConfiguration { + esm: boolean featureDefaultLanguage: string featurePaths: string[] formats: IConfigurationFormat[] @@ -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(), diff --git a/src/cli/configuration_builder_spec.ts b/src/cli/configuration_builder_spec.ts index 46138c50e..61cda90c1 100644 --- a/src/cli/configuration_builder_spec.ts +++ b/src/cli/configuration_builder_spec.ts @@ -29,6 +29,7 @@ describe('Configuration', () => { // Assert expect(result).to.eql({ + esm: false, featureDefaultLanguage: 'en', featurePaths: [], formatOptions: {}, @@ -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]) + }) }) }) diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index c56414f22..514b22460 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -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 @@ -29,8 +32,11 @@ export async function getExpandedArgv({ cwd, }: IGetExpandedArgvRequest): Promise { 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)) } diff --git a/src/cli/index.ts b/src/cli/index.ts index 25020be63..a8e5f3672 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,6 +30,8 @@ import { IParsedArgvFormatOptions } from './argv_parser' import HttpStream from '../formatter/http_stream' import { Writable } from 'stream' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const importers = require('../importers') const { incrementing, uuid } = IdGenerator export interface ICliRunResult { @@ -51,10 +53,16 @@ interface IGetSupportCodeLibraryRequest { supportCodePaths: string[] } +export type IUserCodeImporter = ( + path: string, + isFilePath?: boolean +) => Promise + export default class Cli { private readonly argv: string[] private readonly cwd: string private readonly stdout: IFormatterStream + private importer: IUserCodeImporter = importers.legacy constructor({ argv, @@ -125,6 +133,7 @@ export default class Cli { eventDataCollector, log: stream.write.bind(stream), parsedArgvOptions: formatOptions, + importer: this.importer, stream, cleanup: stream === this.stdout @@ -142,7 +151,7 @@ export default class Cli { ) type = 'progress' } - return FormatterBuilder.build(type, typeOptions) + return await FormatterBuilder.build(type, typeOptions) } ) return async function () { @@ -152,14 +161,18 @@ export default class Cli { } } - getSupportCodeLibrary({ + async getSupportCodeLibrary({ newId, supportCodeRequiredModules, supportCodePaths, - }: IGetSupportCodeLibraryRequest): ISupportCodeLibrary { - supportCodeRequiredModules.map((module) => require(module)) + }: IGetSupportCodeLibraryRequest): Promise { + for (const requiredModule of supportCodeRequiredModules) { + await this.importer(requiredModule) + } supportCodeLibraryBuilder.reset(this.cwd, newId) - supportCodePaths.forEach((codePath) => require(codePath)) + for (const codePath of supportCodePaths) { + await this.importer(codePath, true) + } return supportCodeLibraryBuilder.finalize() } @@ -178,7 +191,10 @@ export default class Cli { configuration.predictableIds && configuration.parallel <= 1 ? incrementing() : uuid() - const supportCodeLibrary = this.getSupportCodeLibrary({ + if (configuration.esm) { + this.importer = importers.esm + } + const supportCodeLibrary = await this.getSupportCodeLibrary({ newId, supportCodePaths: configuration.supportCodePaths, supportCodeRequiredModules: configuration.supportCodeRequiredModules, diff --git a/src/cli/profile_loader.ts b/src/cli/profile_loader.ts index 0dd9b6344..893fe32b1 100644 --- a/src/cli/profile_loader.ts +++ b/src/cli/profile_loader.ts @@ -3,13 +3,13 @@ import fs from 'mz/fs' import path from 'path' import stringArgv from 'string-argv' import { doesHaveValue, doesNotHaveValue } from '../value_checker' +import { IUserCodeImporter } from './index' export default class ProfileLoader { - private readonly directory: string - - constructor(directory: string) { - this.directory = directory - } + constructor( + private readonly directory: string, + private readonly importer: IUserCodeImporter + ) {} async getDefinitions(): Promise> { const definitionsFilePath = path.join(this.directory, 'cucumber.js') @@ -17,7 +17,7 @@ export default class ProfileLoader { if (!exists) { return {} } - const definitions = require(definitionsFilePath) // eslint-disable-line @typescript-eslint/no-var-requires + const definitions = await this.importer(definitionsFilePath, true) if (typeof definitions !== 'object') { throw new Error(`${definitionsFilePath} does not export an object`) } diff --git a/src/cli/profile_loader_spec.ts b/src/cli/profile_loader_spec.ts index a57d52ccd..d8813fcc6 100644 --- a/src/cli/profile_loader_spec.ts +++ b/src/cli/profile_loader_spec.ts @@ -7,6 +7,9 @@ import tmp, { DirOptions } from 'tmp' import { promisify } from 'util' import { doesHaveValue, valueOrDefault } from '../value_checker' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const importers = require('../importers') + interface TestProfileLoaderOptions { definitionsFileContent?: string profiles?: string[] @@ -24,7 +27,7 @@ async function testProfileLoader( opts.definitionsFileContent ) } - const profileLoader = new ProfileLoader(cwd) + const profileLoader = new ProfileLoader(cwd, importers.legacy) return await profileLoader.getArgv(valueOrDefault(opts.profiles, [])) } diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index 27da244eb..4f6d2b10d 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -20,10 +20,11 @@ import { Writable as WritableStream } from 'stream' import { IParsedArgvFormatOptions } from '../cli/argv_parser' import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax' import HtmlFormatter from './html_formatter' -import createRequire from 'create-require' +import { IUserCodeImporter } from '../cli' interface IGetStepDefinitionSnippetBuilderOptions { cwd: string + importer: IUserCodeImporter snippetInterface?: SnippetInterface snippetSyntax?: string supportCodeLibrary: ISupportCodeLibrary @@ -35,24 +36,29 @@ export interface IBuildOptions { eventDataCollector: EventDataCollector log: IFormatterLogFn parsedArgvOptions: IParsedArgvFormatOptions + importer: IUserCodeImporter stream: WritableStream cleanup: IFormatterCleanupFn supportCodeLibrary: ISupportCodeLibrary } const FormatterBuilder = { - build(type: string, options: IBuildOptions): Formatter { - const FormatterConstructor = FormatterBuilder.getConstructorByType( + async build(type: string, options: IBuildOptions): Promise { + const FormatterConstructor = await FormatterBuilder.getConstructorByType( type, - options.cwd + options.cwd, + options.importer ) const colorFns = getColorFns(options.parsedArgvOptions.colorsEnabled) - const snippetBuilder = FormatterBuilder.getStepDefinitionSnippetBuilder({ - cwd: options.cwd, - snippetInterface: options.parsedArgvOptions.snippetInterface, - snippetSyntax: options.parsedArgvOptions.snippetSyntax, - supportCodeLibrary: options.supportCodeLibrary, - }) + const snippetBuilder = await FormatterBuilder.getStepDefinitionSnippetBuilder( + { + cwd: options.cwd, + importer: options.importer, + snippetInterface: options.parsedArgvOptions.snippetInterface, + snippetSyntax: options.parsedArgvOptions.snippetSyntax, + supportCodeLibrary: options.supportCodeLibrary, + } + ) return new FormatterConstructor({ colorFns, snippetBuilder, @@ -60,7 +66,11 @@ const FormatterBuilder = { }) }, - getConstructorByType(type: string, cwd: string): typeof Formatter { + async getConstructorByType( + type: string, + cwd: string, + importer: IUserCodeImporter + ): Promise { switch (type) { case 'json': return JsonFormatter @@ -83,12 +93,13 @@ const FormatterBuilder = { case 'usage-json': return UsageJsonFormatter default: - return FormatterBuilder.loadCustomFormatter(type, cwd) + return await FormatterBuilder.loadCustomFormatter(type, cwd, importer) } }, - getStepDefinitionSnippetBuilder({ + async getStepDefinitionSnippetBuilder({ cwd, + importer, snippetInterface, snippetSyntax, supportCodeLibrary, @@ -99,7 +110,8 @@ const FormatterBuilder = { let Syntax = JavascriptSnippetSyntax if (doesHaveValue(snippetSyntax)) { const fullSyntaxPath = path.resolve(cwd, snippetSyntax) - Syntax = require(fullSyntaxPath) // eslint-disable-line @typescript-eslint/no-var-requires + Syntax = await importer(fullSyntaxPath, true) + Syntax = FormatterBuilder.resolveConstructor(Syntax) } return new StepDefinitionSnippetBuilder({ snippetSyntax: new Syntax(snippetInterface), @@ -107,20 +119,34 @@ const FormatterBuilder = { }) }, - loadCustomFormatter(customFormatterPath: string, cwd: string) { - const CustomFormatter = createRequire(cwd)(customFormatterPath) - - if (typeof CustomFormatter === 'function') { + async loadCustomFormatter( + customFormatterPath: string, + cwd: string, + importer: IUserCodeImporter + ) { + let CustomFormatter = customFormatterPath.startsWith(`.`) + ? await importer(path.resolve(cwd, customFormatterPath), true) + : await importer(customFormatterPath) + CustomFormatter = FormatterBuilder.resolveConstructor(CustomFormatter) + if (doesHaveValue(CustomFormatter)) { return CustomFormatter + } else { + throw new Error( + `Custom formatter (${customFormatterPath}) does not export a function` + ) + } + }, + + resolveConstructor(ImportedCode: any) { + if (typeof ImportedCode === 'function') { + return ImportedCode } else if ( - doesHaveValue(CustomFormatter) && - typeof CustomFormatter.default === 'function' + doesHaveValue(ImportedCode) && + typeof ImportedCode.default === 'function' ) { - return CustomFormatter.default + return ImportedCode.default } - throw new Error( - `Custom formatter (${customFormatterPath}) does not export a function` - ) + return null }, } diff --git a/src/formatter/helpers/issue_helpers_spec.ts b/src/formatter/helpers/issue_helpers_spec.ts index ddae29f74..5afa258a6 100644 --- a/src/formatter/helpers/issue_helpers_spec.ts +++ b/src/formatter/helpers/issue_helpers_spec.ts @@ -23,8 +23,9 @@ async function testFormatIssue(sourceData: string): Promise { cwd: 'project/', colorFns: getColorFns(false), number: 1, - snippetBuilder: FormatterBuilder.getStepDefinitionSnippetBuilder({ + snippetBuilder: await FormatterBuilder.getStepDefinitionSnippetBuilder({ cwd: 'project/', + importer: async (path) => await import(path), supportCodeLibrary, }), supportCodeLibrary, diff --git a/src/formatter/progress_bar_formatter_spec.ts b/src/formatter/progress_bar_formatter_spec.ts index 9ff6712f4..deff57861 100644 --- a/src/formatter/progress_bar_formatter_spec.ts +++ b/src/formatter/progress_bar_formatter_spec.ts @@ -55,16 +55,17 @@ async function testProgressBarFormatter({ output += data } const passThrough = new PassThrough() - const progressBarFormatter = FormatterBuilder.build('progress-bar', { + const progressBarFormatter = (await FormatterBuilder.build('progress-bar', { cwd: '', eventBroadcaster, eventDataCollector: new EventDataCollector(eventBroadcaster), log: logFn, parsedArgvOptions: {}, + importer: async (path) => await import(path), stream: passThrough, cleanup: bluebird.promisify(passThrough.end.bind(passThrough)), supportCodeLibrary, - }) as ProgressBarFormatter + })) as ProgressBarFormatter let mocked = false for (const envelope of envelopes) { eventBroadcaster.emit('envelope', envelope) diff --git a/src/importers.js b/src/importers.js new file mode 100644 index 000000000..0a125fdec --- /dev/null +++ b/src/importers.js @@ -0,0 +1,17 @@ +/* +Provides the async `import()` function to source code that needs it, +without having it transpiled down to commonjs `require()` by TypeScript. +When we drop Node 10 support, we'll stop transpiling to commonjs and remove this. + */ + +const { pathToFileURL } = require('url') + +module.exports = { + legacy: async (descriptor) => await Promise.resolve(require(descriptor)), + esm: async (descriptor, isFilePath) => { + if (isFilePath) { + descriptor = pathToFileURL(descriptor).toString() + } + return await import(descriptor) + }, +} diff --git a/src/wrapper.mjs b/src/wrapper.mjs new file mode 100644 index 000000000..360d5eee7 --- /dev/null +++ b/src/wrapper.mjs @@ -0,0 +1,38 @@ +import cucumber from './index.js' + +export const Cli = cucumber.Cli +export const parseGherkinMessageStream = cucumber.parseGherkinMessageStream +export const PickleFilter = cucumber.PickleFilter +export const Runtime = cucumber.Runtime +export const supportCodeLibraryBuilder = cucumber.supportCodeLibraryBuilder +export const Status = cucumber.Status +export const DataTable = cucumber.DataTable + +export const Formatter = cucumber.Formatter +export const FormatterBuilder = cucumber.FormatterBuilder +export const JsonFormatter = cucumber.JsonFormatter +export const ProgressFormatter = cucumber.ProgressFormatter +export const RerunFormatter = cucumber.RerunFormatter +export const SnippetsFormatter = cucumber.SnippetsFormatter +export const SummaryFormatter = cucumber.SummaryFormatter +export const UsageFormatter = cucumber.UsageFormatter +export const UsageJsonFormatter = cucumber.UsageJsonFormatter +export const formatterHelpers = cucumber.formatterHelpers + +export const After = cucumber.After +export const AfterAll = cucumber.AfterAll +export const AfterStep = cucumber.AfterStep +export const Before = cucumber.Before +export const BeforeAll = cucumber.BeforeAll +export const BeforeStep = cucumber.BeforeStep +export const defineParameterType = cucumber.defineParameterType +export const defineStep = cucumber.defineStep +export const Given = cucumber.Given +export const setDefaultTimeout = cucumber.setDefaultTimeout +export const setDefinitionFunctionWrapper = cucumber.setDefinitionFunctionWrapper +export const setWorldConstructor = cucumber.setWorldConstructor +export const Then = cucumber.Then +export const When = cucumber.When + +export const World = cucumber.World + diff --git a/test/formatter_helpers.ts b/test/formatter_helpers.ts index b12246b6b..2b791daf0 100644 --- a/test/formatter_helpers.ts +++ b/test/formatter_helpers.ts @@ -59,12 +59,13 @@ export async function testFormatter({ output += data } const passThrough = new PassThrough() - FormatterBuilder.build(type, { + await FormatterBuilder.build(type, { cwd: '', eventBroadcaster, eventDataCollector, log: logFn, parsedArgvOptions, + importer: async (path) => await import(path), stream: passThrough, cleanup: bluebird.promisify(passThrough.end.bind(passThrough)), supportCodeLibrary, diff --git a/tsconfig.json b/tsconfig.json index fdb94c5fc..c3204a3cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "esModuleInterop": true, - "lib": ["es2017"], + "lib": ["es2018"], "module": "commonjs", "noImplicitAny": true, "noImplicitReturns": true, @@ -9,7 +9,7 @@ "resolveJsonModule": true, "sourceMap": true, "inlineSources": true, - "target": "es2017", + "target": "es2018", "typeRoots": [ "./node_modules/@types", "./src/types" diff --git a/yarn.lock b/yarn.lock index ce9e2cb80..a17b699e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1359,7 +1359,7 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -create-require@^1.1.0, create-require@^1.1.1: +create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== From e6ae1e7451dcd67cce2c7e029287f0ae0218347d Mon Sep 17 00:00:00 2001 From: davidgoss Date: Thu, 22 Apr 2021 12:56:49 +0100 Subject: [PATCH 02/25] add failing scenario for deep imports --- features/direct_imports.feature | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/features/direct_imports.feature b/features/direct_imports.feature index c01905136..a9ebd7250 100644 --- a/features/direct_imports.feature +++ b/features/direct_imports.feature @@ -44,3 +44,20 @@ Feature: Core feature elements execution using direct imports """ features/step_definitions/cucumber_steps.js:3 """ + + Scenario: deep imports don't break everything + Given a file named "features/a.feature" with: + """ + Feature: some feature + Scenario: some scenario + Given a step passes + """ + And a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {Given} = require('@cucumber/cucumber') + const TestCaseHookDefinition = require('@cucumber/cucumber/lib/models/test_case_hook_definition') + + Given(/^a step passes$/, function() {}); + """ + When I run cucumber-js + Then it passes \ No newline at end of file From ec04d2faa7ac2de94eeffe8e32b0b0082692910a Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 1 Jul 2021 14:03:11 +0100 Subject: [PATCH 03/25] define entry point with dot --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0c8d35498..c58e27978 100644 --- a/package.json +++ b/package.json @@ -164,8 +164,10 @@ }, "main": "./lib/index.js", "exports": { - "import": "./lib/wrapper.mjs", - "require": "./lib/index.js" + ".": { + "import": "./lib/wrapper.mjs", + "require": "./lib/index.js" + } }, "types": "./lib/index.d.ts", "engines": { From 16e4f2f1b382c3f4cbae31cff4731bde5f8fdbd7 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 1 Jul 2021 14:05:51 +0100 Subject: [PATCH 04/25] make deep imports work via export patterns --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index c58e27978..67e49030d 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,9 @@ ".": { "import": "./lib/wrapper.mjs", "require": "./lib/index.js" + }, + "./lib/*": { + "require": "./lib/*.js" } }, "types": "./lib/index.d.ts", From a3a0414028f73a8f1993e8dd002a07f454fd23ad Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 1 Jul 2021 17:26:06 +0100 Subject: [PATCH 05/25] move doc to own file --- docs/cli.md | 16 ---------------- docs/esm.md | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 docs/esm.md diff --git a/docs/cli.md b/docs/cli.md index 226517afb..072e9e7ec 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -81,22 +81,6 @@ You can pass in format options with `--format-options `. 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. - -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}'` diff --git a/docs/esm.md b/docs/esm.md new file mode 100644 index 000000000..40b8bcd41 --- /dev/null +++ b/docs/esm.md @@ -0,0 +1,15 @@ +# 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. + +To enable this, run with the `--esm` CLI option. + +This will also expand the default glob for support files to include the `.mjs` file extension. + +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. From ce73823f8abd64c1bbe047ddd92ae9abb121fcf6 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 1 Jul 2021 17:27:14 +0100 Subject: [PATCH 06/25] link to doc from readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 32d4a9014..6421f0950 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ The following documentation is for master. See below for documentation for older * [Attachments](/docs/support_files/attachments.md) * [API Reference](/docs/support_files/api_reference.md) * Guides + * [ES Modules](./docs/esm.md) * [Running in parallel](./docs/parallel.md) * [Retrying failing scenarios](./docs/retry.md) * [Profiles](./docs/profiles.md) From 3bc91ad5b678aae13b106e4615250b6039fff006 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 1 Jul 2021 17:28:27 +0100 Subject: [PATCH 07/25] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ec65b8f..495387aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO ### Added +* Add support for user code as native ES modules + ### Changed ### Deprecated From 47a2c683d9d7b8ff40adbf211e5d86bceda59de0 Mon Sep 17 00:00:00 2001 From: David Goss Date: Fri, 2 Jul 2021 12:48:12 +0100 Subject: [PATCH 08/25] add example to doc --- docs/esm.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/esm.md b/docs/esm.md index 40b8bcd41..8c0d1d96d 100644 --- a/docs/esm.md +++ b/docs/esm.md @@ -4,7 +4,27 @@ You can optionally write your support code (steps, hooks, etc) with native ES mo To enable this, run with the `--esm` CLI option. -This will also expand the default glob for support files to include the `.mjs` file extension. +This will also expand the default glob for support files to include the `.mjs` file extension (note that it's fine to still use the `.js` extension as well/instead). + +Example (adapted from [our original example](./nodejs_example.md)): + +```javascript +// features/support/steps.mjs +import { Given, When, Then } from '@cucumber/cucumber' +import { strict as assert } from 'assert' + +Given('a variable set to {int}', function (number) { + this.setTo(number) +}) + +When('I increment the variable by {int}', function (number) { + this.incrementBy(number) +}) + +Then('the variable should contain {int}', function (number) { + assert.equal(this.variable, number) +}) +``` As well as support code, these things can also be in ES modules syntax: From 6b8ffd9a582d3a2c4dc7d5706fc12d558a15d3bb Mon Sep 17 00:00:00 2001 From: David Goss Date: Fri, 2 Jul 2021 12:48:33 +0100 Subject: [PATCH 09/25] remove confusing comment --- src/importers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/importers.js b/src/importers.js index 0a125fdec..0f6c89303 100644 --- a/src/importers.js +++ b/src/importers.js @@ -1,7 +1,6 @@ /* Provides the async `import()` function to source code that needs it, without having it transpiled down to commonjs `require()` by TypeScript. -When we drop Node 10 support, we'll stop transpiling to commonjs and remove this. */ const { pathToFileURL } = require('url') From 947b39fea3b5b052da2fb4babeb56238d0b88359 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sat, 3 Jul 2021 12:54:59 +0100 Subject: [PATCH 10/25] remove cli option, use import by default --- dependency-lint.yml | 2 +- docs/esm.md | 1 - features/esm.feature | 34 +++---------- features/support/hooks.ts | 4 -- package.json | 2 +- src/cli/argv_parser.ts | 2 - src/cli/configuration_builder.ts | 4 +- src/cli/configuration_builder_spec.ts | 53 ++------------------ src/cli/helpers.ts | 8 +-- src/cli/index.ts | 23 +++------ src/cli/profile_loader.ts | 9 ++-- src/cli/profile_loader_spec.ts | 5 +- src/formatter/builder.ts | 26 ++++------ src/formatter/helpers/issue_helpers_spec.ts | 1 - src/formatter/progress_bar_formatter_spec.ts | 1 - src/importer.js | 11 ++++ src/importers.js | 16 ------ src/runtime/parallel/worker.ts | 11 +++- test/formatter_helpers.ts | 1 - 19 files changed, 57 insertions(+), 157 deletions(-) create mode 100644 src/importer.js delete mode 100644 src/importers.js diff --git a/dependency-lint.yml b/dependency-lint.yml index 98d57551d..42bbef163 100644 --- a/dependency-lint.yml +++ b/dependency-lint.yml @@ -45,7 +45,7 @@ requiredModules: - 'dist/**/*' - 'lib/**/*' - 'node_modules/**/*' - - 'src/importers.js' + - 'src/importer.js' - 'tmp/**/*' root: '**/*.{js,ts}' stripLoaders: false diff --git a/docs/esm.md b/docs/esm.md index 8c0d1d96d..6c55584de 100644 --- a/docs/esm.md +++ b/docs/esm.md @@ -30,6 +30,5 @@ 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. diff --git a/features/esm.feature b/features/esm.feature index 96397709c..41bafbcea 100644 --- a/features/esm.feature +++ b/features/esm.feature @@ -3,7 +3,7 @@ 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 + Scenario Outline: native module syntax works in support code, formatters and snippets Given a file named "features/a.feature" with: """ Feature: @@ -19,12 +19,6 @@ Feature: ES modules support 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' @@ -39,12 +33,12 @@ Feature: ES modules support } } """ - When I run cucumber-js with ` --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}'` + When I run cucumber-js with ` --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}' ` Then it passes Examples: - | options | - | --esm | - | --esm --parallel 2 | + | args | + | | + | --parallel 2 | @esm Scenario: .mjs support code files are matched by default when using --esm @@ -58,23 +52,7 @@ Feature: ES modules support """ 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 \ No newline at end of file + Then it passes diff --git a/features/support/hooks.ts b/features/support/hooks.ts index a5f056d55..5c97e27d7 100644 --- a/features/support/hooks.ts +++ b/features/support/hooks.ts @@ -44,10 +44,6 @@ Before(function ( }) 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', diff --git a/package.json b/package.json index 67e49030d..f488dfc0f 100644 --- a/package.json +++ b/package.json @@ -263,7 +263,7 @@ "typescript": "4.3.5" }, "scripts": { - "build-local": "tsc -p tsconfig.node.json && cp src/importers.js lib/ && cp src/wrapper.mjs lib/", + "build-local": "tsc -p tsconfig.node.json && cp src/importer.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", diff --git a/src/cli/argv_parser.ts b/src/cli/argv_parser.ts index adfa5d28e..87dbd2f1c 100644 --- a/src/cli/argv_parser.ts +++ b/src/cli/argv_parser.ts @@ -21,7 +21,6 @@ export interface IParsedArgvFormatOptions { export interface IParsedArgvOptions { backtrace: boolean dryRun: boolean - esm: boolean exit: boolean failFast: boolean format: string[] @@ -112,7 +111,6 @@ 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', diff --git a/src/cli/configuration_builder.ts b/src/cli/configuration_builder.ts index dc598c0ef..92d1345ee 100644 --- a/src/cli/configuration_builder.ts +++ b/src/cli/configuration_builder.ts @@ -18,7 +18,6 @@ export interface IConfigurationFormat { } export interface IConfiguration { - esm: boolean featureDefaultLanguage: string featurePaths: string[] formats: IConfigurationFormat[] @@ -78,11 +77,10 @@ export default class ConfigurationBuilder { } supportCodePaths = await this.expandPaths( unexpandedSupportCodePaths, - this.options.esm ? '.@(js|mjs)' : '.js' + '.@(js|mjs)' ) } return { - esm: this.options.esm, featureDefaultLanguage: this.options.language, featurePaths, formats: this.getFormats(), diff --git a/src/cli/configuration_builder_spec.ts b/src/cli/configuration_builder_spec.ts index 1c611f19a..f0cc4f317 100644 --- a/src/cli/configuration_builder_spec.ts +++ b/src/cli/configuration_builder_spec.ts @@ -29,7 +29,6 @@ describe('Configuration', () => { // Assert expect(result).to.eql({ - esm: false, featureDefaultLanguage: 'en', featurePaths: [], formatOptions: {}, @@ -73,8 +72,10 @@ describe('Configuration', () => { 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 jsSupportCodePath = path.join(cwd, 'features', 'a.js') + await fsExtra.outputFile(jsSupportCodePath, '') + const esmSupportCodePath = path.join(cwd, 'features', 'a.mjs') + await fsExtra.outputFile(esmSupportCodePath, '') const argv = baseArgv.concat([relativeFeaturePath]) // Act @@ -84,7 +85,7 @@ describe('Configuration', () => { // Assert expect(featurePaths).to.eql([featurePath]) expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath]) - expect(supportCodePaths).to.eql([supportCodePath]) + expect(supportCodePaths).to.eql([jsSupportCodePath, esmSupportCodePath]) }) it('returns the appropriate .md and support code paths', async function () { @@ -125,50 +126,6 @@ describe('Configuration', () => { expect(featurePaths).to.eql([featurePath]) }) }) - - 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]) - }) - }) - - 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]) - }) - }) }) describe('path to a nested feature', () => { diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index 18627ff2c..bd371886a 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -15,9 +15,6 @@ 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') - export interface IGetExpandedArgvRequest { argv: string[] cwd: string @@ -28,11 +25,8 @@ export async function getExpandedArgv({ cwd, }: IGetExpandedArgvRequest): Promise { const { options } = ArgvParser.parse(argv) - const importer = options.esm ? importers.esm : importers.legacy let fullArgv = argv - const profileArgv = await new ProfileLoader(cwd, importer).getArgv( - options.profile - ) + const profileArgv = await new ProfileLoader(cwd).getArgv(options.profile) if (profileArgv.length > 0) { fullArgv = argv.slice(0, 2).concat(profileArgv).concat(argv.slice(2)) } diff --git a/src/cli/index.ts b/src/cli/index.ts index f9572bebe..ba4286ca1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,9 +29,10 @@ import { ISupportCodeLibrary } from '../support_code_library_builder/types' import { IParsedArgvFormatOptions } from './argv_parser' import HttpStream from '../formatter/http_stream' import { Writable } from 'stream' +import { pathToFileURL } from 'url' // eslint-disable-next-line @typescript-eslint/no-var-requires -const importers = require('../importers') +const { importer } = require('../importer') const { incrementing, uuid } = IdGenerator export interface ICliRunResult { @@ -53,16 +54,10 @@ interface IGetSupportCodeLibraryRequest { supportCodePaths: string[] } -export type IUserCodeImporter = ( - path: string, - isFilePath?: boolean -) => Promise - export default class Cli { private readonly argv: string[] private readonly cwd: string private readonly stdout: IFormatterStream - private importer: IUserCodeImporter = importers.legacy constructor({ argv, @@ -133,7 +128,6 @@ export default class Cli { eventDataCollector, log: stream.write.bind(stream), parsedArgvOptions: formatOptions, - importer: this.importer, stream, cleanup: stream === this.stdout @@ -168,12 +162,14 @@ export default class Cli { supportCodeRequiredModules, supportCodePaths, }: IGetSupportCodeLibraryRequest): Promise { - for (const requiredModule of supportCodeRequiredModules) { - await this.importer(requiredModule) - } + supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, newId) for (const codePath of supportCodePaths) { - await this.importer(codePath, true) + if (!supportCodeRequiredModules.length) { + await importer(pathToFileURL(codePath)) + } else { + require(codePath) + } } return supportCodeLibraryBuilder.finalize() } @@ -193,9 +189,6 @@ export default class Cli { configuration.predictableIds && configuration.parallel <= 1 ? incrementing() : uuid() - if (configuration.esm) { - this.importer = importers.esm - } const supportCodeLibrary = await this.getSupportCodeLibrary({ newId, supportCodePaths: configuration.supportCodePaths, diff --git a/src/cli/profile_loader.ts b/src/cli/profile_loader.ts index 821d454b6..8dc5196f0 100644 --- a/src/cli/profile_loader.ts +++ b/src/cli/profile_loader.ts @@ -2,13 +2,9 @@ import fs from 'mz/fs' import path from 'path' import stringArgv from 'string-argv' import { doesHaveValue, doesNotHaveValue } from '../value_checker' -import { IUserCodeImporter } from './index' export default class ProfileLoader { - constructor( - private readonly directory: string, - private readonly importer: IUserCodeImporter - ) {} + constructor(private readonly directory: string) {} async getDefinitions(): Promise> { const definitionsFilePath = path.join(this.directory, 'cucumber.js') @@ -16,7 +12,8 @@ export default class ProfileLoader { if (!exists) { return {} } - const definitions = await this.importer(definitionsFilePath, true) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const definitions = require(definitionsFilePath) if (typeof definitions !== 'object') { throw new Error(`${definitionsFilePath} does not export an object`) } diff --git a/src/cli/profile_loader_spec.ts b/src/cli/profile_loader_spec.ts index d8813fcc6..a57d52ccd 100644 --- a/src/cli/profile_loader_spec.ts +++ b/src/cli/profile_loader_spec.ts @@ -7,9 +7,6 @@ import tmp, { DirOptions } from 'tmp' import { promisify } from 'util' import { doesHaveValue, valueOrDefault } from '../value_checker' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const importers = require('../importers') - interface TestProfileLoaderOptions { definitionsFileContent?: string profiles?: string[] @@ -27,7 +24,7 @@ async function testProfileLoader( opts.definitionsFileContent ) } - const profileLoader = new ProfileLoader(cwd, importers.legacy) + const profileLoader = new ProfileLoader(cwd) return await profileLoader.getArgv(valueOrDefault(opts.profiles, [])) } diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index d4ed5682e..4770741d9 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -20,11 +20,12 @@ import { Writable as WritableStream } from 'stream' import { IParsedArgvFormatOptions } from '../cli/argv_parser' import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax' import HtmlFormatter from './html_formatter' -import { IUserCodeImporter } from '../cli' +import { pathToFileURL } from 'url' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../importer') interface IGetStepDefinitionSnippetBuilderOptions { cwd: string - importer: IUserCodeImporter snippetInterface?: SnippetInterface snippetSyntax?: string supportCodeLibrary: ISupportCodeLibrary @@ -36,7 +37,6 @@ export interface IBuildOptions { eventDataCollector: EventDataCollector log: IFormatterLogFn parsedArgvOptions: IParsedArgvFormatOptions - importer: IUserCodeImporter stream: WritableStream cleanup: IFormatterCleanupFn supportCodeLibrary: ISupportCodeLibrary @@ -46,14 +46,12 @@ const FormatterBuilder = { async build(type: string, options: IBuildOptions): Promise { const FormatterConstructor = await FormatterBuilder.getConstructorByType( type, - options.cwd, - options.importer + options.cwd ) const colorFns = getColorFns(options.parsedArgvOptions.colorsEnabled) const snippetBuilder = await FormatterBuilder.getStepDefinitionSnippetBuilder({ cwd: options.cwd, - importer: options.importer, snippetInterface: options.parsedArgvOptions.snippetInterface, snippetSyntax: options.parsedArgvOptions.snippetSyntax, supportCodeLibrary: options.supportCodeLibrary, @@ -67,8 +65,7 @@ const FormatterBuilder = { async getConstructorByType( type: string, - cwd: string, - importer: IUserCodeImporter + cwd: string ): Promise { switch (type) { case 'json': @@ -92,13 +89,12 @@ const FormatterBuilder = { case 'usage-json': return UsageJsonFormatter default: - return await FormatterBuilder.loadCustomFormatter(type, cwd, importer) + return await FormatterBuilder.loadCustomFormatter(type, cwd) } }, async getStepDefinitionSnippetBuilder({ cwd, - importer, snippetInterface, snippetSyntax, supportCodeLibrary, @@ -109,7 +105,7 @@ const FormatterBuilder = { let Syntax = JavascriptSnippetSyntax if (doesHaveValue(snippetSyntax)) { const fullSyntaxPath = path.resolve(cwd, snippetSyntax) - Syntax = await importer(fullSyntaxPath, true) + Syntax = await importer(pathToFileURL(fullSyntaxPath)) Syntax = FormatterBuilder.resolveConstructor(Syntax) } return new StepDefinitionSnippetBuilder({ @@ -118,13 +114,9 @@ const FormatterBuilder = { }) }, - async loadCustomFormatter( - customFormatterPath: string, - cwd: string, - importer: IUserCodeImporter - ) { + async loadCustomFormatter(customFormatterPath: string, cwd: string) { let CustomFormatter = customFormatterPath.startsWith(`.`) - ? await importer(path.resolve(cwd, customFormatterPath), true) + ? await importer(pathToFileURL(path.resolve(cwd, customFormatterPath))) : await importer(customFormatterPath) CustomFormatter = FormatterBuilder.resolveConstructor(CustomFormatter) if (doesHaveValue(CustomFormatter)) { diff --git a/src/formatter/helpers/issue_helpers_spec.ts b/src/formatter/helpers/issue_helpers_spec.ts index a14b0b357..65ed9f1a1 100644 --- a/src/formatter/helpers/issue_helpers_spec.ts +++ b/src/formatter/helpers/issue_helpers_spec.ts @@ -25,7 +25,6 @@ async function testFormatIssue(sourceData: string): Promise { number: 1, snippetBuilder: await FormatterBuilder.getStepDefinitionSnippetBuilder({ cwd: 'project/', - importer: async (path) => await import(path), supportCodeLibrary, }), supportCodeLibrary, diff --git a/src/formatter/progress_bar_formatter_spec.ts b/src/formatter/progress_bar_formatter_spec.ts index 7b899a442..1d62dc3fe 100644 --- a/src/formatter/progress_bar_formatter_spec.ts +++ b/src/formatter/progress_bar_formatter_spec.ts @@ -64,7 +64,6 @@ async function testProgressBarFormatter({ eventDataCollector: new EventDataCollector(eventBroadcaster), log: logFn, parsedArgvOptions: {}, - importer: async (path) => await import(path), stream: passThrough, cleanup: bluebird.promisify(passThrough.end.bind(passThrough)), supportCodeLibrary, diff --git a/src/importer.js b/src/importer.js new file mode 100644 index 000000000..ac391c470 --- /dev/null +++ b/src/importer.js @@ -0,0 +1,11 @@ +/* +Provides the async `import()` function to source code that needs it, +without having it transpiled down to commonjs `require()` by TypeScript. +See https://github.com/microsoft/TypeScript/issues/43329. + */ + +module.exports = { + importer: async (descriptor) => { + return await import(descriptor) + }, +} diff --git a/src/importers.js b/src/importers.js deleted file mode 100644 index 0f6c89303..000000000 --- a/src/importers.js +++ /dev/null @@ -1,16 +0,0 @@ -/* -Provides the async `import()` function to source code that needs it, -without having it transpiled down to commonjs `require()` by TypeScript. - */ - -const { pathToFileURL } = require('url') - -module.exports = { - legacy: async (descriptor) => await Promise.resolve(require(descriptor)), - esm: async (descriptor, isFilePath) => { - if (isFilePath) { - descriptor = pathToFileURL(descriptor).toString() - } - return await import(descriptor) - }, -} diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index b5c371d5b..cf69b8a27 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -19,7 +19,10 @@ import { doesHaveValue, valueOrDefault } from '../../value_checker' import { IRuntimeOptions } from '../index' import { PredictableTestRunStopwatch, RealTestRunStopwatch } from '../stopwatch' import { duration } from 'durations' +import { pathToFileURL } from 'url' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../../importer') const { uuid } = IdGenerator type IExitFunction = (exitCode: number, error?: Error, message?: string) => void @@ -73,7 +76,13 @@ export default class Worker { }: IWorkerCommandInitialize): Promise { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, this.newId) - supportCodePaths.forEach((codePath) => require(codePath)) + for (const codePath of supportCodePaths) { + if (!supportCodeRequiredModules.length) { + await importer(pathToFileURL(codePath)) + } else { + require(codePath) + } + } this.supportCodeLibrary = supportCodeLibraryBuilder.finalize(supportCodeIds) this.worldParameters = options.worldParameters diff --git a/test/formatter_helpers.ts b/test/formatter_helpers.ts index cf816304d..84c8295e5 100644 --- a/test/formatter_helpers.ts +++ b/test/formatter_helpers.ts @@ -66,7 +66,6 @@ export async function testFormatter({ eventDataCollector, log: logFn, parsedArgvOptions, - importer: async (path) => await import(path), stream: passThrough, cleanup: bluebird.promisify(passThrough.end.bind(passThrough)), supportCodeLibrary, From 5fa807c5583a3cf40f21e5913ec77e4373ac0339 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 8 Jul 2021 12:59:45 +0100 Subject: [PATCH 11/25] update documentation --- docs/esm.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/esm.md b/docs/esm.md index 6c55584de..c8ae9208e 100644 --- a/docs/esm.md +++ b/docs/esm.md @@ -1,10 +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. - -To enable this, run with the `--esm` CLI option. - -This will also expand the default glob for support files to include the `.mjs` file extension (note that it's fine to still use the `.js` extension as well/instead). +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. Example (adapted from [our original example](./nodejs_example.md)): @@ -31,4 +27,4 @@ As well as support code, these things can also be in ES modules syntax: - Custom formatters - Custom snippets -You can use ES modules selectively/incrementally - the module loading strategy that the `--esm` flag activates supports both ES modules and CommonJS. +You can use ES modules selectively/incrementally - so you can have a mixture of CommonJS and ESM in the same project. From 2b59f6e58befbee01a2ede57eaa65b991c158247 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 8 Jul 2021 13:02:57 +0100 Subject: [PATCH 12/25] remove redundant describe --- src/cli/configuration_builder_spec.ts | 114 +++++++++++++------------- 1 file changed, 56 insertions(+), 58 deletions(-) diff --git a/src/cli/configuration_builder_spec.ts b/src/cli/configuration_builder_spec.ts index f0cc4f317..7e6c95fd3 100644 --- a/src/cli/configuration_builder_spec.ts +++ b/src/cli/configuration_builder_spec.ts @@ -65,66 +65,64 @@ describe('Configuration', () => { }) describe('path to a feature', () => { - 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 jsSupportCodePath = path.join(cwd, 'features', 'a.js') - await fsExtra.outputFile(jsSupportCodePath, '') - const esmSupportCodePath = path.join(cwd, 'features', 'a.mjs') - await fsExtra.outputFile(esmSupportCodePath, '') - 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([jsSupportCodePath, esmSupportCodePath]) - }) + 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 jsSupportCodePath = path.join(cwd, 'features', 'a.js') + await fsExtra.outputFile(jsSupportCodePath, '') + const esmSupportCodePath = path.join(cwd, 'features', 'a.mjs') + await fsExtra.outputFile(esmSupportCodePath, '') + const argv = baseArgv.concat([relativeFeaturePath]) - it('returns the appropriate .md and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const relativeFeaturePath = path.join('features', 'a.feature.md') - 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 }) - it('deduplicates the .feature files before returning', 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 argv = baseArgv.concat([ - `${relativeFeaturePath}:3`, - `${relativeFeaturePath}:4`, - ]) - - // Act - const { featurePaths } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(featurePaths).to.eql([featurePath]) - }) + // Assert + expect(featurePaths).to.eql([featurePath]) + expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath]) + expect(supportCodePaths).to.eql([jsSupportCodePath, esmSupportCodePath]) + }) + + it('returns the appropriate .md and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + const relativeFeaturePath = path.join('features', 'a.feature.md') + 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]) + }) + + it('deduplicates the .feature files before returning', 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 argv = baseArgv.concat([ + `${relativeFeaturePath}:3`, + `${relativeFeaturePath}:4`, + ]) + + // Act + const { featurePaths } = await ConfigurationBuilder.build({ argv, cwd }) + + // Assert + expect(featurePaths).to.eql([featurePath]) }) }) From 54657837ffedc63f919140e89eb8e6d48802ea37 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 8 Jul 2021 13:06:11 +0100 Subject: [PATCH 13/25] fix ordering --- src/cli/configuration_builder_spec.ts | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/cli/configuration_builder_spec.ts b/src/cli/configuration_builder_spec.ts index 7e6c95fd3..63c58b5aa 100644 --- a/src/cli/configuration_builder_spec.ts +++ b/src/cli/configuration_builder_spec.ts @@ -87,42 +87,42 @@ describe('Configuration', () => { expect(supportCodePaths).to.eql([jsSupportCodePath, esmSupportCodePath]) }) - it('returns the appropriate .md and support code paths', async function () { + it('deduplicates the .feature files before returning', async function () { // Arrange const cwd = await buildTestWorkingDirectory() - const relativeFeaturePath = path.join('features', 'a.feature.md') + 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]) + const argv = baseArgv.concat([ + `${relativeFeaturePath}:3`, + `${relativeFeaturePath}:4`, + ]) // Act - const { featurePaths, pickleFilterOptions, supportCodePaths } = - await ConfigurationBuilder.build({ argv, cwd }) + const { featurePaths } = await ConfigurationBuilder.build({ argv, cwd }) // Assert expect(featurePaths).to.eql([featurePath]) - expect(pickleFilterOptions.featurePaths).to.eql([relativeFeaturePath]) - expect(supportCodePaths).to.eql([supportCodePath]) }) - it('deduplicates the .feature files before returning', async function () { + it('returns the appropriate .md and support code paths', async function () { // Arrange const cwd = await buildTestWorkingDirectory() - const relativeFeaturePath = path.join('features', 'a.feature') + const relativeFeaturePath = path.join('features', 'a.feature.md') const featurePath = path.join(cwd, relativeFeaturePath) await fsExtra.outputFile(featurePath, '') - const argv = baseArgv.concat([ - `${relativeFeaturePath}:3`, - `${relativeFeaturePath}:4`, - ]) + const supportCodePath = path.join(cwd, 'features', 'a.js') + await fsExtra.outputFile(supportCodePath, '') + const argv = baseArgv.concat([relativeFeaturePath]) // Act - const { featurePaths } = await ConfigurationBuilder.build({ argv, cwd }) + 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]) }) }) From 7cfdd73b3bac5453ad52f82820b9fbd6c04372d9 Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 12 Jul 2021 10:30:07 +0100 Subject: [PATCH 14/25] Update features/esm.feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aurélien Reeves --- features/esm.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/esm.feature b/features/esm.feature index 41bafbcea..d8b2b21d7 100644 --- a/features/esm.feature +++ b/features/esm.feature @@ -1,6 +1,6 @@ Feature: ES modules support - cucumber-js works with native ES modules, via a Cli flag `--esm` + cucumber-js works with native ES modules @esm Scenario Outline: native module syntax works in support code, formatters and snippets From 0244b610e364afbbf4b394265adad93384757451 Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 12 Jul 2021 10:30:14 +0100 Subject: [PATCH 15/25] Update features/esm.feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aurélien Reeves --- features/esm.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/esm.feature b/features/esm.feature index d8b2b21d7..e0d62deb3 100644 --- a/features/esm.feature +++ b/features/esm.feature @@ -41,7 +41,7 @@ Feature: ES modules support | --parallel 2 | @esm - Scenario: .mjs support code files are matched by default when using --esm + Scenario: .mjs support code files are matched by default Given a file named "features/a.feature" with: """ Feature: From c71d5b90e9af8eecbf7642df9625074bf472f861 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 28 Jul 2021 09:59:22 +0100 Subject: [PATCH 16/25] simplify tagging --- features/esm.feature | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/esm.feature b/features/esm.feature index e0d62deb3..c07471935 100644 --- a/features/esm.feature +++ b/features/esm.feature @@ -1,8 +1,8 @@ +@esm Feature: ES modules support cucumber-js works with native ES modules - @esm Scenario Outline: native module syntax works in support code, formatters and snippets Given a file named "features/a.feature" with: """ @@ -40,7 +40,6 @@ Feature: ES modules support | | | --parallel 2 | - @esm Scenario: .mjs support code files are matched by default Given a file named "features/a.feature" with: """ From 809f5af56aa43940e8733272730614cae108a540 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 28 Jul 2021 10:25:48 +0100 Subject: [PATCH 17/25] use import only if a javascript file --- src/cli/index.ts | 6 +++++- src/runtime/parallel/worker.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 012e4c333..8bd57cfa9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -162,7 +162,11 @@ export default class Cli { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, newId) for (const codePath of supportCodePaths) { - if (!supportCodeRequiredModules.length) { + if ( + codePath.endsWith('.js') || + codePath.endsWith('.mjs') || + codePath.endsWith('.cjs') + ) { await importer(pathToFileURL(codePath)) } else { require(codePath) diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index aab47ea5b..9acff213d 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -76,7 +76,11 @@ export default class Worker { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, this.newId) for (const codePath of supportCodePaths) { - if (!supportCodeRequiredModules.length) { + if ( + codePath.endsWith('.js') || + codePath.endsWith('.mjs') || + codePath.endsWith('.cjs') + ) { await importer(pathToFileURL(codePath)) } else { require(codePath) From a87013842fd2432558c2632fed799dfc1803c435 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 28 Jul 2021 10:28:01 +0100 Subject: [PATCH 18/25] add note about no transpilers --- docs/esm.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/esm.md b/docs/esm.md index c8ae9208e..805cf702c 100644 --- a/docs/esm.md +++ b/docs/esm.md @@ -28,3 +28,5 @@ As well as support code, these things can also be in ES modules syntax: - Custom snippets You can use ES modules selectively/incrementally - so you can have a mixture of CommonJS and ESM in the same project. + +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). From 46125e2c4b5002ca84950d2631630ff2c6920a56 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 28 Jul 2021 10:50:50 +0100 Subject: [PATCH 19/25] inline to avoid confusing reassignment --- src/formatter/builder.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index 4770741d9..72862b074 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -105,8 +105,9 @@ const FormatterBuilder = { let Syntax = JavascriptSnippetSyntax if (doesHaveValue(snippetSyntax)) { const fullSyntaxPath = path.resolve(cwd, snippetSyntax) - Syntax = await importer(pathToFileURL(fullSyntaxPath)) - Syntax = FormatterBuilder.resolveConstructor(Syntax) + Syntax = FormatterBuilder.resolveConstructor( + await importer(pathToFileURL(fullSyntaxPath)) + ) } return new StepDefinitionSnippetBuilder({ snippetSyntax: new Syntax(snippetInterface), From ebbf550130fffe40bc1449290a25109a9aedd435 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 28 Jul 2021 18:05:17 +0100 Subject: [PATCH 20/25] whoops, re-add try/catch --- src/cli/index.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 8bd57cfa9..23ecec77d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -162,14 +162,19 @@ export default class Cli { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, newId) for (const codePath of supportCodePaths) { - if ( - codePath.endsWith('.js') || - codePath.endsWith('.mjs') || - codePath.endsWith('.cjs') - ) { - await importer(pathToFileURL(codePath)) - } else { - require(codePath) + try { + if ( + codePath.endsWith('.js') || + codePath.endsWith('.mjs') || + codePath.endsWith('.cjs') + ) { + await importer(pathToFileURL(codePath)) + } else { + require(codePath) + } + } catch (e) { + console.error(e.stack) + console.error('codepath: ' + codePath) } } return supportCodeLibraryBuilder.finalize() From 8ece5f8ab2c28c392d759fc60029da1720d89043 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 5 Sep 2021 10:09:04 +0100 Subject: [PATCH 21/25] use require with transpilers; import otherwise --- src/cli/index.ts | 17 ++++------------- src/runtime/parallel/worker.ts | 10 +++------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 23ecec77d..929fe03b4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -162,19 +162,10 @@ export default class Cli { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, newId) for (const codePath of supportCodePaths) { - try { - if ( - codePath.endsWith('.js') || - codePath.endsWith('.mjs') || - codePath.endsWith('.cjs') - ) { - await importer(pathToFileURL(codePath)) - } else { - require(codePath) - } - } catch (e) { - console.error(e.stack) - console.error('codepath: ' + codePath) + if (supportCodeRequiredModules.length) { + require(codePath) + } else { + await importer(pathToFileURL(codePath)) } } return supportCodeLibraryBuilder.finalize() diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index 9acff213d..b8b42b38b 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -76,14 +76,10 @@ export default class Worker { supportCodeRequiredModules.map((module) => require(module)) supportCodeLibraryBuilder.reset(this.cwd, this.newId) for (const codePath of supportCodePaths) { - if ( - codePath.endsWith('.js') || - codePath.endsWith('.mjs') || - codePath.endsWith('.cjs') - ) { - await importer(pathToFileURL(codePath)) - } else { + if (supportCodeRequiredModules.length) { require(codePath) + } else { + await importer(pathToFileURL(codePath)) } } this.supportCodeLibrary = supportCodeLibraryBuilder.finalize(supportCodeIds) From b3e81cc28fd98dbb5495ad23f93fc4deba4fc173 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 5 Sep 2021 10:25:29 +0100 Subject: [PATCH 22/25] remove pointless return --- features/support/hooks.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/features/support/hooks.ts b/features/support/hooks.ts index 5c97e27d7..650a50ee4 100644 --- a/features/support/hooks.ts +++ b/features/support/hooks.ts @@ -48,7 +48,6 @@ Before('@esm', function (this: World) { name: 'feature-test-pickle', type: 'module', }) - return undefined }) Before('@global-install', function (this: World) { From c457355324cdd2729ab429787931518f6747dac6 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 5 Sep 2021 22:52:13 +0100 Subject: [PATCH 23/25] support .cjs config file --- docs/esm.md | 2 ++ features/esm.feature | 6 ++++++ features/profiles.feature | 4 ++++ src/cli/argv_parser.ts | 6 +----- src/cli/profile_loader.ts | 22 ++++++++++++++++------ src/cli/profile_loader_spec.ts | 34 +++++++++++++++++++++++++--------- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/docs/esm.md b/docs/esm.md index 805cf702c..9e7e2a54e 100644 --- a/docs/esm.md +++ b/docs/esm.md @@ -30,3 +30,5 @@ As well as support code, these things can also be in ES modules syntax: You can use ES modules selectively/incrementally - so you can have a mixture of CommonJS and ESM in the same project. 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). + +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. diff --git a/features/esm.feature b/features/esm.feature index c07471935..0191a59cf 100644 --- a/features/esm.feature +++ b/features/esm.feature @@ -33,6 +33,12 @@ Feature: ES modules support } } """ + And a file named "cucumber.cjs" with: + """ + module.exports = { + 'default': '--format summary' + } + """ When I run cucumber-js with ` --format ./custom-formatter.js --format-options '{"snippetSyntax": "./custom-snippet-syntax.js"}' ` Then it passes Examples: diff --git a/features/profiles.feature b/features/profiles.feature index c229ca4e8..fca00d0fc 100644 --- a/features/profiles.feature +++ b/features/profiles.feature @@ -91,3 +91,7 @@ Feature: default command line arguments | OPT | | -c | | --config | + + Scenario: specifying a configuration file that doesn't exist + When I run cucumber-js with `--config doesntexist.js` + Then it fails diff --git a/src/cli/argv_parser.ts b/src/cli/argv_parser.ts index cdc787b9e..9b1ac0bc9 100644 --- a/src/cli/argv_parser.ts +++ b/src/cli/argv_parser.ts @@ -107,11 +107,7 @@ const ArgvParser = { .usage('[options] [...]') .version(version, '-v, --version') .option('-b, --backtrace', 'show full backtrace for errors') - .option( - '-c, --config ', - 'specify configuration file', - 'cucumber.js' - ) + .option('-c, --config ', 'specify configuration file') .option( '-d, --dry-run', 'invoke formatters without executing steps', diff --git a/src/cli/profile_loader.ts b/src/cli/profile_loader.ts index 1b6e8008f..8cd97d499 100644 --- a/src/cli/profile_loader.ts +++ b/src/cli/profile_loader.ts @@ -3,19 +3,29 @@ import path from 'path' import stringArgv from 'string-argv' import { doesHaveValue, doesNotHaveValue } from '../value_checker' +const DEFAULT_FILENAMES = ['cucumber.cjs', 'cucumber.js'] + export default class ProfileLoader { constructor(private readonly directory: string) {} async getDefinitions(configFile?: string): Promise> { - const definitionsFilePath: string = path.join( - this.directory, - configFile || 'cucumber.js' + if (configFile) { + return this.loadFile(configFile) + } + + const defaultFile = DEFAULT_FILENAMES.find((filename) => + fs.existsSync(path.join(this.directory, filename)) ) - const exists = await fs.exists(definitionsFilePath) - if (!exists) { - return {} + if (defaultFile) { + return this.loadFile(defaultFile) } + + return {} + } + + loadFile(configFile: string): Record { + const definitionsFilePath: string = path.join(this.directory, configFile) // eslint-disable-next-line @typescript-eslint/no-var-requires const definitions = require(definitionsFilePath) if (typeof definitions !== 'object') { diff --git a/src/cli/profile_loader_spec.ts b/src/cli/profile_loader_spec.ts index 909dbb281..fcbe4c035 100644 --- a/src/cli/profile_loader_spec.ts +++ b/src/cli/profile_loader_spec.ts @@ -9,8 +9,9 @@ import { doesHaveValue, valueOrDefault } from '../value_checker' interface TestProfileLoaderOptions { definitionsFileContent?: string + definitionsFileName?: string profiles?: string[] - configFile?: string + configOption?: string } async function testProfileLoader( @@ -19,15 +20,11 @@ async function testProfileLoader( const cwd = await promisify(tmp.dir)({ unsafeCleanup: true, }) - let configurationFileName = 'cucumber.js' - - if (doesHaveValue(opts.configFile)) { - configurationFileName = opts.configFile - } + const definitionsFileName = opts.definitionsFileName ?? 'cucumber.js' if (doesHaveValue(opts.definitionsFileContent)) { await fs.writeFile( - path.join(cwd, configurationFileName), + path.join(cwd, definitionsFileName), opts.definitionsFileContent ) } @@ -35,7 +32,7 @@ async function testProfileLoader( const profileLoader = new ProfileLoader(cwd) return await profileLoader.getArgv( valueOrDefault(opts.profiles, []), - opts.configFile + opts.configOption ) } @@ -171,13 +168,32 @@ describe('ProfileLoader', () => { // Act const result = await testProfileLoader({ definitionsFileContent, + definitionsFileName: '.cucumber-rc.js', profiles: ['profile3'], - configFile: '.cucumber-rc.js', + configOption: '.cucumber-rc.js', }) // Assert expect(result).to.eql(['--opt3', '--opt4']) }) + + it('throws when the file doesnt exist', async () => { + // Arrange + const definitionsFileContent = + 'module.exports = {profile3: "--opt3 --opt4"}' + + // Act + try { + await testProfileLoader({ + definitionsFileContent, + profiles: [], + configOption: 'doesntexist.js', + }) + expect.fail('should throw') + } catch (e) { + expect(e.message).to.contain('Cannot find module') + } + }) }) }) }) From 8037766148022187a361d315ba7c30d245526494 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 16 Sep 2021 15:00:13 +0100 Subject: [PATCH 24/25] type and import the importer --- src/cli/index.ts | 3 +-- src/formatter/builder.ts | 3 +-- src/importer.js | 12 ++++++++---- src/runtime/parallel/worker.ts | 3 +-- tsconfig.json | 1 + 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 929fe03b4..80e9684e5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,9 +30,8 @@ import HttpStream from '../formatter/http_stream' import { promisify } from 'util' import { Writable } from 'stream' import { pathToFileURL } from 'url' +import importer from '../importer' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { importer } = require('../importer') const { incrementing, uuid } = IdGenerator export interface ICliRunResult { diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index 72862b074..113ea23ab 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -21,8 +21,7 @@ import { IParsedArgvFormatOptions } from '../cli/argv_parser' import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax' import HtmlFormatter from './html_formatter' import { pathToFileURL } from 'url' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { importer } = require('../importer') +import importer from '../importer' interface IGetStepDefinitionSnippetBuilderOptions { cwd: string diff --git a/src/importer.js b/src/importer.js index ac391c470..7fbfb1da1 100644 --- a/src/importer.js +++ b/src/importer.js @@ -4,8 +4,12 @@ without having it transpiled down to commonjs `require()` by TypeScript. See https://github.com/microsoft/TypeScript/issues/43329. */ -module.exports = { - importer: async (descriptor) => { - return await import(descriptor) - }, +/** + * @param {any} descriptor - A URL or path for the module to load + * @return {Promise} Promise that resolves to the loaded module + */ +async function importer(descriptor) { + return await import(descriptor) } + +module.exports = importer diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index b8b42b38b..ec2271b93 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -19,9 +19,8 @@ import { IRuntimeOptions } from '../index' import { PredictableTestRunStopwatch, RealTestRunStopwatch } from '../stopwatch' import { duration } from 'durations' import { pathToFileURL } from 'url' +import importer from '../../importer' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { importer } = require('../../importer') const { uuid } = IdGenerator type IExitFunction = (exitCode: number, error?: Error, message?: string) => void diff --git a/tsconfig.json b/tsconfig.json index b9d4b64ad..7d1e4e685 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "esModuleInterop": true, "lib": ["es2019"], "module": "commonjs", From 83e513bdd343e0c0bd599814e9a48280312656b6 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sat, 18 Sep 2021 09:51:31 +0100 Subject: [PATCH 25/25] actually dont import - causes issues --- src/cli/index.ts | 3 ++- src/formatter/builder.ts | 3 ++- src/importer.js | 12 +++++------- src/runtime/parallel/worker.ts | 3 ++- tsconfig.json | 1 - 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 80e9684e5..929fe03b4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,8 +30,9 @@ import HttpStream from '../formatter/http_stream' import { promisify } from 'util' import { Writable } from 'stream' import { pathToFileURL } from 'url' -import importer from '../importer' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../importer') const { incrementing, uuid } = IdGenerator export interface ICliRunResult { diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index 113ea23ab..72862b074 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -21,7 +21,8 @@ import { IParsedArgvFormatOptions } from '../cli/argv_parser' import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax' import HtmlFormatter from './html_formatter' import { pathToFileURL } from 'url' -import importer from '../importer' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../importer') interface IGetStepDefinitionSnippetBuilderOptions { cwd: string diff --git a/src/importer.js b/src/importer.js index 7fbfb1da1..5347a38ca 100644 --- a/src/importer.js +++ b/src/importer.js @@ -1,10 +1,8 @@ -/* -Provides the async `import()` function to source code that needs it, -without having it transpiled down to commonjs `require()` by TypeScript. -See https://github.com/microsoft/TypeScript/issues/43329. - */ - /** + * Provides the async `import()` function to source code that needs it, + * without having it transpiled down to commonjs `require()` by TypeScript. + * See https://github.com/microsoft/TypeScript/issues/43329. + * * @param {any} descriptor - A URL or path for the module to load * @return {Promise} Promise that resolves to the loaded module */ @@ -12,4 +10,4 @@ async function importer(descriptor) { return await import(descriptor) } -module.exports = importer +module.exports = { importer } diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index ec2271b93..b8b42b38b 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -19,8 +19,9 @@ import { IRuntimeOptions } from '../index' import { PredictableTestRunStopwatch, RealTestRunStopwatch } from '../stopwatch' import { duration } from 'durations' import { pathToFileURL } from 'url' -import importer from '../../importer' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../../importer') const { uuid } = IdGenerator type IExitFunction = (exitCode: number, error?: Error, message?: string) => void diff --git a/tsconfig.json b/tsconfig.json index 7d1e4e685..b9d4b64ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "allowJs": true, "esModuleInterop": true, "lib": ["es2019"], "module": "commonjs",