Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add world and context proxies for use in arrow functions #2402

Merged
merged 7 commits into from
May 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber.

## [Unreleased]
### Added
- Add `world` and `context` to allow accessing state from arrow functions ([#2402](https://github.com/cucumber/cucumber-js/pull/2402))

## [10.7.0] - 2024-05-11
### Added
Expand Down
21 changes: 16 additions & 5 deletions docs/support_files/world.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,29 @@ Scenario: Will fail
Given my color is "red"
Then my color should not be red
```
**Important Note:** The following will NOT work as [arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) do not have their own bindings to `this` and are not suitable for the [apply](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/apply) method Cucumber uses internally to call your [step definitions](./step_definitions.md) and
[hooks](./hooks.md).

## Arrow functions

ℹ️ Added in v10.8.0

[Arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) have traditionally not played nicely with Cucumber's pattern of binding the World to `this`, because of their different scoping behaviour. However, you can now use the `world` object to get a handle on your World from an arrow function. Here's the equivalent of the first example in this doc:

```javascript
// This WON'T work!!
Then("my color should not be blue", () => {
if (this.color === "red") {
const { Given, Then, world } = require('@cucumber/cucumber')

Given("my color is {string}", (color) => {
world.color = color
})

Then("my color should not be red", () => {
if (world.color === "red") {
throw new Error("Wrong Color");
}
});
```

Note that this will throw if you try to call it outside of a step or hook.

## Built-in world

By default, the world is an instance of Cucumber's built-in `World` class. Cucumber provides a number of formatting helpers that are passed into the constructor as an options object. The default world binds these helpers as follows:
Expand Down
13 changes: 13 additions & 0 deletions exports/root/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export const BeforeStep: (<WorldType = IWorld_2<any>>(code: TestStepHookFunction
// @public @deprecated (undocumented)
export const Cli: typeof Cli_2;

// @beta
const context_2: IContext<any>;
export { context_2 as context }

// @public (undocumented)
export class DataTable {
constructor(sourceTable: messages.PickleTable | string[][]);
Expand Down Expand Up @@ -201,6 +205,12 @@ declare namespace GherkinDocumentParser {
// @public (undocumented)
export const Given: IDefineStep_2;

// @public (undocumented)
export interface IContext<ParametersType = any> {
// (undocumented)
readonly parameters: ParametersType;
}

// @public (undocumented)
export interface IFormatterOptions {
// (undocumented)
Expand Down Expand Up @@ -531,6 +541,9 @@ export class World<ParametersType = any> implements IWorld<ParametersType> {
readonly parameters: ParametersType;
}

// @beta
export const world: IWorld<any>;

// @public (undocumented)
export function wrapPromiseWithTimeout<T>(promise: Promise<T>, timeoutInMilliseconds: number, timeoutMessage?: string): Promise<T>;

Expand Down
62 changes: 62 additions & 0 deletions features/scope_proxies.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
Feature: Scope proxies

Background:
Given a file named "features/a.feature" with:
"""
Feature: some feature
Scenario: some scenario
Given a step
"""
And a file named "features/support/world.js" with:
"""
const {setWorldConstructor,World} = require('@cucumber/cucumber')
setWorldConstructor(class WorldConstructor extends World {
isWorld() { return true }
})
"""
And a file named "cucumber.json" with:
"""
{
"default": {
"worldParameters": {
"a": 1
}
}
}
"""

Scenario: world and context can be used from appropriate scopes
Given a file named "features/step_definitions/cucumber_steps.js" with:
"""
const {BeforeAll,Given,BeforeStep,Before,world,context} = require('@cucumber/cucumber')
const assert = require('node:assert/strict')

BeforeAll(() => assert.equal(context.parameters.a, 1))
Given('a step', () => assert(world.isWorld()))
BeforeStep(() => assert(world.isWorld()))
Before(() => assert(world.isWorld()))
"""
When I run cucumber-js
Then it passes

Scenario: world proxy cannot be used outside correct scope
Given a file named "features/step_definitions/cucumber_steps.js" with:
"""
const {BeforeAll,world} = require('@cucumber/cucumber')
const assert = require('node:assert/strict')

BeforeAll(() => assert(world.isWorld()))
"""
When I run cucumber-js
Then it fails

Scenario: context proxy cannot be used outside correct scope
Given a file named "features/step_definitions/cucumber_steps.js" with:
"""
const {Given,context} = require('@cucumber/cucumber')
const assert = require('node:assert/strict')

Given(() => console.log(context.parameters))
"""
When I run cucumber-js
Then it fails
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export {
IWorld,
IWorldOptions,
} from './support_code_library_builder/world'
export { IContext } from './support_code_library_builder/context'
export { worldProxy as world, contextProxy as context } from './runtime/scope'
export { parallelCanAssignHelpers }

export {
Expand Down
22 changes: 13 additions & 9 deletions src/runtime/run_test_run_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import UserCodeRunner from '../user_code_runner'
import { formatLocation } from '../formatter/helpers'
import { doesHaveValue, valueOrDefault } from '../value_checker'
import TestRunHookDefinition from '../models/test_run_hook_definition'
import { runInTestRunScope } from './scope'

export type RunsTestRunHooks = (
definitions: TestRunHookDefinition[],
Expand All @@ -18,16 +19,19 @@ export const makeRunTestRunHooks = (
dryRun
? async () => {}
: async (definitions, name) => {
const context = { parameters: worldParameters }
for (const hookDefinition of definitions) {
const { error } = await UserCodeRunner.run({
argsArray: [],
fn: hookDefinition.code,
thisArg: { parameters: worldParameters },
timeoutInMilliseconds: valueOrDefault(
hookDefinition.options.timeout,
defaultTimeout
),
})
const { error } = await runInTestRunScope({ context }, () =>
UserCodeRunner.run({
argsArray: [],
fn: hookDefinition.code,
thisArg: context,
timeoutInMilliseconds: valueOrDefault(
hookDefinition.options.timeout,
defaultTimeout
),
})
)
if (doesHaveValue(error)) {
const location = formatLocation(hookDefinition)
throw new Error(errorMessage(name, location), { cause: error })
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/scope/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './test_case_scope'
export * from './test_run_scope'
40 changes: 40 additions & 0 deletions src/runtime/scope/make_proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export function makeProxy<T>(getThing: () => any): T {
return new Proxy(
{},
{
defineProperty(_, property, attributes) {
return Reflect.defineProperty(getThing(), property, attributes)
},
deleteProperty(_, property) {
return Reflect.get(getThing(), property)
},
get(_, property) {
return Reflect.get(getThing(), property, getThing())
},
getOwnPropertyDescriptor(_, property) {
return Reflect.getOwnPropertyDescriptor(getThing(), property)
},
getPrototypeOf(_) {
return Reflect.getPrototypeOf(getThing())
},
has(_, key) {
return Reflect.has(getThing(), key)
},
isExtensible(_) {
return Reflect.isExtensible(getThing())
},
ownKeys(_) {
return Reflect.ownKeys(getThing())
},
preventExtensions(_) {
return Reflect.preventExtensions(getThing())
},
set(_, property, value) {
return Reflect.set(getThing(), property, value, getThing())
},
setPrototypeOf(_, proto) {
return Reflect.setPrototypeOf(getThing(), proto)
},
}
) as T
}
38 changes: 38 additions & 0 deletions src/runtime/scope/test_case_scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { IWorld } from '../../support_code_library_builder/world'
import { makeProxy } from './make_proxy'

interface TestCaseScopeStore<ParametersType = any> {
world: IWorld<ParametersType>
}

const testCaseScope = new AsyncLocalStorage<TestCaseScopeStore>()

export async function runInTestCaseScope<ResponseType>(
store: TestCaseScopeStore,
callback: () => ResponseType
) {
return testCaseScope.run(store, callback)
}

function getWorld<ParametersType = any>(): IWorld<ParametersType> {
const store = testCaseScope.getStore()
if (!store) {
throw new Error(
'Attempted to access `world` from incorrect scope; only applicable to steps and case-level hooks'
)
}
return store.world as IWorld<ParametersType>
}

/**
* A proxy to the World instance for the currently-executing test case
*
* @beta
* @remarks
* Useful for getting a handle on the World when using arrow functions and thus
* being unable to rely on the value of `this`. Only callable from the body of a
* step or a `Before`, `After`, `BeforeStep` or `AfterStep` hook (will throw
* otherwise).
*/
export const worldProxy = makeProxy<IWorld>(getWorld)
50 changes: 50 additions & 0 deletions src/runtime/scope/test_case_scope_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import sinon from 'sinon'
import { expect } from 'chai'
import World from '../../support_code_library_builder/world'
import { ICreateAttachment } from '../attachment_manager'
import { IFormatterLogFn } from '../../formatter'
import { runInTestCaseScope, worldProxy } from './test_case_scope'

describe('testCaseScope', () => {
class CustomWorld extends World {
firstNumber: number = 0
secondNumber: number = 0

get numbers() {
return [this.firstNumber, this.secondNumber]
}

sum() {
return this.firstNumber + this.secondNumber
}
}

it('provides a proxy to the world that works when running a test case', async () => {
const customWorld = new CustomWorld({
attach: sinon.stub() as unknown as ICreateAttachment,
log: sinon.stub() as IFormatterLogFn,
parameters: {},
})
const customProxy = worldProxy as CustomWorld

await runInTestCaseScope({ world: customWorld }, () => {
// simple property access
customProxy.firstNumber = 1
customProxy.secondNumber = 2
expect(customProxy.firstNumber).to.eq(1)
expect(customProxy.secondNumber).to.eq(2)
// getters using internal state
expect(customProxy.numbers).to.deep.eq([1, 2])
// instance methods using internal state
expect(customProxy.sum()).to.eq(3)
// enumeration
expect(Object.keys(customProxy)).to.deep.eq([
'attach',
'log',
'parameters',
'firstNumber',
'secondNumber',
])
})
})
})
37 changes: 37 additions & 0 deletions src/runtime/scope/test_run_scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { IContext } from '../../support_code_library_builder/context'
import { makeProxy } from './make_proxy'

interface TestRunScopeStore<ParametersType = any> {
context: IContext<ParametersType>
}

const testRunScope = new AsyncLocalStorage<TestRunScopeStore>()

export async function runInTestRunScope<ResponseType>(
store: TestRunScopeStore,
callback: () => ResponseType
) {
return testRunScope.run(store, callback)
}

function getContext<ParametersType = any>(): IContext<ParametersType> {
const store = testRunScope.getStore()
if (!store) {
throw new Error(
'Attempted to access `context` from incorrect scope; only applicable to run-level hooks'
)
}
return store.context as IContext<ParametersType>
}

/**
* A proxy to the context for the currently-executing test run.
*
* @beta
* @remarks
* Useful for getting a handle on the context when using arrow functions and thus
* being unable to rely on the value of `this`. Only callable from the body of a
* `BeforeAll` or `AfterAll` hook (will throw otherwise).
*/
export const contextProxy = makeProxy<IContext>(getContext)
20 changes: 20 additions & 0 deletions src/runtime/scope/test_run_scope_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect } from 'chai'
import { contextProxy, runInTestRunScope } from './test_run_scope'

describe('testRunScope', () => {
it('provides a proxy to the context that works when running a test run hook', async () => {
const context = {
parameters: {
foo: 1,
bar: 2,
},
}

await runInTestRunScope({ context }, () => {
// simple property access
expect(contextProxy.parameters.foo).to.eq(1)
contextProxy.parameters.foo = 'baz'
expect(contextProxy.parameters.foo).to.eq('baz')
})
})
})
Loading
Loading