Skip to content

Commit cc6284f

Browse files
authored
Merge pull request #13 from probitas-test/fix/timeout
Fix scenario timeout error handling and add scenario-level timeout configuration
2 parents 70bb0a3 + b4675ef commit cc6284f

File tree

3 files changed

+82
-61
lines changed

3 files changed

+82
-61
lines changed

packages/probitas-builder/scenario_builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ class ScenarioBuilderState<
161161
const definition: ScenarioDefinition = Object.freeze({
162162
name: this.#name,
163163
tags: [...(this.#scenarioOptions.tags ?? [])],
164+
timeout: this.#scenarioOptions.timeout,
164165
steps: [...this.#steps],
165166
origin,
166167
});

packages/probitas-core/types/scenario.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { StepDefinition, StepMetadata, StepOptions } from "./step.ts";
1010
* ```ts
1111
* const options: ScenarioOptions = {
1212
* tags: ["api", "integration", "slow"],
13+
* timeout: 30000,
1314
* stepOptions: {
1415
* timeout: 60000,
1516
* retry: { maxAttempts: 2, backoff: "linear" }
@@ -32,6 +33,30 @@ export interface ScenarioOptions {
3233
*/
3334
readonly tags?: readonly string[];
3435

36+
/**
37+
* Maximum time in milliseconds for the entire scenario to complete.
38+
*
39+
* When specified, this timeout applies to the total execution time of all
40+
* steps in the scenario. If the scenario exceeds this limit, a
41+
* {@linkcode ScenarioTimeoutError} is thrown.
42+
*
43+
* Priority: scenario timeout > RunOptions timeout
44+
*
45+
* @default undefined (uses RunOptions timeout if set, otherwise no timeout)
46+
*
47+
* @example
48+
* ```ts
49+
* import { scenario } from "@probitas/builder";
50+
*
51+
* scenario("Quick API Test", { timeout: 5000 })
52+
* .step("Call API", async () => {
53+
* // This step and all others must complete within 5 seconds total
54+
* })
55+
* .build();
56+
* ```
57+
*/
58+
readonly timeout?: number;
59+
3560
/**
3661
* Default options applied to all steps in this scenario.
3762
*
@@ -106,6 +131,14 @@ export interface ScenarioDefinition {
106131
*/
107132
readonly tags: readonly string[];
108133

134+
/**
135+
* Maximum time in milliseconds for the entire scenario to complete.
136+
*
137+
* When set, overrides the RunOptions timeout for this specific scenario.
138+
* When timeout occurs, a {@linkcode ScenarioTimeoutError} is thrown.
139+
*/
140+
readonly timeout?: number;
141+
109142
/** Ordered sequence of entries (resources → setups → steps) */
110143
readonly steps: readonly StepDefinition[];
111144

@@ -147,6 +180,13 @@ export interface ScenarioMetadata {
147180
*/
148181
readonly tags: readonly string[];
149182

183+
/**
184+
* Maximum time in milliseconds for the entire scenario to complete.
185+
*
186+
* When set, overrides the RunOptions timeout for this specific scenario.
187+
*/
188+
readonly timeout?: number;
189+
150190
/** Entry metadata (functions omitted for serialization) */
151191
readonly steps: readonly StepMetadata[];
152192

packages/probitas-runner/runner.ts

Lines changed: 41 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { chunk } from "@std/collections/chunk";
2-
import { deadline } from "@std/async/deadline";
32
import type { ScenarioDefinition, StepOptions } from "@probitas/core";
43
import type {
54
Reporter,
@@ -11,7 +10,7 @@ import { ScenarioRunner } from "./scenario_runner.ts";
1110
import { toScenarioMetadata } from "./metadata.ts";
1211
import { timeit } from "./utils/timeit.ts";
1312
import { mergeSignals } from "./utils/signal.ts";
14-
import { ScenarioTimeoutError } from "./errors.ts";
13+
import { ScenarioTimeoutError, StepTimeoutError } from "./errors.ts";
1514

1615
/**
1716
* Top-level test runner that orchestrates execution of multiple scenarios.
@@ -116,64 +115,40 @@ export class Runner {
116115
timeout: number,
117116
signal?: AbortSignal,
118117
): 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-
);
118+
const timeoutSignal = mergeSignals(
119+
signal,
120+
AbortSignal.timeout(timeout),
121+
);
122+
123+
const result = await timeit(() =>
124+
scenarioRunner.run(scenario, { signal: timeoutSignal })
125+
);
126+
127+
// Handle timeit result
128+
if (result.status === "failed") {
129+
// timeit itself failed (this should be rare)
130+
throw result.error;
131+
}
131132

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;
133+
const scenarioResult = result.value;
134+
135+
// Check if scenario failed due to timeout
136+
if (
137+
scenarioResult.status === "failed" &&
138+
isTimeoutError(scenarioResult.error)
139+
) {
140+
return {
141+
...scenarioResult,
142+
error: new ScenarioTimeoutError(
143+
scenario.name,
144+
timeout,
145+
result.duration,
146+
{ cause: scenarioResult.error },
147+
),
148+
};
176149
}
150+
151+
return scenarioResult;
177152
}
178153

179154
async #run(
@@ -198,11 +173,13 @@ export class Runner {
198173
signal?.throwIfAborted();
199174

200175
// Execute scenario with optional timeout
201-
const scenarioResult = timeout > 0
176+
// Priority: scenario timeout > RunOptions timeout
177+
const effectiveTimeout = scenario.timeout ?? timeout;
178+
const scenarioResult = effectiveTimeout > 0
202179
? await this.#runWithTimeout(
203180
scenarioRunner,
204181
scenario,
205-
timeout,
182+
effectiveTimeout,
206183
signal,
207184
)
208185
: await scenarioRunner.run(scenario, { signal });
@@ -221,5 +198,8 @@ export class Runner {
221198
}
222199

223200
function isTimeoutError(error: unknown): boolean {
224-
return error instanceof DOMException && error.name === "TimeoutError";
201+
return (
202+
(error instanceof DOMException && error.name === "TimeoutError") ||
203+
error instanceof StepTimeoutError
204+
);
225205
}

0 commit comments

Comments
 (0)