From 8fdb5e744e1d31eb53a8db9cd7e7ff5262a123b5 Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 6 May 2024 15:50:01 +0100 Subject: [PATCH 1/5] implement getWorld and getContext --- docs/support_files/world.md | 21 +++++-- exports/root/report.api.md | 12 ++++ features/scope_helpers.feature | 62 +++++++++++++++++++++ src/index.ts | 2 + src/runtime/run_test_run_hooks.ts | 23 +++++--- src/runtime/scope/index.ts | 2 + src/runtime/scope/test_case_scope.ts | 35 ++++++++++++ src/runtime/scope/test_run_scope.ts | 34 +++++++++++ src/runtime/step_runner.ts | 15 +++-- src/support_code_library_builder/context.ts | 3 + src/wrapper.mjs | 2 + 11 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 features/scope_helpers.feature create mode 100644 src/runtime/scope/index.ts create mode 100644 src/runtime/scope/test_case_scope.ts create mode 100644 src/runtime/scope/test_run_scope.ts create mode 100644 src/support_code_library_builder/context.ts diff --git a/docs/support_files/world.md b/docs/support_files/world.md index 69d0aee51..218659b0b 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.7.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 `getWorld` function 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, getWorld } = require('@cucumber/cucumber') + +Given("my color is {string}", (color) => { + getWorld().color = color +}) + +Then("my color should not be red", () => { + if (getWorld().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..6036c5caf 100644 --- a/exports/root/report.api.md +++ b/exports/root/report.api.md @@ -159,6 +159,9 @@ declare namespace formatterHelpers { } export { formatterHelpers } +// @public +export function getContext(): IContext; + // @public (undocumented) function getGherkinExampleRuleMap(gherkinDocument: messages.GherkinDocument): Record; @@ -189,6 +192,9 @@ function getStepKeywordType({ keyword, language, previousKeywordType, }: IGetSte // @public (undocumented) function getUsage({ stepDefinitions, eventDataCollector, }: IGetUsageRequest): IUsage[]; +// @public +export function getWorld(): IWorld; + declare namespace GherkinDocumentParser { export { getGherkinStepMap, @@ -201,6 +207,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) diff --git a/features/scope_helpers.feature b/features/scope_helpers.feature new file mode 100644 index 000000000..676e6f69f --- /dev/null +++ b/features/scope_helpers.feature @@ -0,0 +1,62 @@ +Feature: Scope helpers + + 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: getWorld and getContext can be used from appropriate scopes + Given a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {BeforeAll,Given,BeforeStep,Before,getWorld,getContext} = require('@cucumber/cucumber') + const assert = require('node:assert/strict') + + BeforeAll(() => assert.equal(getContext().parameters.a, 1)) + Given('a step', () => assert(getWorld().isWorld())) + BeforeStep(() => assert(getWorld().isWorld())) + Before(() => assert(getWorld().isWorld())) + """ + When I run cucumber-js + Then it passes + + Scenario: getWorld cannot be used outside correct scope + Given a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {BeforeAll,getWorld} = require('@cucumber/cucumber') + const assert = require('node:assert/strict') + + BeforeAll(() => assert(getWorld().isWorld())) + """ + When I run cucumber-js + Then it fails + + Scenario: getContext cannot be used outside correct scope + Given a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {Given,getContext} = require('@cucumber/cucumber') + const assert = require('node:assert/strict') + + Given(() => console.log(getContext().parameters)) + """ + When I run cucumber-js + Then it fails diff --git a/src/index.ts b/src/index.ts index a642318ae..f1529e468 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 { getWorld, getContext } 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..6784c077e 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[], @@ -19,15 +20,19 @@ export const makeRunTestRunHooks = ( ? async () => {} : async (definitions, name) => { 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: { parameters: worldParameters } }, + () => + UserCodeRunner.run({ + argsArray: [], + fn: hookDefinition.code, + thisArg: { parameters: worldParameters }, + 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/test_case_scope.ts b/src/runtime/scope/test_case_scope.ts new file mode 100644 index 000000000..571d779a8 --- /dev/null +++ b/src/runtime/scope/test_case_scope.ts @@ -0,0 +1,35 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { IWorld } from '../../support_code_library_builder/world' + +interface TestCaseScopeStore { + world: IWorld +} + +const testCaseScope = new AsyncLocalStorage() + +export async function runInTestCaseScope( + store: TestCaseScopeStore, + callback: () => ResponseType +) { + return testCaseScope.run(store, callback) +} + +/** + * Retrieve the World for the currently executing test case. + * + * @public + * @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 function getWorld(): IWorld { + const store = testCaseScope.getStore() + if (!store) { + throw new Error( + 'Called `getWorld` from incorrect scope; only applicable to steps and case-level hooks' + ) + } + return store.world as IWorld +} diff --git a/src/runtime/scope/test_run_scope.ts b/src/runtime/scope/test_run_scope.ts new file mode 100644 index 000000000..8ac0aa6e9 --- /dev/null +++ b/src/runtime/scope/test_run_scope.ts @@ -0,0 +1,34 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { IContext } from '../../support_code_library_builder/context' + +interface TestRunScopeStore { + context: IContext +} + +const testRunScope = new AsyncLocalStorage() + +export async function runInTestRunScope( + store: TestRunScopeStore, + callback: () => ResponseType +) { + return testRunScope.run(store, callback) +} + +/** + * Retrieve the context for the currently-executing test run. + * + * @public + * @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 function getContext(): IContext { + const store = testRunScope.getStore() + if (!store) { + throw new Error( + 'Called `getContext` from incorrect scope; only applicable to run-level hooks' + ) + } + return store.context as IContext +} 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..b5407a2cd 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 getWorld = cucumber.getWorld +export const getContext = cucumber.getContext export const parallelCanAssignHelpers = cucumber.parallelCanAssignHelpers export const wrapPromiseWithTimeout = cucumber.wrapPromiseWithTimeout From 54c8ab926dadb441d7e009825e8c2122a84d8abc Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 6 May 2024 16:11:54 +0100 Subject: [PATCH 2/5] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7689c5bd..49ebf3f66 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 `getWorld` and `getContext` to allow accessing the World from arrow functions ([#2402](https://github.com/cucumber/cucumber-js/pull/2402)) ## [10.6.0] - 2024-04-25 ### Added From 411871726c3577f018b3e55d6424910b84514647 Mon Sep 17 00:00:00 2001 From: David Goss Date: Mon, 6 May 2024 16:22:44 +0100 Subject: [PATCH 3/5] small refactor --- src/runtime/run_test_run_hooks.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/runtime/run_test_run_hooks.ts b/src/runtime/run_test_run_hooks.ts index 6784c077e..9e256731b 100644 --- a/src/runtime/run_test_run_hooks.ts +++ b/src/runtime/run_test_run_hooks.ts @@ -19,19 +19,18 @@ export const makeRunTestRunHooks = ( dryRun ? async () => {} : async (definitions, name) => { + const context = { parameters: worldParameters } for (const hookDefinition of definitions) { - const { error } = await runInTestRunScope( - { context: { parameters: worldParameters } }, - () => - 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) From 0aea6971a0d74fce80575e374e92d847b7f363d7 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sat, 11 May 2024 09:15:20 +0100 Subject: [PATCH 4/5] switch to using proxies --- CHANGELOG.md | 2 +- docs/support_files/world.md | 8 +-- exports/root/report.api.md | 13 ++--- ..._helpers.feature => scope_proxies.feature} | 28 +++++------ src/index.ts | 2 +- src/runtime/scope/make_proxy.ts | 40 +++++++++++++++ src/runtime/scope/test_case_scope.ts | 25 ++++++---- src/runtime/scope/test_case_scope_spec.ts | 50 +++++++++++++++++++ src/runtime/scope/test_run_scope.ts | 25 ++++++---- src/runtime/scope/test_run_scope_spec.ts | 20 ++++++++ src/wrapper.mjs | 4 +- 11 files changed, 167 insertions(+), 50 deletions(-) rename features/{scope_helpers.feature => scope_proxies.feature} (58%) create mode 100644 src/runtime/scope/make_proxy.ts create mode 100644 src/runtime/scope/test_case_scope_spec.ts create mode 100644 src/runtime/scope/test_run_scope_spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 49ebf3f66..82a9bc8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Please see [CONTRIBUTING.md](./CONTRIBUTING.md) on how to contribute to Cucumber ## [Unreleased] ### Added -- Add `getWorld` and `getContext` to allow accessing the World from arrow functions ([#2402](https://github.com/cucumber/cucumber-js/pull/2402)) +- Add `world` and `context` to allow accessing state from arrow functions ([#2402](https://github.com/cucumber/cucumber-js/pull/2402)) ## [10.6.0] - 2024-04-25 ### Added diff --git a/docs/support_files/world.md b/docs/support_files/world.md index 218659b0b..a80e7dde2 100644 --- a/docs/support_files/world.md +++ b/docs/support_files/world.md @@ -36,17 +36,17 @@ Scenario: Will fail ℹ️ Added in v10.7.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 `getWorld` function to get a handle on your World from an arrow function. Here's the equivalent of the first example in this doc: +[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 -const { Given, Then, getWorld } = require('@cucumber/cucumber') +const { Given, Then, world } = require('@cucumber/cucumber') Given("my color is {string}", (color) => { - getWorld().color = color + world.color = color }) Then("my color should not be red", () => { - if (getWorld().color === "red") { + if (world.color === "red") { throw new Error("Wrong Color"); } }); diff --git a/exports/root/report.api.md b/exports/root/report.api.md index 6036c5caf..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[][]); @@ -159,9 +163,6 @@ declare namespace formatterHelpers { } export { formatterHelpers } -// @public -export function getContext(): IContext; - // @public (undocumented) function getGherkinExampleRuleMap(gherkinDocument: messages.GherkinDocument): Record; @@ -192,9 +193,6 @@ function getStepKeywordType({ keyword, language, previousKeywordType, }: IGetSte // @public (undocumented) function getUsage({ stepDefinitions, eventDataCollector, }: IGetUsageRequest): IUsage[]; -// @public -export function getWorld(): IWorld; - declare namespace GherkinDocumentParser { export { getGherkinStepMap, @@ -543,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_helpers.feature b/features/scope_proxies.feature similarity index 58% rename from features/scope_helpers.feature rename to features/scope_proxies.feature index 676e6f69f..36dc7da43 100644 --- a/features/scope_helpers.feature +++ b/features/scope_proxies.feature @@ -1,4 +1,4 @@ -Feature: Scope helpers +Feature: Scope proxies Background: Given a file named "features/a.feature" with: @@ -25,38 +25,38 @@ Feature: Scope helpers } """ - Scenario: getWorld and getContext can be used from appropriate scopes + 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,getWorld,getContext} = require('@cucumber/cucumber') + const {BeforeAll,Given,BeforeStep,Before,world,context} = require('@cucumber/cucumber') const assert = require('node:assert/strict') - BeforeAll(() => assert.equal(getContext().parameters.a, 1)) - Given('a step', () => assert(getWorld().isWorld())) - BeforeStep(() => assert(getWorld().isWorld())) - Before(() => assert(getWorld().isWorld())) + 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: getWorld cannot be used outside correct scope + Scenario: world proxy cannot be used outside correct scope Given a file named "features/step_definitions/cucumber_steps.js" with: """ - const {BeforeAll,getWorld} = require('@cucumber/cucumber') + const {BeforeAll,world} = require('@cucumber/cucumber') const assert = require('node:assert/strict') - BeforeAll(() => assert(getWorld().isWorld())) + BeforeAll(() => assert(world.isWorld())) """ When I run cucumber-js Then it fails - Scenario: getContext cannot be used outside correct scope + Scenario: context proxy cannot be used outside correct scope Given a file named "features/step_definitions/cucumber_steps.js" with: """ - const {Given,getContext} = require('@cucumber/cucumber') + const {Given,context} = require('@cucumber/cucumber') const assert = require('node:assert/strict') - Given(() => console.log(getContext().parameters)) + Given(() => console.log(context.parameters)) """ When I run cucumber-js - Then it fails + Then it fails \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f1529e468..0864f20fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,7 +61,7 @@ export { IWorldOptions, } from './support_code_library_builder/world' export { IContext } from './support_code_library_builder/context' -export { getWorld, getContext } from './runtime/scope' +export { worldProxy as world, contextProxy as context } from './runtime/scope' export { parallelCanAssignHelpers } export { 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 index 571d779a8..496c3c0a2 100644 --- a/src/runtime/scope/test_case_scope.ts +++ b/src/runtime/scope/test_case_scope.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { IWorld } from '../../support_code_library_builder/world' +import { makeProxy } from './make_proxy' interface TestCaseScopeStore { world: IWorld @@ -14,22 +15,24 @@ export async function runInTestCaseScope( 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 +} + /** - * Retrieve the World for the currently executing test case. + * A proxy to the World instance for the currently-executing test case * - * @public + * @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 function getWorld(): IWorld { - const store = testCaseScope.getStore() - if (!store) { - throw new Error( - 'Called `getWorld` from incorrect scope; only applicable to steps and case-level hooks' - ) - } - return store.world as IWorld -} +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 index 8ac0aa6e9..e4d1e780a 100644 --- a/src/runtime/scope/test_run_scope.ts +++ b/src/runtime/scope/test_run_scope.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { IContext } from '../../support_code_library_builder/context' +import { makeProxy } from './make_proxy' interface TestRunScopeStore { context: IContext @@ -14,21 +15,23 @@ export async function runInTestRunScope( return testRunScope.run(store, callback) } -/** - * Retrieve the context for the currently-executing test run. - * - * @public - * @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 function getContext(): IContext { +function getContext(): IContext { const store = testRunScope.getStore() if (!store) { throw new Error( - 'Called `getContext` from incorrect scope; only applicable to run-level hooks' + '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/wrapper.mjs b/src/wrapper.mjs index b5407a2cd..9b2148c70 100644 --- a/src/wrapper.mjs +++ b/src/wrapper.mjs @@ -34,8 +34,8 @@ export const setWorldConstructor = cucumber.setWorldConstructor export const Then = cucumber.Then export const When = cucumber.When export const World = cucumber.World -export const getWorld = cucumber.getWorld -export const getContext = cucumber.getContext +export const world = cucumber.world +export const context = cucumber.context export const parallelCanAssignHelpers = cucumber.parallelCanAssignHelpers export const wrapPromiseWithTimeout = cucumber.wrapPromiseWithTimeout From a172af408acfeeb52322d2b789ccb12b654b139e Mon Sep 17 00:00:00 2001 From: David Goss Date: Sat, 11 May 2024 13:29:30 +0100 Subject: [PATCH 5/5] update first released version --- docs/support_files/world.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/support_files/world.md b/docs/support_files/world.md index a80e7dde2..209b45df1 100644 --- a/docs/support_files/world.md +++ b/docs/support_files/world.md @@ -34,7 +34,7 @@ Scenario: Will fail ## Arrow functions -ℹ️ Added in v10.7.0 +ℹ️ 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: