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_loader.zigmodified+89-38
Changes to src/test_loader.zig
@@ -1,6 +1,9 @@
11const std = ;
22const discovery = ;
33const sharding = ;
4const pipeline = ;
5const reporter = ;
6const suite = ;
47const coverage = ;
58const ui_server = ;
69const compat = ;
@@ -17,6 +20,15 @@ pub const LoaderOptions = struct {
1720 use_colors: bool = true,
1821 /// Optional one-based file shard to execute
1922 shard: ?sharding.ShardOptions = null,
23 /// Reporter shared with programmatic execution
24 reporter_type: reporter.ReporterType = .spec,
25 /// Output path used by the JUnit reporter
26 junit_output: []const u8 = "test-results.xml",
27 /// Global per-file timeout checked by the shared execution policy
28 timeout_ms: ?u64 = null,
29 /// Optional output supplied by a CLI host. Embedded/test callers default
30 /// to stderr so they do not interfere with Zig's stdout test protocol.
31 reporter_writer: ?*std.Io.Writer = null,
2032 /// Coverage options
2133 coverage_options: ?coverage.CoverageOptions = null,
2234 /// UI server for real-time updates
@@ -34,7 +46,9 @@ pub fn runDiscoveredTests(
3446 return false;
3547 }
3648
37 const selected_files = try selectedFileCount(discovered, options.shard);
49 var plan = try discoveryPlan(allocator, discovered, options.shard);
50 defer plan.deinit();
51 const selected_files = plan.items.items.len;
3852 if (options.shard) |shard| {
3953 std.debug.print(
4054 "Shard {}/{}: selected {} of {} test file(s).\n",
@@ -43,12 +57,37 @@ pub fn runDiscoveredTests(
4357 }
4458
4559 std.debug.print("Found {} test file(s):\n", .{selected_files});
46 for (discovered.files.items) |file| {
47 if (!try fileSelected(file.relative_path, options.shard)) continue;
60 for (plan.items.items) |item| {
61 const file = item.discovered;
4862 std.debug.print(" - {s}\n", .{file.relative_path});
4963 }
5064 std.debug.print("\n", .{});
5165
66 const stderr_file = std.Io.File.stderr();
67 var stderr_buffer: [4096]u8 = undefined;
68 var threaded_io: std.Io.Threaded = .init(std.mem.Allocator.failing, .{ .environ = .empty });
69 defer threaded_io.deinit();
70 var stderr_writer = stderr_file.writer(threaded_io.io(), &stderr_buffer);
71 const reporter_writer = options.reporter_writer orelse &stderr_writer.interface;
72 var reporters = reporter.ReporterSet.initRef(
73 allocator,
74 reporter_writer,
75 options.reporter_type,
76 options.junit_output,
77 options.use_colors,
78 );
79 defer reporters.deinit();
80 const events = pipeline.EventStream{ .rep = reporters.selected() };
81 const policy = pipeline.ExecutionPolicy{
82 .bail = options.bail,
83 .filter = options.filter,
84 .timeout_ms = options.timeout_ms,
85 };
86 var results = reporter.TestResults.init(allocator);
87 defer results.deinit();
88
89 try events.emit(.{ .run_started = selected_files });
90
5291 // Notify UI of run start
5392 if (options.ui_server) |server| {
5493 var buffer: [256]u8 = undefined;
@@ -56,8 +95,6 @@ pub fn runDiscoveredTests(
5695 try server.broadcast("run_start", json);
5796 }
5897
59 var total_passed: usize = 0;
60 var total_failed: usize = 0;
6198 var files_run: usize = 0;
6299
63100 // Clean coverage directory once before all tests if coverage is enabled
@@ -68,11 +105,12 @@ pub fn runDiscoveredTests(
68105 }
69106 }
70107
71 for (discovered.files.items) |file| {
72 if (!try fileSelected(file.relative_path, options.shard)) continue;
73
108 for (plan.items.items) |item| {
109 const file = item.discovered;
74110 // Run each test file using zig test command
75111 std.debug.print("Running {s}...\n", .{file.relative_path});
112 try events.emit(.{ .suite_started = file.relative_path });
113 try events.emit(.{ .test_started = file.name });
76114
77115 // Notify UI of test file start
78116 if (options.ui_server) |server| {
@@ -83,11 +121,25 @@ pub fn runDiscoveredTests(
83121 }
84122
85123 const start_time = compat.nanoTimestamp();
86 const result = try runTestFile(allocator, file.path, options);
124 var result = try runTestFile(allocator, file.path, options);
87125 const end_time = compat.nanoTimestamp();
88126 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;
89129
90130 files_run += 1;
131 var test_case = suite.TestCase.init(file.name, externalTestNoop);
132 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";
139 }
140 try results.addTest(&test_case);
141 try events.emit(.{ .test_finished = &test_case });
142 try events.emit(.{ .suite_finished = file.relative_path });
91143
92144 // Notify UI of test file end
93145 if (options.ui_server) |server| {
@@ -99,15 +151,10 @@ pub fn runDiscoveredTests(
99151 }
100152
101153 if (result) {
102 total_passed += 1;
103 if (options.verbose) {
104 std.debug.print(" ✓ {s} passed\n", .{file.name});
105 }
154 if (options.verbose) std.debug.print(" ✓ {s} passed\n", .{file.name});
106155 } else {
107 total_failed += 1;
108156 std.debug.print(" ✗ {s} failed\n", .{file.name});
109
110 if (options.bail) {
157 if (policy.shouldStop(&results)) {
111158 std.debug.print("\nStopping on first failure (--bail)\n", .{});
112159 break;
113160 }
@@ -118,48 +165,49 @@ pub fn runDiscoveredTests(
118165 if (options.coverage_options) |cov_opts| {
119166 if (cov_opts.enabled and files_run > 0) {
120167 std.debug.print("\n", .{});
121 const cov_result = coverage.parseCoverageReport(allocator, cov_opts.output_dir) catch |err| {
168 const cov_result: ?coverage.CoverageResult = coverage.parseCoverageReport(allocator, cov_opts.output_dir) catch |err| result: {
122169 std.debug.print("Warning: Could not parse coverage report: {any}\n", .{err});
123 return total_failed == 0;
170 break :result null;
124171 };
125
126 coverage.printCoverageSummary(cov_result);
172 if (cov_result) |summary| coverage.printCoverageSummary(summary);
127173 }
128174 }
129175
130176 // Notify UI of run end
131177 if (options.ui_server) |server| {
132178 var buffer: [512]u8 = undefined;
133 const json = try std.fmt.bufPrint(&buffer, "", .{ files_run, total_passed, total_failed });
179 const json = try std.fmt.bufPrint(&buffer, "{{\"total\":{d},\"passed\":{d},\"failed\":{d},\"skipped\":{d}}}", .{ results.total, results.passed, results.failed, results.skipped });
134180 try server.broadcast("run_end", json);
135181 }
136182
137 std.debug.print("\n", .{});
138 std.debug.print("Test Summary:\n", .{});
139183 if (options.shard) |shard| {
184 std.debug.print("\nShard Summary:\n", .{});
140185 std.debug.print(" Shard: {}/{}\n", .{ shard.index, shard.count });
141186 std.debug.print(" Selected: {} of {} files\n", .{ selected_files, discovered.files.items.len });
142187 }
143 std.debug.print(" Files run: {}\n", .{files_run});
144 std.debug.print(" Passed: {}\n", .{total_passed});
145 std.debug.print(" Failed: {}\n", .{total_failed});
188 try events.emit(.{ .run_finished = &results });
189 try reporters.flush();
146190
147 return total_failed == 0;
191 return results.failed == 0;
148192}
149193
194fn externalTestNoop(_: std.mem.Allocator) !void {}
195
150196fn fileSelected(relative_path: []const u8, shard: ?sharding.ShardOptions) !bool {
151197 return if (shard) |selection| selection.includes(relative_path) else true;
152198}
153199
154fn selectedFileCount(
155 discovered: *const discovery.DiscoveryResult,
200fn discoveryPlan(
201 allocator: std.mem.Allocator,
202 discovered: *discovery.DiscoveryResult,
156203 shard: ?sharding.ShardOptions,
157) !usize {
158 var count: usize = 0;
159 for (discovered.files.items) |file| {
160 if (try fileSelected(file.relative_path, shard)) count += 1;
204) !pipeline.TestPlan {
205 var plan = pipeline.TestPlan.init(allocator);
206 errdefer plan.deinit();
207 for (discovered.files.items) |*file| {
208 if (try fileSelected(file.relative_path, shard)) try plan.appendDiscovered(file);
161209 }
162 return count;
210 return plan;
163211}
164212
165213/// Run a single test file using `zig test`
@@ -241,6 +289,10 @@ test "LoaderOptions default values" {
241289 try std.testing.expectEqual(false, options.verbose);
242290 try std.testing.expectEqual(true, options.use_colors);
243291 try std.testing.expectEqual(@as(?sharding.ShardOptions, null), options.shard);
292 try std.testing.expectEqual(reporter.ReporterType.spec, options.reporter_type);
293 try std.testing.expectEqualStrings("test-results.xml", options.junit_output);
294 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
295 try std.testing.expectEqual(@as(?*std.Io.Writer, null), options.reporter_writer);
244296 try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options);
245297}
246298
@@ -303,10 +355,9 @@ test "file sharding partitions discovered files exactly once" {
303355
304356 var total_selected: usize = 0;
305357 for (1..4) |index| {
306 total_selected += try selectedFileCount(
307 &discovered,
308 .{ .index = index, .count = 3 },
309 );
358 var plan = try discoveryPlan(allocator, &discovered, .{ .index = index, .count = 3 });
359 defer plan.deinit();
360 total_selected += plan.items.items.len;
310361 }
311362
312363 try std.testing.expectEqual(discovered.files.items.len, total_selected);