11import { chunk } from "@std/collections/chunk" ;
2+ import { deadline } from "@std/async/deadline" ;
23import type { ScenarioDefinition , StepOptions } from "@probitas/core" ;
34import type {
45 Reporter ,
@@ -9,6 +10,8 @@ import type {
910import { ScenarioRunner } from "./scenario_runner.ts" ;
1011import { toScenarioMetadata } from "./metadata.ts" ;
1112import { timeit } from "./utils/timeit.ts" ;
13+ import { mergeSignals } from "./utils/signal.ts" ;
14+ import { ScenarioTimeoutError } from "./errors.ts" ;
1215
1316/**
1417 * Top-level test runner that orchestrates execution of multiple scenarios.
@@ -73,6 +76,7 @@ export class Runner {
7376 // Execute scenarios
7477 const maxConcurrency = options ?. maxConcurrency ?? 0 ;
7578 const maxFailures = options ?. maxFailures ?? 0 ;
79+ const timeout = options ?. timeout ?? 0 ;
7680
7781 const scenarioResults : ScenarioResult [ ] = [ ] ;
7882 const result = await timeit ( ( ) =>
@@ -81,6 +85,7 @@ export class Runner {
8185 scenarioResults ,
8286 maxConcurrency ,
8387 maxFailures ,
88+ timeout ,
8489 signal ,
8590 options ?. stepOptions ,
8691 )
@@ -105,11 +110,78 @@ export class Runner {
105110 return runResult ;
106111 }
107112
113+ async #runWithTimeout(
114+ scenarioRunner : ScenarioRunner ,
115+ scenario : ScenarioDefinition ,
116+ timeout : number ,
117+ signal ?: AbortSignal ,
118+ ) : Promise < ScenarioResult > {
119+ try {
120+ const timeoutSignal = mergeSignals (
121+ signal ,
122+ AbortSignal . timeout ( timeout ) ,
123+ ) ;
124+ const result = await timeit ( ( ) =>
125+ deadline (
126+ scenarioRunner . run ( scenario , { signal : timeoutSignal } ) ,
127+ timeout ,
128+ { signal : timeoutSignal } ,
129+ )
130+ ) ;
131+
132+ // Handle successful execution
133+ if ( result . status === "passed" ) {
134+ let scenarioResult = result . value ;
135+
136+ // Handle timeout errors from within scenario
137+ if (
138+ scenarioResult . status === "failed" &&
139+ isTimeoutError ( scenarioResult . error )
140+ ) {
141+ const timeoutError = new ScenarioTimeoutError (
142+ scenario . name ,
143+ timeout ,
144+ result . duration ,
145+ { cause : scenarioResult . error } ,
146+ ) ;
147+ scenarioResult = {
148+ ...scenarioResult ,
149+ error : timeoutError ,
150+ } ;
151+ }
152+
153+ return scenarioResult ;
154+ } else {
155+ // timeit itself failed (this should be rare)
156+ throw result . error ;
157+ }
158+ } catch ( error ) {
159+ // Catch timeout errors thrown by deadline
160+ if ( isTimeoutError ( error ) ) {
161+ const metadata = toScenarioMetadata ( scenario ) ;
162+ return {
163+ status : "failed" ,
164+ duration : timeout ,
165+ metadata,
166+ steps : [ ] ,
167+ error : new ScenarioTimeoutError (
168+ scenario . name ,
169+ timeout ,
170+ timeout ,
171+ { cause : error } ,
172+ ) ,
173+ } ;
174+ }
175+ throw error ;
176+ }
177+ }
178+
108179 async #run(
109180 scenarios : readonly ScenarioDefinition [ ] ,
110181 scenarioResults : ScenarioResult [ ] ,
111182 maxConcurrency : number ,
112183 maxFailures : number ,
184+ timeout : number ,
113185 signal ?: AbortSignal ,
114186 stepOptions ?: StepOptions ,
115187 ) : Promise < void > {
@@ -124,10 +196,17 @@ export class Runner {
124196 await Promise . all (
125197 batch . map ( async ( scenario : ScenarioDefinition ) => {
126198 signal ?. throwIfAborted ( ) ;
127- const scenarioResult = await scenarioRunner . run (
128- scenario ,
129- { signal } ,
130- ) ;
199+
200+ // Execute scenario with optional timeout
201+ const scenarioResult = timeout > 0
202+ ? await this . #runWithTimeout(
203+ scenarioRunner ,
204+ scenario ,
205+ timeout ,
206+ signal ,
207+ )
208+ : await scenarioRunner . run ( scenario , { signal } ) ;
209+
131210 scenarioResults . push ( scenarioResult ) ;
132211 if ( scenarioResult . status === "failed" ) {
133212 failureCount ++ ;
@@ -140,3 +219,7 @@ export class Runner {
140219 }
141220 }
142221}
222+
223+ function isTimeoutError ( error : unknown ) : boolean {
224+ return error instanceof DOMException && error . name === "TimeoutError" ;
225+ }
0 commit comments