Skip to content

Commit

Permalink
Add world and context proxies for use in arrow functions (#2402)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidjgoss authored May 26, 2024
1 parent 2990fe4 commit 1901ec8
Show file tree
Hide file tree
Showing 15 changed files with 309 additions and 20 deletions.
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

0 comments on commit 1901ec8

Please sign in to comment.