Skip to content

Commit 16c5423

Browse files
authored
Merge pull request #19 from probitas-test/fix/error
Improve timeout error handling and maxFailures behavior
2 parents 366264f + a767fe9 commit 16c5423

File tree

10 files changed

+906
-157
lines changed

10 files changed

+906
-157
lines changed

packages/probitas-runner/errors.ts

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,35 +31,128 @@ export class StepTimeoutError extends Error {
3131
this.attemptNumber = attemptNumber;
3232
this.elapsedMs = elapsedMs;
3333
}
34+
35+
/**
36+
* Creates an AbortSignal that aborts with StepTimeoutError after the specified timeout.
37+
*
38+
* @param timeout - Timeout duration in milliseconds
39+
* @param params - Step information for the error
40+
* @returns AbortSignal with Disposable interface for cleanup
41+
*/
42+
static timeoutSignal(
43+
timeout: number,
44+
params: {
45+
stepName: string;
46+
attemptNumber: number;
47+
},
48+
): AbortSignal & Disposable {
49+
const controller = new AbortController();
50+
const startTime = performance.now();
51+
const timeoutId = setTimeout(() => {
52+
const elapsedMs = Math.round(performance.now() - startTime);
53+
const error = new StepTimeoutError(
54+
params.stepName,
55+
timeout,
56+
params.attemptNumber,
57+
elapsedMs,
58+
);
59+
controller.abort(error);
60+
}, timeout);
61+
62+
// Cleanup timeout when signal is aborted by other means
63+
controller.signal.addEventListener("abort", () => {
64+
clearTimeout(timeoutId);
65+
}, { once: true });
66+
67+
// Add Disposable interface for manual cleanup
68+
return Object.assign(controller.signal, {
69+
[Symbol.dispose]: () => {
70+
clearTimeout(timeoutId);
71+
},
72+
});
73+
}
3474
}
3575

