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
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.