From efff3a3516a6fd2f2a77de2f1dd6ac515349e8d6 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 9 Dec 2021 12:50:24 +0000 Subject: [PATCH] api: add runCucumber function internally (#1849) --- compatibility/cck_spec.ts | 38 +-- cucumber.js | 34 +-- features/i18n.feature | 1 + features/support/world.ts | 1 + src/cli/argv_parser.ts | 23 +- src/cli/configuration_builder.ts | 283 +++++------------ src/cli/configuration_builder_spec.ts | 418 ++++---------------------- src/cli/helpers.ts | 11 +- src/cli/helpers_spec.ts | 5 +- src/cli/index.ts | 246 ++------------- src/cli/run.ts | 7 +- src/configuration/index.ts | 1 + src/configuration/types.ts | 31 ++ src/formatter/publish.ts | 2 + src/run/formatters.ts | 107 +++++++ src/run/index.ts | 2 + src/run/paths.ts | 116 +++++++ src/run/paths_spec.ts | 226 ++++++++++++++ src/run/runCucumber.ts | 98 ++++++ src/run/runtime.ts | 60 ++++ src/run/support.ts | 31 ++ src/run/types.ts | 13 + src/runtime/helpers.ts | 5 +- src/runtime/helpers_spec.ts | 10 +- src/runtime/index.ts | 16 +- src/runtime/parallel/coordinator.ts | 14 +- 26 files changed, 934 insertions(+), 865 deletions(-) create mode 100644 src/configuration/index.ts create mode 100644 src/configuration/types.ts create mode 100644 src/formatter/publish.ts create mode 100644 src/run/formatters.ts create mode 100644 src/run/index.ts create mode 100644 src/run/paths.ts create mode 100644 src/run/paths_spec.ts create mode 100644 src/run/runCucumber.ts create mode 100644 src/run/runtime.ts create mode 100644 src/run/support.ts create mode 100644 src/run/types.ts diff --git a/compatibility/cck_spec.ts b/compatibility/cck_spec.ts index 0f5f0dbce..a9f834572 100644 --- a/compatibility/cck_spec.ts +++ b/compatibility/cck_spec.ts @@ -5,7 +5,6 @@ import glob from 'glob' import fs from 'fs' import path from 'path' import { PassThrough, pipeline, Writable } from 'stream' -import { Cli } from '../src' import toString from 'stream-to-string' import { ignorableKeys, @@ -14,6 +13,8 @@ import { import * as messages from '@cucumber/messages' import * as messageStreams from '@cucumber/message-streams' import util from 'util' +import { runCucumber } from '../src/run' +import { IRunConfiguration } from '../src/configuration' const asyncPipeline = util.promisify(pipeline) const PROJECT_PATH = path.join(__dirname, '..') @@ -29,27 +30,28 @@ describe('Cucumber Compatibility Kit', () => { const suiteName = match[1] const extension = match[2] it(`passes the cck suite for '${suiteName}'`, async () => { - const cliOptions = [ - `${CCK_FEATURES_PATH}/${suiteName}/${suiteName}${extension}`, - '--require', - `${CCK_IMPLEMENTATIONS_PATH}/${suiteName}/${suiteName}.ts`, - '--profile', - 'cck', - ] - if (suiteName === 'retry') { - cliOptions.push('--retry', '2') - } - const args = [ - 'node', - path.join(PROJECT_PATH, 'bin', 'cucumber-js'), - ].concat(cliOptions) const stdout = new PassThrough() + const runConfiguration: IRunConfiguration = { + sources: { + paths: [`${CCK_FEATURES_PATH}/${suiteName}/${suiteName}${extension}`], + }, + support: { + transpileWith: ['ts-node/register'], + paths: [`${CCK_IMPLEMENTATIONS_PATH}/${suiteName}/${suiteName}.ts`], + }, + formats: { + stdout: 'message', + }, + runtime: { + retry: suiteName === 'retry' ? 2 : 0, + }, + } try { - await new Cli({ - argv: args, + await runCucumber(runConfiguration, { cwd: PROJECT_PATH, stdout, - }).run() + env: process.env, + }) } catch (ignored) { console.error(ignored) } diff --git a/cucumber.js b/cucumber.js index 872e7de51..541114c45 100644 --- a/cucumber.js +++ b/cucumber.js @@ -1,24 +1,14 @@ -const feature = [ - '--require-module ts-node/register', - '--require features/**/*.ts', - `--format progress-bar`, - '--format rerun:@rerun.txt', - '--format usage:usage.txt', - '--format message:messages.ndjson', - '--format html:html-formatter.html', - '--retry 2', - '--retry-tag-filter @flaky', - '--publish-quiet', -].join(' ') - -const cck = [ - '--require-module', - 'ts-node/register', - '--format', - 'message', -].join(' ') - module.exports = { - default: feature, - cck, + default: [ + '--require-module ts-node/register', + '--require features/**/*.ts', + `--format progress-bar`, + '--format rerun:@rerun.txt', + '--format usage:usage.txt', + '--format message:messages.ndjson', + '--format html:html-formatter.html', + '--retry 2', + '--retry-tag-filter @flaky', + '--publish-quiet', + ].join(' '), } diff --git a/features/i18n.feature b/features/i18n.feature index d08db44e7..af7983c2e 100644 --- a/features/i18n.feature +++ b/features/i18n.feature @@ -1,3 +1,4 @@ +@spawn Feature: internationalization Scenario: view available languages diff --git a/features/support/world.ts b/features/support/world.ts index 6b908df7a..4974bda79 100644 --- a/features/support/world.ts +++ b/features/support/world.ts @@ -83,6 +83,7 @@ export class World { argv: args, cwd, stdout, + env, }) let error: any, stderr: string try { diff --git a/src/cli/argv_parser.ts b/src/cli/argv_parser.ts index 0519a7a1d..65267a1bb 100644 --- a/src/cli/argv_parser.ts +++ b/src/cli/argv_parser.ts @@ -2,7 +2,9 @@ import { Command } from 'commander' import path from 'path' import { dialects } from '@cucumber/gherkin' import { SnippetInterface } from '../formatter/step_definition_snippet_builder/snippet_syntax' +import { getKeywords, getLanguages } from './i18n' import Formatters from '../formatter/helpers/formatters' +import { PickleOrder } from './helpers' // Using require instead of import so compiled typescript will have the desired folder structure const { version } = require('../../package.json') // eslint-disable-line @typescript-eslint/no-var-requires @@ -31,7 +33,7 @@ export interface IParsedArgvOptions { i18nLanguages: boolean language: string name: string[] - order: string + order: PickleOrder parallel: number profile: string[] publish: boolean @@ -216,14 +218,21 @@ const ArgvParser = { {} ) - program.on('--help', () => { - /* eslint-disable no-console */ - console.log( - ' For more details please visit https://github.com/cucumber/cucumber-js/blob/master/docs/cli.md\n' - ) - /* eslint-enable no-console */ + program.on('option:i18n-languages', () => { + console.log(getLanguages()) + process.exit() + }) + + program.on('option:i18n-keywords', function (isoCode: string) { + console.log(getKeywords(isoCode)) + process.exit() }) + program.addHelpText( + 'afterAll', + 'For more details please visit https://github.com/cucumber/cucumber-js/blob/main/docs/cli.md' + ) + program.parse(argv) const options: IParsedArgvOptions = program.opts() ArgvParser.validateRetryOptions(options) diff --git a/src/cli/configuration_builder.ts b/src/cli/configuration_builder.ts index 384a3da67..74827aaf2 100644 --- a/src/cli/configuration_builder.ts +++ b/src/cli/configuration_builder.ts @@ -1,224 +1,79 @@ -import ArgvParser, { - IParsedArgvFormatOptions, - IParsedArgvOptions, -} from './argv_parser' -import fs from 'mz/fs' -import path from 'path' +import { IParsedArgv, IParsedArgvOptions } from './argv_parser' import OptionSplitter from './option_splitter' -import glob from 'glob' -import { promisify } from 'util' -import { IPickleFilterOptions } from '../pickle_filter' -import { IRuntimeOptions } from '../runtime' -import { valueOrDefault } from '../value_checker' - -export interface IConfigurationFormat { - outputTo: string - type: string +import { IRunConfiguration } from '../configuration' + +export async function buildConfiguration( + fromArgv: IParsedArgv, + env: NodeJS.ProcessEnv +): Promise { + const { args, options } = fromArgv + return { + sources: { + paths: args, + defaultDialect: options.language, + names: options.name, + tagExpression: options.tags, + order: options.order, + }, + support: { + transpileWith: options.requireModule, + paths: options.require, + }, + runtime: { + dryRun: options.dryRun, + failFast: options.failFast, + filterStacktraces: !options.backtrace, + parallel: options.parallel, + retry: options.retry, + retryTagFilter: options.retryTagFilter, + strict: options.strict, + worldParameters: options.worldParameters, + }, + formats: { + stdout: options.format.find((option) => !option.includes(':')), + files: options.format + .filter((option) => option.includes(':')) + .reduce((mapped, item) => { + const [type, target] = OptionSplitter.split(item) + return { + ...mapped, + [target]: type, + } + }, {}), + publish: makePublishConfig(options, env), + options: options.formatOptions, + }, + } } -export interface IConfiguration { - featureDefaultLanguage: string - featurePaths: string[] - formats: IConfigurationFormat[] - formatOptions: IParsedArgvFormatOptions - publishing: boolean - listI18nKeywordsFor: string - listI18nLanguages: boolean - order: string - parallel: number - pickleFilterOptions: IPickleFilterOptions - profiles: string[] - runtimeOptions: IRuntimeOptions - shouldExitImmediately: boolean - supportCodePaths: string[] - supportCodeRequiredModules: string[] - suppressPublishAdvertisement: boolean +export function isTruthyString(s: string | undefined): boolean { + if (s === undefined) { + return false + } + return s.match(/^(false|no|0)$/i) === null } -export interface INewConfigurationBuilderOptions { - argv: string[] - cwd: string +function isPublishing( + options: IParsedArgvOptions, + env: NodeJS.ProcessEnv +): boolean { + return ( + options.publish || + isTruthyString(env.CUCUMBER_PUBLISH_ENABLED) || + env.CUCUMBER_PUBLISH_TOKEN !== undefined + ) } -const DEFAULT_CUCUMBER_PUBLISH_URL = 'https://messages.cucumber.io/api/reports' - -export default class ConfigurationBuilder { - static async build( - options: INewConfigurationBuilderOptions - ): Promise { - const builder = new ConfigurationBuilder(options) - return await builder.build() - } - - private readonly cwd: string - private readonly args: string[] - private readonly options: IParsedArgvOptions - - constructor({ argv, cwd }: INewConfigurationBuilderOptions) { - this.cwd = cwd - const parsedArgv = ArgvParser.parse(argv) - this.args = parsedArgv.args - this.options = parsedArgv.options - } - - async build(): Promise { - const listI18nKeywordsFor = this.options.i18nKeywords - const listI18nLanguages = this.options.i18nLanguages - const unexpandedFeaturePaths = await this.getUnexpandedFeaturePaths() - let featurePaths: string[] = [] - let supportCodePaths: string[] = [] - if (listI18nKeywordsFor === '' && !listI18nLanguages) { - featurePaths = await this.expandFeaturePaths(unexpandedFeaturePaths) - let unexpandedSupportCodePaths = this.options.require - if (unexpandedSupportCodePaths.length === 0) { - unexpandedSupportCodePaths = this.getFeatureDirectoryPaths(featurePaths) - } - supportCodePaths = await this.expandPaths( - unexpandedSupportCodePaths, - '.@(js|mjs)' - ) - } - return { - featureDefaultLanguage: this.options.language, - featurePaths, - formats: this.getFormats(), - formatOptions: this.options.formatOptions, - publishing: this.isPublishing(), - listI18nKeywordsFor, - listI18nLanguages, - order: this.options.order, - parallel: this.options.parallel, - pickleFilterOptions: { - cwd: this.cwd, - featurePaths: unexpandedFeaturePaths, - names: this.options.name, - tagExpression: this.options.tags, - }, - profiles: this.options.profile, - runtimeOptions: { - dryRun: this.options.dryRun, - failFast: this.options.failFast, - filterStacktraces: !this.options.backtrace, - retry: this.options.retry, - retryTagFilter: this.options.retryTagFilter, - strict: this.options.strict, - worldParameters: this.options.worldParameters, - }, - shouldExitImmediately: this.options.exit, - supportCodePaths, - supportCodeRequiredModules: this.options.requireModule, - suppressPublishAdvertisement: this.isPublishAdvertisementSuppressed(), - } - } - - async expandPaths( - unexpandedPaths: string[], - defaultExtension: string - ): Promise { - const expandedPaths = await Promise.all( - unexpandedPaths.map(async (unexpandedPath) => { - const matches = await promisify(glob)(unexpandedPath, { - absolute: true, - cwd: this.cwd, - }) - const expanded = await Promise.all( - matches.map(async (match) => { - if (path.extname(match) === '') { - return await promisify(glob)(`${match}/**/*${defaultExtension}`) - } - return [match] - }) - ) - return expanded.flat() - }) - ) - return expandedPaths.flat().map((x) => path.normalize(x)) - } - - async expandFeaturePaths(featurePaths: string[]): Promise { - featurePaths = featurePaths.map((p) => p.replace(/(:\d+)*$/g, '')) // Strip line numbers - featurePaths = [...new Set(featurePaths)] // Deduplicate the feature files - return await this.expandPaths(featurePaths, '.feature') +function makePublishConfig( + options: IParsedArgvOptions, + env: NodeJS.ProcessEnv +): any { + const enabled = isPublishing(options, env) + if (!enabled) { + return false } - - getFeatureDirectoryPaths(featurePaths: string[]): string[] { - const featureDirs = featurePaths.map((featurePath) => { - let featureDir = path.dirname(featurePath) - let childDir: string - let parentDir = featureDir - while (childDir !== parentDir) { - childDir = parentDir - parentDir = path.dirname(childDir) - if (path.basename(parentDir) === 'features') { - featureDir = parentDir - break - } - } - return path.relative(this.cwd, featureDir) - }) - return [...new Set(featureDirs)] - } - - isPublishing(): boolean { - return ( - this.options.publish || - this.isTruthyString(process.env.CUCUMBER_PUBLISH_ENABLED) || - process.env.CUCUMBER_PUBLISH_TOKEN !== undefined - ) - } - - isPublishAdvertisementSuppressed(): boolean { - return ( - this.options.publishQuiet || - this.isTruthyString(process.env.CUCUMBER_PUBLISH_QUIET) - ) - } - - getFormats(): IConfigurationFormat[] { - const mapping: { [key: string]: string } = { '': 'progress' } - this.options.format.forEach((format) => { - const [type, outputTo] = OptionSplitter.split(format) - mapping[outputTo] = type - }) - if (this.isPublishing()) { - const publishUrl = valueOrDefault( - process.env.CUCUMBER_PUBLISH_URL, - DEFAULT_CUCUMBER_PUBLISH_URL - ) - - mapping[publishUrl] = 'message' - } - return Object.keys(mapping).map((outputTo) => ({ - outputTo, - type: mapping[outputTo], - })) - } - - isTruthyString(s: string | undefined): boolean { - if (s === undefined) { - return false - } - return s.match(/^(false|no|0)$/i) === null - } - - async getUnexpandedFeaturePaths(): Promise { - if (this.args.length > 0) { - const nestedFeaturePaths = await Promise.all( - this.args.map(async (arg) => { - const filename = path.basename(arg) - if (filename[0] === '@') { - const filePath = path.join(this.cwd, arg) - const content = await fs.readFile(filePath, 'utf8') - return content.split('\n').map((x) => x.trim()) - } - return [arg] - }) - ) - const featurePaths = nestedFeaturePaths.flat() - if (featurePaths.length > 0) { - return featurePaths.filter((x) => x !== '') - } - } - return ['features/**/*.{feature,feature.md}'] + return { + url: env.CUCUMBER_PUBLISH_URL, + token: env.CUCUMBER_PUBLISH_TOKEN, } } diff --git a/src/cli/configuration_builder_spec.ts b/src/cli/configuration_builder_spec.ts index b2b8a0111..01e03772d 100644 --- a/src/cli/configuration_builder_spec.ts +++ b/src/cli/configuration_builder_spec.ts @@ -1,374 +1,66 @@ import { describe, it } from 'mocha' import { expect } from 'chai' -import ConfigurationBuilder from './configuration_builder' -import fsExtra from 'fs-extra' -import path from 'path' -import tmp, { DirOptions } from 'tmp' -import { promisify } from 'util' -import { SnippetInterface } from '../formatter/step_definition_snippet_builder/snippet_syntax' - -async function buildTestWorkingDirectory(): Promise { - const cwd = await promisify(tmp.dir)({ - unsafeCleanup: true, - }) - await fsExtra.mkdirp(path.join(cwd, 'features')) - return cwd -} - +import { buildConfiguration } from './configuration_builder' +import ArgvParser from './argv_parser' const baseArgv = ['/path/to/node', '/path/to/cucumber-js'] -describe('Configuration', () => { - describe('no argv', () => { - it('returns the default configuration', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv - - // Act - const result = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(result).to.eql({ - featureDefaultLanguage: 'en', - featurePaths: [], - formatOptions: {}, - formats: [{ outputTo: '', type: 'progress' }], - publishing: false, - listI18nKeywordsFor: '', - listI18nLanguages: false, - order: 'defined', +describe('buildConfiguration', () => { + it('should derive correct defaults', async () => { + const result = await buildConfiguration(ArgvParser.parse([...baseArgv]), {}) + + expect(result).to.eql({ + formats: { + files: {}, + options: {}, + publish: false, + stdout: undefined, + }, + runtime: { + dryRun: false, + failFast: false, + filterStacktraces: true, parallel: 0, - pickleFilterOptions: { - cwd, - featurePaths: ['features/**/*.{feature,feature.md}'], - names: [], - tagExpression: '', - }, - profiles: [], - runtimeOptions: { - dryRun: false, - failFast: false, - filterStacktraces: true, - retry: 0, - retryTagFilter: '', - strict: true, - worldParameters: {}, - }, - shouldExitImmediately: false, - supportCodePaths: [], - supportCodeRequiredModules: [], - suppressPublishAdvertisement: false, - }) - }) - }) - - 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 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('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]) - }) - - 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]) - }) - }) - - describe('path to a nested feature', () => { - it('returns the appropriate .feature and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const relativeFeaturePath = path.join('features', 'nested', '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]) - }) - - it('returns the appropriate .md and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const relativeFeaturePath = path.join( - 'features', - 'nested', - '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]) - }) - }) - - describe('path to an empty rerun file', () => { - it('returns empty featurePaths and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - - const relativeRerunPath = '@empty_rerun.txt' - const rerunPath = path.join(cwd, '@empty_rerun.txt') - await fsExtra.outputFile(rerunPath, '') - const argv = baseArgv.concat([relativeRerunPath]) - - // Act - const { featurePaths, pickleFilterOptions, supportCodePaths } = - await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(featurePaths).to.eql([]) - expect(pickleFilterOptions.featurePaths).to.eql([]) - expect(supportCodePaths).to.eql([]) - }) - }) - - describe('path to an rerun file with new line', () => { - it('returns empty featurePaths and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - - const relativeRerunPath = '@empty_rerun.txt' - const rerunPath = path.join(cwd, '@empty_rerun.txt') - await fsExtra.outputFile(rerunPath, '\n') - const argv = baseArgv.concat([relativeRerunPath]) - - // Act - const { featurePaths, pickleFilterOptions, supportCodePaths } = - await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(featurePaths).to.eql([]) - expect(pickleFilterOptions.featurePaths).to.eql([]) - expect(supportCodePaths).to.eql([]) - }) - }) - - describe('path to a rerun file with one new line character', () => { - it('returns empty featurePaths and support code paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - - const relativeRerunPath = '@empty_rerun.txt' - const rerunPath = path.join(cwd, '@empty_rerun.txt') - await fsExtra.outputFile(rerunPath, '\n\n') - const argv = baseArgv.concat([relativeRerunPath]) - - // Act - const { featurePaths, pickleFilterOptions, supportCodePaths } = - await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(featurePaths).to.eql([]) - expect(pickleFilterOptions.featurePaths).to.eql([]) - expect(supportCodePaths).to.eql([]) - }) - }) - - describe('formatters', () => { - it('adds a default', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([{ outputTo: '', type: 'progress' }]) - }) - - it('adds a message formatter with reports URL when --publish specified', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat(['--publish']) - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([ - { outputTo: '', type: 'progress' }, - { - outputTo: 'https://messages.cucumber.io/api/reports', - type: 'message', - }, - ]) - }) - - it('sets publishing to true when --publish is specified', async function () { - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat(['--publish']) - const configuration = await ConfigurationBuilder.build({ argv, cwd }) - - expect(configuration.publishing).to.eq(true) - }) - - it('sets suppressPublishAdvertisement to true when --publish-quiet is specified', async function () { - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat(['--publish-quiet']) - const configuration = await ConfigurationBuilder.build({ argv, cwd }) - - expect(configuration.suppressPublishAdvertisement).to.eq(true) - }) - - it('splits relative unix paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat([ - '-f', - '../custom/formatter:../formatter/output.txt', - ]) - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([ - { outputTo: '', type: 'progress' }, - { outputTo: '../formatter/output.txt', type: '../custom/formatter' }, - ]) - }) - - it('splits absolute unix paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat([ - '-f', - '/custom/formatter:/formatter/output.txt', - ]) - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([ - { outputTo: '', type: 'progress' }, - { outputTo: '/formatter/output.txt', type: '/custom/formatter' }, - ]) - }) - - it('splits absolute windows paths', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat([ - '-f', - 'C:\\custom\\formatter:D:\\formatter\\output.txt', - ]) - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([ - { outputTo: '', type: 'progress' }, - { - outputTo: 'D:\\formatter\\output.txt', - type: 'C:\\custom\\formatter', - }, - ]) - }) - - it('does not split absolute windows paths without an output', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat(['-f', 'C:\\custom\\formatter']) - - // Act - const { formats } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formats).to.eql([{ outputTo: '', type: 'C:\\custom\\formatter' }]) + retry: 0, + retryTagFilter: '', + strict: true, + worldParameters: {}, + }, + sources: { + defaultDialect: 'en', + names: [], + order: 'defined', + paths: [], + tagExpression: '', + }, + support: { + paths: [], + transpileWith: [], + }, }) }) - describe('formatOptions', () => { - it('joins the objects', async function () { - // Arrange - const cwd = await buildTestWorkingDirectory() - const argv = baseArgv.concat([ - '--format-options', - '{"snippetInterface": "promise"}', - '--format-options', - '{"colorsEnabled": false}', - ]) - - // Act - const { formatOptions } = await ConfigurationBuilder.build({ argv, cwd }) - - // Assert - expect(formatOptions).to.eql({ - colorsEnabled: false, - snippetInterface: SnippetInterface.Promise, - }) + it('should map formatters', async () => { + const result = await buildConfiguration( + ArgvParser.parse([ + ...baseArgv, + '--format', + 'message', + '--format', + 'json:./report.json', + '--format', + 'html:./report.html', + ]), + {} + ) + + expect(result.formats).to.eql({ + stdout: 'message', + files: { + './report.html': 'html', + './report.json': 'json', + }, + publish: false, + options: {}, }) }) }) diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index a0a326399..0967a52fa 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -41,10 +41,12 @@ interface IParseGherkinMessageStreamRequest { eventBroadcaster: EventEmitter eventDataCollector: EventDataCollector gherkinMessageStream: Readable - order: string + order: PickleOrder pickleFilter: PickleFilter } +export type PickleOrder = 'defined' | 'random' + export async function parseGherkinMessageStream({ cwd, eventBroadcaster, @@ -84,7 +86,7 @@ export async function parseGherkinMessageStream({ } // Orders the pickleIds in place - morphs input -export function orderPickleIds(pickleIds: string[], order: string): void { +export function orderPickleIds(pickleIds: string[], order: PickleOrder): void { let [type, seed] = OptionSplitter.split(order) switch (type) { case 'defined': @@ -112,12 +114,13 @@ export function isJavaScript(filePath: string): boolean { } export async function emitMetaMessage( - eventBroadcaster: EventEmitter + eventBroadcaster: EventEmitter, + env: NodeJS.ProcessEnv ): Promise { // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require('../../package.json') eventBroadcaster.emit('envelope', { - meta: createMeta('cucumber-js', version, process.env), + meta: createMeta('cucumber-js', version, env), }) } diff --git a/src/cli/helpers_spec.ts b/src/cli/helpers_spec.ts index f4200b536..c5c151657 100644 --- a/src/cli/helpers_spec.ts +++ b/src/cli/helpers_spec.ts @@ -5,6 +5,7 @@ import { emitSupportCodeMessages, isJavaScript, parseGherkinMessageStream, + PickleOrder, } from './helpers' import { EventEmitter } from 'events' import PickleFilter from '../pickle_filter' @@ -31,7 +32,7 @@ const noopFunction = (): void => { interface ITestParseGherkinMessageStreamRequest { cwd: string gherkinMessageStream: Readable - order: string + order: PickleOrder pickleFilter: PickleFilter } @@ -103,7 +104,7 @@ describe('helpers', () => { const envelopes: messages.Envelope[] = [] const eventBroadcaster = new EventEmitter() eventBroadcaster.on('envelope', (e) => envelopes.push(e)) - await emitMetaMessage(eventBroadcaster) + await emitMetaMessage(eventBroadcaster, {}) expect(envelopes).to.have.length(1) expect(envelopes[0].meta.implementation.name).to.eq('cucumber-js') diff --git a/src/cli/index.ts b/src/cli/index.ts index bd5488e60..629eeb443 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,257 +1,59 @@ -import { EventDataCollector } from '../formatter/helpers' -import { - emitMetaMessage, - emitSupportCodeMessages, - getExpandedArgv, - isJavaScript, - parseGherkinMessageStream, -} from './helpers' +import { getExpandedArgv } from './helpers' import { validateInstall } from './install_validator' -import * as I18n from './i18n' -import ConfigurationBuilder, { - IConfiguration, - IConfigurationFormat, -} from './configuration_builder' -import { EventEmitter } from 'events' -import FormatterBuilder from '../formatter/builder' -import fs from 'mz/fs' -import path from 'path' -import PickleFilter from '../pickle_filter' -import ParallelRuntimeCoordinator from '../runtime/parallel/coordinator' -import Runtime from '../runtime' -import supportCodeLibraryBuilder from '../support_code_library_builder' -import { IdGenerator } from '@cucumber/messages' -import Formatter, { IFormatterStream } from '../formatter' -import { WriteStream as TtyWriteStream } from 'tty' -import { doesNotHaveValue } from '../value_checker' -import { GherkinStreams } from '@cucumber/gherkin-streams' -import { ISupportCodeLibrary } from '../support_code_library_builder/types' -import { IParsedArgvFormatOptions } from './argv_parser' -import HttpStream from '../formatter/http_stream' -import { promisify } from 'util' -import { Writable } from 'stream' -import { pathToFileURL } from 'url' - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { importer } = require('../importer') -const { uuid } = IdGenerator +import { buildConfiguration, isTruthyString } from './configuration_builder' +import { IFormatterStream } from '../formatter' +import { runCucumber } from '../run' +import ArgvParser from './argv_parser' export interface ICliRunResult { + shouldAdvertisePublish: boolean shouldExitImmediately: boolean success: boolean } -interface IInitializeFormattersRequest { - eventBroadcaster: EventEmitter - eventDataCollector: EventDataCollector - formatOptions: IParsedArgvFormatOptions - formats: IConfigurationFormat[] - supportCodeLibrary: ISupportCodeLibrary -} - -interface IGetSupportCodeLibraryRequest { - newId: IdGenerator.NewId - supportCodeRequiredModules: string[] - supportCodePaths: string[] -} - export default class Cli { private readonly argv: string[] private readonly cwd: string private readonly stdout: IFormatterStream + private readonly env: NodeJS.ProcessEnv constructor({ argv, cwd, stdout, + env, }: { argv: string[] cwd: string stdout: IFormatterStream + env: NodeJS.ProcessEnv }) { this.argv = argv this.cwd = cwd this.stdout = stdout - } - - async getConfiguration(): Promise { - const fullArgv = await getExpandedArgv({ - argv: this.argv, - cwd: this.cwd, - }) - return await ConfigurationBuilder.build({ - argv: fullArgv, - cwd: this.cwd, - }) - } - - async initializeFormatters({ - eventBroadcaster, - eventDataCollector, - formatOptions, - formats, - supportCodeLibrary, - }: IInitializeFormattersRequest): Promise<() => Promise> { - const formatters: Formatter[] = await Promise.all( - formats.map(async ({ type, outputTo }) => { - let stream: IFormatterStream = this.stdout - if (outputTo !== '') { - if (outputTo.match(/^https?:\/\//) !== null) { - const headers: { [key: string]: string } = {} - if (process.env.CUCUMBER_PUBLISH_TOKEN !== undefined) { - headers.Authorization = `Bearer ${process.env.CUCUMBER_PUBLISH_TOKEN}` - } - - stream = new HttpStream(outputTo, 'GET', headers) - const readerStream = new Writable({ - objectMode: true, - write: function (responseBody: string, encoding, writeCallback) { - console.error(responseBody) - writeCallback() - }, - }) - stream.pipe(readerStream) - } else { - const fd = await fs.open(path.resolve(this.cwd, outputTo), 'w') - stream = fs.createWriteStream(null, { fd }) - } - } - - stream.on('error', (error) => { - console.error(error.message) - process.exit(1) - }) - - const typeOptions = { - cwd: this.cwd, - eventBroadcaster, - eventDataCollector, - log: stream.write.bind(stream), - parsedArgvOptions: formatOptions, - stream, - cleanup: - stream === this.stdout - ? async () => await Promise.resolve() - : promisify(stream.end.bind(stream)), - supportCodeLibrary, - } - if (doesNotHaveValue(formatOptions.colorsEnabled)) { - typeOptions.parsedArgvOptions.colorsEnabled = ( - stream as TtyWriteStream - ).isTTY - } - if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) { - const outputToName = outputTo === '' ? 'stdout' : outputTo - console.warn( - `Cannot use 'progress-bar' formatter for output to '${outputToName}' as not a TTY. Switching to 'progress' formatter.` - ) - type = 'progress' - } - return await FormatterBuilder.build(type, typeOptions) - }) - ) - return async function () { - await Promise.all(formatters.map(async (f) => await f.finished())) - } - } - - async getSupportCodeLibrary({ - newId, - supportCodeRequiredModules, - supportCodePaths, - }: IGetSupportCodeLibraryRequest): Promise { - supportCodeRequiredModules.map((module) => require(module)) - supportCodeLibraryBuilder.reset(this.cwd, newId) - for (const codePath of supportCodePaths) { - if (supportCodeRequiredModules.length || !isJavaScript(codePath)) { - require(codePath) - } else { - await importer(pathToFileURL(codePath)) - } - } - return supportCodeLibraryBuilder.finalize() + this.env = env } async run(): Promise { await validateInstall(this.cwd) - const configuration = await this.getConfiguration() - if (configuration.listI18nLanguages) { - this.stdout.write(I18n.getLanguages()) - return { shouldExitImmediately: true, success: true } - } - if (configuration.listI18nKeywordsFor !== '') { - this.stdout.write(I18n.getKeywords(configuration.listI18nKeywordsFor)) - return { shouldExitImmediately: true, success: true } - } - const newId = uuid() - const supportCodeLibrary = await this.getSupportCodeLibrary({ - newId, - supportCodePaths: configuration.supportCodePaths, - supportCodeRequiredModules: configuration.supportCodeRequiredModules, - }) - const eventBroadcaster = new EventEmitter() - const eventDataCollector = new EventDataCollector(eventBroadcaster) - const cleanup = await this.initializeFormatters({ - eventBroadcaster, - eventDataCollector, - formatOptions: configuration.formatOptions, - formats: configuration.formats, - supportCodeLibrary, - }) - await emitMetaMessage(eventBroadcaster) - const gherkinMessageStream = GherkinStreams.fromPaths( - configuration.featurePaths, - { - defaultDialect: configuration.featureDefaultLanguage, - newId, - relativeTo: this.cwd, - } - ) - let pickleIds: string[] = [] - - if (configuration.featurePaths.length > 0) { - pickleIds = await parseGherkinMessageStream({ + const fromArgv = ArgvParser.parse( + await getExpandedArgv({ + argv: this.argv, cwd: this.cwd, - eventBroadcaster, - eventDataCollector, - gherkinMessageStream, - order: configuration.order, - pickleFilter: new PickleFilter(configuration.pickleFilterOptions), }) - } - emitSupportCodeMessages({ - eventBroadcaster, - supportCodeLibrary, - newId, + ) + const configuration = await buildConfiguration(fromArgv, this.env) + const { success } = await runCucumber(configuration, { + cwd: this.cwd, + stdout: this.stdout, + env: this.env, }) - let success - if (configuration.parallel > 1) { - const parallelRuntimeCoordinator = new ParallelRuntimeCoordinator({ - cwd: this.cwd, - eventBroadcaster, - eventDataCollector, - options: configuration.runtimeOptions, - newId, - pickleIds, - supportCodeLibrary, - supportCodePaths: configuration.supportCodePaths, - supportCodeRequiredModules: configuration.supportCodeRequiredModules, - }) - success = await parallelRuntimeCoordinator.run(configuration.parallel) - } else { - const runtime = new Runtime({ - eventBroadcaster, - eventDataCollector, - options: configuration.runtimeOptions, - newId, - pickleIds, - supportCodeLibrary, - }) - success = await runtime.start() - } - await cleanup() return { - shouldExitImmediately: configuration.shouldExitImmediately, + shouldAdvertisePublish: + !configuration.formats.publish && + !fromArgv.options.publishQuiet && + !isTruthyString(this.env.CUCUMBER_PUBLISH_QUIET), + shouldExitImmediately: fromArgv.options.exit, success, } } diff --git a/src/cli/run.ts b/src/cli/run.ts index c2253d0f2..508b71666 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -12,11 +12,11 @@ function displayPublishAdvertisementBanner(): void { } export default async function run(): Promise { - const cwd = process.cwd() const cli = new Cli({ argv: process.argv, - cwd, + cwd: process.cwd(), stdout: process.stdout, + env: process.env, }) let result: ICliRunResult @@ -26,8 +26,7 @@ export default async function run(): Promise { exitWithError(error) } - const config = await cli.getConfiguration() - if (!config.publishing && !config.suppressPublishAdvertisement) { + if (result.shouldAdvertisePublish) { displayPublishAdvertisementBanner() } diff --git a/src/configuration/index.ts b/src/configuration/index.ts new file mode 100644 index 000000000..c9f6f047d --- /dev/null +++ b/src/configuration/index.ts @@ -0,0 +1 @@ +export * from './types' diff --git a/src/configuration/types.ts b/src/configuration/types.ts new file mode 100644 index 000000000..c7fbd4453 --- /dev/null +++ b/src/configuration/types.ts @@ -0,0 +1,31 @@ +import { IRuntimeOptions } from '../runtime' +import { IParsedArgvFormatOptions } from '../cli/argv_parser' +import { PickleOrder } from '../cli/helpers' + +export interface IRunConfiguration { + sources: { + defaultDialect?: string + paths?: string[] + names?: string[] + tagExpression?: string + order?: PickleOrder + } + support: { + transpileWith: string[] + paths: string[] + } + runtime?: Partial & { parallel?: number } + formats?: IFormatterConfiguration +} + +export interface IFormatterConfiguration { + stdout?: string + files?: Record + publish?: + | { + url?: string + token?: string + } + | false + options?: IParsedArgvFormatOptions +} diff --git a/src/formatter/publish.ts b/src/formatter/publish.ts new file mode 100644 index 000000000..ca16d98c3 --- /dev/null +++ b/src/formatter/publish.ts @@ -0,0 +1,2 @@ +export const DEFAULT_CUCUMBER_PUBLISH_URL = + 'https://messages.cucumber.io/api/reports' diff --git a/src/run/formatters.ts b/src/run/formatters.ts new file mode 100644 index 000000000..9da8cf5e2 --- /dev/null +++ b/src/run/formatters.ts @@ -0,0 +1,107 @@ +import Formatter, { IFormatterStream } from '../formatter' +import { EventEmitter } from 'events' +import { EventDataCollector } from '../formatter/helpers' +import { ISupportCodeLibrary } from '../support_code_library_builder/types' +import { promisify } from 'util' +import { doesNotHaveValue } from '../value_checker' +import { WriteStream as TtyWriteStream } from 'tty' +import FormatterBuilder from '../formatter/builder' +import fs from 'mz/fs' +import path from 'path' +import { DEFAULT_CUCUMBER_PUBLISH_URL } from '../formatter/publish' +import HttpStream from '../formatter/http_stream' +import { Writable } from 'stream' +import { IFormatterConfiguration } from '../configuration' + +export async function initializeFormatters({ + cwd, + stdout, + eventBroadcaster, + eventDataCollector, + configuration = {}, + supportCodeLibrary, +}: { + cwd: string + stdout: IFormatterStream + eventBroadcaster: EventEmitter + eventDataCollector: EventDataCollector + configuration: IFormatterConfiguration + supportCodeLibrary: ISupportCodeLibrary +}): Promise<() => Promise> { + async function initializeFormatter( + stream: IFormatterStream, + target: string, + type: string + ): Promise { + stream.on('error', (error) => { + console.error(error.message) + process.exit(1) + }) + const typeOptions = { + cwd, + eventBroadcaster, + eventDataCollector, + log: stream.write.bind(stream), + parsedArgvOptions: configuration.options ?? {}, + stream, + cleanup: + stream === stdout + ? async () => await Promise.resolve() + : promisify(stream.end.bind(stream)), + supportCodeLibrary, + } + if (doesNotHaveValue(configuration.options?.colorsEnabled)) { + typeOptions.parsedArgvOptions.colorsEnabled = ( + stream as TtyWriteStream + ).isTTY + } + if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) { + console.warn( + `Cannot use 'progress-bar' formatter for output to '${target}' as not a TTY. Switching to 'progress' formatter.` + ) + type = 'progress' + } + return await FormatterBuilder.build(type, typeOptions) + } + + const formatters: Formatter[] = [] + + formatters.push( + await initializeFormatter( + stdout, + 'stdout', + configuration.stdout ?? 'progress' + ) + ) + + if (configuration.files) { + for (const [target, type] of Object.entries(configuration.files)) { + const stream: IFormatterStream = fs.createWriteStream(null, { + fd: await fs.open(path.resolve(cwd, target), 'w'), + }) + formatters.push(await initializeFormatter(stream, target, type)) + } + } + + if (configuration.publish) { + const { url = DEFAULT_CUCUMBER_PUBLISH_URL, token } = configuration.publish + const headers: { [key: string]: string } = {} + if (token !== undefined) { + headers.Authorization = `Bearer ${token}` + } + const stream = new HttpStream(url, 'GET', headers) + const readerStream = new Writable({ + objectMode: true, + write: function (responseBody: string, encoding, writeCallback) { + console.error(responseBody) + writeCallback() + }, + }) + stream.pipe(readerStream) + formatters.push(await initializeFormatter(stream, url, 'message')) + } + + return async function () { + await Promise.all(formatters.map(async (f) => await f.finished())) + } +} diff --git a/src/run/index.ts b/src/run/index.ts new file mode 100644 index 000000000..ac2fd9083 --- /dev/null +++ b/src/run/index.ts @@ -0,0 +1,2 @@ +export * from './runCucumber' +export * from './types' diff --git a/src/run/paths.ts b/src/run/paths.ts new file mode 100644 index 000000000..ab91f3354 --- /dev/null +++ b/src/run/paths.ts @@ -0,0 +1,116 @@ +import { promisify } from 'util' +import glob from 'glob' +import path from 'path' +import fs from 'mz/fs' +import { IRunConfiguration } from '../configuration' + +export async function resolvePaths( + cwd: string, + configuration: Pick +): Promise<{ + unexpandedFeaturePaths: string[] + featurePaths: string[] + supportCodePaths: string[] +}> { + const unexpandedFeaturePaths = await getUnexpandedFeaturePaths( + cwd, + configuration.sources.paths + ) + const featurePaths: string[] = await expandFeaturePaths( + cwd, + unexpandedFeaturePaths + ) + let unexpandedSupportCodePaths = configuration.support.paths ?? [] + if (unexpandedSupportCodePaths.length === 0) { + unexpandedSupportCodePaths = getFeatureDirectoryPaths(cwd, featurePaths) + } + const supportCodePaths = await expandPaths( + cwd, + unexpandedSupportCodePaths, + '.@(js|mjs)' + ) + return { + unexpandedFeaturePaths, + featurePaths, + supportCodePaths, + } +} + +async function expandPaths( + cwd: string, + unexpandedPaths: string[], + defaultExtension: string +): Promise { + const expandedPaths = await Promise.all( + unexpandedPaths.map(async (unexpandedPath) => { + const matches = await promisify(glob)(unexpandedPath, { + absolute: true, + cwd, + }) + const expanded = await Promise.all( + matches.map(async (match) => { + if (path.extname(match) === '') { + return await promisify(glob)(`${match}/**/*${defaultExtension}`) + } + return [match] + }) + ) + return expanded.flat() + }) + ) + return expandedPaths.flat().map((x) => path.normalize(x)) +} + +async function getUnexpandedFeaturePaths( + cwd: string, + args: string[] +): Promise { + if (args.length > 0) { + const nestedFeaturePaths = await Promise.all( + args.map(async (arg) => { + const filename = path.basename(arg) + if (filename[0] === '@') { + const filePath = path.join(cwd, arg) + const content = await fs.readFile(filePath, 'utf8') + return content.split('\n').map((x) => x.trim()) + } + return [arg] + }) + ) + const featurePaths = nestedFeaturePaths.flat() + if (featurePaths.length > 0) { + return featurePaths.filter((x) => x !== '') + } + } + return ['features/**/*.{feature,feature.md}'] +} + +function getFeatureDirectoryPaths( + cwd: string, + featurePaths: string[] +): string[] { + const featureDirs = featurePaths.map((featurePath) => { + let featureDir = path.dirname(featurePath) + let childDir: string + let parentDir = featureDir + while (childDir !== parentDir) { + childDir = parentDir + parentDir = path.dirname(childDir) + if (path.basename(parentDir) === 'features') { + featureDir = parentDir + break + } + } + return path.relative(cwd, featureDir) + }) + return [...new Set(featureDirs)] +} + +async function expandFeaturePaths( + cwd: string, + featurePaths: string[] +): Promise { + featurePaths = featurePaths.map((p) => p.replace(/(:\d+)*$/g, '')) // Strip line numbers + featurePaths = [...new Set(featurePaths)] // Deduplicate the feature files + return await expandPaths(cwd, featurePaths, '.feature') +} diff --git a/src/run/paths_spec.ts b/src/run/paths_spec.ts new file mode 100644 index 000000000..37d81eefb --- /dev/null +++ b/src/run/paths_spec.ts @@ -0,0 +1,226 @@ +import { promisify } from 'util' +import tmp, { DirOptions } from 'tmp' +import fsExtra from 'fs-extra' +import path from 'path' +import { describe, it } from 'mocha' +import { expect } from 'chai' +import { resolvePaths } from './paths' + +async function buildTestWorkingDirectory(): Promise { + const cwd = await promisify(tmp.dir)({ + unsafeCleanup: true, + }) + await fsExtra.mkdirp(path.join(cwd, 'features')) + return cwd +} + +describe('resolvePaths', () => { + 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 jsSupportCodePath = path.join(cwd, 'features', 'a.js') + await fsExtra.outputFile(jsSupportCodePath, '') + const esmSupportCodePath = path.join(cwd, 'features', 'a.mjs') + await fsExtra.outputFile(esmSupportCodePath, '') + + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { + paths: [relativeFeaturePath], + }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([featurePath]) + expect(unexpandedFeaturePaths).to.eql([relativeFeaturePath]) + expect(supportCodePaths).to.eql([jsSupportCodePath, esmSupportCodePath]) + }) + + 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, '') + // Act + const { featurePaths } = await resolvePaths(cwd, { + sources: { + paths: [`${relativeFeaturePath}:3`, `${relativeFeaturePath}:4`], + }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([featurePath]) + }) + + 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, '') + + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeFeaturePath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([featurePath]) + expect(unexpandedFeaturePaths).to.eql([relativeFeaturePath]) + expect(supportCodePaths).to.eql([supportCodePath]) + }) + }) + + describe('path to a nested feature', () => { + it('returns the appropriate .feature and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + const relativeFeaturePath = path.join('features', 'nested', 'a.feature') + const featurePath = path.join(cwd, relativeFeaturePath) + await fsExtra.outputFile(featurePath, '') + const supportCodePath = path.join(cwd, 'features', 'a.js') + await fsExtra.outputFile(supportCodePath, '') + + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeFeaturePath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([featurePath]) + expect(unexpandedFeaturePaths).to.eql([relativeFeaturePath]) + expect(supportCodePaths).to.eql([supportCodePath]) + }) + + it('returns the appropriate .md and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + const relativeFeaturePath = path.join( + 'features', + 'nested', + '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, '') + + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeFeaturePath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([featurePath]) + expect(unexpandedFeaturePaths).to.eql([relativeFeaturePath]) + expect(supportCodePaths).to.eql([supportCodePath]) + }) + }) + + describe('path to an empty rerun file', () => { + it('returns empty featurePaths and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + + const relativeRerunPath = '@empty_rerun.txt' + const rerunPath = path.join(cwd, '@empty_rerun.txt') + await fsExtra.outputFile(rerunPath, '') + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeRerunPath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([]) + expect(unexpandedFeaturePaths).to.eql([]) + expect(supportCodePaths).to.eql([]) + }) + }) + + describe('path to an rerun file with new line', () => { + it('returns empty featurePaths and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + + const relativeRerunPath = '@empty_rerun.txt' + const rerunPath = path.join(cwd, '@empty_rerun.txt') + await fsExtra.outputFile(rerunPath, '\n') + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeRerunPath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([]) + expect(unexpandedFeaturePaths).to.eql([]) + expect(supportCodePaths).to.eql([]) + }) + }) + + describe('path to a rerun file with one new line character', () => { + it('returns empty featurePaths and support code paths', async function () { + // Arrange + const cwd = await buildTestWorkingDirectory() + + const relativeRerunPath = '@empty_rerun.txt' + const rerunPath = path.join(cwd, '@empty_rerun.txt') + await fsExtra.outputFile(rerunPath, '\n\n') + + // Act + const { featurePaths, unexpandedFeaturePaths, supportCodePaths } = + await resolvePaths(cwd, { + sources: { paths: [relativeRerunPath] }, + support: { + paths: [], + transpileWith: [], + }, + }) + + // Assert + expect(featurePaths).to.eql([]) + expect(unexpandedFeaturePaths).to.eql([]) + expect(supportCodePaths).to.eql([]) + }) + }) +}) diff --git a/src/run/runCucumber.ts b/src/run/runCucumber.ts new file mode 100644 index 000000000..cbd7f8aa9 --- /dev/null +++ b/src/run/runCucumber.ts @@ -0,0 +1,98 @@ +import { IdGenerator } from '@cucumber/messages' +import { EventEmitter } from 'events' +import { EventDataCollector } from '../formatter/helpers' +import { + emitMetaMessage, + emitSupportCodeMessages, + parseGherkinMessageStream, +} from '../cli/helpers' +import { GherkinStreams } from '@cucumber/gherkin-streams' +import PickleFilter from '../pickle_filter' +import { IRunConfiguration } from '../configuration' +import { IRunEnvironment, IRunResult } from './types' +import { resolvePaths } from './paths' +import { makeRuntime } from './runtime' +import { initializeFormatters } from './formatters' +import { getSupportCodeLibrary } from './support' + +export async function runCucumber( + configuration: IRunConfiguration, + environment: IRunEnvironment = { + cwd: process.cwd(), + stdout: process.stdout, + env: process.env, + } +): Promise { + const { cwd, stdout, env } = environment + const newId = IdGenerator.uuid() + + const { unexpandedFeaturePaths, featurePaths, supportCodePaths } = + await resolvePaths(cwd, configuration) + + const supportCodeLibrary = await getSupportCodeLibrary({ + cwd, + newId, + supportCodePaths, + supportCodeRequiredModules: configuration.support.transpileWith, + }) + + const eventBroadcaster = new EventEmitter() + const eventDataCollector = new EventDataCollector(eventBroadcaster) + + const cleanup = await initializeFormatters({ + cwd, + stdout, + eventBroadcaster, + eventDataCollector, + configuration: configuration.formats, + supportCodeLibrary, + }) + await emitMetaMessage(eventBroadcaster, env) + + const gherkinMessageStream = GherkinStreams.fromPaths(featurePaths, { + defaultDialect: configuration.sources.defaultDialect, + newId, + relativeTo: cwd, + }) + let pickleIds: string[] = [] + + if (featurePaths.length > 0) { + pickleIds = await parseGherkinMessageStream({ + cwd, + eventBroadcaster, + eventDataCollector, + gherkinMessageStream, + order: configuration.sources.order ?? 'defined', + pickleFilter: new PickleFilter({ + cwd, + featurePaths: unexpandedFeaturePaths, + names: configuration.sources.names, + tagExpression: configuration.sources.tagExpression, + }), + }) + } + emitSupportCodeMessages({ + eventBroadcaster, + supportCodeLibrary, + newId, + }) + + const runtime = makeRuntime({ + cwd, + eventBroadcaster, + eventDataCollector, + pickleIds, + newId, + supportCodeLibrary, + supportCodePaths, + supportCodeRequiredModules: configuration.support.transpileWith, + options: configuration.runtime, + }) + const success = await runtime.start() + await cleanup() + + return { + success, + support: supportCodeLibrary, + } +} diff --git a/src/run/runtime.ts b/src/run/runtime.ts new file mode 100644 index 000000000..6f827fb46 --- /dev/null +++ b/src/run/runtime.ts @@ -0,0 +1,60 @@ +import Runtime, { + DEFAULT_RUNTIME_OPTIONS, + IRuntime, + IRuntimeOptions, +} from '../runtime' +import { EventEmitter } from 'events' +import { EventDataCollector } from '../formatter/helpers' +import { IdGenerator } from '@cucumber/messages' +import { ISupportCodeLibrary } from '../support_code_library_builder/types' +import Coordinator from '../runtime/parallel/coordinator' + +export function makeRuntime({ + cwd, + eventBroadcaster, + eventDataCollector, + pickleIds, + newId, + supportCodeLibrary, + supportCodePaths, + supportCodeRequiredModules, + options: { parallel = 0, ...runtimeOptions } = {}, +}: { + cwd: string + eventBroadcaster: EventEmitter + eventDataCollector: EventDataCollector + newId: IdGenerator.NewId + pickleIds: string[] + supportCodeLibrary: ISupportCodeLibrary + supportCodePaths: string[] + supportCodeRequiredModules: string[] + options: Partial & { parallel?: number } +}): IRuntime { + // sprinkle specified runtime options over the defaults + const options = { + ...DEFAULT_RUNTIME_OPTIONS, + ...runtimeOptions, + } + if (parallel > 0) { + return new Coordinator({ + cwd, + eventBroadcaster, + eventDataCollector, + pickleIds, + options, + newId, + supportCodeLibrary, + supportCodePaths, + supportCodeRequiredModules, + numberOfWorkers: parallel, + }) + } + return new Runtime({ + eventBroadcaster, + eventDataCollector, + newId, + pickleIds, + supportCodeLibrary, + options, + }) +} diff --git a/src/run/support.ts b/src/run/support.ts new file mode 100644 index 000000000..599497ae6 --- /dev/null +++ b/src/run/support.ts @@ -0,0 +1,31 @@ +import { IdGenerator } from '@cucumber/messages' +import { ISupportCodeLibrary } from '../support_code_library_builder/types' +import supportCodeLibraryBuilder from '../support_code_library_builder' +import { pathToFileURL } from 'url' +import { isJavaScript } from '../cli/helpers' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { importer } = require('../importer') + +export async function getSupportCodeLibrary({ + cwd, + newId, + supportCodeRequiredModules, + supportCodePaths, +}: { + cwd: string + newId: IdGenerator.NewId + supportCodeRequiredModules: string[] + supportCodePaths: string[] +}): Promise { + supportCodeLibraryBuilder.reset(cwd, newId) + supportCodeRequiredModules.map((module) => require(module)) + for (const codePath of supportCodePaths) { + if (supportCodeRequiredModules.length || !isJavaScript(codePath)) { + require(codePath) + } else { + await importer(pathToFileURL(codePath)) + } + } + return supportCodeLibraryBuilder.finalize() +} diff --git a/src/run/types.ts b/src/run/types.ts new file mode 100644 index 000000000..a3f36b8fc --- /dev/null +++ b/src/run/types.ts @@ -0,0 +1,13 @@ +import { ISupportCodeLibrary } from '../support_code_library_builder/types' +import { IFormatterStream } from '../formatter' + +export interface IRunEnvironment { + cwd: string + stdout: IFormatterStream + env: NodeJS.ProcessEnv +} + +export interface IRunResult { + success: boolean + support: ISupportCodeLibrary +} diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index 5ecc031cb..2b5c00643 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -49,12 +49,15 @@ export function retriesForPickle( pickle: messages.Pickle, options: IRuntimeOptions ): number { + if (!options.retry) { + return 0 + } const retries = options.retry if (retries === 0) { return 0 } const retryTagFilter = options.retryTagFilter - if (retryTagFilter === '') { + if (!retryTagFilter) { return retries } const pickleTagFilter = new PickleTagFilter(retryTagFilter) diff --git a/src/runtime/helpers_spec.ts b/src/runtime/helpers_spec.ts index 484342138..3aae88fe8 100644 --- a/src/runtime/helpers_spec.ts +++ b/src/runtime/helpers_spec.ts @@ -70,7 +70,10 @@ describe('Helpers', () => { it('returns options.retry is set and the pickle tags match options.retryTagFilter', async () => { // Arrange const pickle = await getPickleWithTags(['@retry']) - const options = buildOptions({ retry: 1, retryTagFilter: '@retry' }) + const options = buildOptions({ + retry: 1, + retryTagFilter: '@retry', + }) // Act const result = retriesForPickle(pickle, options) @@ -82,7 +85,10 @@ describe('Helpers', () => { it('returns 0 if options.retry is set but the pickle tags do not match options.retryTagFilter', async () => { // Arrange const pickle = await getPickleWithTags([]) - const options = buildOptions({ retry: 1, retryTagFilter: '@retry' }) + const options = buildOptions({ + retry: 1, + retryTagFilter: '@retry', + }) // Act const result = retriesForPickle(pickle, options) diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 5efced4ca..675b46553 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -13,6 +13,10 @@ import { doesHaveValue, valueOrDefault } from '../value_checker' import { ITestRunStopwatch, RealTestRunStopwatch } from './stopwatch' import { assembleTestCases } from './assemble_test_cases' +export interface IRuntime { + start: () => Promise +} + export interface INewRuntimeOptions { eventBroadcaster: EventEmitter eventDataCollector: EventDataCollector @@ -32,7 +36,17 @@ export interface IRuntimeOptions { worldParameters: any } -export default class Runtime { +export const DEFAULT_RUNTIME_OPTIONS: IRuntimeOptions = { + dryRun: false, + failFast: false, + filterStacktraces: true, + retry: 0, + retryTagFilter: '', + strict: true, + worldParameters: {}, +} + +export default class Runtime implements IRuntime { private readonly eventBroadcaster: EventEmitter private readonly eventDataCollector: EventDataCollector private readonly stopwatch: ITestRunStopwatch diff --git a/src/runtime/parallel/coordinator.ts b/src/runtime/parallel/coordinator.ts index 73e840f63..a4672dd7d 100644 --- a/src/runtime/parallel/coordinator.ts +++ b/src/runtime/parallel/coordinator.ts @@ -4,7 +4,7 @@ import { retriesForPickle, shouldCauseFailure } from '../helpers' import * as messages from '@cucumber/messages' import { EventEmitter } from 'events' import { EventDataCollector } from '../../formatter/helpers' -import { IRuntimeOptions } from '..' +import { IRuntime, IRuntimeOptions } from '..' import { ISupportCodeLibrary } from '../../support_code_library_builder/types' import { ICoordinatorReport, IWorkerCommand } from './command_types' import { doesHaveValue } from '../../value_checker' @@ -24,6 +24,7 @@ export interface INewCoordinatorOptions { supportCodeLibrary: ISupportCodeLibrary supportCodePaths: string[] supportCodeRequiredModules: string[] + numberOfWorkers: number } interface IWorker { @@ -31,7 +32,7 @@ interface IWorker { process: ChildProcess } -export default class Coordinator { +export default class Coordinator implements IRuntime { private readonly cwd: string private readonly eventBroadcaster: EventEmitter private readonly eventDataCollector: EventDataCollector @@ -46,6 +47,7 @@ export default class Coordinator { private readonly supportCodeLibrary: ISupportCodeLibrary private readonly supportCodePaths: string[] private readonly supportCodeRequiredModules: string[] + private readonly numberOfWorkers: number private success: boolean constructor({ @@ -58,6 +60,7 @@ export default class Coordinator { supportCodeLibrary, supportCodePaths, supportCodeRequiredModules, + numberOfWorkers, }: INewCoordinatorOptions) { this.cwd = cwd this.eventBroadcaster = eventBroadcaster @@ -69,6 +72,7 @@ export default class Coordinator { this.supportCodePaths = supportCodePaths this.supportCodeRequiredModules = supportCodeRequiredModules this.pickleIds = pickleIds + this.numberOfWorkers = numberOfWorkers this.nextPickleIdIndex = 0 this.success = true this.workers = {} @@ -163,7 +167,7 @@ export default class Coordinator { } } - async run(numberOfWorkers: number): Promise { + async start(): Promise { const envelope: messages.Envelope = { testRunStarted: { timestamp: this.stopwatch.timestamp(), @@ -180,8 +184,8 @@ export default class Coordinator { supportCodeLibrary: this.supportCodeLibrary, }) return await new Promise((resolve) => { - for (let i = 0; i <= numberOfWorkers; i++) { - this.startWorker(i.toString(), numberOfWorkers) + for (let i = 0; i <= this.numberOfWorkers; i++) { + this.startWorker(i.toString(), this.numberOfWorkers) } this.onFinish = resolve })