3676
/**
3777
* Error thrown when a scenario execution times out.
3878
*
3979
* Contains structured information about the timeout including
40-
* the scenario name, timeout duration, and total elapsed time.
80+
* the scenario name, timeout duration, total elapsed time,
81+
* and optionally the step being executed when timeout occurred.
4182
*/
4283
export class ScenarioTimeoutError extends Error {
4384
override readonly name = "ScenarioTimeoutError";
4485
readonly scenarioName: string;
4586
readonly timeoutMs: number;
4687
readonly elapsedMs: number;
88+
readonly currentStepName?: string;
89+
readonly currentStepIndex?: number;
4790

4891
constructor(
4992
scenarioName: string,
5093
timeoutMs: number,
5194
elapsedMs: number,
52-
options?: ErrorOptions,
95+
options?: ErrorOptions & {
96+
currentStepName?: string;
97+
currentStepIndex?: number;
98+
},
5399
) {
100+
const stepMsg = options?.currentStepName
101+
? ` while executing step "${options.currentStepName}"${
102+
options.currentStepIndex !== undefined
103+
? ` (step ${options.currentStepIndex + 1})`
104+
: ""
105+
}`
106+
: "";
54107
const elapsedMsg = elapsedMs !== timeoutMs
55108
? `, total elapsed: ${elapsedMs}ms`
56109
: "";
57110
super(
58-
`Scenario "${scenarioName}" timed out after ${timeoutMs}ms${elapsedMsg}`,
111+
`Scenario "${scenarioName}" timed out after ${timeoutMs}ms${stepMsg}${elapsedMsg}`,
59112
options,
60113
);
61114
this.scenarioName = scenarioName;
62115
this.timeoutMs = timeoutMs;
63116
this.elapsedMs = elapsedMs;
117+
this.currentStepName = options?.currentStepName;
118+
this.currentStepIndex = options?.currentStepIndex;
119+
}
120+
121+
/**
122+
* Creates an AbortSignal that aborts with ScenarioTimeoutError after the specified timeout.
123+
*
124+
* @param timeout - Timeout duration in milliseconds
125+
* @param params - Scenario information for the error
126+
* @returns AbortSignal with Disposable interface for cleanup
127+
*/
128+
static timeoutSignal(
129+
timeout: number,
130+
params: {
131+
scenarioName: string;
132+
},
133+
): AbortSignal & Disposable {
134+
const controller = new AbortController();
135+
const startTime = performance.now();
136+
const timeoutId = setTimeout(() => {
137+
const elapsedMs = Math.round(performance.now() - startTime);
138+
const error = new ScenarioTimeoutError(
139+
params.scenarioName,
140+
timeout,
141+
elapsedMs,
142+
);
143+
controller.abort(error);
144+
}, timeout);
145+
146+
// Cleanup timeout when signal is aborted by other means
147+
controller.signal.addEventListener("abort", () => {
148+
clearTimeout(timeoutId);
149+
}, { once: true });
150+
151+
// Add Disposable interface for manual cleanup
152+
return Object.assign(controller.signal, {
153+
[Symbol.dispose]: () => {
154+
clearTimeout(timeoutId);
155+
},
156+
});
64157
}
65158
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { expect } from "@std/expect";
2+
import { delay } from "@std/async/delay";
3+
import { ScenarioTimeoutError, StepTimeoutError } from "./errors.ts";
4+
5+
Deno.test("ScenarioTimeoutError - constructor creates error with correct properties", () => {
6+
const error = new ScenarioTimeoutError("test-scenario", 5000, 5100);
7+
8+
expect(error.name).toBe("ScenarioTimeoutError");
9+
expect(error.scenarioName).toBe("test-scenario");
10+
expect(error.timeoutMs).toBe(5000);
11+
expect(error.elapsedMs).toBe(5100);
12+
expect(error.currentStepName).toBeUndefined();
13+
expect(error.currentStepIndex).toBeUndefined();
14+
expect(error.message).toBe(
15+
'Scenario "test-scenario" timed out after 5000ms, total elapsed: 5100ms',
16+
);
17+
});
18+
19+
Deno.test("ScenarioTimeoutError - constructor with step information", () => {
20+
const error = new ScenarioTimeoutError("test-scenario", 5000, 5100, {
21+
currentStepName: "slow-step",
22+
currentStepIndex: 2,
23+
});
24+
25+
expect(error.currentStepName).toBe("slow-step");
26+
expect(error.currentStepIndex).toBe(2);
27+
expect(error.message).toBe(
28+
'Scenario "test-scenario" timed out after 5000ms while executing step "slow-step" (step 3), total elapsed: 5100ms',
29+
);
30+
});
31+
32+
Deno.test("ScenarioTimeoutError - constructor with only step name", () => {
33+
const error = new ScenarioTimeoutError("test-scenario", 5000, 5000, {
34+
currentStepName: "slow-step",
35+
});
36+
37+
expect(error.currentStepName).toBe("slow-step");
38+
expect(error.currentStepIndex).toBeUndefined();
39+
expect(error.message).toBe(
40+
'Scenario "test-scenario" timed out after 5000ms while executing step "slow-step"',
41+
);
42+
});
43+
44+
Deno.test("ScenarioTimeoutError.timeoutSignal - creates signal that aborts after timeout", async () => {
45+
using signal = ScenarioTimeoutError.timeoutSignal(50, {
46+
scenarioName: "test-scenario",
47+
});
48+
49+
expect(signal.aborted).toBe(false);
50+
51+
await delay(100);
52+
53+
expect(signal.aborted).toBe(true);
54+
expect(signal.reason).toBeInstanceOf(ScenarioTimeoutError);
55+
56+
const error = signal.reason as ScenarioTimeoutError;
57+
expect(error.scenarioName).toBe("test-scenario");
58+
expect(error.timeoutMs).toBe(50);
59+
expect(error.elapsedMs).toBeGreaterThanOrEqual(50);
60+
expect(error.elapsedMs).toBeLessThan(200);
61+
});
62+
63+
Deno.test("ScenarioTimeoutError.timeoutSignal - cleanup via dispose", async () => {
64+
const signal = ScenarioTimeoutError.timeoutSignal(1000, {
65+
scenarioName: "test-scenario",
66+
});
67+
68+
expect(signal.aborted).toBe(false);
69+
70+
// Dispose immediately to cleanup timer
71+
signal[Symbol.dispose]();
72+
73+
await delay(50);
74+
75+
// Should not abort because timer was cleaned up
76+
expect(signal.aborted).toBe(false);
77+
});
78+
79+
Deno.test("ScenarioTimeoutError.timeoutSignal - cleanup via using declaration", async () => {
80+
{
81+
using signal = ScenarioTimeoutError.timeoutSignal(1000, {
82+
scenarioName: "test-scenario",
83+
});
84+
85+
expect(signal.aborted).toBe(false);
86+
} // signal is disposed here
87+
88+
await delay(50);
89+
90+
// Timer should be cleaned up, no leak
91+
});
92+
93+
Deno.test("StepTimeoutError - constructor creates error with correct properties", () => {
94+
const error = new StepTimeoutError("test-step", 3000, 2, 3200);
95+
96+
expect(error.name).toBe("StepTimeoutError");
97+
expect(error.stepName).toBe("test-step");
98+
expect(error.timeoutMs).toBe(3000);
99+
expect(error.attemptNumber).toBe(2);
100+
expect(error.elapsedMs).toBe(3200);
101+
expect(error.message).toBe(
102+
'Step "test-step" timed out after 3000ms (attempt 2), total elapsed: 3200ms',
103+
);
104+
});
105+
106+
Deno.test("StepTimeoutError - constructor for first attempt", () => {
107+
const error = new StepTimeoutError("test-step", 3000, 1, 3000);
108+
109+
expect(error.attemptNumber).toBe(1);
110+
expect(error.message).toBe(
111+
'Step "test-step" timed out after 3000ms',
112+
);
113+
});
114+
115+
Deno.test("StepTimeoutError.timeoutSignal - creates signal that aborts after timeout", async () => {
116+
using signal = StepTimeoutError.timeoutSignal(50, {
117+
stepName: "test-step",
118+
attemptNumber: 1,
119+
});
120+
121+
expect(signal.aborted).toBe(false);
122+
123+
await delay(100);
124+
125+
expect(signal.aborted).toBe(true);
126+
expect(signal.reason).toBeInstanceOf(StepTimeoutError);
127+
128+
const error = signal.reason as StepTimeoutError;
129+
expect(error.stepName).toBe("test-step");
130+
expect(error.timeoutMs).toBe(50);
131+
expect(error.attemptNumber).toBe(1);
132+
expect(error.elapsedMs).toBeGreaterThanOrEqual(50);
133+
expect(error.elapsedMs).toBeLessThan(200);
134+
});
135+
136+
Deno.test("StepTimeoutError.timeoutSignal - cleanup via dispose", async () => {
137+
const signal = StepTimeoutError.timeoutSignal(1000, {
138+
stepName: "test-step",
139+
attemptNumber: 1,
140+
});
141+
142+
expect(signal.aborted).toBe(false);
143+
144+
// Dispose immediately to cleanup timer
145+
signal[Symbol.dispose]();
146+
147+
await delay(50);
148+
149+
// Should not abort because timer was cleaned up
150+
expect(signal.aborted).toBe(false);
151+
});
152+
153+
Deno.test("StepTimeoutError.timeoutSignal - measures elapsed time correctly", async () => {
154+
using signal = StepTimeoutError.timeoutSignal(50, {
155+
stepName: "test-step",
156+
attemptNumber: 1,
157+
});
158+
159+
await delay(100);
160+
161+
const error = signal.reason as StepTimeoutError;
162+
// Elapsed time should be approximately 50ms (actual timeout duration)
163+
// with some tolerance for timer precision
164+
expect(error.elapsedMs).toBeGreaterThanOrEqual(50);
165+
expect(error.elapsedMs).toBeLessThan(150);
166+
});

0 commit comments

Comments
 (0)