timjroberts / cucumber-js-tsflow

Provides 'specflow' like bindings for Cucumber.js in TypeScript 1.7+.
MIT License
133 stars 34 forks source link

How to access the world in a transformer in a parameterType #125

Closed gzp79 closed 8 months ago

gzp79 commented 1 year ago

I define a new type something like this:

defineParameterType(
    {
    name: 'stringExpr',
    regexp: [/\'(.*)\'/, /\"(.*)\"/, /\((.*)\)/],
    transformer: function (singleQ, doubleQ, expr) {
        const karate = this.karate // what should I write here
        if (expr) {
            const value = new Function('karate', `return ${expr}`)(
                karate.properties
            );
            chai.assert(
                typeof value === 'string',
                `String expected, got: ${typeof value}`
            );
            return value;
        } else {
            return singleQ ?? doubleQ;
        }
    }
});

I'd like to access the world in the transformer. and based on the cucumber-js docs it should be feasible (at least in javascript). Using typescript and tsflow I could not find out how. (Inspecting the content of 'this' (wich is a World) the solution does not seems to be so simple)

For me it'd be crucial as these types and processors in the transformer simplifies the steps a lot.

(offtopic, some context, yes I'm porting some of my test from karate to cucumber as karate has no support for importing npm packages and I've implemented some of the karate steps)

update:

I've managed to extract my data like this: const karate = (this as any)?.__SCENARIO_CONTEXT?._activeObjects?.get(KarateState.prototype) But I'm sure there must be a simpler/nicer solution.

Fryuni commented 1 year ago

This library does not interfere in any of the machinery involved in custom parameter types. For this you are working directly against cucumber-js and what they specify is what holds.

For transformers this is the World object, you can use it directly exactly how you are doing.

Now, if you are using TS, it might prevent you from writing anything because the World type from Cucumber only declares the provided properties and no extension. You'll probably need to declare that your function receives a subtype that is writable. You can declare exactly what you need or you can make a WritableWorld like we did here:

https://github.com/timjroberts/cucumber-js-tsflow/blob/00c9d0cfe478943c736e822410939969dfa3da5f/cucumber-tsflow/src/binding-decorator.ts#L15-L17

Your code would look like this:

import { World } from "@cucumber/cucumber";

interface WritableWorld extends World {
  [key: string]: any;
}

defineParameterType(
    {
    name: 'stringExpr',
    regexp: [/\'(.*)\'/, /\"(.*)\"/, /\((.*)\)/],
    //                      \/ This declaration here
    transformer: function (this: WritableWorld, singleQ, doubleQ, expr) {
        const karate = this.karate;
        if (expr) {
            const value = new Function('karate', `return ${expr}`)(
                karate.properties
            );
            chai.assert(
                typeof value === 'string',
                `String expected, got: ${typeof value}`
            );
            return value;
        } else {
            return singleQ ?? doubleQ;
        }
    }
});
gzp79 commented 1 year ago

Thanks for the answer. My question might have been not clear as I'm still learning the concepts/naming of the framework. I'm using @binding to share data between the steps:

//state.ts:
export class State{}

//steps.ts:
@binding[(State)] 
export class Steps
{ 
  constructor(private readonly state:State){} 
  /*steps using state */
}

It seems as these bindings are shared through the world under this?.__SCENARIO_CONTEXT?._activeObjects?.get(State.prototype) and not stored as a "state"property in the world object.

So a more proper question would have been how to access these without abusing the internal implementation details?

Fryuni commented 1 year ago

Oh, I see.

You want to access one of the injected bindings from the transformer, is that right? I don't think this functionality is currently exposed. It doesn't seem very difficult to provide though.

Would an API like this suffice to you?

import { World } from "@cucumber/cucumber";
import { getBindingFromWorld } from "cucumber-tsflow";

defineParameterType(
    {
    transformer: function (this: World, singleQ, doubleQ, expr) {
        // Returns the same instance of `State` that is given to the bindings
        const state = getBindingFromWorld(this, State);
    }
});

I'm not sure if the this: World signature would be necessary, TS might be able to infer it.

I'd like to provide a TSFlowWorld type such that you could do this.getBindingFor(State), but I think the transformer might get executed before we have the opportunity to inject that function. If that is possible it would look like this:

import { TSFlowWorld } from "cucumber-tsflow";

defineParameterType(
    {
    transformer: function (this: TSFlowWorld, singleQ, doubleQ, expr) {
        // Returns the same instance of `State` that is given to the bindings
        const state = this.getBindingFor(State);
    }
});

WDYT?

gzp79 commented 1 year ago

Yes, that'd look great. Thanks a lot.

Just some brainstorming without knowing it's feasibility. I wonder if ParamTypes could also get a binding attribute similar to steps:

@binding([State])
class StringExprType {
   consturctor(private readonly state: State) {}

   name = ...;
   regexp = ...;

   transformer(expr: string,): string {
         return this.state.transorm(expr)
    }
}

defineParameterType(new StringExprType( ???? )); // some typescript magic :)

But I have the feeling it'd have issues with the registration and construction for the defineParameterType as noted above. Also I think ParameterType registration has a "global" scope while injected object should have a scenario "scope", so this would also ruin the binding concept.

I left the brainstorming ideas here for inspiration, but I'm completely fine with any of your suggestion.

Fryuni commented 1 year ago

I'd have to look deeper into how defineParameterType works to know how your idea could be implemented as I never used that functionality. I supposed it is defined only once for all tests and executed for each step that uses it, so in theory it could be wired similarly to the before/after hooks. If possible, it would look something like this:

@binding([State])
class StringExprType {
   constructor(private readonly state: State) {}

   @parameterType({name: '...', regexp: ...})
   transformer(expr: string,): string {
       return this.state.transform(expr)
   }
}

Multiple types could be declared inside the same class, just like steps.

I think providing an external access like the getBindingFromWorld is a good idea for a escape-hatch so features from cucumber that do not have an equivalent here may still access the bound objects.

Adding support for such features using the decorator style can be done incrementally (when possible).

Thinking a bit more about it, I think providing a free-function is better than injecting a method to the world. Let us keep the world clean šŸ¤£ .

I'll try to implement the getBindingFromWorld function this week, but I'll leave this issue open as a feature request for the decorator style.

Fryuni commented 1 year ago

Support for external access to context types was released on v4.1.1