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/pipeline.zigadded+169-0
Changes to src/pipeline.zig
@@ -0,0 +1,169 @@
1const std = ;
2const discovery = ;
3const reporter = ;
4const suite = ;
5
6pub const PlanOrigin = enum {
7 registered,
8 discovered,
9};
10
11pub const RegisteredTest = struct {
12 test_suite: *suite.TestSuite,
13 test_case: *suite.TestCase,
14};
15
16pub const PlanItem = union(PlanOrigin) {
17 registered: RegisteredTest,
18 discovered: *const discovery.TestFile,
19
20 pub fn identity(self: PlanItem) []const u8 {
21 return switch (self) {
22 .registered => |item| item.test_case.name,
23 .discovered => |file| file.relative_path,
24 };
25 }
26};
27
28/// Executor-neutral plan produced by registration or file discovery.
29pub const TestPlan = struct {
30 allocator: std.mem.Allocator,
31 items: std.ArrayList(PlanItem) = .empty,
32
33 pub fn init(allocator: std.mem.Allocator) TestPlan {
34 return .{ .allocator = allocator };
35 }
36
37 pub fn deinit(self: *TestPlan) void {
38 self.items.deinit(self.allocator);
39 }
40
41 pub fn fromRegistry(
42 allocator: std.mem.Allocator,
43 registry: *suite.TestRegistry,
44 ) !TestPlan {
45 var plan = TestPlan.init(allocator);
46 errdefer plan.deinit();
47 for (registry.root_suites.items) |test_suite| {
48 try plan.appendSuite(test_suite);
49 }
50 return plan;
51 }
52
53 pub fn fromDiscovery(
54 allocator: std.mem.Allocator,
55 discovered: *const discovery.DiscoveryResult,
56 ) !TestPlan {
57 var plan = TestPlan.init(allocator);
58 errdefer plan.deinit();
59 for (discovered.files.items) |*file| {
60 try plan.appendDiscovered(file);
61 }
62 return plan;
63 }
64
65 pub fn appendDiscovered(self: *TestPlan, file: *const discovery.TestFile) !void {
66 try self.items.append(self.allocator, .{ .discovered = file });
67 }
68
69 fn appendSuite(self: *TestPlan, test_suite: *suite.TestSuite) !void {
70 for (test_suite.tests.items) |*test_case| {
71 try self.items.append(self.allocator, .{ .registered = .{
72 .test_suite = test_suite,
73 .test_case = test_case,
74 } });
75 }
76 for (test_suite.suites.items) |nested| {
77 try self.appendSuite(nested);
78 }
79 }
80};
81
82/// Shared execution policy. Executors apply it at their natural granularity:
83/// registered test names or filters forwarded to an external Zig test process.
84pub const ExecutionPolicy = struct {
85 bail: bool = false,
86 filter: ?[]const u8 = null,
87 timeout_ms: ?u64 = null,
88
89 pub fn matches(self: ExecutionPolicy, name: []const u8) bool {
90 const filter = self.filter orelse return true;
91 return std.mem.indexOf(u8, name, filter) != null;
92 }
93
94 pub fn shouldStop(self: ExecutionPolicy, results: *const reporter.TestResults) bool {
95 return self.bail and results.failed > 0;
96 }
97
98 pub fn timedOut(self: ExecutionPolicy, execution_time_ns: u64) bool {
99 const timeout_ms = self.timeout_ms orelse return false;
100 return execution_time_ns > timeout_ms * std.time.ns_per_ms;
101 }
102};
103
104pub const LifecycleEvent = union(enum) {
105 run_started: usize,
106 run_finished: *reporter.TestResults,
107 suite_started: []const u8,
108 suite_finished: []const u8,
109 test_started: []const u8,
110 test_finished: *const suite.TestCase,
111};
112
113/// Routes the typed lifecycle shared by every executor into existing reporter
114/// callbacks. Additional consumers can be added without changing executors.
115pub const EventStream = struct {
116 rep: *reporter.Reporter,
117
118 pub fn emit(self: EventStream, event: LifecycleEvent) !void {
119 switch (event) {
120 .run_started => |total| try self.rep.onRunStart(total),
121 .run_finished => |results| try self.rep.onRunEnd(results),
122 .suite_started => |name| try self.rep.onSuiteStart(name),
123 .suite_finished => |name| try self.rep.onSuiteEnd(name),
124 .test_started => |name| try self.rep.onTestStart(name),
125 .test_finished => |test_case| try self.rep.onTestEnd(test_case),
126 }
127 }
128};
129
130fn passingTest(_: std.mem.Allocator) !void {}
131
132test "registered and discovered inputs produce one plan model" {
133 const allocator = std.testing.allocator;
134
135 var registry = suite.TestRegistry.init(allocator);
136 defer registry.deinit();
137 const test_suite = try suite.TestSuite.init(allocator, "unit");
138 try test_suite.addTest(suite.TestCase.init("passes", passingTest));
139 try registry.registerSuite(test_suite);
140
141 var registered = try TestPlan.fromRegistry(allocator, &registry);
142 defer registered.deinit();
143 try std.testing.expectEqual(@as(usize, 1), registered.items.items.len);
144 try std.testing.expectEqualStrings("passes", registered.items.items[0].identity());
145
146 var discovered = discovery.DiscoveryResult.init(allocator);
147 defer discovered.deinit();
148 try discovered.addFile("tests/sample.test.zig", "sample.test.zig", "sample.test.zig");
149
150 var external = try TestPlan.fromDiscovery(allocator, &discovered);
151 defer external.deinit();
152 try std.testing.expectEqual(@as(usize, 1), external.items.items.len);
153 try std.testing.expectEqualStrings("sample.test.zig", external.items.items[0].identity());
154}
155
156test "execution policy shares matching and bail semantics" {
157 const policy = ExecutionPolicy{ .bail = true, .filter = "selected", .timeout_ms = 500 };
158 try std.testing.expect(policy.matches("selected test"));
159 try std.testing.expect(!policy.matches("other test"));
160 try std.testing.expectEqual(@as(?u64, 500), policy.timeout_ms);
161 try std.testing.expect(!policy.timedOut(499 * std.time.ns_per_ms));
162 try std.testing.expect(policy.timedOut(501 * std.time.ns_per_ms));
163
164 var results = reporter.TestResults.init(std.testing.allocator);
165 defer results.deinit();
166 try std.testing.expect(!policy.shouldStop(&results));
167 results.failed = 1;
168 try std.testing.expect(policy.shouldStop(&results));
169}