From d3571504c95ad3ec8f15017c69099b10b6d6caff Mon Sep 17 00:00:00 2001 From: davidjgoss Date: Sun, 25 Aug 2024 01:29:43 +0100 Subject: [PATCH] Consolidate serial and parallel runtimes (#2422) --- CHANGELOG.md | 3 + src/api/run_cucumber.ts | 19 +- src/api/runtime.ts | 50 --- .../assemble_test_cases.ts | 68 ++-- .../assemble_test_cases_spec.ts | 62 ++-- src/assemble/index.ts | 2 + src/assemble/types.ts | 12 + src/formatter/helpers/summary_helpers_spec.ts | 4 +- src/formatter/progress_bar_formatter_spec.ts | 4 +- src/runtime/coordinator.ts | 43 +++ src/runtime/helpers.ts | 6 +- src/runtime/index.ts | 136 +------- src/runtime/make_runtime.ts | 51 +++ src/runtime/parallel/adapter.ts | 210 ++++++++++++ src/runtime/parallel/command_types.ts | 40 --- src/runtime/parallel/coordinator.ts | 299 ------------------ src/runtime/parallel/run_worker.ts | 4 +- src/runtime/parallel/types.ts | 50 +++ src/runtime/parallel/worker.ts | 114 +++---- src/runtime/serial/adapter.ts | 41 +++ src/runtime/stopwatch.ts | 10 +- src/runtime/stopwatch_spec.ts | 4 +- src/runtime/test_case_runner.ts | 14 +- src/runtime/test_case_runner_spec.ts | 30 +- src/runtime/types.ts | 20 ++ src/runtime/worker.ts | 71 +++++ src/support_code_library_builder/index.ts | 4 +- src/support_code_library_builder/types.ts | 6 + test/formatter_helpers.ts | 91 +++--- test/gherkin_helpers.ts | 16 +- test/runtime_helpers.ts | 6 +- 31 files changed, 731 insertions(+), 759 deletions(-) delete mode 100644 src/api/runtime.ts rename src/{runtime => assemble}/assemble_test_cases.ts (72%) rename src/{runtime => assemble}/assemble_test_cases_spec.ts (89%) create mode 100644 src/assemble/index.ts create mode 100644 src/assemble/types.ts create mode 100644 src/runtime/coordinator.ts create mode 100644 src/runtime/make_runtime.ts create mode 100644 src/runtime/parallel/adapter.ts delete mode 100644 src/runtime/parallel/command_types.ts delete mode 100644 src/runtime/parallel/coordinator.ts create mode 100644 src/runtime/parallel/types.ts create mode 100644 src/runtime/serial/adapter.ts create mode 100644 src/runtime/types.ts create mode 100644 src/runtime/worker.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66abfc224..530e39d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber. ## [Unreleased] +### Changed +- Major refactor of runtime code for both serial and parallel modes ([#2422](https://github.com/cucumber/cucumber-js/pull/2422)) + ### Removed - BREAKING CHANGE: Remove previously-deprecated `parseGherkinMessageStream` ([#2420](https://github.com/cucumber/cucumber-js/pull/2420)) - BREAKING CHANGE: Remove previously-deprecated `PickleFilter` ([#2420](https://github.com/cucumber/cucumber-js/pull/2420)) diff --git a/src/api/run_cucumber.ts b/src/api/run_cucumber.ts index 0d86a0bb1..e9651c84e 100644 --- a/src/api/run_cucumber.ts +++ b/src/api/run_cucumber.ts @@ -5,8 +5,9 @@ import { emitMetaMessage, emitSupportCodeMessages } from '../cli/helpers' import { resolvePaths } from '../paths' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { version } from '../version' +import { IFilterablePickle } from '../filter' +import { makeRuntime } from '../runtime' import { IRunOptions, IRunEnvironment, IRunResult } from './types' -import { makeRuntime } from './runtime' import { initializeFormatters } from './formatters' import { getSupportCodeLibrary } from './support' import { mergeEnvironment } from './environment' @@ -105,7 +106,7 @@ Running from: ${__dirname} }) await emitMetaMessage(eventBroadcaster, env) - let pickleIds: string[] = [] + let filteredPickles: ReadonlyArray = [] let parseErrors: ParseError[] = [] if (sourcePaths.length > 0) { const gherkinResult = await getPicklesAndErrors({ @@ -115,15 +116,14 @@ Running from: ${__dirname} coordinates: options.sources, onEnvelope: (envelope) => eventBroadcaster.emit('envelope', envelope), }) - const filteredPickles = await pluginManager.transform( + filteredPickles = await pluginManager.transform( 'pickles:filter', gherkinResult.filterablePickles ) - const orderedPickles = await pluginManager.transform( + filteredPickles = await pluginManager.transform( 'pickles:order', filteredPickles ) - pickleIds = orderedPickles.map(({ pickle }) => pickle.id) parseErrors = gherkinResult.parseErrors } if (parseErrors.length) { @@ -146,17 +146,16 @@ Running from: ${__dirname} newId, }) - const runtime = makeRuntime({ - cwd, + const runtime = await makeRuntime({ + environment, logger, eventBroadcaster, - eventDataCollector, - pickleIds, + sourcedPickles: filteredPickles, newId, supportCodeLibrary, options: options.runtime, }) - const success = await runtime.start() + const success = await runtime.run() await pluginManager.cleanup() await cleanupFormatters() diff --git a/src/api/runtime.ts b/src/api/runtime.ts deleted file mode 100644 index c3e55fa92..000000000 --- a/src/api/runtime.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { IdGenerator } from '@cucumber/messages' -import Runtime, { IRuntime } from '../runtime' -import { EventDataCollector } from '../formatter/helpers' -import { SupportCodeLibrary } from '../support_code_library_builder/types' -import Coordinator from '../runtime/parallel/coordinator' -import { ILogger } from '../logger' -import { IRunOptionsRuntime } from './types' - -export function makeRuntime({ - cwd, - logger, - eventBroadcaster, - eventDataCollector, - pickleIds, - newId, - supportCodeLibrary, - options: { parallel, ...options }, -}: { - cwd: string - logger: ILogger - eventBroadcaster: EventEmitter - eventDataCollector: EventDataCollector - newId: IdGenerator.NewId - pickleIds: string[] - supportCodeLibrary: SupportCodeLibrary - options: IRunOptionsRuntime -}): IRuntime { - if (parallel > 0) { - return new Coordinator({ - cwd, - logger, - eventBroadcaster, - eventDataCollector, - pickleIds, - options, - newId, - supportCodeLibrary, - numberOfWorkers: parallel, - }) - } - return new Runtime({ - eventBroadcaster, - eventDataCollector, - newId, - pickleIds, - supportCodeLibrary, - options, - }) -} diff --git a/src/runtime/assemble_test_cases.ts b/src/assemble/assemble_test_cases.ts similarity index 72% rename from src/runtime/assemble_test_cases.ts rename to src/assemble/assemble_test_cases.ts index bab2993e1..0803fb233 100644 --- a/src/runtime/assemble_test_cases.ts +++ b/src/assemble/assemble_test_cases.ts @@ -1,46 +1,47 @@ import { EventEmitter } from 'node:events' -import * as messages from '@cucumber/messages' -import { IdGenerator } from '@cucumber/messages' +import { + Envelope, + IdGenerator, + Pickle, + TestCase, + TestStep, + Group as MessagesGroup, +} from '@cucumber/messages' import { Group } from '@cucumber/cucumber-expressions' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { doesHaveValue } from '../value_checker' - -export declare type IAssembledTestCases = Record - -export interface IAssembleTestCasesOptions { - eventBroadcaster: EventEmitter - newId: IdGenerator.NewId - pickles: messages.Pickle[] - supportCodeLibrary: SupportCodeLibrary -} +import { AssembledTestCase, SourcedPickle } from './types' export async function assembleTestCases({ eventBroadcaster, newId, - pickles, + sourcedPickles, supportCodeLibrary, -}: IAssembleTestCasesOptions): Promise { - const result: IAssembledTestCases = {} - for (const pickle of pickles) { - const { id: pickleId } = pickle +}: { + eventBroadcaster: EventEmitter + newId: IdGenerator.NewId + sourcedPickles: ReadonlyArray + supportCodeLibrary: SupportCodeLibrary +}): Promise> { + return sourcedPickles.map(({ gherkinDocument, pickle }) => { const testCaseId = newId() - const fromBeforeHooks: messages.TestStep[] = makeBeforeHookSteps({ + const fromBeforeHooks: TestStep[] = makeBeforeHookSteps({ supportCodeLibrary, pickle, newId, }) - const fromStepDefinitions: messages.TestStep[] = makeSteps({ + const fromStepDefinitions: TestStep[] = makeSteps({ pickle, supportCodeLibrary, newId, }) - const fromAfterHooks: messages.TestStep[] = makeAfterHookSteps({ + const fromAfterHooks: TestStep[] = makeAfterHookSteps({ supportCodeLibrary, pickle, newId, }) - const testCase: messages.TestCase = { - pickleId, + const testCase: TestCase = { + pickleId: pickle.id, id: testCaseId, testSteps: [ ...fromBeforeHooks, @@ -48,10 +49,13 @@ export async function assembleTestCases({ ...fromAfterHooks, ], } - eventBroadcaster.emit('envelope', { testCase }) - result[pickleId] = testCase - } - return result + eventBroadcaster.emit('envelope', { testCase } satisfies Envelope) + return { + gherkinDocument, + pickle, + testCase, + } + }) } function makeAfterHookSteps({ @@ -60,9 +64,9 @@ function makeAfterHookSteps({ newId, }: { supportCodeLibrary: SupportCodeLibrary - pickle: messages.Pickle + pickle: Pickle newId: IdGenerator.NewId -}): messages.TestStep[] { +}): TestStep[] { return supportCodeLibrary.afterTestCaseHookDefinitions .slice(0) .reverse() @@ -79,9 +83,9 @@ function makeBeforeHookSteps({ newId, }: { supportCodeLibrary: SupportCodeLibrary - pickle: messages.Pickle + pickle: Pickle newId: IdGenerator.NewId -}): messages.TestStep[] { +}): TestStep[] { return supportCodeLibrary.beforeTestCaseHookDefinitions .filter((hookDefinition) => hookDefinition.appliesToTestCase(pickle)) .map((hookDefinition) => ({ @@ -95,10 +99,10 @@ function makeSteps({ supportCodeLibrary, newId, }: { - pickle: messages.Pickle + pickle: Pickle supportCodeLibrary: SupportCodeLibrary newId: () => string -}): messages.TestStep[] { +}): TestStep[] { return pickle.steps.map((pickleStep) => { const stepDefinitions = supportCodeLibrary.stepDefinitions.filter( (stepDefinition) => stepDefinition.matchesStepName(pickleStep.text) @@ -124,7 +128,7 @@ function makeSteps({ }) } -function mapArgumentGroup(group: Group): messages.Group { +function mapArgumentGroup(group: Group): MessagesGroup { return { start: group.start, value: group.value, diff --git a/src/runtime/assemble_test_cases_spec.ts b/src/assemble/assemble_test_cases_spec.ts similarity index 89% rename from src/runtime/assemble_test_cases_spec.ts rename to src/assemble/assemble_test_cases_spec.ts index eaee9418f..9bca85c46 100644 --- a/src/runtime/assemble_test_cases_spec.ts +++ b/src/assemble/assemble_test_cases_spec.ts @@ -1,6 +1,11 @@ import { EventEmitter } from 'node:events' -import { IdGenerator } from '@cucumber/messages' -import * as messages from '@cucumber/messages' +import { + Envelope, + GherkinDocument, + IdGenerator, + Pickle, + TestCase, +} from '@cucumber/messages' import { afterEach, beforeEach, describe, it } from 'mocha' import FakeTimers, { InstalledClock } from '@sinonjs/fake-timers' import { expect } from 'chai' @@ -8,28 +13,29 @@ import timeMethods from '../time' import { buildSupportCodeLibrary } from '../../test/runtime_helpers' import { parse } from '../../test/gherkin_helpers' import { SupportCodeLibrary } from '../support_code_library_builder/types' -import { assembleTestCases, IAssembledTestCases } from './assemble_test_cases' +import { assembleTestCases } from './assemble_test_cases' +import { AssembledTestCase } from './types' -interface IRequest { - gherkinDocument: messages.GherkinDocument - pickles: messages.Pickle[] +async function testAssembleTestCases({ + gherkinDocument, + pickles, + supportCodeLibrary, +}: { + gherkinDocument: GherkinDocument + pickles: Pickle[] supportCodeLibrary: SupportCodeLibrary -} - -interface IResponse { - envelopes: messages.Envelope[] - result: IAssembledTestCases -} - -async function testAssembleTestCases(options: IRequest): Promise { - const envelopes: messages.Envelope[] = [] +}): Promise<{ + envelopes: Envelope[] + result: ReadonlyArray +}> { + const envelopes: Envelope[] = [] const eventBroadcaster = new EventEmitter() eventBroadcaster.on('envelope', (e) => envelopes.push(e)) const result = await assembleTestCases({ eventBroadcaster, newId: IdGenerator.incrementing(), - pickles: options.pickles, - supportCodeLibrary: options.supportCodeLibrary, + sourcedPickles: pickles.map((pickle) => ({ gherkinDocument, pickle })), + supportCodeLibrary, }) return { envelopes, result } } @@ -71,7 +77,7 @@ describe('assembleTestCases', () => { supportCodeLibrary, }) - const testCase1: messages.TestCase = { + const testCase0: TestCase = { id: '0', pickleId: pickles[0].id, testSteps: [ @@ -88,7 +94,7 @@ describe('assembleTestCases', () => { ], } - const testCase2: messages.TestCase = { + const testCase1: TestCase = { id: '2', pickleId: pickles[1].id, testSteps: [ @@ -108,15 +114,25 @@ describe('assembleTestCases', () => { // Assert expect(envelopes).to.eql([ { - testCase: testCase1, + testCase: testCase0, }, { - testCase: testCase2, + testCase: testCase1, }, ]) - expect(Object.keys(result)).to.eql([pickles[0].id, pickles[1].id]) - expect(Object.values(result)).to.eql([testCase1, testCase2]) + expect(result).to.eql([ + { + gherkinDocument, + pickle: pickles[0], + testCase: testCase0, + }, + { + gherkinDocument, + pickle: pickles[1], + testCase: testCase1, + }, + ]) }) describe('with a parameterised step', () => { diff --git a/src/assemble/index.ts b/src/assemble/index.ts new file mode 100644 index 000000000..549c096ae --- /dev/null +++ b/src/assemble/index.ts @@ -0,0 +1,2 @@ +export * from './assemble_test_cases' +export * from './types' diff --git a/src/assemble/types.ts b/src/assemble/types.ts new file mode 100644 index 000000000..49ba81fd6 --- /dev/null +++ b/src/assemble/types.ts @@ -0,0 +1,12 @@ +import { GherkinDocument, Pickle, TestCase } from '@cucumber/messages' + +export interface SourcedPickle { + gherkinDocument: GherkinDocument + pickle: Pickle +} + +export interface AssembledTestCase { + gherkinDocument: GherkinDocument + pickle: Pickle + testCase: TestCase +} diff --git a/src/formatter/helpers/summary_helpers_spec.ts b/src/formatter/helpers/summary_helpers_spec.ts index 375c31cee..c49d508ba 100644 --- a/src/formatter/helpers/summary_helpers_spec.ts +++ b/src/formatter/helpers/summary_helpers_spec.ts @@ -8,13 +8,13 @@ import { getTestCaseAttempts } from '../../../test/formatter_helpers' import { getBaseSupportCodeLibrary } from '../../../test/fixtures/steps' import timeMethods, { durationBetweenTimestamps } from '../../time' import { buildSupportCodeLibrary } from '../../../test/runtime_helpers' -import { IRuntimeOptions } from '../../runtime' +import { RuntimeOptions } from '../../runtime' import { SupportCodeLibrary } from '../../support_code_library_builder/types' import { doesNotHaveValue } from '../../value_checker' import { formatSummary } from './summary_helpers' interface ITestFormatSummaryOptions { - runtimeOptions?: Partial + runtimeOptions?: Partial sourceData: string supportCodeLibrary?: SupportCodeLibrary testRunStarted?: messages.TestRunStarted diff --git a/src/formatter/progress_bar_formatter_spec.ts b/src/formatter/progress_bar_formatter_spec.ts index aac4f8beb..15a761ad8 100644 --- a/src/formatter/progress_bar_formatter_spec.ts +++ b/src/formatter/progress_bar_formatter_spec.ts @@ -15,7 +15,7 @@ import { import { buildSupportCodeLibrary } from '../../test/runtime_helpers' import { getBaseSupportCodeLibrary } from '../../test/fixtures/steps' import timeMethods from '../time' -import { IRuntimeOptions } from '../runtime' +import { RuntimeOptions } from '../runtime' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { doesHaveValue, doesNotHaveValue } from '../value_checker' import ProgressBarFormatter from './progress_bar_formatter' @@ -23,7 +23,7 @@ import FormatterBuilder from './builder' import { EventDataCollector } from './helpers' interface ITestProgressBarFormatterOptions { - runtimeOptions?: Partial + runtimeOptions?: Partial shouldStopFn: (envelope: messages.Envelope) => boolean sources?: ITestSource[] supportCodeLibrary?: SupportCodeLibrary diff --git a/src/runtime/coordinator.ts b/src/runtime/coordinator.ts new file mode 100644 index 000000000..116276afd --- /dev/null +++ b/src/runtime/coordinator.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from 'node:events' +import { Envelope, IdGenerator } from '@cucumber/messages' +import { assembleTestCases, SourcedPickle } from '../assemble' +import { SupportCodeLibrary } from '../support_code_library_builder/types' +import { RuntimeAdapter } from './types' +import { timestamp } from './stopwatch' +import { Runtime } from './index' + +export class Coordinator implements Runtime { + constructor( + private eventBroadcaster: EventEmitter, + private newId: IdGenerator.NewId, + private sourcedPickles: ReadonlyArray, + private supportCodeLibrary: SupportCodeLibrary, + private adapter: RuntimeAdapter + ) {} + + async run(): Promise { + this.eventBroadcaster.emit('envelope', { + testRunStarted: { + timestamp: timestamp(), + }, + } satisfies Envelope) + + const assembledTestCases = await assembleTestCases({ + eventBroadcaster: this.eventBroadcaster, + newId: this.newId, + sourcedPickles: this.sourcedPickles, + supportCodeLibrary: this.supportCodeLibrary, + }) + + const success = await this.adapter.run(assembledTestCases) + + this.eventBroadcaster.emit('envelope', { + testRunFinished: { + timestamp: timestamp(), + success, + }, + } satisfies Envelope) + + return success + } +} diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index 770811ffe..7a5c25d72 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -4,7 +4,7 @@ import * as messages from '@cucumber/messages' import { formatLocation } from '../formatter/helpers/location_helpers' import { PickleTagFilter } from '../pickle_filter' import StepDefinition from '../models/step_definition' -import { IRuntimeOptions } from '.' +import { RuntimeOptions } from '.' export function getAmbiguousStepException( stepDefinitions: StepDefinition[] @@ -47,7 +47,7 @@ export function getAmbiguousStepException( export function retriesForPickle( pickle: messages.Pickle, - options: IRuntimeOptions + options: RuntimeOptions ): number { if (!options.retry) { return 0 @@ -69,7 +69,7 @@ export function retriesForPickle( export function shouldCauseFailure( status: messages.TestStepResultStatus, - options: IRuntimeOptions + options: RuntimeOptions ): boolean { if (options.dryRun) { return false diff --git a/src/runtime/index.ts b/src/runtime/index.ts index b75ad487e..9a917287a 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,134 +1,2 @@ -import { EventEmitter } from 'node:events' -import * as messages from '@cucumber/messages' -import { IdGenerator } from '@cucumber/messages' -import { JsonObject } from 'type-fest' -import { EventDataCollector } from '../formatter/helpers' -import { SupportCodeLibrary } from '../support_code_library_builder/types' -import { assembleTestCases } from './assemble_test_cases' -import { retriesForPickle, shouldCauseFailure } from './helpers' -import { makeRunTestRunHooks, RunsTestRunHooks } from './run_test_run_hooks' -import { IStopwatch, create } from './stopwatch' -import TestCaseRunner from './test_case_runner' - -export interface IRuntime { - start: () => Promise -} - -export interface INewRuntimeOptions { - eventBroadcaster: EventEmitter - eventDataCollector: EventDataCollector - newId: IdGenerator.NewId - options: IRuntimeOptions - pickleIds: string[] - supportCodeLibrary: SupportCodeLibrary -} - -export interface IRuntimeOptions { - dryRun: boolean - failFast: boolean - filterStacktraces: boolean - retry: number - retryTagFilter: string - strict: boolean - worldParameters: JsonObject -} - -export default class Runtime implements IRuntime { - private readonly eventBroadcaster: EventEmitter - private readonly eventDataCollector: EventDataCollector - private readonly stopwatch: IStopwatch - private readonly newId: IdGenerator.NewId - private readonly options: IRuntimeOptions - private readonly pickleIds: string[] - private readonly supportCodeLibrary: SupportCodeLibrary - private success: boolean - private readonly runTestRunHooks: RunsTestRunHooks - - constructor({ - eventBroadcaster, - eventDataCollector, - newId, - options, - pickleIds, - supportCodeLibrary, - }: INewRuntimeOptions) { - this.eventBroadcaster = eventBroadcaster - this.eventDataCollector = eventDataCollector - this.stopwatch = create() - this.newId = newId - this.options = options - this.pickleIds = pickleIds - this.supportCodeLibrary = supportCodeLibrary - this.success = true - this.runTestRunHooks = makeRunTestRunHooks( - this.options.dryRun, - this.supportCodeLibrary.defaultTimeout, - this.options.worldParameters, - (name, location) => `${name} hook errored, process exiting: ${location}` - ) - } - - async runTestCase( - pickleId: string, - testCase: messages.TestCase - ): Promise { - const pickle = this.eventDataCollector.getPickle(pickleId) - const retries = retriesForPickle(pickle, this.options) - const skip = this.options.dryRun || (this.options.failFast && !this.success) - const testCaseRunner = new TestCaseRunner({ - eventBroadcaster: this.eventBroadcaster, - stopwatch: this.stopwatch, - gherkinDocument: this.eventDataCollector.getGherkinDocument(pickle.uri), - newId: this.newId, - pickle, - testCase, - retries, - skip, - filterStackTraces: this.options.filterStacktraces, - supportCodeLibrary: this.supportCodeLibrary, - worldParameters: this.options.worldParameters, - }) - const status = await testCaseRunner.run() - if (shouldCauseFailure(status, this.options)) { - this.success = false - } - } - - async start(): Promise { - const testRunStarted: messages.Envelope = { - testRunStarted: { - timestamp: this.stopwatch.timestamp(), - }, - } - this.eventBroadcaster.emit('envelope', testRunStarted) - this.stopwatch.start() - await this.runTestRunHooks( - this.supportCodeLibrary.beforeTestRunHookDefinitions, - 'a BeforeAll' - ) - const assembledTestCases = await assembleTestCases({ - eventBroadcaster: this.eventBroadcaster, - newId: this.newId, - pickles: this.pickleIds.map((pickleId) => - this.eventDataCollector.getPickle(pickleId) - ), - supportCodeLibrary: this.supportCodeLibrary, - }) - for (const pickleId of this.pickleIds) { - await this.runTestCase(pickleId, assembledTestCases[pickleId]) - } - await this.runTestRunHooks( - this.supportCodeLibrary.afterTestRunHookDefinitions.slice(0).reverse(), - 'an AfterAll' - ) - this.stopwatch.stop() - const testRunFinished: messages.Envelope = { - testRunFinished: { - timestamp: this.stopwatch.timestamp(), - success: this.success, - }, - } - this.eventBroadcaster.emit('envelope', testRunFinished) - return this.success - } -} +export * from './make_runtime' +export { Runtime, RuntimeOptions } from './types' diff --git a/src/runtime/make_runtime.ts b/src/runtime/make_runtime.ts new file mode 100644 index 000000000..8a3b12368 --- /dev/null +++ b/src/runtime/make_runtime.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from 'node:events' +import { IdGenerator } from '@cucumber/messages' +import { IRunEnvironment, IRunOptionsRuntime } from '../api' +import { ILogger } from '../logger' +import { SourcedPickle } from '../assemble' +import { SupportCodeLibrary } from '../support_code_library_builder/types' +import { Runtime, RuntimeAdapter } from './types' +import { ChildProcessAdapter } from './parallel/adapter' +import { InProcessAdapter } from './serial/adapter' +import { Coordinator } from './coordinator' + +export async function makeRuntime({ + environment, + logger, + eventBroadcaster, + sourcedPickles, + newId, + supportCodeLibrary, + options, +}: { + environment: IRunEnvironment + logger: ILogger + eventBroadcaster: EventEmitter + newId: IdGenerator.NewId + sourcedPickles: ReadonlyArray + supportCodeLibrary: SupportCodeLibrary + options: IRunOptionsRuntime +}): Promise { + const adapter: RuntimeAdapter = + options.parallel > 0 + ? new ChildProcessAdapter( + environment, + logger, + eventBroadcaster, + options, + supportCodeLibrary + ) + : new InProcessAdapter( + eventBroadcaster, + newId, + options, + supportCodeLibrary + ) + return new Coordinator( + eventBroadcaster, + newId, + sourcedPickles, + supportCodeLibrary, + adapter + ) +} diff --git a/src/runtime/parallel/adapter.ts b/src/runtime/parallel/adapter.ts new file mode 100644 index 000000000..b937800d8 --- /dev/null +++ b/src/runtime/parallel/adapter.ts @@ -0,0 +1,210 @@ +import { ChildProcess, fork } from 'node:child_process' +import path from 'node:path' +import { EventEmitter } from 'node:events' +import { SupportCodeLibrary } from '../../support_code_library_builder/types' +import { AssembledTestCase } from '../../assemble' +import { ILogger } from '../../logger' +import { RuntimeAdapter } from '../types' +import { IRunEnvironment, IRunOptionsRuntime } from '../../api' +import { + FinalizeCommand, + InitializeCommand, + RunCommand, + WorkerToCoordinatorEvent, +} from './types' + +const runWorkerPath = path.resolve(__dirname, 'run_worker.js') + +const enum WorkerState { + 'idle', + 'closed', + 'running', + 'new', +} + +interface ManagedWorker { + state: WorkerState + process: ChildProcess + id: string +} + +interface WorkPlacement { + index: number + item: AssembledTestCase +} + +export class ChildProcessAdapter implements RuntimeAdapter { + private idleInterventions: number = 0 + private failing: boolean = false + private onFinish: (success: boolean) => void + private todo: Array = [] + private readonly inProgress: Record = {} + private readonly workers: Record = {} + + constructor( + private readonly environment: IRunEnvironment, + private readonly logger: ILogger, + private readonly eventBroadcaster: EventEmitter, + private readonly options: IRunOptionsRuntime, + private readonly supportCodeLibrary: SupportCodeLibrary + ) {} + + parseWorkerMessage( + worker: ManagedWorker, + message: WorkerToCoordinatorEvent + ): void { + switch (message.type) { + case 'READY': + worker.state = WorkerState.idle + this.awakenWorkers(worker) + break + case 'ENVELOPE': + this.eventBroadcaster.emit('envelope', message.envelope) + break + case 'FINISHED': + if (!message.success) { + this.failing = true + } + delete this.inProgress[worker.id] + worker.state = WorkerState.idle + this.awakenWorkers(worker) + break + default: + throw new Error( + `Unexpected message from worker: ${JSON.stringify(message)}` + ) + } + } + + awakenWorkers(triggeringWorker: ManagedWorker): void { + Object.values(this.workers).forEach((worker) => { + if (worker.state === WorkerState.idle) { + this.giveWork(worker) + } + return worker.state !== WorkerState.idle + }) + + if (Object.keys(this.inProgress).length == 0 && this.todo.length > 0) { + this.giveWork(triggeringWorker, true) + this.idleInterventions++ + } + } + + startWorker(id: string, total: number): void { + const workerProcess = fork(runWorkerPath, [], { + cwd: this.environment.cwd, + env: { + ...this.environment.env, + CUCUMBER_PARALLEL: 'true', + CUCUMBER_TOTAL_WORKERS: total.toString(), + CUCUMBER_WORKER_ID: id, + }, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + }) + const worker = { state: WorkerState.new, process: workerProcess, id } + this.workers[id] = worker + worker.process.on('message', (message: WorkerToCoordinatorEvent) => { + this.parseWorkerMessage(worker, message) + }) + worker.process.on('close', (exitCode) => { + worker.state = WorkerState.closed + this.onWorkerProcessClose(exitCode) + }) + worker.process.send({ + type: 'INITIALIZE', + supportCodeCoordinates: this.supportCodeLibrary.originalCoordinates, + supportCodeIds: { + stepDefinitionIds: this.supportCodeLibrary.stepDefinitions.map( + (s) => s.id + ), + beforeTestCaseHookDefinitionIds: + this.supportCodeLibrary.beforeTestCaseHookDefinitions.map( + (h) => h.id + ), + afterTestCaseHookDefinitionIds: + this.supportCodeLibrary.afterTestCaseHookDefinitions.map((h) => h.id), + }, + options: this.options, + } satisfies InitializeCommand) + } + + onWorkerProcessClose(exitCode: number): void { + if (exitCode !== 0) { + this.failing = true + } + + if ( + Object.values(this.workers).every((x) => x.state === WorkerState.closed) + ) { + this.onFinish(!this.failing) + } + } + + async run( + assembledTestCases: ReadonlyArray + ): Promise { + this.todo = Array.from(assembledTestCases) + return await new Promise((resolve) => { + for (let i = 0; i < this.options.parallel; i++) { + this.startWorker(i.toString(), this.options.parallel) + } + this.onFinish = (status) => { + if (this.idleInterventions > 0) { + this.logger.warn( + `WARNING: All workers went idle ${this.idleInterventions} time(s). Consider revising handler passed to setParallelCanAssign.` + ) + } + + resolve(status) + } + }) + } + + nextWorkPlacement(): WorkPlacement { + for (let index = 0; index < this.todo.length; index++) { + const placement = this.placementAt(index) + if ( + this.supportCodeLibrary.parallelCanAssign( + placement.item.pickle, + Object.values(this.inProgress).map(({ pickle }) => pickle) + ) + ) { + return placement + } + } + + return null + } + + placementAt(index: number): WorkPlacement { + return { + index, + item: this.todo[index], + } + } + + giveWork(worker: ManagedWorker, force: boolean = false): void { + if (this.todo.length < 1) { + worker.state = WorkerState.running + worker.process.send({ type: 'FINALIZE' } satisfies FinalizeCommand) + return + } + + const workPlacement = force ? this.placementAt(0) : this.nextWorkPlacement() + + if (workPlacement === null) { + return + } + + const { index: nextIndex, item } = workPlacement + + this.todo.splice(nextIndex, 1) + this.inProgress[worker.id] = item + worker.state = WorkerState.running + worker.process.send({ + type: 'RUN', + assembledTestCase: item, + failing: this.failing, + } satisfies RunCommand) + } +} diff --git a/src/runtime/parallel/command_types.ts b/src/runtime/parallel/command_types.ts deleted file mode 100644 index bfb4f26a3..000000000 --- a/src/runtime/parallel/command_types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as messages from '@cucumber/messages' -import { Envelope } from '@cucumber/messages' -import { IRuntimeOptions } from '../index' -import { ISupportCodeCoordinates } from '../../api' - -// Messages from Coordinator to Worker - -export interface IWorkerCommand { - initialize?: IWorkerCommandInitialize - run?: IWorkerCommandRun - finalize?: boolean -} - -export interface IWorkerCommandInitialize { - supportCodeCoordinates: ISupportCodeCoordinates - supportCodeIds?: ICanonicalSupportCodeIds - options: IRuntimeOptions -} - -export interface ICanonicalSupportCodeIds { - stepDefinitionIds: string[] - beforeTestCaseHookDefinitionIds: string[] - afterTestCaseHookDefinitionIds: string[] -} - -export interface IWorkerCommandRun { - retries: number - skip: boolean - elapsed: messages.Duration - pickle: messages.Pickle - testCase: messages.TestCase - gherkinDocument: messages.GherkinDocument -} - -// Messages from Worker to Coordinator - -export interface ICoordinatorReport { - jsonEnvelope?: Envelope - ready?: boolean -} diff --git a/src/runtime/parallel/coordinator.ts b/src/runtime/parallel/coordinator.ts deleted file mode 100644 index a72c58329..000000000 --- a/src/runtime/parallel/coordinator.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { ChildProcess, fork } from 'node:child_process' -import path from 'node:path' -import { EventEmitter } from 'node:events' -import * as messages from '@cucumber/messages' -import { IdGenerator } from '@cucumber/messages' -import { retriesForPickle, shouldCauseFailure } from '../helpers' -import { EventDataCollector } from '../../formatter/helpers' -import { IRuntime, IRuntimeOptions } from '..' -import { SupportCodeLibrary } from '../../support_code_library_builder/types' -import { doesHaveValue } from '../../value_checker' -import { IStopwatch, create } from '../stopwatch' -import { assembleTestCases, IAssembledTestCases } from '../assemble_test_cases' -import { ILogger } from '../../logger' -import { ICoordinatorReport, IWorkerCommand } from './command_types' - -const runWorkerPath = path.resolve(__dirname, 'run_worker.js') - -export interface INewCoordinatorOptions { - cwd: string - logger: ILogger - eventBroadcaster: EventEmitter - eventDataCollector: EventDataCollector - options: IRuntimeOptions - newId: IdGenerator.NewId - pickleIds: string[] - supportCodeLibrary: SupportCodeLibrary - numberOfWorkers: number -} - -const enum WorkerState { - 'idle', - 'closed', - 'running', - 'new', -} - -interface IWorker { - state: WorkerState - process: ChildProcess - id: string -} - -interface IPicklePlacement { - index: number - pickle: messages.Pickle -} - -export default class Coordinator implements IRuntime { - private readonly cwd: string - private readonly eventBroadcaster: EventEmitter - private readonly eventDataCollector: EventDataCollector - private readonly stopwatch: IStopwatch - private onFinish: (success: boolean) => void - private readonly options: IRuntimeOptions - private readonly newId: IdGenerator.NewId - private readonly pickleIds: string[] - private assembledTestCases: IAssembledTestCases - private readonly inProgressPickles: Record - private readonly workers: Record - private readonly supportCodeLibrary: SupportCodeLibrary - private readonly numberOfWorkers: number - private readonly logger: ILogger - private success: boolean - private idleInterventions: number - - constructor({ - cwd, - logger, - eventBroadcaster, - eventDataCollector, - pickleIds, - options, - newId, - supportCodeLibrary, - numberOfWorkers, - }: INewCoordinatorOptions) { - this.cwd = cwd - this.logger = logger - this.eventBroadcaster = eventBroadcaster - this.eventDataCollector = eventDataCollector - this.stopwatch = create() - this.options = options - this.newId = newId - this.supportCodeLibrary = supportCodeLibrary - this.pickleIds = Array.from(pickleIds) - this.numberOfWorkers = numberOfWorkers - this.success = true - this.workers = {} - this.inProgressPickles = {} - this.idleInterventions = 0 - } - - parseWorkerMessage(worker: IWorker, message: ICoordinatorReport): void { - if (message.ready) { - worker.state = WorkerState.idle - this.awakenWorkers(worker) - } else if (doesHaveValue(message.jsonEnvelope)) { - const envelope = message.jsonEnvelope - this.eventBroadcaster.emit('envelope', envelope) - if (doesHaveValue(envelope.testCaseFinished)) { - this.parseTestCaseResult(envelope.testCaseFinished, worker.id) - } - } else { - throw new Error( - `Unexpected message from worker: ${JSON.stringify(message)}` - ) - } - } - - awakenWorkers(triggeringWorker: IWorker): void { - Object.values(this.workers).forEach((worker) => { - if (worker.state === WorkerState.idle) { - this.giveWork(worker) - } - return worker.state !== WorkerState.idle - }) - - if ( - Object.keys(this.inProgressPickles).length == 0 && - this.pickleIds.length > 0 - ) { - this.giveWork(triggeringWorker, true) - this.idleInterventions++ - } - } - - startWorker(id: string, total: number): void { - const workerProcess = fork(runWorkerPath, [], { - cwd: this.cwd, - env: { - ...process.env, - CUCUMBER_PARALLEL: 'true', - CUCUMBER_TOTAL_WORKERS: total.toString(), - CUCUMBER_WORKER_ID: id, - }, - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - }) - const worker = { state: WorkerState.new, process: workerProcess, id } - this.workers[id] = worker - worker.process.on('message', (message: ICoordinatorReport) => { - this.parseWorkerMessage(worker, message) - }) - worker.process.on('close', (exitCode) => { - worker.state = WorkerState.closed - this.onWorkerProcessClose(exitCode) - }) - const initializeCommand: IWorkerCommand = { - initialize: { - supportCodeCoordinates: this.supportCodeLibrary.originalCoordinates, - supportCodeIds: { - stepDefinitionIds: this.supportCodeLibrary.stepDefinitions.map( - (s) => s.id - ), - beforeTestCaseHookDefinitionIds: - this.supportCodeLibrary.beforeTestCaseHookDefinitions.map( - (h) => h.id - ), - afterTestCaseHookDefinitionIds: - this.supportCodeLibrary.afterTestCaseHookDefinitions.map( - (h) => h.id - ), - }, - options: this.options, - }, - } - worker.process.send(initializeCommand) - } - - onWorkerProcessClose(exitCode: number): void { - const success = exitCode === 0 - if (!success) { - this.success = false - } - - if ( - Object.values(this.workers).every((x) => x.state === WorkerState.closed) - ) { - const envelope: messages.Envelope = { - testRunFinished: { - timestamp: this.stopwatch.timestamp(), - success, - }, - } - this.eventBroadcaster.emit('envelope', envelope) - this.onFinish(this.success) - } - } - - parseTestCaseResult( - testCaseFinished: messages.TestCaseFinished, - workerId: string - ): void { - const { worstTestStepResult } = this.eventDataCollector.getTestCaseAttempt( - testCaseFinished.testCaseStartedId - ) - if (!testCaseFinished.willBeRetried) { - delete this.inProgressPickles[workerId] - - if (shouldCauseFailure(worstTestStepResult.status, this.options)) { - this.success = false - } - } - } - - async start(): Promise { - const envelope: messages.Envelope = { - testRunStarted: { - timestamp: this.stopwatch.timestamp(), - }, - } - this.eventBroadcaster.emit('envelope', envelope) - this.stopwatch.start() - this.assembledTestCases = await assembleTestCases({ - eventBroadcaster: this.eventBroadcaster, - newId: this.newId, - pickles: this.pickleIds.map((pickleId) => - this.eventDataCollector.getPickle(pickleId) - ), - supportCodeLibrary: this.supportCodeLibrary, - }) - return await new Promise((resolve) => { - for (let i = 0; i < this.numberOfWorkers; i++) { - this.startWorker(i.toString(), this.numberOfWorkers) - } - this.onFinish = (status) => { - if (this.idleInterventions > 0) { - this.logger.warn( - `WARNING: All workers went idle ${this.idleInterventions} time(s). Consider revising handler passed to setParallelCanAssign.` - ) - } - - resolve(status) - } - }) - } - - nextPicklePlacement(): IPicklePlacement { - for (let index = 0; index < this.pickleIds.length; index++) { - const placement = this.placementAt(index) - if ( - this.supportCodeLibrary.parallelCanAssign( - placement.pickle, - Object.values(this.inProgressPickles) - ) - ) { - return placement - } - } - - return null - } - - placementAt(index: number): IPicklePlacement { - return { - index, - pickle: this.eventDataCollector.getPickle(this.pickleIds[index]), - } - } - - giveWork(worker: IWorker, force: boolean = false): void { - if (this.pickleIds.length < 1) { - const finalizeCommand: IWorkerCommand = { finalize: true } - worker.state = WorkerState.running - worker.process.send(finalizeCommand) - return - } - - const picklePlacement = force - ? this.placementAt(0) - : this.nextPicklePlacement() - - if (picklePlacement === null) { - return - } - - const { index: nextPickleIndex, pickle } = picklePlacement - - this.pickleIds.splice(nextPickleIndex, 1) - this.inProgressPickles[worker.id] = pickle - const testCase = this.assembledTestCases[pickle.id] - const gherkinDocument = this.eventDataCollector.getGherkinDocument( - pickle.uri - ) - const retries = retriesForPickle(pickle, this.options) - const skip = this.options.dryRun || (this.options.failFast && !this.success) - const runCommand: IWorkerCommand = { - run: { - retries, - skip, - elapsed: this.stopwatch.duration(), - pickle, - testCase, - gherkinDocument, - }, - } - worker.state = WorkerState.running - worker.process.send(runCommand) - } -} diff --git a/src/runtime/parallel/run_worker.ts b/src/runtime/parallel/run_worker.ts index 5ec226ae6..dea7f485f 100644 --- a/src/runtime/parallel/run_worker.ts +++ b/src/runtime/parallel/run_worker.ts @@ -1,5 +1,5 @@ import { doesHaveValue } from '../../value_checker' -import Worker from './worker' +import { ChildProcessWorker } from './worker' function run(): void { const exit = (exitCode: number, error?: Error, message?: string): void => { @@ -8,7 +8,7 @@ function run(): void { } process.exit(exitCode) } - const worker = new Worker({ + const worker = new ChildProcessWorker({ id: process.env.CUCUMBER_WORKER_ID, sendMessage: (message: any) => process.send(message), cwd: process.cwd(), diff --git a/src/runtime/parallel/types.ts b/src/runtime/parallel/types.ts new file mode 100644 index 000000000..fc56ffc83 --- /dev/null +++ b/src/runtime/parallel/types.ts @@ -0,0 +1,50 @@ +import { Envelope } from '@cucumber/messages' +import { RuntimeOptions } from '../index' +import { ISupportCodeCoordinates } from '../../api' +import { AssembledTestCase } from '../../assemble' +import { CanonicalSupportCodeIds } from '../../support_code_library_builder/types' + +// Messages from Coordinator to Worker + +export type CoordinatorToWorkerCommand = + | InitializeCommand + | RunCommand + | FinalizeCommand + +export interface InitializeCommand { + type: 'INITIALIZE' + supportCodeCoordinates: ISupportCodeCoordinates + supportCodeIds: CanonicalSupportCodeIds + options: RuntimeOptions +} + +export interface RunCommand { + type: 'RUN' + assembledTestCase: AssembledTestCase + failing: boolean +} + +export interface FinalizeCommand { + type: 'FINALIZE' +} + +// Messages from Worker to Coordinator + +export type WorkerToCoordinatorEvent = + | ReadyEvent + | EnvelopeEvent + | FinishedEvent + +export interface ReadyEvent { + type: 'READY' +} + +export interface EnvelopeEvent { + type: 'ENVELOPE' + envelope: Envelope +} + +export interface FinishedEvent { + type: 'FINISHED' + success: boolean +} diff --git a/src/runtime/parallel/worker.ts b/src/runtime/parallel/worker.ts index 5d2f5aa2a..5fe665d9e 100644 --- a/src/runtime/parallel/worker.ts +++ b/src/runtime/parallel/worker.ts @@ -1,40 +1,35 @@ import { EventEmitter } from 'node:events' import { pathToFileURL } from 'node:url' import { register } from 'node:module' -import * as messages from '@cucumber/messages' -import { IdGenerator } from '@cucumber/messages' -import { JsonObject } from 'type-fest' +import { Envelope, IdGenerator } from '@cucumber/messages' import supportCodeLibraryBuilder from '../../support_code_library_builder' import { SupportCodeLibrary } from '../../support_code_library_builder/types' -import { doesHaveValue } from '../../value_checker' -import { makeRunTestRunHooks, RunsTestRunHooks } from '../run_test_run_hooks' -import { create } from '../stopwatch' -import TestCaseRunner from '../test_case_runner' import tryRequire from '../../try_require' +import { Worker } from '../worker' +import { RuntimeOptions } from '../index' import { - ICoordinatorReport, - IWorkerCommand, - IWorkerCommandInitialize, - IWorkerCommandRun, -} from './command_types' + WorkerToCoordinatorEvent, + CoordinatorToWorkerCommand, + InitializeCommand, + RunCommand, +} from './types' const { uuid } = IdGenerator type IExitFunction = (exitCode: number, error?: Error, message?: string) => void -type IMessageSender = (command: ICoordinatorReport) => void +type IMessageSender = (command: WorkerToCoordinatorEvent) => void -export default class Worker { +export class ChildProcessWorker { private readonly cwd: string private readonly exit: IExitFunction private readonly id: string private readonly eventBroadcaster: EventEmitter - private filterStacktraces: boolean private readonly newId: IdGenerator.NewId private readonly sendMessage: IMessageSender + private options: RuntimeOptions private supportCodeLibrary: SupportCodeLibrary - private worldParameters: JsonObject - private runTestRunHooks: RunsTestRunHooks + private worker: Worker constructor({ cwd, @@ -53,16 +48,16 @@ export default class Worker { this.exit = exit this.sendMessage = sendMessage this.eventBroadcaster = new EventEmitter() - this.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => { - this.sendMessage({ jsonEnvelope: envelope }) - }) + this.eventBroadcaster.on('envelope', (envelope: Envelope) => + this.sendMessage({ type: 'ENVELOPE', envelope }) + ) } async initialize({ supportCodeCoordinates, supportCodeIds, options, - }: IWorkerCommandInitialize): Promise { + }: InitializeCommand): Promise { supportCodeLibraryBuilder.reset( this.cwd, this.newId, @@ -78,64 +73,45 @@ export default class Worker { } this.supportCodeLibrary = supportCodeLibraryBuilder.finalize(supportCodeIds) - this.worldParameters = options.worldParameters - this.filterStacktraces = options.filterStacktraces - this.runTestRunHooks = makeRunTestRunHooks( - options.dryRun, - this.supportCodeLibrary.defaultTimeout, - this.worldParameters, - (name, location) => - `${name} hook errored on worker ${this.id}, process exiting: ${location}` - ) - await this.runTestRunHooks( - this.supportCodeLibrary.beforeTestRunHookDefinitions, - 'a BeforeAll' + this.options = options + this.worker = new Worker( + this.id, + this.eventBroadcaster, + this.newId, + this.options, + this.supportCodeLibrary ) - this.sendMessage({ ready: true }) + await this.worker.runBeforeAllHooks() + this.sendMessage({ type: 'READY' }) } async finalize(): Promise { - await this.runTestRunHooks( - this.supportCodeLibrary.afterTestRunHookDefinitions, - 'an AfterAll' - ) + await this.worker.runAfterAllHooks() this.exit(0) } - async receiveMessage(message: IWorkerCommand): Promise { - if (doesHaveValue(message.initialize)) { - await this.initialize(message.initialize) - } else if (message.finalize) { - await this.finalize() - } else if (doesHaveValue(message.run)) { - await this.runTestCase(message.run) + async receiveMessage(command: CoordinatorToWorkerCommand): Promise { + switch (command.type) { + case 'INITIALIZE': + await this.initialize(command) + break + case 'RUN': + await this.runTestCase(command) + break + case 'FINALIZE': + await this.finalize() + break } } - async runTestCase({ - gherkinDocument, - pickle, - testCase, - elapsed, - retries, - skip, - }: IWorkerCommandRun): Promise { - const stopwatch = create(elapsed) - const testCaseRunner = new TestCaseRunner({ - workerId: this.id, - eventBroadcaster: this.eventBroadcaster, - stopwatch, - gherkinDocument, - newId: this.newId, - pickle, - testCase, - retries, - skip, - filterStackTraces: this.filterStacktraces, - supportCodeLibrary: this.supportCodeLibrary, - worldParameters: this.worldParameters, + async runTestCase(command: RunCommand): Promise { + const success = await this.worker.runTestCase( + command.assembledTestCase, + command.failing + ) + this.sendMessage({ + type: 'FINISHED', + success, }) - await testCaseRunner.run() - this.sendMessage({ ready: true }) } } diff --git a/src/runtime/serial/adapter.ts b/src/runtime/serial/adapter.ts new file mode 100644 index 000000000..5be77281e --- /dev/null +++ b/src/runtime/serial/adapter.ts @@ -0,0 +1,41 @@ +import { EventEmitter } from 'node:events' +import { IdGenerator } from '@cucumber/messages' +import { RuntimeAdapter } from '../types' +import { AssembledTestCase } from '../../assemble' +import { Worker } from '../worker' +import { RuntimeOptions } from '../index' +import { SupportCodeLibrary } from '../../support_code_library_builder/types' + +export class InProcessAdapter implements RuntimeAdapter { + private readonly worker: Worker + private failing: boolean = false + + constructor( + eventBroadcaster: EventEmitter, + newId: IdGenerator.NewId, + options: RuntimeOptions, + supportCodeLibrary: SupportCodeLibrary + ) { + this.worker = new Worker( + undefined, + eventBroadcaster, + newId, + options, + supportCodeLibrary + ) + } + + async run( + assembledTestCases: ReadonlyArray + ): Promise { + await this.worker.runBeforeAllHooks() + for (const item of assembledTestCases) { + const success = await this.worker.runTestCase(item, this.failing) + if (!success) { + this.failing = true + } + } + await this.worker.runAfterAllHooks() + return !this.failing + } +} diff --git a/src/runtime/stopwatch.ts b/src/runtime/stopwatch.ts index b41711814..9284e4a2b 100644 --- a/src/runtime/stopwatch.ts +++ b/src/runtime/stopwatch.ts @@ -1,4 +1,4 @@ -import { Duration, TimeConversion, Timestamp } from '@cucumber/messages' +import { Duration, TimeConversion } from '@cucumber/messages' import methods from '../time' /** @@ -9,7 +9,6 @@ export interface IStopwatch { start: () => IStopwatch stop: () => IStopwatch duration: () => Duration - timestamp: () => Timestamp } class StopwatchImpl implements IStopwatch { @@ -39,10 +38,9 @@ class StopwatchImpl implements IStopwatch { ) ) } - - timestamp(): Timestamp { - return TimeConversion.millisecondsSinceEpochToTimestamp(methods.Date.now()) - } } export const create = (base?: Duration): IStopwatch => new StopwatchImpl(base) + +export const timestamp = () => + TimeConversion.millisecondsSinceEpochToTimestamp(methods.Date.now()) diff --git a/src/runtime/stopwatch_spec.ts b/src/runtime/stopwatch_spec.ts index 017f44bd4..10bc78579 100644 --- a/src/runtime/stopwatch_spec.ts +++ b/src/runtime/stopwatch_spec.ts @@ -1,7 +1,7 @@ import { describe, it } from 'mocha' import { expect } from 'chai' import { TimeConversion } from '@cucumber/messages' -import { create } from './stopwatch' +import { create, timestamp } from './stopwatch' describe('stopwatch', () => { it('returns a duration between the start and stop', async () => { @@ -47,7 +47,7 @@ describe('stopwatch', () => { it('returns a timestamp close to now', () => { expect( - TimeConversion.timestampToMillisecondsSinceEpoch(create().timestamp()) + TimeConversion.timestampToMillisecondsSinceEpoch(timestamp()) ).to.be.closeTo(Date.now(), 100) }) }) diff --git a/src/runtime/test_case_runner.ts b/src/runtime/test_case_runner.ts index 5471d53ee..efca1392a 100644 --- a/src/runtime/test_case_runner.ts +++ b/src/runtime/test_case_runner.ts @@ -13,7 +13,7 @@ import { IDefinition } from '../models/definition' import { doesHaveValue, doesNotHaveValue } from '../value_checker' import StepDefinition from '../models/step_definition' import { IWorldOptions } from '../support_code_library_builder/world' -import { IStopwatch } from './stopwatch' +import { timestamp } from './stopwatch' import StepRunner from './step_runner' import AttachmentManager from './attachment_manager' import { getAmbiguousStepException } from './helpers' @@ -21,7 +21,6 @@ import { getAmbiguousStepException } from './helpers' export interface INewTestCaseRunnerOptions { workerId?: string eventBroadcaster: EventEmitter - stopwatch: IStopwatch gherkinDocument: messages.GherkinDocument newId: IdGenerator.NewId pickle: messages.Pickle @@ -39,7 +38,6 @@ export default class TestCaseRunner { private currentTestCaseStartedId: string private currentTestStepId: string private readonly eventBroadcaster: EventEmitter - private readonly stopwatch: IStopwatch private readonly gherkinDocument: messages.GherkinDocument private readonly newId: IdGenerator.NewId private readonly pickle: messages.Pickle @@ -55,7 +53,6 @@ export default class TestCaseRunner { constructor({ workerId, eventBroadcaster, - stopwatch, gherkinDocument, newId, pickle, @@ -88,7 +85,6 @@ export default class TestCaseRunner { } ) this.eventBroadcaster = eventBroadcaster - this.stopwatch = stopwatch this.gherkinDocument = gherkinDocument this.maxAttempts = 1 + (skip ? 0 : retries) this.newId = newId @@ -169,7 +165,7 @@ export default class TestCaseRunner { testStepStarted: { testCaseStartedId: this.currentTestCaseStartedId, testStepId, - timestamp: this.stopwatch.timestamp(), + timestamp: timestamp(), }, } this.eventBroadcaster.emit('envelope', testStepStarted) @@ -182,7 +178,7 @@ export default class TestCaseRunner { testCaseStartedId: this.currentTestCaseStartedId, testStepId, testStepResult, - timestamp: this.stopwatch.timestamp(), + timestamp: timestamp(), }, } this.eventBroadcaster.emit('envelope', testStepFinished) @@ -215,7 +211,7 @@ export default class TestCaseRunner { attempt, testCaseId: this.testCase.id, id: this.currentTestCaseStartedId, - timestamp: this.stopwatch.timestamp(), + timestamp: timestamp(), }, } if (this.workerId) { @@ -260,7 +256,7 @@ export default class TestCaseRunner { const testCaseFinished: messages.Envelope = { testCaseFinished: { testCaseStartedId: this.currentTestCaseStartedId, - timestamp: this.stopwatch.timestamp(), + timestamp: timestamp(), willBeRetried, }, } diff --git a/src/runtime/test_case_runner_spec.ts b/src/runtime/test_case_runner_spec.ts index 33a07f752..0a84c3190 100644 --- a/src/runtime/test_case_runner_spec.ts +++ b/src/runtime/test_case_runner_spec.ts @@ -2,8 +2,8 @@ import { EventEmitter } from 'node:events' import sinon from 'sinon' import { expect } from 'chai' import { afterEach, beforeEach, describe, it } from 'mocha' -import { IdGenerator } from '@cucumber/messages' import * as messages from '@cucumber/messages' +import { Envelope, IdGenerator } from '@cucumber/messages' import FakeTimers, { InstalledClock } from '@sinonjs/fake-timers' import { buildSupportCodeLibrary } from '../../test/runtime_helpers' import { parse } from '../../test/gherkin_helpers' @@ -11,46 +11,42 @@ import timeMethods from '../time' import { getBaseSupportCodeLibrary } from '../../test/fixtures/steps' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { valueOrDefault } from '../value_checker' +import { assembleTestCases } from '../assemble' import TestCaseRunner from './test_case_runner' -import { create } from './stopwatch' -import { assembleTestCases } from './assemble_test_cases' -import IEnvelope = messages.Envelope -interface ITestRunnerRequest { +async function testRunner(options: { workerId?: string gherkinDocument: messages.GherkinDocument pickle: messages.Pickle retries?: number skip?: boolean supportCodeLibrary: SupportCodeLibrary -} - -interface ITestRunnerResponse { +}): Promise<{ envelopes: messages.Envelope[] result: messages.TestStepResultStatus -} - -async function testRunner( - options: ITestRunnerRequest -): Promise { - const envelopes: IEnvelope[] = [] +}> { + const envelopes: Envelope[] = [] const eventBroadcaster = new EventEmitter() const newId = IdGenerator.incrementing() const testCase = ( await assembleTestCases({ eventBroadcaster, newId, - pickles: [options.pickle], + sourcedPickles: [ + { + gherkinDocument: options.gherkinDocument, + pickle: options.pickle, + }, + ], supportCodeLibrary: options.supportCodeLibrary, }) - )[options.pickle.id] + )[0].testCase // listen for envelopers _after_ we've assembled test cases eventBroadcaster.on('envelope', (e) => envelopes.push(e)) const runner = new TestCaseRunner({ workerId: options.workerId, eventBroadcaster, - stopwatch: create(), gherkinDocument: options.gherkinDocument, newId, pickle: options.pickle, diff --git a/src/runtime/types.ts b/src/runtime/types.ts new file mode 100644 index 000000000..207b24fd6 --- /dev/null +++ b/src/runtime/types.ts @@ -0,0 +1,20 @@ +import { JsonObject } from 'type-fest' +import { AssembledTestCase } from '../assemble' + +export interface RuntimeOptions { + dryRun: boolean + failFast: boolean + filterStacktraces: boolean + retry: number + retryTagFilter: string + strict: boolean + worldParameters: JsonObject +} + +export interface Runtime { + run: () => Promise +} + +export interface RuntimeAdapter { + run(assembledTestCases: ReadonlyArray): Promise +} diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts new file mode 100644 index 000000000..0897abaf8 --- /dev/null +++ b/src/runtime/worker.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events' +import { IdGenerator } from '@cucumber/messages' +import { AssembledTestCase } from '../assemble' +import { SupportCodeLibrary } from '../support_code_library_builder/types' +import TestCaseRunner from './test_case_runner' +import { retriesForPickle, shouldCauseFailure } from './helpers' +import { makeRunTestRunHooks, RunsTestRunHooks } from './run_test_run_hooks' +import { RuntimeOptions } from './index' + +export class Worker { + private readonly runTestRunHooks: RunsTestRunHooks + + constructor( + private readonly workerId: string | undefined, + private readonly eventBroadcaster: EventEmitter, + private readonly newId: IdGenerator.NewId, + private readonly options: RuntimeOptions, + private readonly supportCodeLibrary: SupportCodeLibrary + ) { + this.runTestRunHooks = makeRunTestRunHooks( + this.options.dryRun, + this.supportCodeLibrary.defaultTimeout, + this.options.worldParameters, + (name, location) => { + let message = `${name} hook errored` + if (this.workerId) { + message += ` on worker ${this.workerId}` + } + message += `, process exiting: ${location}` + return message + } + ) + } + + async runBeforeAllHooks() { + await this.runTestRunHooks( + this.supportCodeLibrary.beforeTestRunHookDefinitions, + 'a BeforeAll' + ) + } + + async runTestCase( + { gherkinDocument, pickle, testCase }: AssembledTestCase, + failing: boolean + ): Promise { + const testCaseRunner = new TestCaseRunner({ + workerId: this.workerId, + eventBroadcaster: this.eventBroadcaster, + newId: this.newId, + gherkinDocument, + pickle, + testCase, + retries: retriesForPickle(pickle, this.options), + skip: this.options.dryRun || (this.options.failFast && failing), + filterStackTraces: this.options.filterStacktraces, + supportCodeLibrary: this.supportCodeLibrary, + worldParameters: this.options.worldParameters, + }) + + const status = await testCaseRunner.run() + + return !shouldCauseFailure(status, this.options) + } + + async runAfterAllHooks() { + await this.runTestRunHooks( + this.supportCodeLibrary.afterTestRunHookDefinitions, + 'an AfterAll' + ) + } +} diff --git a/src/support_code_library_builder/index.ts b/src/support_code_library_builder/index.ts index eb3c4005b..4c5cb4fa2 100644 --- a/src/support_code_library_builder/index.ts +++ b/src/support_code_library_builder/index.ts @@ -11,7 +11,6 @@ import TestRunHookDefinition from '../models/test_run_hook_definition' import StepDefinition from '../models/step_definition' import { formatLocation } from '../formatter/helpers' import { doesHaveValue } from '../value_checker' -import { ICanonicalSupportCodeIds } from '../runtime/parallel/command_types' import { GherkinStepKeyword } from '../models/gherkin_step_keyword' import validateArguments from './validate_arguments' @@ -29,6 +28,7 @@ import { ParallelAssignmentValidator, ISupportCodeCoordinates, IDefineStep, + CanonicalSupportCodeIds, } from './types' import World from './world' import { getDefinitionLineAndUri } from './get_definition_line_and_uri' @@ -415,7 +415,7 @@ export class SupportCodeLibraryBuilder { return { stepDefinitions, undefinedParameterTypes } } - finalize(canonicalIds?: ICanonicalSupportCodeIds): SupportCodeLibrary { + finalize(canonicalIds?: CanonicalSupportCodeIds): SupportCodeLibrary { this.status = 'FINALIZED' const stepDefinitionsResult = this.buildStepDefinitions( canonicalIds?.stepDefinitionIds diff --git a/src/support_code_library_builder/types.ts b/src/support_code_library_builder/types.ts index 9ff76284f..779c89c09 100644 --- a/src/support_code_library_builder/types.ts +++ b/src/support_code_library_builder/types.ts @@ -151,6 +151,12 @@ export interface ISupportCodeCoordinates { loaders: string[] } +export interface CanonicalSupportCodeIds { + stepDefinitionIds: string[] + beforeTestCaseHookDefinitionIds: string[] + afterTestCaseHookDefinitionIds: string[] +} + export interface SupportCodeLibrary { readonly originalCoordinates: ISupportCodeCoordinates readonly afterTestCaseHookDefinitions: TestCaseHookDefinition[] diff --git a/test/formatter_helpers.ts b/test/formatter_helpers.ts index 65a2af137..0e0629605 100644 --- a/test/formatter_helpers.ts +++ b/test/formatter_helpers.ts @@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream' import { promisify } from 'node:util' import { IdGenerator } from '@cucumber/messages' import * as messages from '@cucumber/messages' -import Runtime, { IRuntimeOptions } from '../src/runtime' +import { makeRuntime, RuntimeOptions } from '../src/runtime' import { EventDataCollector } from '../src/formatter/helpers' import FormatterBuilder from '../src/formatter/builder' import { SupportCodeLibrary } from '../src/support_code_library_builder/types' @@ -11,10 +11,11 @@ import { ITestCaseAttempt } from '../src/formatter/helpers/event_data_collector' import { doesNotHaveValue } from '../src/value_checker' import { emitSupportCodeMessages } from '../src/cli/helpers' import { FormatOptions } from '../src/formatter' -import { generateEvents } from './gherkin_helpers' +import { SourcedPickle } from '../src/assemble' +import { IRunEnvironment } from '../src/api' +import { generatePickles } from './gherkin_helpers' import { buildOptions, buildSupportCodeLibrary } from './runtime_helpers' - -const { uuid } = IdGenerator +import { FakeLogger } from './fake_logger' export interface ITestSource { data: string @@ -22,7 +23,7 @@ export interface ITestSource { } export interface ITestRunOptions { - runtimeOptions?: Partial + runtimeOptions?: Partial supportCodeLibrary?: SupportCodeLibrary sources?: ITestSource[] pickleFilter?: (pickle: messages.Pickle) => boolean @@ -53,7 +54,7 @@ export async function testFormatter({ emitSupportCodeMessages({ supportCodeLibrary, eventBroadcaster, - newId: uuid(), + newId: IdGenerator.uuid(), }) let output = '' const logFn = (data: string): void => { @@ -71,25 +72,30 @@ export async function testFormatter({ cleanup: promisify(passThrough.end.bind(passThrough)), supportCodeLibrary, }) - let pickleIds: string[] = [] + + let sourcedPickles: SourcedPickle[] = [] for (const source of sources) { - const { pickles } = await generateEvents({ + const generated = await generatePickles({ data: source.data, eventBroadcaster, uri: source.uri, }) - pickleIds = pickleIds.concat(pickles.map((p) => p.id)) + sourcedPickles = sourcedPickles.concat(generated) } - const runtime = new Runtime({ + + const runtime = await makeRuntime({ + environment: {} as IRunEnvironment, + logger: new FakeLogger(), eventBroadcaster, - eventDataCollector, - newId: uuid(), - options: buildOptions(runtimeOptions), - pickleIds, + sourcedPickles, + newId: IdGenerator.uuid(), supportCodeLibrary, + options: { + ...buildOptions(runtimeOptions), + parallel: 0, + }, }) - - await runtime.start() + await runtime.run() return normalizeSummaryDuration(output) } @@ -99,31 +105,11 @@ export async function getTestCaseAttempts({ supportCodeLibrary, sources = [], }: ITestRunOptions): Promise { - if (doesNotHaveValue(supportCodeLibrary)) { - supportCodeLibrary = buildSupportCodeLibrary() - } - const eventBroadcaster = new EventEmitter() - const eventDataCollector = new EventDataCollector(eventBroadcaster) - let pickleIds: string[] = [] - for (const source of sources) { - const { pickles } = await generateEvents({ - data: source.data, - eventBroadcaster, - uri: source.uri, - }) - pickleIds = pickleIds.concat(pickles.map((p) => p.id)) - } - const runtime = new Runtime({ - eventBroadcaster, - eventDataCollector, - newId: uuid(), - options: buildOptions(runtimeOptions), - pickleIds, + const { eventDataCollector } = await getEnvelopesAndEventDataCollector({ + runtimeOptions, supportCodeLibrary, + sources, }) - - await runtime.start() - return eventDataCollector.getTestCaseAttempts() } @@ -145,25 +131,32 @@ export async function getEnvelopesAndEventDataCollector({ eventBroadcaster, newId: IdGenerator.uuid(), }) - let pickleIds: string[] = [] + + let sourcedPickles: SourcedPickle[] = [] for (const source of sources) { - const { pickles } = await generateEvents({ + const generated = await generatePickles({ data: source.data, eventBroadcaster, uri: source.uri, }) - pickleIds = pickleIds.concat(pickles.filter(pickleFilter).map((p) => p.id)) + sourcedPickles = sourcedPickles.concat( + generated.filter((item) => pickleFilter(item.pickle)) + ) } - const runtime = new Runtime({ + + const runtime = await makeRuntime({ + environment: {} as IRunEnvironment, + logger: new FakeLogger(), eventBroadcaster, - eventDataCollector, - newId: uuid(), - options: buildOptions(runtimeOptions), - pickleIds, + sourcedPickles, + newId: IdGenerator.uuid(), supportCodeLibrary, + options: { + ...buildOptions(runtimeOptions), + parallel: 0, + }, }) - - await runtime.start() + await runtime.run() return { envelopes, eventDataCollector } } diff --git a/test/gherkin_helpers.ts b/test/gherkin_helpers.ts index 1e544f98f..fc43ac2e2 100644 --- a/test/gherkin_helpers.ts +++ b/test/gherkin_helpers.ts @@ -4,6 +4,7 @@ import { SourceMediaType } from '@cucumber/messages' import { IGherkinOptions } from '@cucumber/gherkin' import { GherkinStreams } from '@cucumber/gherkin-streams' import { doesHaveValue } from '../src/value_checker' +import { SourcedPickle } from '../src/assemble' export interface IParsedSource { pickles: messages.Pickle[] @@ -70,23 +71,28 @@ export async function parse({ }) } -export interface IGenerateEventsRequest { +export interface GeneratePicklesRequest { data: string eventBroadcaster: EventEmitter uri: string } -export async function generateEvents({ +export async function generatePickles({ data, eventBroadcaster, uri, -}: IGenerateEventsRequest): Promise { - const { envelopes, source, gherkinDocument, pickles } = await parse({ +}: GeneratePicklesRequest): Promise> { + const { envelopes, gherkinDocument, pickles } = await parse({ data, uri, }) envelopes.forEach((envelope) => eventBroadcaster.emit('envelope', envelope)) - return { source, gherkinDocument, pickles } + return pickles.map((pickle) => { + return { + gherkinDocument, + pickle, + } + }) } export async function getPickleWithTags( diff --git a/test/runtime_helpers.ts b/test/runtime_helpers.ts index 5b9acc70f..cd9f8fbe4 100644 --- a/test/runtime_helpers.ts +++ b/test/runtime_helpers.ts @@ -1,6 +1,6 @@ import { IdGenerator } from '@cucumber/messages' import { SupportCodeLibraryBuilder } from '../src/support_code_library_builder' -import { IRuntimeOptions } from '../src/runtime' +import { RuntimeOptions } from '../src/runtime' import { IDefineSupportCodeMethods, SupportCodeLibrary, @@ -8,8 +8,8 @@ import { import { doesHaveValue } from '../src/value_checker' export function buildOptions( - overrides: Partial -): IRuntimeOptions { + overrides: Partial +): RuntimeOptions { return { dryRun: false, failFast: false,