diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b04f59b7..2e6f3fe96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/support_files/world.md b/docs/support_files/world.md index 69d0aee51..209b45df1 100644 --- a/docs/support_files/world.md +++ b/docs/support_files/world.md @@ -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: diff --git a/exports/root/report.api.md b/exports/root/report.api.md index 71ec60da8..c60ed00ed 100644 --- a/exports/root/report.api.md +++ b/exports/root/report.api.md @@ -52,6 +52,10 @@ export const BeforeStep: (>(code: TestStepHookFunction // @public @deprecated (undocumented) export const Cli: typeof Cli_2; +// @beta +const context_2: IContext; +export { context_2 as context } + // @public (undocumented) export class DataTable { constructor(sourceTable: messages.PickleTable | string[][]); @@ -201,6 +205,12 @@ declare namespace GherkinDocumentParser { // @public (undocumented) export const Given: IDefineStep_2; +// @public (undocumented) +export interface IContext { + // (undocumented) + readonly parameters: ParametersType; +} + // @public (undocumented) export interface IFormatterOptions { // (undocumented) @@ -531,6 +541,9 @@ export class World implements IWorld { readonly parameters: ParametersType; } +// @beta +export const world: IWorld; + // @public (undocumented) export function wrapPromiseWithTimeout(promise: Promise, timeoutInMilliseconds: number, timeoutMessage?: string): Promise; diff --git a/features/scope_proxies.feature b/features/scope_proxies.feature new file mode 100644 index 000000000..36dc7da43 --- /dev/null +++ b/features/scope_proxies.feature @@ -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 \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a642318ae..0864f20fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/runtime/run_test_run_hooks.ts b/src/runtime/run_test_run_hooks.ts index c9370e315..9e256731b 100644 --- a/src/runtime/run_test_run_hooks.ts +++ b/src/runtime/run_test_run_hooks.ts @@ -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[], @@ -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 }) diff --git a/src/runtime/scope/index.ts b/src/runtime/scope/index.ts new file mode 100644 index 000000000..51f0e668b --- /dev/null +++ b/src/runtime/scope/index.ts @@ -0,0 +1,2 @@ +export * from './test_case_scope' +export * from './test_run_scope' diff --git a/src/runtime/scope/make_proxy.ts b/src/runtime/scope/make_proxy.ts new file mode 100644 index 000000000..a4d70a8e7 --- /dev/null +++ b/src/runtime/scope/make_proxy.ts @@ -0,0 +1,40 @@ +export function makeProxy(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 +} diff --git a/src/runtime/scope/test_case_scope.ts b/src/runtime/scope/test_case_scope.ts new file mode 100644 index 000000000..496c3c0a2 --- /dev/null +++ b/src/runtime/scope/test_case_scope.ts @@ -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 { + world: IWorld +} + +const testCaseScope = new AsyncLocalStorage() + +export async function runInTestCaseScope( + store: TestCaseScopeStore, + callback: () => ResponseType +) { + return testCaseScope.run(store, callback) +} + +function getWorld(): IWorld { + 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 +} + +/** + * 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(getWorld) diff --git a/src/runtime/scope/test_case_scope_spec.ts b/src/runtime/scope/test_case_scope_spec.ts new file mode 100644 index 000000000..777d7b0bf --- /dev/null +++ b/src/runtime/scope/test_case_scope_spec.ts @@ -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', + ]) + }) + }) +}) diff --git a/src/runtime/scope/test_run_scope.ts b/src/runtime/scope/test_run_scope.ts new file mode 100644 index 000000000..e4d1e780a --- /dev/null +++ b/src/runtime/scope/test_run_scope.ts @@ -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 { + context: IContext +} + +const testRunScope = new AsyncLocalStorage() + +export async function runInTestRunScope( + store: TestRunScopeStore, + callback: () => ResponseType +) { + return testRunScope.run(store, callback) +} + +function getContext(): IContext { + 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 +} + +/** + * 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(getContext) diff --git a/src/runtime/scope/test_run_scope_spec.ts b/src/runtime/scope/test_run_scope_spec.ts new file mode 100644 index 000000000..9eeb4df26 --- /dev/null +++ b/src/runtime/scope/test_run_scope_spec.ts @@ -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') + }) + }) +}) diff --git a/src/runtime/step_runner.ts b/src/runtime/step_runner.ts index 10416aca0..4de2f6c9b 100644 --- a/src/runtime/step_runner.ts +++ b/src/runtime/step_runner.ts @@ -7,6 +7,7 @@ import { doesNotHaveValue, valueOrDefault, } from '../value_checker' +import { runInTestCaseScope } from './scope' import { create } from './stopwatch' import { formatError } from './format_error' @@ -47,12 +48,14 @@ export async function run({ ) if (invocationData.validCodeLengths.includes(stepDefinition.code.length)) { - const data = await UserCodeRunner.run({ - argsArray: invocationData.parameters, - fn: stepDefinition.code, - thisArg: world, - timeoutInMilliseconds, - }) + const data = await runInTestCaseScope({ world }, async () => + UserCodeRunner.run({ + argsArray: invocationData.parameters, + fn: stepDefinition.code, + thisArg: world, + timeoutInMilliseconds, + }) + ) error = data.error result = data.result } else { diff --git a/src/support_code_library_builder/context.ts b/src/support_code_library_builder/context.ts new file mode 100644 index 000000000..d556a7364 --- /dev/null +++ b/src/support_code_library_builder/context.ts @@ -0,0 +1,3 @@ +export interface IContext { + readonly parameters: ParametersType +} diff --git a/src/wrapper.mjs b/src/wrapper.mjs index ec151fe3b..9b2148c70 100644 --- a/src/wrapper.mjs +++ b/src/wrapper.mjs @@ -34,6 +34,8 @@ export const setWorldConstructor = cucumber.setWorldConstructor export const Then = cucumber.Then export const When = cucumber.When export const World = cucumber.World +export const world = cucumber.world +export const context = cucumber.context export const parallelCanAssignHelpers = cucumber.parallelCanAssignHelpers export const wrapPromiseWithTimeout = cucumber.wrapPromiseWithTimeout