Skip to content

Commit 1162c84

Browse files
committed
feat: add stepTimeout option for quickpickle
1 parent 7ad035c commit 1162c84

File tree

7 files changed

+149
-31
lines changed

7 files changed

+149
-31
lines changed

.changeset/rude-houses-drop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"quickpickle": minor
3+
---
4+
5+
feat: added stepTimeout option for quickpickle

packages/main/README.md

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,12 @@ Also add the configuration to get the feature files, step definitions, world fil
170170

171171
```ts
172172
// vite.config.ts
173-
import { quickpickle } from 'quickpickle';
173+
import { quickpickle, type QuickPickleConfigSetting } from './src';
174+
const qpOptions:QuickPickleConfigSetting = {}
174175

175176
export default {
176177
plugins: [
177-
quickpickle() // <-- Add the quickpickle plugin
178+
quickpickle(qpOptions) // <-- Add the quickpickle plugin
178179
],
179180
test: {
180181
include : [
@@ -193,30 +194,100 @@ different browser environments, different world constructors, etc.
193194
```ts
194195
// vitest.workspace.ts
195196
import { defineWorkspace } from 'vitest/config';
197+
import type { QuickPickleConfigSetting } from 'quickpickle';
198+
199+
const options1:QuickPickleConfigSetting = {} // some settings for e.g. e2e tests
200+
const options2:QuickPickleConfigSetting = {} // other settings for e.g. component tests
196201

