Skip to content

Commit 12ebe2c

Browse files
committed
fix(@probitas/runner): abort all scenarios when maxFailures is reached
Previously, when maxFailures was reached, the Runner would throw an error, leaving remaining scenarios unrecorded. This caused incomplete test results and prevented proper cleanup. Now, the Runner: - Calls controller.abort() with Skip reason when maxFailures is reached - Aborts in-progress scenarios via signal propagation (status: "skipped") - Creates skip results for unexecuted scenarios (status: "skipped") - Preserves signal.reason from external aborts (e.g., TimeoutError) This ensures all scenarios are properly recorded and reporter events are emitted consistently, providing complete test results even when stopping early due to maxFailures.
1 parent f915cfc commit 12ebe2c

File tree

2 files changed

+265
-14
lines changed

2 files changed

+265
-14
lines changed

packages/probitas-runner/runner.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { toScenarioMetadata } from "./metadata.ts";
1111
import { timeit } from "./utils/timeit.ts";
1212
import { mergeSignals } from "./utils/signal.ts";
1313
import { ScenarioTimeoutError } from "./errors.ts";
14+
import { Skip } from "./skip.ts";
1415

1516
/**
1617
* Top-level test runner that orchestrates execution of multiple scenarios.
@@ -69,7 +70,6 @@ export class Runner {
6970

7071
// Create abort controller for outer context
7172
const controller = new AbortController();
72-
const { signal } = controller;
7373
options?.signal?.addEventListener("abort", () => {
7474
// Pass the reason from external signal to internal controller
7575
controller.abort(options.signal?.reason);
@@ -88,7 +88,7 @@ export class Runner {
8888
maxConcurrency,
8989
maxFailures,
9090
timeout,
91-
signal,
91+
controller,
9292
options?.stepOptions,
9393
)
9494
);
@@ -145,20 +145,43 @@ export class Runner {
145145
maxConcurrency: number,
146146
maxFailures: number,
147147
timeout: number,
148-
signal?: AbortSignal,
148+
controller: AbortController,
149149
stepOptions?: StepOptions,
150150
): Promise<void> {
151151
// Parallel execution with concurrency control
152152
// maxConcurrency=1 means sequential execution
153153
const concurrency = maxConcurrency || scenarios.length;
154154
const scenarioRunner = new ScenarioRunner(this.reporter, stepOptions);
155+
const { signal } = controller;
155156

156157
let failureCount = 0;
158+
157159
for (const batch of chunk(scenarios, concurrency)) {
158-
signal?.throwIfAborted();
160+
// Don't throw - just skip remaining batches if aborted
161+
if (signal.aborted) {
162+
break;
163+
}
164+
159165
await Promise.all(
160166
batch.map(async (scenario: ScenarioDefinition) => {
161-
signal?.throwIfAborted();
167+
// Check if already aborted (by maxFailures or external signal)
168+
if (signal.aborted) {
169+
const skipResult: ScenarioResult = {
170+
status: "skipped",
171+
metadata: toScenarioMetadata(scenario),
172+
duration: 0,
173+
steps: [],
174+
error: signal.reason ??
175+
new Skip("Skipped due to previous failures"),
176+
};
177+
scenarioResults.push(skipResult);
178+
await this.reporter.onScenarioStart?.(skipResult.metadata);
179+
await this.reporter.onScenarioEnd?.(
180+
skipResult.metadata,
181+
skipResult,
182+
);
183+
return;
184+
}
162185

163186
// Execute scenario with optional timeout
164187
// Priority: scenario timeout > RunOptions timeout
@@ -173,14 +196,35 @@ export class Runner {
173196
: await scenarioRunner.run(scenario, { signal });
174197

175198
scenarioResults.push(scenarioResult);
199+
200+
// Check if we've reached maxFailures - abort all remaining scenarios
176201
if (scenarioResult.status === "failed") {
177202
failureCount++;
178203
if (maxFailures !== 0 && failureCount >= maxFailures) {
179-
throw scenarioResult.error;
204+
controller.abort(new Skip("Skipped due to previous failures"));
180205
}
181206
}
182207
}),
183208
);
184209
}
210+
211+
// Add skip results for any remaining scenarios that weren't executed
212+
// This handles the case where we broke out of the batch loop early
213+
const executedCount = scenarioResults.length;
214+
if (executedCount < scenarios.length) {
215+
for (let i = executedCount; i < scenarios.length; i++) {
216+
const scenario = scenarios[i];
217+
const skipResult: ScenarioResult = {
218+
status: "skipped",
219+
metadata: toScenarioMetadata(scenario),
220+
duration: 0,
221+
steps: [],
222+
error: signal.reason ?? new Skip("Skipped due to previous failures"),
223+
};
224+
scenarioResults.push(skipResult);
225+
await this.reporter.onScenarioStart?.(skipResult.metadata);
226+
await this.reporter.onScenarioEnd?.(skipResult.metadata, skipResult);
227+
}
228+
}
185229
}
186230
}

packages/probitas-runner/runner_test.ts

Lines changed: 215 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Deno.test("Runner run runs multiple scenarios (skip)", async () => {
141141
});
142142
});
143143

144-
Deno.test("Runner run runs multiple scenarios (failed)", async () => {
144+
Deno.test("Runner run runs multiple scenarios (failed) - aborts on maxFailures", async () => {
145145
using signal = createScopedSignal();
146146
const runner = new Runner(reporter);
147147
const scenarios = [
@@ -150,8 +150,8 @@ Deno.test("Runner run runs multiple scenarios (failed)", async () => {
150150
steps: [
151151
createTestStep({
152152
name: "Step 1",
153-
fn: async () => {
154-
await delay(0, { signal });
153+
fn: async (ctx) => {
154+
await delay(0, { signal: ctx.signal });
155155
return "1";
156156
},
157157
}),
@@ -162,8 +162,8 @@ Deno.test("Runner run runs multiple scenarios (failed)", async () => {
162162
steps: [
163163
createTestStep({
164164
name: "Step 1",
165-
fn: async () => {
166-
await delay(50, { signal });
165+
fn: async (ctx) => {
166+
await delay(50, { signal: ctx.signal });
167167
throw "2";
168168
},
169169
}),
@@ -174,8 +174,8 @@ Deno.test("Runner run runs multiple scenarios (failed)", async () => {
174174
steps: [
175175
createTestStep({
176176
name: "Step 1",
177-
fn: async () => {
178-
await delay(100, { signal });
177+
fn: async (ctx) => {
178+
await delay(100, { signal: ctx.signal });
179179
return "3";
180180
},
181181
}),
@@ -187,12 +187,219 @@ Deno.test("Runner run runs multiple scenarios (failed)", async () => {
187187
signal,
188188
maxFailures: 1,
189189
});
190+
191+
// When maxFailures is reached, all scenarios (running or pending) should be aborted
192+
// Scenario 1 passes (fastest, 0ms delay)
193+
// Scenario 2 fails (50ms delay, triggers maxFailures)
194+
// Scenario 3 should be aborted/skipped (100ms delay, still running when maxFailures reached)
190195
expect(summary).toMatchObject({
191196
total: 3,
192197
passed: 1,
193-
skipped: 1,
198+
skipped: 1, // Scenario 3 aborted
199+
failed: 1, // Scenario 2
200+
});
201+
});
202+
203+
Deno.test("Runner run with maxFailures skips remaining scenarios (sequential)", async () => {
204+
const runner = new Runner(reporter);
205+
const executionOrder: string[] = [];
206+
const scenarios = [
207+
createTestScenario({
208+
name: "Scenario 1",
209+
steps: [
210+
createTestStep({
211+
name: "Step 1",
212+
fn: () => {
213+
executionOrder.push("Scenario 1");
214+
return "1";
215+
},
216+
}),
217+
],
218+
}),
219+
createTestScenario({
220+
name: "Scenario 2",
221+
steps: [
222+
createTestStep({
223+
name: "Step 1",
224+
fn: () => {
225+
executionOrder.push("Scenario 2");
226+
throw new Error("Scenario 2 failed");
227+
},
228+
}),
229+
],
230+
}),
231+
createTestScenario({
232+
name: "Scenario 3",
233+
steps: [
234+
createTestStep({
235+
name: "Step 1",
236+
fn: () => {
237+
executionOrder.push("Scenario 3");
238+
return "3";
239+
},
240+
}),
241+
],
242+
}),
243+
createTestScenario({
244+
name: "Scenario 4",
245+
steps: [
246+
createTestStep({
247+
name: "Step 1",
248+
fn: () => {
249+
executionOrder.push("Scenario 4");
250+
return "4";
251+
},
252+
}),
253+
],
254+
}),
255+
];
256+
257+
const summary = await runner.run(scenarios, {
258+
maxConcurrency: 1, // Sequential execution
259+
maxFailures: 1,
260+
});
261+
262+
// Scenario 1 should pass, Scenario 2 should fail, Scenario 3 and 4 should be skipped
263+
expect(summary).toMatchObject({
264+
total: 4,
265+
passed: 1,
194266
failed: 1,
267+
skipped: 2,
268+
});
269+
270+
// Only Scenario 1 and 2 should have been executed
271+
expect(executionOrder).toEqual(["Scenario 1", "Scenario 2"]);
272+
273+
// Check scenario results
274+
const results = summary.scenarios;
275+
expect(results.length).toBe(4);
276+
expect(results[0].status).toBe("passed");
277+
expect(results[0].metadata.name).toBe("Scenario 1");
278+
expect(results[1].status).toBe("failed");
279+
expect(results[1].metadata.name).toBe("Scenario 2");
280+
expect(results[2].status).toBe("skipped");
281+
expect(results[2].metadata.name).toBe("Scenario 3");
282+
expect(results[3].status).toBe("skipped");
283+
expect(results[3].metadata.name).toBe("Scenario 4");
284+
});
285+
286+
Deno.test("Runner run with maxFailures aborts in-progress scenarios (parallel)", async () => {
287+
using signal = createScopedSignal();
288+
const runner = new Runner(reporter);
289+
const executionOrder: string[] = [];
290+
const scenarios = [
291+
createTestScenario({
292+
name: "Scenario 1",
293+
steps: [
294+
createTestStep({
295+
name: "Step 1",
296+
fn: async (ctx) => {
297+
executionOrder.push("Scenario 1 start");
298+
try {
299+
await delay(100, { signal: ctx.signal }); // Slow - will be aborted
300+
executionOrder.push("Scenario 1 end");
301+
return "1";
302+
} catch (err) {
303+
executionOrder.push("Scenario 1 aborted");
304+
throw err;
305+
}
306+
},
307+
}),
308+
],
309+
}),
310+
createTestScenario({
311+
name: "Scenario 2",
312+
steps: [
313+
createTestStep({
314+
name: "Step 1",
315+
fn: async (ctx) => {
316+
executionOrder.push("Scenario 2 start");
317+
await delay(10, { signal: ctx.signal }); // Fast
318+
executionOrder.push("Scenario 2 end");
319+
throw new Error("Scenario 2 failed");
320+
},
321+
}),
322+
],
323+
}),
324+
createTestScenario({
325+
name: "Scenario 3",
326+
steps: [
327+
createTestStep({
328+
name: "Step 1",
329+
fn: async (ctx) => {
330+
executionOrder.push("Scenario 3 start");
331+
try {
332+
await delay(100, { signal: ctx.signal }); // Slow - will be aborted
333+
executionOrder.push("Scenario 3 end");
334+
return "3";
335+
} catch (err) {
336+
executionOrder.push("Scenario 3 aborted");
337+
throw err;
338+
}
339+
},
340+
}),
341+
],
342+
}),
343+
createTestScenario({
344+
name: "Scenario 4",
345+
steps: [
346+
createTestStep({
347+
name: "Step 1",
348+
fn: () => {
349+
executionOrder.push("Scenario 4 start");
350+
return "4";
351+
},
352+
}),
353+
],
354+
}),
355+
];
356+
357+
const summary = await runner.run(scenarios, {
358+
signal,
359+
maxConcurrency: 3, // Scenarios 1-3 run in parallel, 4 is in next batch
360+
maxFailures: 1,
195361
});
362+
363+
// Scenario 1, 2, 3 all start in parallel
364+
// Scenario 2 finishes first and fails, triggers maxFailures
365+
// Scenarios 1, 3 are aborted immediately (signal.abort called)
366+
// Scenario 4 is in the next batch, never starts
367+
expect(summary).toMatchObject({
368+
total: 4,
369+
passed: 0,
370+
failed: 1, // Scenario 2
371+
skipped: 3, // Scenarios 1, 3, 4 all skipped
372+
});
373+
374+
// Verify execution order
375+
expect(executionOrder).toContain("Scenario 1 start");
376+
expect(executionOrder).toContain("Scenario 1 aborted"); // Aborted, not completed
377+
expect(executionOrder).not.toContain("Scenario 1 end");
378+
379+
expect(executionOrder).toContain("Scenario 2 start");
380+
expect(executionOrder).toContain("Scenario 2 end");
381+
382+
expect(executionOrder).toContain("Scenario 3 start");
383+
expect(executionOrder).toContain("Scenario 3 aborted"); // Aborted, not completed
384+
expect(executionOrder).not.toContain("Scenario 3 end");
385+
386+
// Scenario 4 should never start
387+
expect(executionOrder).not.toContain("Scenario 4 start");
388+
389+
// Check scenario results
390+
const results = summary.scenarios;
391+
expect(results.length).toBe(4);
392+
393+
// Find scenarios by name since order may vary in parallel execution
394+
const scenario1 = results.find((r) => r.metadata.name === "Scenario 1")!;
395+
const scenario2 = results.find((r) => r.metadata.name === "Scenario 2")!;
396+
const scenario3 = results.find((r) => r.metadata.name === "Scenario 3")!;
397+
const scenario4 = results.find((r) => r.metadata.name === "Scenario 4")!;
398+
399+
expect(scenario1.status).toBe("skipped");
400+
expect(scenario2.status).toBe("failed");
401+
expect(scenario3.status).toBe("skipped");
402+
expect(scenario4.status).toBe("skipped");
196403
});
197404

198405
Deno.test({

0 commit comments

Comments
 (0)