@@ -47,15 +47,27 @@ interface StepDefinitionMatch {
4747 parameters : any [ ] ;
4848}
4949
50+ const stackRegex = / \. f e a t u r e (?: \. m d ) ? : \d + : \d + /
5051export function formatStack ( text :string , line :string ) {
52+ if ( ! text . match ( stackRegex ) ) return text
5153 let stack = text . split ( '\n' )
52- while ( ! stack [ 0 ] ?. match ( / \. f e a t u r e (?: \. m d ) ? : \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
136154export 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
148167export 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
0 commit comments