Skip to content

Commit 873ef9d

Browse files
authoredMar 25, 2025··
Add World customisation and injection as this (#47)
1 parent a7dc37b commit 873ef9d

15 files changed

+225
-34
lines changed
 

‎CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
### Added
10+
- Add World customisation and injection as `this` ([#47](https://github.com/cucumber/cucumber-node/pull/47))
911

1012
## [0.3.0] - 2025-02-28
1113
### Added

‎README.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ There are some pretty standard Cucumber features that are missing (but not for l
161161
- [Filtering by tag expression](https://github.com/cucumber/cucumber-node/issues/9)
162162
- [BeforeAll/AfterAll hooks](https://github.com/cucumber/cucumber-node/issues/8)
163163
- [Regular expression steps](https://github.com/cucumber/cucumber-node/issues/6)
164-
- [Customise World creation and type](https://github.com/cucumber/cucumber-node/issues/7)
165164
- [Snippets](https://github.com/cucumber/cucumber-node/issues/36)
166165

167166
## What's different?
@@ -174,6 +173,6 @@ Some behaviour of cucumber-node is different - and better - than in cucumber-js:
174173

175174
### Arrow functions
176175

177-
There's no reliance on `this` in your step and hook functions to access state, since we pass a context object as the first argument to all functions. This means you're free to use arrow functions as you normally would in JavaScript.
176+
There's no reliance on `this` in your step and hook functions to access state, since we pass a context object as the first argument to those functions. This means you're free to use arrow functions as you normally would in JavaScript.
178177

179178

‎cucumber-node.api.md

+10-4
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class DataTable {
3939
export function Given(text: string, fn: StepFunction): void;
4040

4141
// @public
42-
export type HookFunction = (context: TestCaseContext) => Promisable<void>;
42+
export type HookFunction = (this: World, context: TestCaseContext) => Promisable<void>;
4343

4444
// @public
4545
export type HookOptions = {
@@ -54,13 +54,13 @@ export function ParameterType(options: ParameterTypeOptions): void;
5454
export type ParameterTypeOptions = {
5555
name: string;
5656
regexp: RegExp | string | readonly RegExp[] | readonly string[];
57-
transformer?: (...match: string[]) => unknown;
57+
transformer?: (this: World, ...match: string[]) => unknown;
5858
useForSnippets?: boolean;
5959
preferForRegexpMatch?: boolean;
6060
};
6161

6262
// @public
63-
export type StepFunction = (context: TestCaseContext, ...args: any) => Promisable<void>;
63+
export type StepFunction = (this: World, context: TestCaseContext, ...args: any) => Promisable<void>;
6464

6565
// @public
6666
export type TestCaseContext = {
@@ -71,7 +71,7 @@ export type TestCaseContext = {
7171
attach(data: Readable | Buffer | string, options: AttachmentOptions): Promise<void>;
7272
log(text: string): Promise<void>;
7373
link(url: string, title?: string): Promise<void>;
74-
world: any;
74+
world: World;
7575
};
7676

7777
// @public
@@ -80,6 +80,12 @@ export function Then(text: string, fn: StepFunction): void;
8080
// @public
8181
export function When(text: string, fn: StepFunction): void;
8282

83+
// @public
84+
export type World = any;
85+
86+
// @public
87+
export function WorldCreator(creator: () => Promisable<World>, destroyer?: (world: World) => Promisable<void>): void;
88+
8389
// (No @packageDocumentation comment for this package)
8490

8591
```

‎src/core/SupportCodeBuilder.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('SupportCodeBuilder', () => {
77
const builder = new SupportCodeBuilder(() => crypto.randomUUID())
88
builder.registerStep('a {thing} happens', () => {}, {})
99

10-
const library = builder.build()
10+
const library = builder.toLibrary()
1111

1212
expect(library.toEnvelopes().find((envelope) => envelope.undefinedParameterType)).to.deep.eq({
1313
undefinedParameterType: {
@@ -22,6 +22,6 @@ describe('SupportCodeBuilder', () => {
2222
// @ts-expect-error passing incorrect type to yield an error
2323
builder.registerStep(null, () => {}, {})
2424

25-
expect(() => builder.build()).to.throw()
25+
expect(() => builder.toLibrary()).to.throw()
2626
})
2727
})

‎src/core/SupportCodeBuilder.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export class SupportCodeBuilder {
179179
})
180180
}
181181

182-
build(): SupportCodeLibrary {
182+
toLibrary(): SupportCodeLibrary {
183183
return new SupportCodeLibrary(
184184
this.buildParameterTypes(),
185185
this.buildSteps(),

‎src/core/makeTestPlan.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface AssembledStep {
2828
id: string
2929
name: string
3030
always: boolean
31-
prepare(): Runnable
31+
prepare(thisArg?: unknown): Runnable
3232
toMessage(): TestStep
3333
}
3434

@@ -72,9 +72,9 @@ function fromBeforeHooks(
7272
id: newId(),
7373
name: def.name ?? '',
7474
always: false,
75-
prepare() {
75+
prepare(thisArg) {
7676
return {
77-
fn: def.fn,
77+
fn: def.fn.bind(thisArg),
7878
args: [],
7979
}
8080
},
@@ -101,9 +101,9 @@ function fromAfterHooks(
101101
id: newId(),
102102
name: def.name ?? '',
103103
always: true,
104-
prepare() {
104+
prepare(thisArg) {
105105
return {
106-
fn: def.fn,
106+
fn: def.fn.bind(thisArg),
107107
args: [],
108108
}
109109
},
@@ -128,7 +128,7 @@ function fromPickleSteps(
128128
id: newId(),
129129
name: pickleStep.text,
130130
always: false,
131-
prepare() {
131+
prepare(thisArg) {
132132
if (matched.length < 1) {
133133
throw new UndefinedError(pickleStep.text)
134134
} else if (matched.length > 1) {
@@ -139,7 +139,7 @@ function fromPickleSteps(
139139
} else {
140140
const { def, args } = matched[0]
141141
// eslint-disable-next-line @typescript-eslint/no-explicit-any
142-
const allArgs: Array<any> = args.map((arg) => arg.getValue(undefined))
142+
const allArgs: Array<any> = args.map((arg) => arg.getValue(thisArg))
143143
if (pickleStep.argument?.docString) {
144144
allArgs.push(pickleStep.argument.docString.content)
145145
} else if (pickleStep.argument?.dataTable) {
@@ -152,7 +152,7 @@ function fromPickleSteps(
152152
)
153153
}
154154
return {
155-
fn: def.fn,
155+
fn: def.fn.bind(thisArg),
156156
args: allArgs,
157157
}
158158
}

‎src/index.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
1+
import { Promisable } from 'type-fest'
2+
13
import { makeSourceReference } from './makeSourceReference.js'
24
import { builder } from './runner/state.js'
3-
import { HookFunction, HookOptions, ParameterTypeOptions, StepFunction } from './types.js'
5+
import { HookFunction, HookOptions, ParameterTypeOptions, StepFunction, World } from './types.js'
46

57
export * from './core/DataTable.js'
68
export * from './types.js'
79

10+
/**
11+
* Define a custom world creator and (optional) destroyer. These will be use to create and
12+
* destroy each World instance. Both functions can either return a value, or promise that resolves
13+
* to a value.
14+
* @public
15+
* @param creator - A function that creates and returns a custom World object
16+
* @param destroyer - An optional function to clean up the World object after each test case
17+
* @example CustomWorld(async () => \{
18+
* return \{ myCustomProperty: 'value' \}
19+
* \}, async (world) => \{
20+
* // cleanup resources
21+
* \})
22+
*/
23+
export function WorldCreator(
24+
creator: () => Promisable<World>,
25+
destroyer?: (world: World) => Promisable<void>
26+
) {
27+
builder.registerWorldCreator(creator)
28+
if (destroyer) {
29+
builder.registerWorldDestroyer(destroyer)
30+
}
31+
}
32+
833
/**
934
* Define a custom parameter type for use in steps.
1035
* @public

‎src/runner/ContextTracker.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { TestContext } from 'node:test'
22

33
import { Envelope } from '@cucumber/messages'
44

5-
import { TestCaseContext } from '../types.js'
5+
import { TestCaseContext, World } from '../types.js'
66
import { makeAttachment, makeLink, makeLog } from './makeAttachment.js'
77

88
export class ContextTracker {
99
outcomeKnown = false
1010

1111
constructor(
1212
private readonly testCaseStartedId: string,
13-
private readonly world: any, // eslint-disable-line @typescript-eslint/no-explicit-any
13+
private readonly world: World,
1414
private readonly onMessage: (envelope: Envelope) => void
1515
) {}
1616

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Promisable } from 'type-fest'
2+
3+
import { SupportCodeBuilder } from '../core/SupportCodeBuilder.js'
4+
import { makeId } from '../makeId.js'
5+
import { World } from '../types.js'
6+
import { WorldFactory } from './WorldFactory.js'
7+
8+
export class ExtendedSupportCodeBuilder extends SupportCodeBuilder {
9+
private worldCreator: () => Promisable<World> = () => ({})
10+
private worldDestroyer: (world: World) => Promisable<void> = () => {}
11+
12+
constructor() {
13+
super(makeId)
14+
}
15+
16+
registerWorldCreator(creator: () => Promisable<World>) {
17+
this.worldCreator = creator
18+
}
19+
20+
registerWorldDestroyer(destroyer: (world: World) => Promisable<void>) {
21+
this.worldDestroyer = destroyer
22+
}
23+
24+
toWorldFactory() {
25+
return new WorldFactory(this.worldCreator, this.worldDestroyer)
26+
}
27+
}

‎src/runner/WorldFactory.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Promisable } from 'type-fest'
2+
3+
import { World } from '../types.js'
4+
5+
export class WorldFactory {
6+
constructor(
7+
private readonly creator: () => Promisable<World>,
8+
private readonly destroyer: (world: World) => Promisable<void>
9+
) {}
10+
11+
async create(): Promise<World> {
12+
return this.creator()
13+
}
14+
15+
async destroy(world: World): Promise<void> {
16+
await this.destroyer(world)
17+
}
18+
}

‎src/runner/index.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function run({ source, gherkinDocument, pickles }: CompiledGherkin) {
2121
messages.push({ gherkinDocument })
2222
messages.push(...pickles.map((pickle) => ({ pickle })))
2323

24-
const library = await loadSupport()
24+
const { library, worldFactory } = await loadSupport()
2525
messages.push(...library.toEnvelopes())
2626

2727
const plan = makeTestPlan(makeId, pickles, library)
@@ -41,7 +41,7 @@ export function run({ source, gherkinDocument, pickles }: CompiledGherkin) {
4141
},
4242
})
4343

44-
const world = {}
44+
const world = await worldFactory.create()
4545
const tracker = new ContextTracker(testCaseStartedId, world, (e) => messages.push(e))
4646

4747
for (const step of item.steps) {
@@ -59,7 +59,7 @@ export function run({ source, gherkinDocument, pickles }: CompiledGherkin) {
5959
async (ctx2) => {
6060
let success = false
6161
try {
62-
const { fn, args } = step.prepare()
62+
const { fn, args } = step.prepare(world)
6363
await fn(tracker.makeContext(ctx2, step.id), ...args)
6464
success = true
6565
} finally {
@@ -80,6 +80,8 @@ export function run({ source, gherkinDocument, pickles }: CompiledGherkin) {
8080
})
8181
}
8282

83+
await worldFactory.destroy(world)
84+
8385
messages.push({
8486
testCaseFinished: {
8587
testCaseStartedId: testCaseStartedId,

‎src/runner/loadSupport.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { pathToFileURL } from 'node:url'
22

33
import { globby } from 'globby'
44

5-
import { SupportCodeLibrary } from '../core/SupportCodeLibrary.js'
65
import { builder } from './state.js'
76

8-
export async function loadSupport(): Promise<SupportCodeLibrary> {
7+
export async function loadSupport() {
98
const paths = await globby('features/**/*.{cjs,js,mjs,cts,mts,ts}')
109
for (const path of paths) {
1110
await import(pathToFileURL(path).toString())
1211
}
13-
return builder.build()
12+
return {
13+
library: builder.toLibrary(),
14+
worldFactory: builder.toWorldFactory(),
15+
}
1416
}

‎src/runner/state.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { SupportCodeBuilder } from '../core/SupportCodeBuilder.js'
2-
import { makeId } from '../makeId.js'
1+
import { ExtendedSupportCodeBuilder } from './ExtendedSupportCodeBuilder.js'
32
import { DiagnosticMessagesCollector, NoopMessagesCollector } from './MessagesCollector.js'
43

5-
export const builder = new SupportCodeBuilder(makeId)
4+
export const builder = new ExtendedSupportCodeBuilder()
65

76
/*
87
If no reporter is listening for messages, we provide

‎src/types.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { TestContext } from 'node:test'
33

44
import { Promisable } from 'type-fest'
55

6+
/**
7+
* An object for sharing state between test steps.
8+
* @public
9+
*/
10+
export type World = any // eslint-disable-line @typescript-eslint/no-explicit-any
11+
612
/**
713
* Options for {@link TestCaseContext.attach}
814
* @public
@@ -78,8 +84,7 @@ export type TestCaseContext = {
7884
* An object scoped only to this test case, that can be used to share state between
7985
* test steps.
8086
*/
81-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
82-
world: any
87+
world: World
8388
}
8489

8590
/**
@@ -102,7 +107,7 @@ export type ParameterTypeOptions = {
102107
* @remarks
103108
* If not provided, the raw matched value(s) will be passed to the step function.
104109
*/
105-
transformer?: (...match: string[]) => unknown
110+
transformer?: (this: World, ...match: string[]) => unknown
106111
/**
107112
* Whether this parameter type should be used when suggesting snippets for missing step
108113
* definitions.
@@ -140,7 +145,7 @@ export type HookOptions = {
140145
* Can optionally return a promise, which will duly be awaited. The actual returned/resolved value
141146
* is not read.
142147
*/
143-
export type HookFunction = (context: TestCaseContext) => Promisable<void>
148+
export type HookFunction = (this: World, context: TestCaseContext) => Promisable<void>
144149

145150
/**
146151
* A function to be executed as a step.
@@ -150,4 +155,4 @@ export type HookFunction = (context: TestCaseContext) => Promisable<void>
150155
* is not read.
151156
*/
152157
// eslint-disable-next-line @typescript-eslint/no-explicit-any
153-
export type StepFunction = (context: TestCaseContext, ...args: any) => Promisable<void>
158+
export type StepFunction = (this: World, context: TestCaseContext, ...args: any) => Promisable<void>

‎test/integration/world.spec.ts

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { stripVTControlCharacters } from 'node:util'
2+
import { expect } from 'chai'
3+
import { makeTestHarness } from '../utils.js'
4+
5+
describe('World', () => {
6+
it('shares state between steps but not between test cases', async () => {
7+
const harness = await makeTestHarness()
8+
await harness.writeFile(
9+
'features/first.feature',
10+
`Feature:
11+
Scenario:
12+
Given a step
13+
And another step
14+
Scenario:
15+
Given a step
16+
And another step
17+
`
18+
)
19+
await harness.writeFile(
20+
'features/steps.js',
21+
`import assert from 'node:assert'
22+
import { Given } from '@cucumber/node'
23+
Given('a step', (t) => {
24+
assert.strictEqual(t.world.foo, undefined)
25+
t.world.foo = 'bar'
26+
})
27+
Given('another step', (t) => {
28+
assert.strictEqual(t.world.foo, 'bar')
29+
})
30+
`
31+
)
32+
const [output] = await harness.run('spec')
33+
const sanitised = stripVTControlCharacters(output.trim())
34+
expect(sanitised).to.include('ℹ pass 6')
35+
})
36+
37+
it('uses the world as `this` for user code functions', async () => {
38+
const harness = await makeTestHarness()
39+
await harness.writeFile(
40+
'features/first.feature',
41+
`Feature:
42+
Scenario:
43+
Given a step
44+
`
45+
)
46+
await harness.writeFile(
47+
'features/steps.js',
48+
`import assert from 'node:assert'
49+
import { After, Before, Given, ParameterType } from '@cucumber/node'
50+
Before(function(t) {
51+
assert.strictEqual(this, t.world)
52+
this.foo = 'bar'
53+
})
54+
ParameterType({
55+
name: 'thing',
56+
regexp: /[a-z]+/,
57+
transformer(thing) {
58+
assert.strictEqual(this.foo, 'bar')
59+
return thing
60+
},
61+
})
62+
Given('a {thing}', function(thing) {
63+
assert.strictEqual(this.foo, 'bar')
64+
})
65+
After(function() {
66+
assert.strictEqual(this.foo, 'bar')
67+
})
68+
`
69+
)
70+
const [output] = await harness.run('spec')
71+
const sanitised = stripVTControlCharacters(output.trim())
72+
expect(sanitised).to.include('ℹ pass 4')
73+
})
74+
75+
it('uses custom world creator and destroyer', async () => {
76+
const harness = await makeTestHarness()
77+
await harness.writeFile(
78+
'features/first.feature',
79+
`Feature:
80+
Scenario:
81+
Given a step
82+
`
83+
)
84+
await harness.writeFile(
85+
'features/steps.js',
86+
`import { Given } from '@cucumber/node'
87+
Given('a step', () => {})
88+
`
89+
)
90+
await harness.writeFile(
91+
'features/support.js',
92+
`import { WorldCreator } from '@cucumber/node'
93+
WorldCreator(async () => {
94+
console.log('Ran custom world creator!')
95+
return {}
96+
}, async (world) => {
97+
console.log('Ran custom world destroyer!')
98+
})
99+
`
100+
)
101+
const [output] = await harness.run('spec')
102+
const sanitised = stripVTControlCharacters(output.trim())
103+
expect(sanitised).to.include('Ran custom world creator!')
104+
expect(sanitised).to.include('Ran custom world destroyer!')
105+
})
106+
})

0 commit comments

Comments
 (0)
Please sign in to comment.