197202
export default defineWorkspace([
198203
{ // configuration for feature files testing the application
199204
extends: './vite.config.ts',
200205
test: {
201-
include : [ 'tests/*.feature' ],
206+
name: 'e2e',
207+
include: [ 'tests/*.feature' ],
202208
setupFiles: [ 'tests/tests.steps.ts' ],
209+
quickpickle: options1,
203210
},
204211
},
205212
{ // a second configuration for feature files testing components
206213
extends: './vite.config.ts',
207214
test: {
208-
include : [ 'src/lib/*.feature' ],
215+
name: 'components',
216+
include: [ 'src/lib/*.feature' ],
209217
setupFiles: [ 'tests/components.steps.ts' ],
218+
quickpickle: options2,
210219
},
211220
},
212221
{ // configuration for unit tests
213222
test: {
214-
include : [ 'tests/*.test.ts' ],
223+
name: 'unit',
224+
include: [ 'tests/*.test.ts' ],
215225
}
216226
}
217227
])
218228
```
219229

230+
The QuickPickle configuration options can be seen
231+
in the [quickpickle defaultConfig object](https://github.com/dnotes/quickpickle/blob/main/packages/main/src/index.ts#L159):
232+
233+
```ts
234+
export const defaultConfig: QuickPickleConfig = {
235+
236+
/**
237+
* The root directory for the tests to run, from vite or vitest config
238+
*/
239+
root: '',
240+
241+
/**
242+
* The maximum time in ms to wait for a step to complete.
243+
*/
244+
stepTimeout: 3000,
245+
246+
/**
247+
* Tags to mark as todo, using Vitest's `test.todo` implementation.
248+
*/
249+
todoTags: ['@todo','@wip'],
250+
251+
/**
252+
* Tags to skip, using Vitest's `test.skip` implementation.
253+
*/
254+
skipTags: ['@skip'],
255+
256+
/**
257+
* Tags to mark as failing, using Vitest's `test.failing` implementation.
258+
*/
259+
failTags: ['@fails', '@failing'],
260+
261+
/**
262+
* Tags to mark as soft failing, allowing further steps to run until the end of the scenario.
263+
*/
264+
softFailTags: ['@soft', '@softfail'],
265+
266+
/**
267+
* Tags to run in parallel, using Vitest's `test.concurrent` implementation.
268+
*/
269+
concurrentTags: ['@concurrent'],
270+
271+
/**
272+
* Tags to run sequentially, using Vitest's `test.sequential` implementation.
273+
*/
274+
sequentialTags: ['@sequential'],
275+
276+
/**
277+
* Explode tags into multiple tests, e.g. for different browsers.
278+
*/
279+
explodeTags: [],
280+
281+
/**
282+
* The config for the World class. Must be serializable with JSON.stringify.
283+
* Not used by the default World class, but may be used by plugins or custom
284+
* implementations, like @quickpickle/playwright.
285+
*/
286+
worldConfig: {},
287+
288+
}
289+
```
290+
220291
### Step definition file
221292

222293
You'll always need a step definition file, to set up the step defintions and potentially the world
@@ -423,7 +494,9 @@ come to notice:
423494
isComplete: boolean // (read only) whether the Scenario is on the last step
424495
config: QuickPickleConfig // (read only) configuration for QuickPickle
425496
worldConfig: QuickPickleConfig['worldConfig'] // (read only) configuration for the World
426-
common: {[key: string]: any} // Common data shared across tests --- USE SPARINGLY
497+
data: {[key:string]:any} // Data limited to the current Scenario
498+
common: Common // Common data shared across ALL tests in one Feature file --- USE SPARINGLY
499+
projectRoot: string // (read only) the project root directory
427500
init: () => Promise<void> // function called by QuickPickle when the world is created
428501
tagsMatch(tags: string[]): string[]|null // function to check if the Scenario tags match the given tags
429502
}

packages/main/gherkin-example/example.feature.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/main/src/index.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,27 @@ interface StepDefinitionMatch {
4747
parameters: any[];
4848
}
4949

50+
const stackRegex = /\.feature(?:\.md)?:\d+:\d+/
5051
export function formatStack(text:string, line:string) {
52+
if (!text.match(stackRegex)) return text
5153
let stack = text.split('\n')
52-
while(!stack[0]?.match(/\.feature(?:\.md)?:\d+:\d+/)) stack.shift()
53-
if (!stack.length) return text
54+
while(!stack[0].match(stackRegex)) stack.shift()
5455
stack[0] = stack[0].replace(/:\d+:\d+$/, `:${line}:1`)
5556
return stack.join('\n')
5657
}
5758

58-
export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: string, state: any, line: number, stepIdx:number, explodeIdx?:number, data?:any): Promise<any> => {
59+
function raceTimeout<T>(work: Promise<T>, ms: number, errorMessage?: string) {
60+
let timerId: ReturnType<typeof setTimeout>;
61+
const timeoutPromise = new Promise<never>((_, reject) => {
62+
timerId = setTimeout(() => reject(new Error(errorMessage)), ms);
63+
});
64+
65+
// make sure to clearTimeout on either success *or* failure
66+
const wrapped = work.finally(() => clearTimeout(timerId));
67+
return Promise.race([wrapped, timeoutPromise]);
68+
}
69+
70+
export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: string, state: any, line: number, stepIdx:number, explodeIdx?:number, data?:any):Promise<any> => {
5971

6072
try {
6173
// Set the state info
@@ -76,31 +88,36 @@ export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: s
7688
dataType = 'docString'
7789
}
7890

79-
await applyHooks('beforeStep', state);
91+
const promise = async () => {
8092

81-
try {
82-
const stepDefinitionMatch: StepDefinitionMatch = findStepDefinitionMatch(step, { stepType, dataType });
83-
await stepDefinitionMatch.stepDefinition.f(state, ...stepDefinitionMatch.parameters, data);
84-
}
85-
catch(e:any) {
86-
// Add the Cucumber info to the error message
87-
e.message = `${step} (#${line})\n${e.message}`
93+
await applyHooks('beforeStep', state);
8894

89-
// Sort out the stack for the Feature file
90-
e.stack = formatStack(e.stack, state.info.line)
95+
try {
96+
const stepDefinitionMatch: StepDefinitionMatch = findStepDefinitionMatch(step, { stepType, dataType });
97+
await stepDefinitionMatch.stepDefinition.f(state, ...stepDefinitionMatch.parameters, data);
98+
}
99+
catch(e:any) {
100+
// Add the Cucumber info to the error message
101+
e.message = `${step} (#${line})\n${e.message}`
91102

92-
// Set the flag that this error has been added to the state
93-
e.isStepError = true
103+
// Sort out the stack for the Feature file
104+
e.stack = formatStack(e.stack, state.info.line)
94105

95-
// Add the error to the state
96-
state.info.errors.push(e)
106+
// Set the flag that this error has been added to the state
107+
e.isStepError = true
108+
109+
// Add the error to the state
110+
state.info.errors.push(e)
111+
112+
// If not in a soft fail mode, re-throw the error
113+
if (state.isComplete || !state.tagsMatch(state.config.softFailTags)) throw e
114+
}
115+
finally {
116+
await applyHooks('afterStep', state);
117+
}
97118

98-
// If not in a soft fail mode, re-throw the error
99-
if (state.isComplete || !state.tagsMatch(state.config.softFailTags)) throw e
100-
}
101-
finally {
102-
await applyHooks('afterStep', state);
103119
}
120+
await raceTimeout(promise(), state.config.stepTimeout, `Step timed out after ${state.config.stepTimeout}ms`)
104121
}
105122
catch(e:any) {
106123

@@ -119,7 +136,7 @@ export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: s
119136

120137
// The After hook is usually run in the rendered file, at the end of the rendered steps.
121138
// But, if the tests have failed, then it should run here, since the test is halted.
122-
await applyHooks('after', state)
139+
await raceTimeout(applyHooks('after', state), state.config.stepTimeout, `After hook timed out after ${state.config.stepTimeout}ms`)
123140

124141
// Otherwise throw the error
125142
throw e
@@ -131,10 +148,12 @@ export const gherkinStep = async (stepType:"Context"|"Action"|"Outcome", step: s
131148
throw error
132149
}
133150
}
151+
134152
};
135153

