ReviewOS

also looking at this

zig-utils/zig-test-framework

Add retries, repeated runs, and flaky-test reporting

#25
Merged glennmichael123 wants to merge codex/issue-7-retries-repeat-flaky into main
15 files +719 -53

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

src/cli.zigmodified+70-0
Changes to src/cli.zig
@@ -45,6 +45,10 @@ pub const CLIOptions = struct {
4545 junit_output: ?[]const u8 = null,
4646 // Timeout options
4747 timeout: ?u64 = null, // Global timeout in milliseconds
48 // Retry and repetition options
49 retries: usize = 0,
50 repeat: usize = 1,
51 fail_on_flaky: bool = false,
4852};
4953
5054pub const version = build_options.version;
@@ -75,6 +79,9 @@ pub const CLI = struct {
7579 self.options.no_recursive = !config.test_options.recursive;
7680 self.options.filter = config.test_options.filter;
7781 self.options.timeout = config.test_options.timeout;
82 self.options.retries = config.test_options.retries;
83 self.options.repeat = config.test_options.repeat;
84 self.options.fail_on_flaky = config.test_options.fail_on_flaky;
7885 self.options.shard_index = config.sharding.index;
7986 self.options.shard_count = config.sharding.count;
8087 self.options.parallel = config.parallel.enabled;
@@ -107,6 +114,32 @@ pub const CLI = struct {
107114 self.options.version = true;
108115 } else if (std.mem.eql(u8, arg, "--bail") or std.mem.eql(u8, arg, "-b")) {
109116 self.options.bail = true;
117 } else if (std.mem.eql(u8, arg, "--retry") or std.mem.eql(u8, arg, "--retries")) {
118 if (i + 1 >= args.len) {
119 std.debug.print("Error: {s} requires a value\n", .{arg});
120 return CLIError.MissingValue;
121 }
122 i += 1;
123 self.options.retries = std.fmt.parseInt(usize, args[i], 10) catch {
124 std.debug.print("Error: {s} must be a valid number\n", .{arg});
125 return CLIError.InvalidArgument;
126 };
127 } else if (std.mem.eql(u8, arg, "--repeat")) {
128 if (i + 1 >= args.len) {
129 std.debug.print("Error: --repeat requires a value\n", .{});
130 return CLIError.MissingValue;
131 }
132 i += 1;
133 self.options.repeat = std.fmt.parseInt(usize, args[i], 10) catch {
134 std.debug.print("Error: --repeat must be a valid positive number\n", .{});
135 return CLIError.InvalidArgument;
136 };
137 if (self.options.repeat == 0) {
138 std.debug.print("Error: --repeat must be greater than zero\n", .{});
139 return CLIError.InvalidArgument;
140 }
141 } else if (std.mem.eql(u8, arg, "--fail-on-flaky")) {
142 self.options.fail_on_flaky = true;
110143 } else if (std.mem.eql(u8, arg, "--verbose")) {
111144 self.options.verbose = true;
112145 } else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) {
@@ -351,6 +384,9 @@ pub const CLI = struct {
351384 \\ -h, --help Show this help message
352385 \\ -v, --version Show version information
353386 \\ -b, --bail Stop test execution on first failure
387 \\ --retry <N> Retry each failed test up to N times
388 \\ --repeat <N> Require N successful runs of each selected test
389 \\ --fail-on-flaky Exit unsuccessfully when a retry recovers a failure
354390 \\ --filter <pattern> Run only tests matching pattern
355391 \\ --grep <pattern> Same as --filter (alias)
356392 \\ --reporter <name> Set reporter type (spec, dot, json, tap, junit)
@@ -414,6 +450,8 @@ pub const CLI = struct {
414450 \\ zig-test --update-snapshots Update all snapshots
415451 \\ zig-test --watch Run in watch mode
416452 \\ zig-test --timeout 10000 Set 10s timeout for all tests
453 \\ zig-test --retry 2 --fail-on-flaky Detect and reject flaky tests
454 \\ zig-test --repeat 100 --filter parser Stress-run selected tests
417455 \\ zig-test --profile-memory --fail-on-leak
418456 \\ zig-test --parallel --jobs 4 Run with 4 parallel workers
419457 \\ zig-test --config zig-test.json Load config from file
@@ -441,6 +479,9 @@ pub const CLI = struct {
441479 .n_jobs = self.options.jobs,
442480 .junit_output = self.options.junit_output orelse "test-results.xml",
443481 .timeout_ms = self.options.timeout,
482 .retries = self.options.retries,
483 .repeat = self.options.repeat,
484 .fail_on_flaky = self.options.fail_on_flaky,
444485 };
445486 }
446487};
@@ -662,6 +703,35 @@ test "CLI parse timeout" {
662703 try std.testing.expectEqual(@as(?u64, 5000), cli.options.timeout);
663704}
664705
706test "CLI parses retry repeat and flaky exit policy" {
707 var cli = CLI.init(std.testing.allocator);
708 const args = [_][]const u8{
709 "zig-test",
710 "--retry",
711 "2",
712 "--repeat",
713 "5",
714 "--fail-on-flaky",
715 };
716 try cli.parse(&args);
717 try std.testing.expectEqual(@as(usize, 2), cli.options.retries);
718 try std.testing.expectEqual(@as(usize, 5), cli.options.repeat);
719 try std.testing.expect(cli.options.fail_on_flaky);
720
721 const runner_options = cli.toRunnerOptions();
722 try std.testing.expectEqual(@as(usize, 2), runner_options.retries);
723 try std.testing.expectEqual(@as(usize, 5), runner_options.repeat);
724 try std.testing.expect(runner_options.fail_on_flaky);
725}
726
727test "CLI rejects a zero repeat count" {
728 var cli = CLI.init(std.testing.allocator);
729 try std.testing.expectError(
730 CLIError.InvalidArgument,
731 cli.parse(&.{ "zig-test", "--repeat", "0" }),
732 );
733}
734
665735test "CLI rejects invalid coverage tool" {
666736 var cli = CLI.init(std.testing.allocator);
667737 const args = [_][]const u8{ "zig-test", "--coverage-tool", "unknown" };
src/config.zigmodified+11-0
Changes to src/config.zig
@@ -29,6 +29,9 @@ pub const TestOptions = struct {
2929 recursive: bool = true,
3030 filter: ?[]const u8 = null,
3131 timeout: ?u64 = null,
32 retries: usize = 0,
33 repeat: usize = 1,
34 fail_on_flaky: bool = false,
3235};
3336
3437pub const ParallelOptions = struct {
@@ -86,6 +89,7 @@ pub const ConfigError = error{
8689 InvalidJobs,
8790 InvalidPort,
8891 InvalidTimeout,
92 InvalidRepeat,
8993 InvalidDebounce,
9094 InvalidShard,
9195};
@@ -183,6 +187,7 @@ fn validate(config: TestConfig) !void {
183187 if (config.test_options.timeout) |timeout| {
184188 if (timeout == 0) return ConfigError.InvalidTimeout;
185189 }
190 if (config.test_options.repeat == 0) return ConfigError.InvalidRepeat;
186191 if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce;
187192 const has_shard_index = config.sharding.index != null;
188193 const has_shard_count = config.sharding.count != null;
@@ -199,6 +204,9 @@ test "TestConfig defaults match CLI discovery defaults" {
199204 try std.testing.expectEqualStrings(".", config.test_options.test_dir);
200205 try std.testing.expect(config.test_options.recursive);
201206 try std.testing.expect(config.test_options.timeout == null);
207 try std.testing.expectEqual(@as(usize, 0), config.test_options.retries);
208 try std.testing.expectEqual(@as(usize, 1), config.test_options.repeat);
209 try std.testing.expect(!config.test_options.fail_on_flaky);
202210 try std.testing.expect(config.sharding.index == null);
203211 try std.testing.expect(config.sharding.count == null);
204212 try std.testing.expect(!config.parallel.enabled);
@@ -219,6 +227,9 @@ test "configuration validation rejects invalid runtime values" {
219227 invalid.parallel.jobs = 0;
220228 try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid));
221229 invalid.parallel.jobs = null;
230 invalid.test_options.repeat = 0;
231 try std.testing.expectError(ConfigError.InvalidRepeat, validate(invalid));
232 invalid.test_options.repeat = 1;
222233 invalid.sharding.index = 2;
223234 try std.testing.expectError(ConfigError.InvalidShard, validate(invalid));
224235 invalid.sharding.count = 1;
src/lib.zigmodified+1-0
Changes to src/lib.zig
@@ -60,6 +60,7 @@ pub const describeTimeout = suite.describeTimeout;
6060pub const TestSuite = suite.TestSuite;
6161pub const TestCase = suite.TestCase;
6262pub const TestStatus = suite.TestStatus;
63pub const TestAttempt = suite.TestAttempt;
6364pub const TestRegistry = suite.TestRegistry;
6465pub const TestFn = suite.TestFn;
6566pub const HookFn = suite.HookFn;
src/main.zigmodified+6-0
Changes to src/main.zig
@@ -221,6 +221,9 @@ pub fn main(init: std.process.Init.Minimal) !void {
221221 .reporter_type = cli_parser.options.reporter,
222222 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
223223 .timeout_ms = cli_parser.options.timeout,
224 .retries = cli_parser.options.retries,
225 .repeat = cli_parser.options.repeat,
226 .fail_on_flaky = cli_parser.options.fail_on_flaky,
224227 .reporter_writer = &stdout_writer.interface,
225228 .coverage_options = cov_opts,
226229 .ui_server = if (ui_server) |*server| server else null,
@@ -264,6 +267,9 @@ pub fn main(init: std.process.Init.Minimal) !void {
264267 .reporter_type = cli_parser.options.reporter,
265268 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
266269 .timeout_ms = cli_parser.options.timeout,
270 .retries = cli_parser.options.retries,
271 .repeat = cli_parser.options.repeat,
272 .fail_on_flaky = cli_parser.options.fail_on_flaky,
267273 .reporter_writer = &stdout_writer.interface,
268274 .coverage_options = cov_opts,
269275 .ui_server = if (ui_server) |*server| server else null,
src/reporter.zigmodified+127-5
Changes to src/reporter.zig
@@ -71,21 +71,25 @@ pub const Reporter = struct {
7171pub const TestResults = struct {
7272 total: usize = 0,
7373 passed: usize = 0,
74 flaky: usize = 0,
7475 failed: usize = 0,
7576 skipped: usize = 0,
7677 total_time_ns: u64 = 0,
7778 failed_tests: std.ArrayList(suite.TestCase),
79 flaky_tests: std.ArrayList(suite.TestCase),
7880 allocator: std.mem.Allocator,
7981
8082 pub fn init(allocator: std.mem.Allocator) TestResults {
8183 return TestResults{
8284 .failed_tests = .empty,
85 .flaky_tests = .empty,
8386 .allocator = allocator,
8487 };
8588 }
8689
8790 pub fn deinit(self: *TestResults) void {
8891 self.failed_tests.deinit(self.allocator);
92 self.flaky_tests.deinit(self.allocator);
8993 }
9094
9195 pub fn addTest(self: *TestResults, test_case: *const suite.TestCase) !void {
@@ -94,6 +98,10 @@ pub const TestResults = struct {
9498
9599 switch (test_case.status) {
96100 .passed => self.passed += 1,
101 .flaky => {
102 self.flaky += 1;
103 try self.flaky_tests.append(self.allocator, test_case.*);
104 },
97105 .failed => {
98106 self.failed += 1;
99107 try self.failed_tests.append(self.allocator, test_case.*);
@@ -188,6 +196,9 @@ pub const SpecReporter = struct {
188196 try s.output().print("{s}Test Summary:{s}\n", .{ Colors.bold, Colors.reset });
189197 try s.output().print(" Total: {d}\n", .{results.total});
190198 try s.output().print(" {s}Passed: {d}{s}\n", .{ Colors.green, results.passed, Colors.reset });
199 if (results.flaky > 0) {
200 try s.output().print(" {s}Flaky: {d}{s}\n", .{ Colors.yellow, results.flaky, Colors.reset });
201 }
191202 if (results.failed > 0) {
192203 try s.output().print(" {s}Failed: {d}{s}\n", .{ Colors.red, results.failed, Colors.reset });
193204 }
@@ -199,6 +210,9 @@ pub const SpecReporter = struct {
199210 try s.output().print("Test Summary:\n", .{});
200211 try s.output().print(" Total: {d}\n", .{results.total});
201212 try s.output().print(" Passed: {d}\n", .{results.passed});
213 if (results.flaky > 0) {
214 try s.output().print(" Flaky: {d}\n", .{results.flaky});
215 }
202216 if (results.failed > 0) {
203217 try s.output().print(" Failed: {d}\n", .{results.failed});
204218 }
@@ -265,6 +279,25 @@ pub const SpecReporter = struct {
265279 try s.output().print("✓ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
266280 }
267281 },
282 .flaky => {
283 if (reporter.use_colors) {
284 try s.output().print("{s}~{s} {s} {s}({d} attempts, {d:.2}ms){s}\n", .{
285 Colors.yellow,
286 Colors.reset,
287 test_case.name,
288 Colors.gray,
289 test_case.attempts.items.len,
290 time_ms,
291 Colors.reset,
292 });
293 } else {
294 try s.output().print("~ {s} (flaky after {d} attempts, {d:.2}ms)\n", .{
295 test_case.name,
296 test_case.attempts.items.len,
297 time_ms,
298 });
299 }
300 },
268301 .failed => {
269302 if (reporter.use_colors) {
270303 try s.output().print("{s}✗{s} {s} {s}({d:.2}ms){s}\n", .{
@@ -353,11 +386,13 @@ pub const DotReporter = struct {
353386
354387 if (reporter.use_colors) {
355388 try s.output().print("{s}Passed: {d}{s}, ", .{ Colors.green, results.passed, Colors.reset });
389 try s.output().print("{s}Flaky: {d}{s}, ", .{ Colors.yellow, results.flaky, Colors.reset });
356390 try s.output().print("{s}Failed: {d}{s}, ", .{ Colors.red, results.failed, Colors.reset });
357391 try s.output().print("Total: {d} ({d:.2}ms)\n", .{ results.total, total_time_ms });
358392 } else {
359 try s.output().print("Passed: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
393 try s.output().print("Passed: {d}, Flaky: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
360394 results.passed,
395 results.flaky,
361396 results.failed,
362397 results.total,
363398 total_time_ms,
@@ -391,6 +426,13 @@ pub const DotReporter = struct {
391426 try s.output().print(".", .{});
392427 }
393428 },
429 .flaky => {
430 if (reporter.use_colors) {
431 try s.output().print("{s}~{s}", .{ Colors.yellow, Colors.reset });
432 } else {
433 try s.output().print("~", .{});
434 }
435 },
394436 .failed => {
395437 if (reporter.use_colors) {
396438 try s.output().print("{s}F{s}", .{ Colors.red, Colors.reset });
@@ -422,6 +464,7 @@ pub const JsonReporter = struct {
422464 writer: std.Io.Writer,
423465 writer_ref: ?*std.Io.Writer = null,
424466 suites: std.ArrayList([]const u8),
467 emitted_tests: bool = false,
425468
426469 const Self = @This();
427470
@@ -472,9 +515,10 @@ pub const JsonReporter = struct {
472515 const s = self(reporter);
473516 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
474517
475 try s.output().print("],\"summary\":}}\n", .{
518 try s.output().print("\n],\"summary\":}}\n", .{
476519 results.total,
477520 results.passed,
521 results.flaky,
478522 results.failed,
479523 results.skipped,
480524 total_time_ms,
@@ -505,11 +549,15 @@ pub const JsonReporter = struct {
505549
506550 const status_str = switch (test_case.status) {
507551 .passed => "passed",
552 .flaky => "flaky",
508553 .failed => "failed",
509554 .skipped => "skipped",
510555 else => "unknown",
511556 };
512557
558 if (s.emitted_tests) try s.output().print(",\n", .{});
559 s.emitted_tests = true;
560
513561 // Note: In a real implementation, you'd want to properly escape JSON strings
514562 try s.output().print(" ", .{});
525592 }
526593};
527594
@@ -593,6 +660,7 @@ pub const TAPReporter = struct {
593660
594661 const status = switch (test_case.status) {
595662 .passed => "ok",
663 .flaky => "ok",
596664 .failed => "not ok",
597665 .skipped => "ok",
598666 else => "not ok",
@@ -600,7 +668,9 @@ pub const TAPReporter = struct {
600668
601669 try self.output().print("{s} {d} - {s}", .{ status, self.test_count, test_case.name });
602670
603 if (test_case.status == .skipped) {
671 if (test_case.status == .flaky) {
672 try self.output().print(" # FLAKY attempts={d}\n", .{test_case.attempts.items.len});
673 } else if (test_case.status == .skipped) {
604674 try self.output().print(" # SKIP\n", .{});
605675 } else if (test_case.status == .failed and test_case.error_message != null) {
606676 try self.output().print("\n ---\n message: {s}\n ...\n", .{test_case.error_message.?});
@@ -631,6 +701,10 @@ pub const JUnitReporter = struct {
631701 if (test_case.error_message) |msg| {
632702 allocator.free(msg);
633703 }
704 for (test_case.attempts) |attempt| {
705 if (attempt.error_message) |msg| allocator.free(msg);
706 }
707 allocator.free(test_case.attempts);
634708 }
635709 self.tests.deinit(allocator);
636710 allocator.free(self.name);
@@ -642,6 +716,15 @@ pub const JUnitReporter = struct {
642716 time: f64,
643717 status: suite.TestStatus,
644718 error_message: ?[]const u8,
719 attempts: []AttemptResult,
720 };
721
722 const AttemptResult = struct {
723 number: usize,
724 repetition: usize,
725 status: suite.TestStatus,
726 duration_ns: u64,
727 error_message: ?[]const u8,
645728 };
646729
647730 pub fn init(allocator: std.mem.Allocator, output_file: []const u8) Self {
@@ -716,11 +799,27 @@ pub const JUnitReporter = struct {
716799 else
717800 null;
718801
802 const attempts = try self.allocator.alloc(AttemptResult, test_case.attempts.items.len);
803 errdefer self.allocator.free(attempts);
804 for (test_case.attempts.items, 0..) |attempt, index| {
805 attempts[index] = .{
806 .number = attempt.number,
807 .repetition = attempt.repetition,
808 .status = attempt.status,
809 .duration_ns = attempt.duration_ns,
810 .error_message = if (attempt.error_message) |message|
811 try self.allocator.dupe(u8, message)
812 else
813 null,
814 };
815 }
816
719817 const test_result = TestCaseResult{
720818 .name = try self.allocator.dupe(u8, test_case.name),
721819 .time = time_seconds,
722820 .status = test_case.status,
723821 .error_message = error_msg,
822 .attempts = attempts,
724823 };
725824
726825 try test_suite.tests.append(self.allocator, test_result);
@@ -732,10 +831,11 @@ pub const JUnitReporter = struct {
732831 defer buffer.deinit(self.allocator);
733832
734833 try buffer.appendSlice(self.allocator, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
735 try buffer.print(self.allocator, "<testsuites tests=\"{d}\" failures=\"{d}\" skipped=\"{d}\">\n", .{
834 try buffer.print(self.allocator, "<testsuites tests=\"{d}\" failures=\"{d}\" skipped=\"{d}\" flaky=\"{d}\">\n", .{
736835 results.total,
737836 results.failed,
738837 results.skipped,
838 results.flaky,
739839 });
740840
741841 for (self.suites.items) |suite_result| {
@@ -768,11 +868,16 @@ pub const JUnitReporter = struct {
768868 try buffer.print(self.allocator, " <failure message=\"{s}\"/>\n", .{
769869 test_result.error_message orelse "Test failed",
770870 });
871 try writeAttemptHistory(&buffer, self.allocator, test_result.attempts);
771872 try buffer.appendSlice(self.allocator, " </testcase>\n");
772873 } else if (test_result.status == .skipped) {
773874 try buffer.appendSlice(self.allocator, ">\n");
774875 try buffer.appendSlice(self.allocator, " <skipped/>\n");
775876 try buffer.appendSlice(self.allocator, " </testcase>\n");
877 } else if (test_result.attempts.len > 1) {
878 try buffer.appendSlice(self.allocator, ">\n");
879 try writeAttemptHistory(&buffer, self.allocator, test_result.attempts);
880 try buffer.appendSlice(self.allocator, " </testcase>\n");
776881 } else {
777882 try buffer.appendSlice(self.allocator, "/>\n");
778883 }
@@ -785,6 +890,23 @@ pub const JUnitReporter = struct {
785890
786891 try compat.writeFile(self.allocator, self.output_file, buffer.items);
787892 }
893
894 fn writeAttemptHistory(
895 buffer: *std.ArrayList(u8),
896 allocator: std.mem.Allocator,
897 attempts: []const AttemptResult,
898 ) !void {
899 try buffer.appendSlice(allocator, " <system-out>");
900 for (attempts, 0..) |attempt, index| {
901 if (index > 0) try buffer.appendSlice(allocator, "&#10;");
902 try buffer.print(
903 allocator,
904 "attempt={d} repetition={d} status={s} duration_ns={d}",
905 .{ attempt.number, attempt.repetition, @tagName(attempt.status), attempt.duration_ns },
906 );
907 }
908 try buffer.appendSlice(allocator, "</system-out>\n");
909 }
788910};
789911
790912/// Owns every built-in reporter and selects one through a shared interface.
src/suite.zigmodified+31-0
Changes to src/suite.zig
@@ -24,10 +24,21 @@ pub const TestStatus = enum {
2424 pending,
2525 running,
2626 passed,
27 flaky,
2728 failed,
2829 skipped,
2930};
3031
32/// The outcome of one execution of a test. A logical test may have several
33/// attempts when retries or repeated runs are enabled.
34pub const TestAttempt = struct {
35 number: usize,
36 repetition: usize,
37 status: TestStatus,
38 duration_ns: u64,
39 error_message: ?[]const u8 = null,
40};
41
3142/// Individual test case
3243pub const TestCase = struct {
3344 name: []const u8,
@@ -37,6 +48,9 @@ pub const TestCase = struct {
3748 status: TestStatus = .pending,
3849 error_message: ?[]const u8 = null,
3950 execution_time_ns: u64 = 0,
51 /// Override the runner retry count for this test when non-null.
52 retry_count: ?usize = null,
53 attempts: std.ArrayList(TestAttempt) = .empty,
4054 skip: bool = false,
4155 only: bool = false,
4256 file: []const u8 = "",
@@ -65,6 +79,20 @@ pub const TestCase = struct {
6579 .timeout_ms = timeout_ms,
6680 };
6781 }
82
83 /// Configure an additional-attempt count for this individual test.
84 pub fn withRetries(self: TestCase, retries: usize) TestCase {
85 var configured = self;
86 configured.retry_count = retries;
87 return configured;
88 }
89
90 pub fn deinit(self: *TestCase, allocator: std.mem.Allocator) void {
91 for (self.attempts.items) |attempt| {
92 if (attempt.error_message) |message| allocator.free(message);
93 }
94 self.attempts.deinit(allocator);
95 }
6896};
6997
7098/// Test suite (describe block)
@@ -103,6 +131,7 @@ pub const TestSuite = struct {
103131 for (self.suites.items) |suite| {
104132 suite.deinit();
105133 }
134 for (self.tests.items) |*test_case| test_case.deinit(self.allocator);
106135 self.tests.deinit(self.allocator);
107136 self.suites.deinit(self.allocator);
108137 self.before_each_hooks.deinit(self.allocator);
@@ -456,12 +485,14 @@ test "TestStatus enum values" {
456485 const pending_status = TestStatus.pending;
457486 const running_status = TestStatus.running;
458487 const passed_status = TestStatus.passed;
488 const flaky_status = TestStatus.flaky;
459489 const failed_status = TestStatus.failed;
460490 const skipped_status = TestStatus.skipped;
461491
462492 try std.testing.expect(pending_status == .pending);
463493 try std.testing.expect(running_status == .running);
464494 try std.testing.expect(passed_status == .passed);
495 try std.testing.expect(flaky_status == .flaky);
465496 try std.testing.expect(failed_status == .failed);
466497 try std.testing.expect(skipped_status == .skipped);
467498}
src/test_loader.zigmodified+63-17
Changes to src/test_loader.zig
@@ -26,6 +26,12 @@ pub const LoaderOptions = struct {
2626 junit_output: []const u8 = "test-results.xml",
2727 /// Global per-file timeout checked by the shared execution policy
2828 timeout_ms: ?u64 = null,
29 /// Additional process attempts after a failed test file.
30 retries: usize = 0,
31 /// Number of successful repetitions required for every selected file.
32 repeat: usize = 1,
33 /// Treat recovered failures as an unsuccessful run.
34 fail_on_flaky: bool = false,
2935 /// Optional output supplied by a CLI host. Embedded/test callers default
3036 /// to stderr so they do not interfere with Zig's stdout test protocol.
3137 reporter_writer: ?*std.Io.Writer = null,
@@ -85,6 +91,11 @@ pub fn runDiscoveredTests(
8591 };
8692 var results = reporter.TestResults.init(allocator);
8793 defer results.deinit();
94 var executed_cases: std.ArrayList(suite.TestCase) = .empty;
95 defer {
96 for (executed_cases.items) |*test_case| test_case.deinit(allocator);
97 executed_cases.deinit(allocator);
98 }
8899
89100 try events.emit(.{ .run_started = selected_files });
90101
@@ -120,23 +131,49 @@ pub fn runDiscoveredTests(
120131 try server.broadcast("test_start", json);
121132 }
122133
123 const start_time = compat.nanoTimestamp();
124 var result = try runTestFile(allocator, file.path, options);
125 const end_time = compat.nanoTimestamp();
126 const execution_time_ns: u64 = @intCast(end_time - start_time);
127 const timed_out = policy.timedOut(execution_time_ns);
128 if (timed_out) result = false;
129
130134 files_run += 1;
131135 var test_case = suite.TestCase.init(file.name, externalTestNoop);
132136 test_case.file = file.relative_path;
133 test_case.execution_time_ns = execution_time_ns;
134 test_case.status = if (result) .passed else .failed;
135 if (timed_out) {
136 test_case.error_message = "External test file exceeded the configured timeout";
137 } else if (!result) {
138 test_case.error_message = "External zig test process failed";
137 const repeat_count = @max(options.repeat, 1);
138 var attempt_number: usize = 0;
139 var had_failure = false;
140 var final_failure = false;
141
142 repetitions: for (1..repeat_count + 1) |repetition| {
143 for (1..options.retries + 2) |attempt_in_repetition| {
144 attempt_number += 1;
145 const start_time = compat.nanoTimestamp();
146 var passed = try runTestFile(allocator, file.path, options);
147 const duration_ns: u64 = @intCast(compat.nanoTimestamp() - start_time);
148 const timed_out = policy.timedOut(duration_ns);
149 if (timed_out) passed = false;
150 const error_message = if (timed_out)
151 try allocator.dupe(u8, "External test file exceeded the configured timeout")
152 else if (!passed)
153 try allocator.dupe(u8, "External zig test process failed")
154 else
155 null;
156 try test_case.attempts.append(allocator, .{
157 .number = attempt_number,
158 .repetition = repetition,
159 .status = if (passed) .passed else .failed,
160 .duration_ns = duration_ns,
161 .error_message = error_message,
162 });
163 test_case.execution_time_ns += duration_ns;
164
165 if (passed) break;
166 had_failure = true;
167 test_case.error_message = error_message;
168 if (attempt_in_repetition == options.retries + 1) {
169 final_failure = true;
170 break :repetitions;
171 }
172 }
139173 }
174 test_case.status = if (final_failure) .failed else if (had_failure) .flaky else .passed;
175 if (!final_failure) test_case.error_message = null;
176 try executed_cases.append(allocator, test_case);
140177 try results.addTest(&test_case);
141178 try events.emit(.{ .test_finished = &test_case });
142179 try events.emit(.{ .suite_finished = file.relative_path });
@@ -144,14 +181,20 @@ pub fn runDiscoveredTests(
144181 // Notify UI of test file end
145182 if (options.ui_server) |server| {
146183 var buffer: [512]u8 = undefined;
147 const status = if (result) "passed" else "failed";
148 const json = try std.fmt.bufPrint(&buffer, "", .{ file.name, status, execution_time_ns });
184 const json = try std.fmt.bufPrint(&buffer, "", .{
185 file.name,
186 @tagName(test_case.status),
187 test_case.execution_time_ns,
188 test_case.attempts.items.len,
189 });
149190 try server.broadcast("test_end", json);
150191 try server.broadcast("suite_end", try std.fmt.bufPrint(&buffer, "", .{file.name}));
151192 }
152193
153 if (result) {
194 if (test_case.status == .passed) {
154195 if (options.verbose) std.debug.print(" ✓ {s} passed\n", .{file.name});
196 } else if (test_case.status == .flaky) {
197 std.debug.print(" ~ {s} flaky after {d} attempts\n", .{ file.name, test_case.attempts.items.len });
155198 } else {
156199 std.debug.print(" ✗ {s} failed\n", .{file.name});
157200 if (policy.shouldStop(&results)) {
@@ -188,7 +231,7 @@ pub fn runDiscoveredTests(
188231 try events.emit(.{ .run_finished = &results });
189232 try reporters.flush();
190233
191 return results.failed == 0;
234 return results.failed == 0 and (!options.fail_on_flaky or results.flaky == 0);
192235}
193236
194237fn externalTestNoop(_: std.mem.Allocator) !void {}
@@ -292,6 +335,9 @@ test "LoaderOptions default values" {
292335 try std.testing.expectEqual(reporter.ReporterType.spec, options.reporter_type);
293336 try std.testing.expectEqualStrings("test-results.xml", options.junit_output);
294337 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
338 try std.testing.expectEqual(@as(usize, 0), options.retries);
339 try std.testing.expectEqual(@as(usize, 1), options.repeat);
340 try std.testing.expect(!options.fail_on_flaky);
295341 try std.testing.expectEqual(@as(?*std.Io.Writer, null), options.reporter_writer);
296342 try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options);
297343}
src/test_runner.zigmodified+216-23
Changes to src/test_runner.zig
@@ -19,6 +19,12 @@ pub const RunnerOptions = struct {
1919 n_jobs: ?usize = null, // Number of parallel jobs
2020 junit_output: []const u8 = "test-results.xml",
2121 timeout_ms: ?u64 = null,
22 /// Additional attempts after a failed execution.
23 retries: usize = 0,
24 /// Number of times every selected test is required to pass.
25 repeat: usize = 1,
26 /// Treat tests that pass after a retry as an unsuccessful run.
27 fail_on_flaky: bool = false,
2228 /// Optional output supplied by a CLI host. Embedded/test callers default
2329 /// to stderr so they do not interfere with Zig's stdout test protocol.
2430 reporter_writer: ?*std.Io.Writer = null,
@@ -75,7 +81,7 @@ pub const TestRunner = struct {
7581 }
7682
7783 // Run tests in parallel or sequential based on options
78 if (self.options.parallel) {
84 if (self.options.parallel and self.options.retries == 0 and self.options.repeat == 1) {
7985 const parallel_opts = parallel.ParallelOptions{
8086 .enabled = true,
8187 .n_jobs = self.options.n_jobs,
@@ -95,7 +101,8 @@ pub const TestRunner = struct {
95101 }
96102
97103 try reporters.flush();
98 return all_passed and self.results.failed == 0;
104 return all_passed and self.results.failed == 0 and
105 (!self.options.fail_on_flaky or self.results.flaky == 0);
99106 }
100107
101108 // Sequential execution (original behavior)
@@ -113,7 +120,8 @@ pub const TestRunner = struct {
113120 // Flush output
114121 try reporters.flush();
115122
116 return self.results.failed == 0;
123 return self.results.failed == 0 and
124 (!self.options.fail_on_flaky or self.results.flaky == 0);
117125 }
118126
119127 fn addSuiteResults(self: *Self, test_suite: *suite.TestSuite) !void {
@@ -189,52 +197,88 @@ pub const TestRunner = struct {
189197 fn runTest(self: *Self, test_case: *suite.TestCase, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
190198 try events.emit(.{ .test_started = test_case.name });
191199
200 for (test_case.attempts.items) |attempt| {
201 if (attempt.error_message) |message| self.allocator.free(message);
202 }
203 test_case.attempts.clearRetainingCapacity();
204 test_case.execution_time_ns = 0;
205 test_case.error_message = null;
206
207 const retries = test_case.retry_count orelse self.options.retries;
208 const repeat_count = @max(self.options.repeat, 1);
209 var attempt_number: usize = 0;
210 var had_failure = false;
211 var final_failure = false;
212
213 repetitions: for (1..repeat_count + 1) |repetition| {
214 for (1..retries + 2) |attempt_in_repetition| {
215 attempt_number += 1;
216 const passed = try self.runSingleAttempt(test_case, test_suite, repetition, attempt_number);
217 if (passed) break;
218
219 had_failure = true;
220 if (attempt_in_repetition == retries + 1) {
221 final_failure = true;
222 break :repetitions;
223 }
224 }
225 }
226
227 if (!final_failure) {
228 test_case.status = if (had_failure) .flaky else .passed;
229 test_case.error_message = null;
230 }
231
232 try self.results.addTest(test_case);
233 try events.emit(.{ .test_finished = test_case });
234 }
235
236 fn runSingleAttempt(
237 self: *Self,
238 test_case: *suite.TestCase,
239 test_suite: *suite.TestSuite,
240 repetition: usize,
241 attempt_number: usize,
242 ) !bool {
192243 test_case.status = .running;
244 test_case.error_message = null;
193245 const start_time = compat.nanoTimestamp();
194246
195 // Get all beforeEach hooks (including parent hooks)
196247 var before_hooks = try test_suite.getAllBeforeEachHooks(self.allocator);
197248 defer before_hooks.deinit(self.allocator);
198249
199 // Run beforeEach hooks
200250 var before_failed = false;
201251 for (before_hooks.items) |hook| {
202252 hook(self.allocator) catch |err| {
203253 test_case.status = .failed;
204 const err_msg = try std.fmt.allocPrint(self.allocator, "beforeEach hook failed: {any}", .{err});
205 test_case.error_message = err_msg;
254 test_case.error_message = try std.fmt.allocPrint(
255 self.allocator,
256 "beforeEach hook failed: {any}",
257 .{err},
258 );
206259 before_failed = true;
207260 break;
208261 };
209262 }
210263
211 // Run the actual test if beforeEach succeeded
212264 if (!before_failed) {
213265 test_case.test_fn(self.allocator) catch |err| {
214266 test_case.status = .failed;
215 const err_msg = try std.fmt.allocPrint(self.allocator, "{any}", .{err});
216 test_case.error_message = err_msg;
267 test_case.error_message = try std.fmt.allocPrint(self.allocator, "{any}", .{err});
217268 };
218
219 if (test_case.status == .running) {
220 test_case.status = .passed;
221 }
269 if (test_case.status == .running) test_case.status = .passed;
222270 }
223271
224 // Get all afterEach hooks (including parent hooks)
225272 var after_hooks = try test_suite.getAllAfterEachHooks(self.allocator);
226273 defer after_hooks.deinit(self.allocator);
227
228 // Run afterEach hooks (always run, even if test failed)
229274 for (after_hooks.items) |hook| {
230275 hook(self.allocator) catch |err| {
231276 std.debug.print("afterEach hook failed: {any}\n", .{err});
232277 };
233278 }
234279
235 const end_time = compat.nanoTimestamp();
236 test_case.execution_time_ns = @intCast(end_time - start_time);
237 if (test_case.status == .passed and self.policy().timedOut(test_case.execution_time_ns)) {
280 const duration_ns: u64 = @intCast(compat.nanoTimestamp() - start_time);
281 if (test_case.status == .passed and self.policy().timedOut(duration_ns)) {
238282 test_case.status = .failed;
239283 test_case.error_message = try std.fmt.allocPrint(
240284 self.allocator,
@@ -242,9 +286,15 @@ pub const TestRunner = struct {
242286 .{self.options.timeout_ms.?},
243287 );
244288 }
245
246 try self.results.addTest(test_case);
247 try events.emit(.{ .test_finished = test_case });
289 test_case.execution_time_ns += duration_ns;
290 try test_case.attempts.append(self.allocator, .{
291 .number = attempt_number,
292 .repetition = repetition,
293 .status = test_case.status,
294 .duration_ns = duration_ns,
295 .error_message = test_case.error_message,
296 });
297 return test_case.status == .passed;
248298 }
249299
250300 /// Skip all tests in a suite
@@ -302,6 +352,9 @@ test "RunnerOptions default values" {
302352 try std.testing.expectEqual(ReporterType.spec, options.reporter_type);
303353 try std.testing.expectEqual(true, options.use_colors);
304354 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
355 try std.testing.expectEqual(@as(usize, 0), options.retries);
356 try std.testing.expectEqual(@as(usize, 1), options.repeat);
357 try std.testing.expect(!options.fail_on_flaky);
305358}
306359
307360test "RunnerOptions custom values" {
@@ -317,3 +370,143 @@ test "RunnerOptions custom values" {
317370 try std.testing.expectEqual(ReporterType.json, options.reporter_type);
318371 try std.testing.expectEqual(false, options.use_colors);
319372}
373
374var flaky_fixture_runs: usize = 0;
375var failing_fixture_runs: usize = 0;
376var skipped_by_bail_runs: usize = 0;
377var repeated_fixture_runs: usize = 0;
378var timeout_fixture_runs: usize = 0;
379
380fn flakyFixture(_: std.mem.Allocator) !void {
381 flaky_fixture_runs += 1;
382 if (flaky_fixture_runs == 1) return error.TransientFailure;
383}
384
385fn failingFixture(_: std.mem.Allocator) !void {
386 failing_fixture_runs += 1;
387 return error.DeterministicFailure;
388}
389
390fn skippedByBailFixture(_: std.mem.Allocator) !void {
391 skipped_by_bail_runs += 1;
392}
393
394fn repeatedFixture(_: std.mem.Allocator) !void {
395 repeated_fixture_runs += 1;
396}
397
398fn timeoutFixture(_: std.mem.Allocator) !void {
399 timeout_fixture_runs += 1;
400 compat.sleep(std.time.ns_per_ms);
401}
402
403test "retries record deterministic flaky attempt history" {
404 flaky_fixture_runs = 0;
405 const allocator = std.testing.allocator;
406 var registry = suite.TestRegistry.init(allocator);
407 defer registry.deinit();
408 const test_suite = try suite.TestSuite.init(allocator, "retry suite");
409 try test_suite.addTest(suite.TestCase.init("flaky fixture", flakyFixture).withRetries(1));
410 try registry.registerSuite(test_suite);
411
412 var output: [4096]u8 = undefined;
413 var writer: std.Io.Writer = .fixed(&output);
414 var runner = TestRunner.init(allocator, &registry, .{
415 .reporter_writer = &writer,
416 .use_colors = false,
417 });
418 defer runner.deinit();
419
420 try std.testing.expect(try runner.run());
421 const test_case = &test_suite.tests.items[0];
422 try std.testing.expectEqual(suite.TestStatus.flaky, test_case.status);
423 try std.testing.expectEqual(@as(usize, 2), test_case.attempts.items.len);
424 try std.testing.expectEqual(suite.TestStatus.failed, test_case.attempts.items[0].status);
425 try std.testing.expectEqual(suite.TestStatus.passed, test_case.attempts.items[1].status);
426 try std.testing.expectEqual(@as(usize, 1), runner.results.flaky);
427
428 var strict_runner = TestRunner.init(allocator, &registry, .{
429 .retries = 1,
430 .fail_on_flaky = true,
431 .reporter_writer = &writer,
432 .use_colors = false,
433 });
434 defer strict_runner.deinit();
435 flaky_fixture_runs = 0;
436 try std.testing.expect(!try strict_runner.run());
437}
438
439test "repeat mode runs every required passing repetition" {
440 repeated_fixture_runs = 0;
441 const allocator = std.testing.allocator;
442 var registry = suite.TestRegistry.init(allocator);
443 defer registry.deinit();
444 const test_suite = try suite.TestSuite.init(allocator, "repeat suite");
445 try test_suite.addTest(suite.TestCase.init("repeated fixture", repeatedFixture));
446 try registry.registerSuite(test_suite);
447
448 var output: [4096]u8 = undefined;
449 var writer: std.Io.Writer = .fixed(&output);
450 var runner = TestRunner.init(allocator, &registry, .{
451 .repeat = 3,
452 .reporter_writer = &writer,
453 .use_colors = false,
454 });
455 defer runner.deinit();
456
457 try std.testing.expect(try runner.run());
458 try std.testing.expectEqual(@as(usize, 3), repeated_fixture_runs);
459 try std.testing.expectEqual(@as(usize, 3), test_suite.tests.items[0].attempts.items.len);
460}
461
462test "bail waits until retries are exhausted" {
463 failing_fixture_runs = 0;
464 skipped_by_bail_runs = 0;
465 const allocator = std.testing.allocator;
466 var registry = suite.TestRegistry.init(allocator);
467 defer registry.deinit();
468 const test_suite = try suite.TestSuite.init(allocator, "bail suite");
469 try test_suite.addTest(suite.TestCase.init("always fails", failingFixture));
470 try test_suite.addTest(suite.TestCase.init("must be skipped", skippedByBailFixture));
471 try registry.registerSuite(test_suite);
472
473 var output: [4096]u8 = undefined;
474 var writer: std.Io.Writer = .fixed(&output);
475 var runner = TestRunner.init(allocator, &registry, .{
476 .bail = true,
477 .retries = 2,
478 .reporter_writer = &writer,
479 .use_colors = false,
480 });
481 defer runner.deinit();
482
483 try std.testing.expect(!try runner.run());
484 try std.testing.expectEqual(@as(usize, 3), failing_fixture_runs);
485 try std.testing.expectEqual(@as(usize, 0), skipped_by_bail_runs);
486 try std.testing.expectEqual(@as(usize, 3), test_suite.tests.items[0].attempts.items.len);
487}
488
489test "timeouts are evaluated for every retry attempt" {
490 timeout_fixture_runs = 0;
491 const allocator = std.testing.allocator;
492 var registry = suite.TestRegistry.init(allocator);
493 defer registry.deinit();
494 const test_suite = try suite.TestSuite.init(allocator, "timeout suite");
495 try test_suite.addTest(suite.TestCase.init("times out", timeoutFixture));
496 try registry.registerSuite(test_suite);
497
498 var output: [4096]u8 = undefined;
499 var writer: std.Io.Writer = .fixed(&output);
500 var runner = TestRunner.init(allocator, &registry, .{
501 .timeout_ms = 0,
502 .retries = 1,
503 .reporter_writer = &writer,
504 .use_colors = false,
505 });
506 defer runner.deinit();
507
508 try std.testing.expect(!try runner.run());
509 try std.testing.expectEqual(@as(usize, 2), timeout_fixture_runs);
510 try std.testing.expectEqual(@as(usize, 2), test_suite.tests.items[0].attempts.items.len);
511 try std.testing.expectEqual(suite.TestStatus.failed, test_suite.tests.items[0].status);
512}
tests/reporter_test.zigmodified+42-0
Changes to tests/reporter_test.zig
@@ -68,6 +68,48 @@ test "reporter json output" {
6868 const output = buffer[0..reporter.writer.end];
6969 try std.testing.expect(std.mem.indexOf(u8, output, "\"name\":\"passes\"") != null);
7070 try std.testing.expect(std.mem.indexOf(u8, output, "\"passed\":1") != null);
71 var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, output, .{});
72 defer parsed.deinit();
73}
74
75test "reporter json output includes flaky attempt history" {
76 const allocator = std.testing.allocator;
77 var buffer: [4096]u8 = undefined;
78 const writer: std.Io.Writer = .fixed(&buffer);
79 var reporter = ztf.JsonReporter.init(allocator, writer);
80 defer reporter.deinit();
81 var results = ztf.TestResults.init(allocator);
82 defer results.deinit();
83 results.total = 1;
84 results.flaky = 1;
85 var test_case = ztf.TestCase.init("eventually passes", passingTest);
86 defer test_case.attempts.deinit(allocator);
87 test_case.status = .flaky;
88 try test_case.attempts.append(allocator, .{
89 .number = 1,
90 .repetition = 1,
91 .status = .failed,
92 .duration_ns = 10,
93 });
94 try test_case.attempts.append(allocator, .{
95 .number = 2,
96 .repetition = 1,
97 .status = .passed,
98 .duration_ns = 5,
99 });
100
101 try reporter.reporter.onRunStart(1);
102 try reporter.reporter.onSuiteStart("suite");
103 try reporter.reporter.onTestEnd(&test_case);
104 try reporter.reporter.onSuiteEnd("suite");
105 try reporter.reporter.onRunEnd(&results);
106
107 const output = buffer[0..reporter.writer.end];
108 try std.testing.expect(std.mem.indexOf(u8, output, "\"status\":\"flaky\"") != null);
109 try std.testing.expect(std.mem.indexOf(u8, output, "\"attempts\":[") != null);
110 try std.testing.expect(std.mem.indexOf(u8, output, "\"durationNs\":10") != null);
111 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, output, .{});
112 defer parsed.deinit();
71113}
72114
73115test "reporter set selects the same built-ins for every executor" {