ReviewOS

also looking at this

zig-utils/zig-test-framework

Unify discovery and programmatic test execution

#23
Merged glennmichael123 wants to merge codex/issue-5-unified-test-pipeline into main
12 files +656 -169
src/test_runner.zigmodified+73-61
Changes to src/test_runner.zig
@@ -1,6 +1,7 @@
11const std = ;
22const suite = ;
33const reporter_mod = ;
4const pipeline = ;
45const parallel = ;
56const compat = ;
67
@@ -16,15 +17,14 @@ pub const RunnerOptions = struct {
1617 use_colors: bool = true,
1718 parallel: bool = false, // Enable parallel execution
1819 n_jobs: ?usize = null, // Number of parallel jobs
20 junit_output: []const u8 = "test-results.xml",
21 timeout_ms: ?u64 = null,
22 /// Optional output supplied by a CLI host. Embedded/test callers default
23 /// to stderr so they do not interfere with Zig's stdout test protocol.
24 reporter_writer: ?*std.Io.Writer = null,
1925};
2026
21pub const ReporterType = enum {
22 spec,
23 dot,
24 json,
25 tap,
26 junit,
27};
27pub const ReporterType = reporter_mod.ReporterType;
2828
2929pub const TestRunner = struct {
3030 allocator: std.mem.Allocator,
@@ -49,32 +49,27 @@ pub const TestRunner = struct {
4949
5050 /// Run all registered tests
5151 pub fn run(self: *Self) !bool {
52 const stdout_file = std.Io.File.stdout();
53 var stdout_buffer: [4096]u8 = undefined;
52 const stderr_file = std.Io.File.stderr();
53 var stderr_buffer: [4096]u8 = undefined;
5454 var threaded_io: std.Io.Threaded = .init(std.mem.Allocator.failing, .{ .environ = .empty });
5555 defer threaded_io.deinit();
56 var stdout_writer = stdout_file.writer(threaded_io.io(), &stdout_buffer);
57
58 // Create reporter
59 var spec_reporter = reporter_mod.SpecReporter.init(self.allocator, stdout_writer.interface);
60 var dot_reporter = reporter_mod.DotReporter.init(self.allocator, stdout_writer.interface);
61 var json_reporter = reporter_mod.JsonReporter.init(self.allocator, stdout_writer.interface);
62 defer json_reporter.deinit();
63 var tap_reporter = reporter_mod.TAPReporter.init(self.allocator, stdout_writer.interface);
64 var junit_reporter = reporter_mod.JUnitReporter.init(self.allocator, "test-results.xml");
65 defer junit_reporter.deinit();
66
67 var current_reporter: *reporter_mod.Reporter = switch (self.options.reporter_type) {
68 .spec => &spec_reporter.reporter,
69 .dot => &dot_reporter.reporter,
70 .json => &json_reporter.reporter,
71 .tap => &tap_reporter.reporter,
72 .junit => &junit_reporter.reporter,
73 };
74
75 current_reporter.use_colors = self.options.use_colors;
76
77 const total_tests = self.registry.countAllTests();
56 var stderr_writer = stderr_file.writer(threaded_io.io(), &stderr_buffer);
57 const reporter_writer = self.options.reporter_writer orelse &stderr_writer.interface;
58
59 var reporters = reporter_mod.ReporterSet.initRef(
60 self.allocator,
61 reporter_writer,
62 self.options.reporter_type,
63 self.options.junit_output,
64 self.options.use_colors,
65 );
66 defer reporters.deinit();
67 const current_reporter = reporters.selected();
68 const events = pipeline.EventStream{ .rep = current_reporter };
69
70 var plan = try pipeline.TestPlan.fromRegistry(self.allocator, self.registry);
71 defer plan.deinit();
72 const total_tests = plan.items.items.len;
7873 if (total_tests == 0) {
7974 return RunnerError.NoTestsFound;
8075 }
@@ -85,6 +80,7 @@ pub const TestRunner = struct {
8580 .enabled = true,
8681 .n_jobs = self.options.n_jobs,
8782 .filter = self.options.filter,
83 .timeout_ms = self.options.timeout_ms,
8884 };
8985
9086 const all_passed = try parallel.runTestsParallel(
@@ -98,24 +94,24 @@ pub const TestRunner = struct {
9894 try self.addSuiteResults(test_suite);
9995 }
10096
101 try stdout_writer.interface.flush();
97 try reporters.flush();
10298 return all_passed and self.results.failed == 0;
10399 }
104100
105101 // Sequential execution (original behavior)
106 try current_reporter.onRunStart(total_tests);
102 try events.emit(.{ .run_started = total_tests });
107103 for (self.registry.root_suites.items) |test_suite| {
108 try self.runSuite(test_suite, current_reporter);
109 if (self.options.bail and self.results.failed > 0) {
104 try self.runSuite(test_suite, events);
105 if (self.policy().shouldStop(&self.results)) {
110106 break;
111107 }
112108 }
113109
114110 // Notify reporter of run end
115 try current_reporter.onRunEnd(&self.results);
111 try events.emit(.{ .run_finished = &self.results });
116112
117113 // Flush output
118 try stdout_writer.interface.flush();
114 try reporters.flush();
119115
120116 return self.results.failed == 0;
121117 }
@@ -126,26 +122,26 @@ pub const TestRunner = struct {
126122 }
127123
128124 /// Run a single test suite
129 fn runSuite(self: *Self, test_suite: *suite.TestSuite, rep: *reporter_mod.Reporter) !void {
125 fn runSuite(self: *Self, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
130126 // Skip if marked as skip or if has_only and this isn't marked as only
131127 if (test_suite.shouldSkip()) {
132 try self.skipAllTests(test_suite);
128 try self.skipAllTests(test_suite, events);
133129 return;
134130 }
135131
136132 if (self.registry.has_only and !test_suite.hasOnly()) {
137 try self.skipAllTests(test_suite);
133 try self.skipAllTests(test_suite, events);
138134 return;
139135 }
140136
141137 // Notify reporter
142 try rep.onSuiteStart(test_suite.name);
138 try events.emit(.{ .suite_started = test_suite.name });
143139
144140 // Run beforeAll hooks
145141 test_suite.runBeforeAllHooks(self.allocator) catch |err| {
146142 std.debug.print("beforeAll hook failed: {any}\n", .{err});
147 try self.skipAllTests(test_suite);
148 try rep.onSuiteEnd(test_suite.name);
143 try self.skipAllTests(test_suite, events);
144 try events.emit(.{ .suite_finished = test_suite.name });
149145 return;
150146 };
151147
@@ -153,32 +149,30 @@ pub const TestRunner = struct {
153149 for (test_suite.tests.items) |*test_case| {
154150 if (test_case.skip or (self.registry.has_only and !test_case.only)) {
155151 test_case.status = .skipped;
156 try rep.onTestEnd(test_case);
152 try events.emit(.{ .test_finished = test_case });
157153 try self.results.addTest(test_case);
158154 continue;
159155 }
160156
161157 // Check filter
162 if (self.options.filter) |filter| {
163 if (std.mem.indexOf(u8, test_case.name, filter) == null) {
164 test_case.status = .skipped;
165 try rep.onTestEnd(test_case);
166 try self.results.addTest(test_case);
167 continue;
168 }
158 if (!self.policy().matches(test_case.name)) {
159 test_case.status = .skipped;
160 try events.emit(.{ .test_finished = test_case });
161 try self.results.addTest(test_case);
162 continue;
169163 }
170164
171 try self.runTest(test_case, test_suite, rep);
165 try self.runTest(test_case, test_suite, events);
172166
173 if (self.options.bail and test_case.status == .failed) {
167 if (self.policy().shouldStop(&self.results)) {
174168 break;
175169 }
176170 }
177171
178172 // Run nested suites
179173 for (test_suite.suites.items) |nested_suite| {
180 try self.runSuite(nested_suite, rep);
181 if (self.options.bail and self.results.failed > 0) {
174 try self.runSuite(nested_suite, events);
175 if (self.policy().shouldStop(&self.results)) {
182176 break;
183177 }
184178 }
@@ -188,12 +182,12 @@ pub const TestRunner = struct {
188182 std.debug.print("afterAll hook failed: {any}\n", .{err});
189183 };
190184
191 try rep.onSuiteEnd(test_suite.name);
185 try events.emit(.{ .suite_finished = test_suite.name });
192186 }
193187
194188 /// Run a single test
195 fn runTest(self: *Self, test_case: *suite.TestCase, test_suite: *suite.TestSuite, rep: *reporter_mod.Reporter) !void {
196 try rep.onTestStart(test_case.name);
189 fn runTest(self: *Self, test_case: *suite.TestCase, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
190 try events.emit(.{ .test_started = test_case.name });
197191
198192 test_case.status = .running;
199193 const start_time = compat.nanoTimestamp();
@@ -240,22 +234,39 @@ pub const TestRunner = struct {
240234
241235 const end_time = compat.nanoTimestamp();
242236 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)) {
238 test_case.status = .failed;
239 test_case.error_message = try std.fmt.allocPrint(
240 self.allocator,
241 "Test exceeded timeout of {d}ms",
242 .{self.options.timeout_ms.?},
243 );
244 }
243245
244246 try self.results.addTest(test_case);
245 try rep.onTestEnd(test_case);
247 try events.emit(.{ .test_finished = test_case });
246248 }
247249
248250 /// Skip all tests in a suite
249 fn skipAllTests(self: *Self, test_suite: *suite.TestSuite) !void {
251 fn skipAllTests(self: *Self, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
250252 for (test_suite.tests.items) |*test_case| {
251253 test_case.status = .skipped;
252254 try self.results.addTest(test_case);
255 try events.emit(.{ .test_finished = test_case });
253256 }
254257
255258 for (test_suite.suites.items) |nested_suite| {
256 try self.skipAllTests(nested_suite);
259 try self.skipAllTests(nested_suite, events);
257260 }
258261 }
262
263 fn policy(self: *const Self) pipeline.ExecutionPolicy {
264 return .{
265 .bail = self.options.bail,
266 .filter = self.options.filter,
267 .timeout_ms = self.options.timeout_ms,
268 };
269 }
259270};
260271
261272/// Helper function to run tests with default options
@@ -290,6 +301,7 @@ test "RunnerOptions default values" {
290301 try std.testing.expectEqual(@as(?[]const u8, null), options.filter);
291302 try std.testing.expectEqual(ReporterType.spec, options.reporter_type);
292303 try std.testing.expectEqual(true, options.use_colors);
304 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
293305}
294306
295307test "RunnerOptions custom values" {