Skip to content

Commit c023b29

Browse files
authored
Merge pull request #11 from probitas-test/fix/timeout-error
Improve timeout error messages with structured context
2 parents 8ce297c + 0655e4b commit c023b29

File tree

8 files changed

+545
-8
lines changed

8 files changed

+545
-8
lines changed

packages/probitas-runner/errors.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Error thrown when a step execution times out.
3+
*
4+
* Contains structured information about the timeout including
5+
* the step name, timeout duration, retry context, and total elapsed time.
6+
*/
7+
export class StepTimeoutError extends Error {
8+
override readonly name = "StepTimeoutError";
9+
readonly stepName: string;
10+
readonly timeoutMs: number;
11+
readonly attemptNumber: number;
12+
readonly elapsedMs: number;
13+
14+
constructor(
15+
stepName: string,
16+
timeoutMs: number,
17+
attemptNumber: number,
18+
elapsedMs: number,
19+
options?: ErrorOptions,
20+
) {
21+
const attemptMsg = attemptNumber > 1 ? ` (attempt ${attemptNumber})` : "";
22+
const elapsedMsg = elapsedMs !== timeoutMs
23+
? `, total elapsed: ${elapsedMs}ms`
24+
: "";
25+
super(
26+
`Step "${stepName}" timed out after ${timeoutMs}ms${attemptMsg}${elapsedMsg}`,
27+
options,
28+
);
29+
this.stepName = stepName;
30+
this.timeoutMs = timeoutMs;
31+
this.attemptNumber = attemptNumber;
32+
this.elapsedMs = elapsedMs;
33+
}
34+
}
35+
36+
/**
37+
* Error thrown when a scenario execution times out.
38+
*
39+
* Contains structured information about the timeout including
40+
* the scenario name, timeout duration, and total elapsed time.
41+
*/
42+
export class ScenarioTimeoutError extends Error {
43+
override readonly name = "ScenarioTimeoutError";
44+
readonly scenarioName: string;
45+
readonly timeoutMs: number;
46+
readonly elapsedMs: number;
47+
48+
constructor(
49+
scenarioName: string,
50+
timeoutMs: number,
51+
elapsedMs: number,
52+
options?: ErrorOptions,
53+
) {
54+
const elapsedMsg = elapsedMs !== timeoutMs
55+
? `, total elapsed: ${elapsedMs}ms`
56+
: "";
57+
super(
58+
`Scenario "${scenarioName}" timed out after ${timeoutMs}ms${elapsedMsg}`,
59+
options,
60+
);
61+
this.scenarioName = scenarioName;
62+
this.timeoutMs = timeoutMs;
63+
this.elapsedMs = elapsedMs;
64+
}
65+
}

packages/probitas-runner/mod.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
*
4242
* ## Error Types
4343
*
44-
* - {@linkcode TimeoutError} - Thrown when a step exceeds its timeout
45-
* - {@linkcode RetryExhaustedError} - Thrown when all retry attempts fail
44+
* - {@linkcode StepTimeoutError} - Thrown when a step exceeds its timeout
45+
* - {@linkcode ScenarioTimeoutError} - Thrown when a scenario exceeds its timeout
4646
*
4747
* @example Basic usage
4848
* ```ts
@@ -134,3 +134,4 @@ export type * from "./types.ts";
134134
export { Skip } from "./skip.ts";
135135
export { Runner } from "./runner.ts";
136136
export { toScenarioMetadata, toStepMetadata } from "./metadata.ts";
137+
export { ScenarioTimeoutError, StepTimeoutError } from "./errors.ts";

packages/probitas-runner/runner.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { chunk } from "@std/collections/chunk";
2+
import { deadline } from "@std/async/deadline";
23
import type { ScenarioDefinition, StepOptions } from "@probitas/core";
34
import type {
45
Reporter,
@@ -9,6 +10,8 @@ import type {
910
import { ScenarioRunner } from "./scenario_runner.ts";
1011
import { toScenarioMetadata } from "./metadata.ts";
1112
import { 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

Comments
 (0)