From 38ba2ba18b5e2bdacbe6462d826bca43340a441a Mon Sep 17 00:00:00 2001 From: Tom Newby Date: Sat, 18 Nov 2023 16:21:13 +1000 Subject: [PATCH 1/3] feat(di): support state objects requiring other state objects --- .../features/custom-context-objects.feature | 224 ++++++++++++++++++ cucumber-tsflow/src/binding-decorator.ts | 8 + .../src/managed-scenario-context.ts | 26 +- cucumber-tsflow/src/types.ts | 4 +- 4 files changed, 250 insertions(+), 12 deletions(-) diff --git a/cucumber-tsflow-specs/features/custom-context-objects.feature b/cucumber-tsflow-specs/features/custom-context-objects.feature index 6c58293..fe764a9 100644 --- a/cucumber-tsflow-specs/features/custom-context-objects.feature +++ b/cucumber-tsflow-specs/features/custom-context-objects.feature @@ -55,3 +55,227 @@ Feature: Custom context objects Then it passes And the output contains "The state is 'initial value'" And the output contains "The state is 'step value'" + + Scenario: Custom context objects can depend on other custom context objects two levels deep + Given a file named "features/a.feature" with: + """feature + Feature: some feature + Scenario: scenario a + Given the state is "initial value" + When I set the state to "step value" + Then the state is "step value" + """ + And a file named "support/level-one-state.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + import {LevelTwoState} from './level-two-state'; + + @binding([LevelTwoState]) + export class LevelOneState { + constructor(public levelTwoState: LevelTwoState) { + } + } + """ + And a file named "support/level-two-state.ts" with: + """ts + export class LevelTwoState { + public value: string = "initial value"; + } + """ + And a file named "step_definitions/one.ts" with: + """ts + import {LevelTwoState} from '../support/level-two-state'; + import {binding, when} from 'cucumber-tsflow'; + + @binding([LevelTwoState]) + class Steps { + public constructor(private readonly levelTwoState: LevelTwoState) { + } + + @when("I set the state to {string}") + public setState(newValue: string) { + this.levelTwoState.value = newValue; + } + } + + export = Steps; + """ + And a file named "step_definitions/two.ts" with: + """ts + import {LevelOneState} from '../support/level-one-state'; + import {binding, then} from 'cucumber-tsflow'; + import * as assert from 'node:assert'; + + @binding([LevelOneState]) + class Steps { + public constructor(private readonly levelOneState: LevelOneState) {} + + @then("the state is {string}") + public checkValue(value: string) { + console.log(`The state is '${this.levelOneState.levelTwoState.value}'`); + assert.equal(this.levelOneState.levelTwoState.value, value, "State value does not match"); + } + } + + export = Steps; + """ + When I run cucumber-js + Then it passes + And the output contains "The state is 'initial value'" + And the output contains "The state is 'step value'" + + Scenario: Custom context objects can depend on other custom context objects three levels deep + Given a file named "features/a.feature" with: + """feature + Feature: some feature + Scenario: scenario a + Given the state is "initial value" + When I set the state to "step value" + Then the state is "step value" + """ + And a file named "support/level-one-state.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + import {LevelTwoState} from './level-two-state'; + + @binding([LevelTwoState]) + export class LevelOneState { + constructor(public levelTwoState: LevelTwoState) { + } + } + """ + And a file named "support/level-two-state.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + import {LevelThreeState} from './level-three-state'; + + @binding([LevelThreeState]) + export class LevelTwoState { + constructor(public levelThreeState: LevelThreeState) { + } + } + """ + And a file named "support/level-three-state.ts" with: + """ts + export class LevelThreeState { + public value: string = "initial value"; + } + """ + And a file named "step_definitions/one.ts" with: + """ts + import {LevelThreeState} from '../support/level-three-state'; + import {binding, when} from 'cucumber-tsflow'; + + @binding([LevelThreeState]) + class Steps { + public constructor(private readonly levelThreeState: LevelThreeState) { + } + + @when("I set the state to {string}") + public setState(newValue: string) { + this.levelThreeState.value = newValue; + } + } + + export = Steps; + """ + And a file named "step_definitions/two.ts" with: + """ts + import {LevelOneState} from '../support/level-one-state'; + import {binding, then} from 'cucumber-tsflow'; + import * as assert from 'node:assert'; + + @binding([LevelOneState]) + class Steps { + public constructor(private readonly levelOneState: LevelOneState) {} + + @then("the state is {string}") + public checkValue(value: string) { + console.log(`The state is '${this.levelOneState.levelTwoState.levelThreeState.value}'`); + assert.equal(this.levelOneState.levelTwoState.levelThreeState.value, value, "State value does not match"); + } + } + + export = Steps; + """ + When I run cucumber-js + Then it passes + And the output contains "The state is 'initial value'" + And the output contains "The state is 'step value'" + + + Scenario: Circular dependencies are explicitly communicated to the developer + Given a file named "features/a.feature" with: + """feature + Feature: some feature + Scenario: scenario a + Given the state is "initial value" + When I set the state to "step value" + Then the state is "step value" + """ + And a file named "support/state-one.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + import {StateTwo} from './state-two'; + + @binding([StateTwo]) + export class StateOne { + constructor(public stateTwo: StateTwo) { + } + } + """ + And a file named "support/state-two.ts" with: + """ts + import {StateOne} from './state-one'; + import {binding} from 'cucumber-tsflow'; + + @binding([StateOne]) + export class StateTwo { + public value: string = "initial value"; + constructor(public stateOne: StateOne) { + } + } + """ + And a file named "step_definitions/one.ts" with: + """ts + import {StateTwo} from '../support/state-two'; + import {binding, when} from 'cucumber-tsflow'; + + @binding([StateTwo]) + class Steps { + public constructor(private readonly stateTwo: StateTwo) { + } + + @when("I set the state to {string}") + public setState(newValue: string) { + this.stateTwo.value = newValue; + } + } + + export = Steps; + """ + And a file named "step_definitions/two.ts" with: + """ts + import {StateOne} from '../support/state-one'; + import {binding, then} from 'cucumber-tsflow'; + import * as assert from 'node:assert'; + + @binding([StateOne]) + class Steps { + public constructor(private readonly stateOne: StateOne) {} + + @then("the state is {string}") + public checkValue(value: string) { + console.log(`The state is '${this.stateOne.stateTwo.value}'`); + assert.equal(this.stateOne.stateTwo.value, value, "State value does not match"); + } + } + + export = Steps; + """ + When I run cucumber-js + Then it fails + And the error output contains text: + """ + Undefined context type at index 0 for StateOne, do you possibly have a circular dependency? + """ diff --git a/cucumber-tsflow/src/binding-decorator.ts b/cucumber-tsflow/src/binding-decorator.ts index 53bd9a1..007dff5 100644 --- a/cucumber-tsflow/src/binding-decorator.ts +++ b/cucumber-tsflow/src/binding-decorator.ts @@ -68,6 +68,14 @@ export function binding(requiredContextTypes?: ContextType[]): TypeDecorator { requiredContextTypes ); + if (Array.isArray(requiredContextTypes)) { + for (const i in requiredContextTypes) { + if (typeof requiredContextTypes[i] === 'undefined') { + throw new Error(`Undefined context type at index ${i} for ${target.name}, do you possibly have a circular dependency?`); + } + } + } + const allBindings: StepBinding[] = [ ...bindingRegistry.getStepBindingsForTarget(target), ...bindingRegistry.getStepBindingsForTarget(target.prototype), diff --git a/cucumber-tsflow/src/managed-scenario-context.ts b/cucumber-tsflow/src/managed-scenario-context.ts index 5eace8d..78ab63f 100644 --- a/cucumber-tsflow/src/managed-scenario-context.ts +++ b/cucumber-tsflow/src/managed-scenario-context.ts @@ -1,4 +1,5 @@ import * as _ from "underscore"; +import { BindingRegistry } from "./binding-registry"; import { ScenarioContext } from "./scenario-context"; import { ScenarioInfo } from "./scenario-info"; import { ContextType, isProvidedContextType } from "./types"; @@ -40,15 +41,15 @@ export class ManagedScenarioContext implements ScenarioContext { * @internal */ public getContextInstance(contextType: ContextType) { - return this.getOrActivateObject(contextType.prototype, () => { - if (isProvidedContextType(contextType)) { - throw new Error( - `The requested type "${contextType.name}" should be provided by cucumber-tsflow, but was not registered. Please report a bug.` - ); - } - - return new contextType(); - }); + return this.getOrActivateObject(contextType.prototype, () => { + if (isProvidedContextType(contextType)) { + throw new Error( + `The requested type "${contextType.name}" should be provided by cucumber-tsflow, but was not registered. Please report a bug.` + ); + } + + return new contextType(); + }); } /** @@ -80,7 +81,12 @@ export class ManagedScenarioContext implements ScenarioContext { return new (targetPrototype.constructor as any)(...args); }; - const contextObjects = _.map(contextTypes, this.getContextInstance.bind(this)); + const contextObjects = _.map(contextTypes, (contextType) => { + return this.getOrActivateBindingClass( + contextType.prototype, + BindingRegistry.instance.getContextTypesForTarget(contextType.prototype) + ); + }); return invokeBindingConstructor(contextObjects); } diff --git a/cucumber-tsflow/src/types.ts b/cucumber-tsflow/src/types.ts index f3c97b4..3c2ad89 100644 --- a/cucumber-tsflow/src/types.ts +++ b/cucumber-tsflow/src/types.ts @@ -19,7 +19,7 @@ export type TagName = string; * Represents a class that will be injected into a binding class to provide context * during the execution of a Cucumber scenario. */ -export type CustomContextType = new () => any; +export type CustomContextType = new (...args: any[]) => any; export type ProvidedContextType = | typeof ScenarioInfo @@ -37,7 +37,7 @@ const providedPrototypes: ProvidedContextType[] = [ ]; export function isProvidedContextType( - typ: ContextType + typ: ContextType, ): typ is ProvidedContextType { return providedPrototypes.some((proto) => Object.is(typ, proto)); } From c607bf0dd37efb4f7e849ac0c6a913b82189de44 Mon Sep 17 00:00:00 2001 From: Tom Newby Date: Tue, 21 Nov 2023 08:10:07 +1000 Subject: [PATCH 2/3] Add in a feature file describing the vague nature of the circular dependencies --- .../features/custom-context-objects.feature | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/cucumber-tsflow-specs/features/custom-context-objects.feature b/cucumber-tsflow-specs/features/custom-context-objects.feature index fe764a9..444f516 100644 --- a/cucumber-tsflow-specs/features/custom-context-objects.feature +++ b/cucumber-tsflow-specs/features/custom-context-objects.feature @@ -203,7 +203,6 @@ Feature: Custom context objects And the output contains "The state is 'initial value'" And the output contains "The state is 'step value'" - Scenario: Circular dependencies are explicitly communicated to the developer Given a file named "features/a.feature" with: """feature @@ -279,3 +278,74 @@ Feature: Custom context objects """ Undefined context type at index 0 for StateOne, do you possibly have a circular dependency? """ + + + Scenario: Circular dependencies within the same file are vaguely communicated to the developer + Given a file named "features/a.feature" with: + """feature + Feature: some feature + Scenario: scenario a + Given the state is "initial value" + When I set the state to "step value" + Then the state is "step value" + """ + And a file named "support/state.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + + export class StateOne { + constructor(public stateTwo: StateTwo) { } + } + + @binding([StateOne]) + export class StateTwo { + public value: string = "initial value"; + constructor(public stateOne: StateOne) { } + } + + exports.StateOne = binding([StateTwo])(StateOne); + """ + And a file named "step_definitions/one.ts" with: + """ts + import {StateTwo} from '../support/state'; + import {binding, when} from 'cucumber-tsflow'; + + @binding([StateTwo]) + class StepsOne { + public constructor(private readonly stateTwo: StateTwo) { + } + + @when("I set the state to {string}") + public setState(newValue: string) { + this.stateTwo.value = newValue; + } + } + + export = StepsOne; + """ + And a file named "step_definitions/two.ts" with: + """ts + import {StateOne} from '../support/state'; + import {binding, then} from 'cucumber-tsflow'; + import * as assert from 'node:assert'; + + @binding([StateOne]) + class StepsTwo { + public constructor(private readonly stateOne: StateOne) {} + + @then("the state is {string}") + public checkValue(value: string) { + console.log(`The state is '${this.stateOne.stateTwo.value}'`); + assert.equal(this.stateOne.stateTwo.value, value, "State value does not match"); + } + } + + export = StepsTwo; + """ + When I run cucumber-js + Then it fails + And the error output contains text: + """ + Undefined context type at index 0 for StepsTwo, do you possibly have a circular dependency? + """ + From 01dba4eae1b3098c688ee2878a11d541fb9c2d17 Mon Sep 17 00:00:00 2001 From: Tom Newby Date: Tue, 21 Nov 2023 09:58:45 +1000 Subject: [PATCH 3/3] Add undesirable, but current spec --- .../features/custom-context-objects.feature | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cucumber-tsflow-specs/features/custom-context-objects.feature b/cucumber-tsflow-specs/features/custom-context-objects.feature index 444f516..b7c154b 100644 --- a/cucumber-tsflow-specs/features/custom-context-objects.feature +++ b/cucumber-tsflow-specs/features/custom-context-objects.feature @@ -349,3 +349,56 @@ Feature: Custom context objects Undefined context type at index 0 for StepsTwo, do you possibly have a circular dependency? """ + Scenario: In-file circular dependencies are thrown as maximum call stack exceeded errors + Given a file named "features/a.feature" with: + """feature + Feature: some feature + Scenario: scenario a + Given the state is "initial value" + When I set the state to "step value" + Then the state is "step value" + """ + And a file named "support/circular.ts" with: + """ts + import {binding} from 'cucumber-tsflow'; + + export class StateOne { + constructor(public stateTwo: StateTwo) { } + } + + @binding([StateOne]) + export class StateTwo { + public value: string = "initial value"; + constructor(public stateOne: StateOne) { } + } + + exports.StateOne = binding([StateTwo])(StateOne); + """ + And a file named "step_definitions/one.ts" with: + """ts + import {StateTwo} from '../support/circular'; + import * as assert from 'node:assert'; + import {binding, when, then} from 'cucumber-tsflow'; + + @binding([StateTwo]) + class Steps { + public constructor(private readonly stateTwo: StateTwo) { + } + + @when("I set the state to {string}") + public setState(newValue: string) { + this.stateTwo.value = newValue; + } + + @then("the state is {string}") + public checkValue(value: string) { + console.log(`The state is '${this.stateTwo.value}'`); + assert.equal(this.stateTwo.value, value, "State value does not match"); + } + } + + export = Steps; + """ + When I run cucumber-js + Then it fails + And the output contains "RangeError: Maximum call stack size exceeded"