136154
export type QuickPickleConfigSetting<T = {[key:string]:any}> = Partial<{
137155
root?:string
156+
stepTimeout: number
138157
todoTags: string|string[]
139158
skipTags: string|string[]
140159
failTags: string|string[]
@@ -147,6 +166,7 @@ export type QuickPickleConfigSetting<T = {[key:string]:any}> = Partial<{
147166

148167
export type QuickPickleConfig<T = {[key:string]:any}> = {
149168
root: string
169+
stepTimeout: number
150170
todoTags: string[]
151171
skipTags: string[]
152172
failTags: string[]
@@ -164,6 +184,11 @@ export const defaultConfig: QuickPickleConfig = {
164184
*/
165185
root: '',
166186

187+
/**
188+
* The maximum time in ms to wait for a step to complete.
189+
*/
190+
stepTimeout: 3000,
191+
167192
/**
168193
* Tags to mark as todo, using Vitest's `test.todo` implementation.
169194
*/
@@ -204,7 +229,7 @@ export const defaultConfig: QuickPickleConfig = {
204229
* Not used by the default World class, but may be used by plugins or custom
205230
* implementations, like @quickpickle/playwright.
206231
*/
207-
worldConfig: {}
232+
worldConfig: {},
208233

209234
}
210235

packages/main/src/world.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface QuickPickleWorldInterface {
3030
worldConfig: QuickPickleConfig['worldConfig'] // (read only) configuration for the World
3131
data: {[key:string]:any} // Data limited to the current Scenario
3232
common: Common // Common data shared across ALL tests in one Feature file --- USE SPARINGLY
33+
projectRoot: string // (read only) the project root directory
3334
init: () => Promise<void> // function called by QuickPickle when the world is created
3435
tagsMatch(tags: string[]): string[]|null // function to check if the Scenario tags match the given tags
3536
}

packages/main/tests/test.feature

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,12 @@ Feature: Basic Test
262262
Given this step is undefined
263263
Then error 1 should contain "Given('this step is undefined', async function (world) {"
264264
And clear error 1
265+
266+
@soft
267+
Rule: Steps should have a timeout
268+
269+
Example: a test with a step that times out
270+
When a step takes too long
271+
Then error 1 should contain "a step takes too long"
272+
And error 1 should contain "timed out after"
273+
And clear error 1

packages/main/tests/tests.steps.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ When('I push all the numbers {updown}( again)', (world, updown:'up'|'down') => {
111111
world.numbers = world.numbers.map(n => updown === 'up' ? n+1 : n-1)
112112
})
113113

114+
// TIMEOUTS
115+
When('a step takes too long', async function (world) {
116+
await new Promise(resolve => setTimeout(resolve, 10000));
117+
});
118+
114119
// RENDERER
115120

116121
Given("the following feature( file)( is rendered):", (world, feature:DocString) => {

0 commit comments

Comments
 (0)