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

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.

build.zigmodified+7-0
Changes to build.zig
@@ -286,6 +286,13 @@ pub fn build(b: *std.Build) void {
286286 run_shard_two.addArgs(&.{ "--test-dir", "tests", "--pattern", "sample.test.zig", "--shard-index", "2", "--shard-count", "2", "--no-color" });
287287 test_step.dependOn(&run_shard_two.step);
288288
289 const run_discovery_tap = b.addRunArtifact(exe);
290 run_discovery_tap.addArgs(&.{ "--test-dir", "tests", "--pattern", "sample.test.zig", "--reporter", "tap", "--no-color" });
291 run_discovery_tap.expectStdOutEqual(
292 "TAP version 14\n1..1\n# Subtest: sample.test.zig\nok 1 - sample.test.zig\n",
293 );
294 test_step.dependOn(&run_discovery_tap.step);
295
289296 const run_version = b.addRunArtifact(exe);
290297 run_version.addArg("--version");
291298 run_version.expectStdErrEqual(b.fmt("Zig Test Framework v{s}\n", .{package_version}));
src/cli.zigmodified+6-7
Changes to src/cli.zig
@@ -326,7 +326,6 @@ pub const CLI = struct {
326326 if (!self.options.watch and self.options.watch_debounce != 300) return "--watch-debounce";
327327 if (!self.options.ui and self.options.ui_port != 8080) return "--ui-port";
328328 if (!self.options.ui and !std.mem.eql(u8, self.options.ui_host, "127.0.0.1")) return "--ui-host";
329 if (self.options.reporter != .spec) return "--reporter";
330329 if (self.options.quiet) return "--quiet";
331330 if (self.options.parallel) return "--parallel";
332331 if (self.options.jobs != null) return "--jobs";
@@ -335,8 +334,6 @@ pub const CLI = struct {
335334 if (self.options.profile_memory) return "--profile-memory";
336335 if (self.options.memory_threshold != 0) return "--memory-threshold";
337336 if (self.options.fail_on_leak) return "--fail-on-leak";
338 if (self.options.junit_output != null) return "--junit-output";
339 if (self.options.timeout != null) return "--timeout";
340337 if (self.options.ui) return "--ui";
341338 return null;
342339 }
@@ -442,6 +439,8 @@ pub const CLI = struct {
442439 .use_colors = !self.options.no_color,
443440 .parallel = self.options.parallel,
444441 .n_jobs = self.options.jobs,
442 .junit_output = self.options.junit_output orelse "test-results.xml",
443 .timeout_ms = self.options.timeout,
445444 };
446445 }
447446};
@@ -691,18 +690,18 @@ test "discovery accepts implemented options" {
691690 try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption());
692691}
693692
694test "discovery identifies unsupported options" {
693test "discovery accepts reporter selection" {
695694 var cli = CLI.init(std.testing.allocator);
696695 const args = [_][]const u8{ "zig-test", "--test-dir", "tests", "--reporter", "json" };
697696 try cli.parse(&args);
698 try std.testing.expectEqualStrings("--reporter", cli.unsupportedDiscoveryOption().?);
697 try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption());
699698}
700699
701test "discovery reports timeout and parallel options as unsupported" {
700test "discovery accepts timeout and reports parallel as unsupported" {
702701 var cli = CLI.init(std.testing.allocator);
703702
704703 cli.options.timeout = 1000;
705 try std.testing.expectEqualStrings("--timeout", cli.unsupportedDiscoveryOption().?);
704 try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption());
706705
707706 cli.options.timeout = null;
708707 cli.options.parallel = true;
src/lib.zigmodified+10-0
Changes to src/lib.zig
@@ -5,6 +5,7 @@ pub const assertions = ;
55pub const suite = ;
66pub const test_runner = ;
77pub const reporter = ;
8pub const pipeline = ;
89pub const matchers = ;
910pub const mock = ;
1011pub const cli = ;
@@ -79,6 +80,15 @@ pub const JsonReporter = reporter.JsonReporter;
7980pub const TAPReporter = reporter.TAPReporter;
8081pub const JUnitReporter = reporter.JUnitReporter;
8182pub const Colors = reporter.Colors;
83pub const ReporterSet = reporter.ReporterSet;
84
85// Unified execution pipeline
86pub const TestPlan = pipeline.TestPlan;
87pub const PlanItem = pipeline.PlanItem;
88pub const PlanOrigin = pipeline.PlanOrigin;
89pub const ExecutionPolicy = pipeline.ExecutionPolicy;
90pub const LifecycleEvent = pipeline.LifecycleEvent;
91pub const EventStream = pipeline.EventStream;
8292
8393// Matchers
8494pub const Matchers = matchers.Matchers;
src/main.zigmodified+15-1
Changes to src/main.zig
@@ -161,6 +161,11 @@ pub fn main(init: std.process.Init.Minimal) !void {
161161 }
162162
163163 var all_passed: bool = undefined;
164 const stdout_file = std.Io.File.stdout();
165 var stdout_buffer: [4096]u8 = undefined;
166 var reporter_io: std.Io.Threaded = .init(std.mem.Allocator.failing, .{ .environ = .empty });
167 defer reporter_io.deinit();
168 var stdout_writer = stdout_file.writer(reporter_io.io(), &stdout_buffer);
164169 const shard_options: ?lib.ShardOptions = if (cli_parser.options.shard_index) |index|
165170 .{ .index = index, .count = cli_parser.options.shard_count.? }
166171 else
@@ -206,6 +211,10 @@ pub fn main(init: std.process.Init.Minimal) !void {
206211 .verbose = cli_parser.options.verbose,
207212 .use_colors = !cli_parser.options.no_color,
208213 .shard = shard_options,
214 .reporter_type = cli_parser.options.reporter,
215 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
216 .timeout_ms = cli_parser.options.timeout,
217 .reporter_writer = &stdout_writer.interface,
209218 .coverage_options = cov_opts,
210219 .ui_server = if (ui_server) |*server| server else null,
211220 };
@@ -245,6 +254,10 @@ pub fn main(init: std.process.Init.Minimal) !void {
245254 .verbose = cli_parser.options.verbose,
246255 .use_colors = !cli_parser.options.no_color,
247256 .shard = shard_options,
257 .reporter_type = cli_parser.options.reporter,
258 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
259 .timeout_ms = cli_parser.options.timeout,
260 .reporter_writer = &stdout_writer.interface,
248261 .coverage_options = cov_opts,
249262 .ui_server = if (ui_server) |*server| server else null,
250263 };
@@ -256,7 +269,8 @@ pub fn main(init: std.process.Init.Minimal) !void {
256269 defer lib.cleanupRegistry();
257270
258271 // Create test runner with CLI options
259 const runner_options = cli_parser.toRunnerOptions();
272 var runner_options = cli_parser.toRunnerOptions();
273 runner_options.reporter_writer = &stdout_writer.interface;
260274 var runner = lib.TestRunner.init(allocator, registry, runner_options);
261275 defer runner.deinit();
262276
src/parallel.zigmodified+15-1
Changes to src/parallel.zig
@@ -1,6 +1,7 @@
11const std = ;
22const suite = ;
33const reporter = ;
4const pipeline = ;
45const compat = ;
56
67/// Options for bounded parallel test execution.
@@ -10,6 +11,7 @@ pub const ParallelOptions = struct {
1011 n_jobs: ?usize = null,
1112 enabled: bool = false,
1213 filter: ?[]const u8 = null,
14 timeout_ms: ?u64 = null,
1315};
1416
1517pub const ParallelError = error{
@@ -31,6 +33,7 @@ const WorkerContext = struct {
3133 tests: []const *suite.TestCase,
3234 next_index: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
3335 error_mutex: compat.Mutex = .{},
36 policy: pipeline.ExecutionPolicy,
3437
3538 fn worker(self: *WorkerContext) void {
3639 while (true) {
@@ -90,8 +93,15 @@ const WorkerContext = struct {
9093 test_case.error_message = std.fmt.allocPrint(self.allocator, format, args) catch "Out of memory";
9194 }
9295
93 fn finishTiming(_: *WorkerContext, test_case: *suite.TestCase, start_time: i128) void {
96 fn finishTiming(self: *WorkerContext, test_case: *suite.TestCase, start_time: i128) void {
9497 test_case.execution_time_ns = @intCast(compat.nanoTimestamp() - start_time);
98 if (test_case.status == .passed and self.policy.timedOut(test_case.execution_time_ns)) {
99 self.fail(
100 test_case,
101 "Test exceeded timeout of {d}ms",
102 .{self.policy.timeout_ms.?},
103 );
104 }
95105 }
96106};
97107
@@ -167,6 +177,10 @@ const RunContext = struct {
167177 .allocator = self.allocator,
168178 .test_suite = test_suite,
169179 .tests = tests,
180 .policy = .{
181 .filter = self.options.filter,
182 .timeout_ms = self.options.timeout_ms,
183 },
170184 };
171185 const count = @min(self.worker_limit, tests.len);
172186 var threads: std.ArrayList(std.Thread) = .empty;
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}
src/reporter.zigmodified+182-56
Changes to src/reporter.zig
@@ -2,6 +2,14 @@ const std = ;
22const suite = ;
33const compat = ;
44
5pub const ReporterType = enum {
6 spec,
7 dot,
8 json,
9 tap,
10 junit,
11};
12
513/// ANSI color codes
614pub const Colors = struct {
715 pub const reset = "\x1b[0m";
@@ -101,6 +109,7 @@ pub const SpecReporter = struct {
101109 reporter: Reporter,
102110 indent_level: usize = 0,
103111 writer: std.Io.Writer,
112 writer_ref: ?*std.Io.Writer = null,
104113
105114 const Self = @This();
106115
@@ -114,6 +123,16 @@ pub const SpecReporter = struct {
114123 };
115124 }
116125
126 pub fn initRef(allocator: std.mem.Allocator, writer: *std.Io.Writer) Self {
127 var result = init(allocator, writer.*);
128 result.writer_ref = writer;
129 return result;
130 }
131
132 fn output(instance: *Self) *std.Io.Writer {
133 return instance.writer_ref orelse &instance.writer;
134 }
135
117136 const vtable = Reporter.VTable{
118137 .onRunStart = onRunStart,
119138 .onRunEnd = onRunEnd,
@@ -129,64 +148,64 @@ pub const SpecReporter = struct {
129148
130149 fn onRunStart(reporter: *Reporter, total_tests: usize) !void {
131150 const s = self(reporter);
132 try s.writer.print("\n", .{});
151 try s.output().print("\n", .{});
133152 if (reporter.use_colors) {
134 try s.writer.print("{s}Running {d} test(s)...{s}\n\n", .{ Colors.bold, total_tests, Colors.reset });
153 try s.output().print("{s}Running {d} test(s)...{s}\n\n", .{ Colors.bold, total_tests, Colors.reset });
135154 } else {
136 try s.writer.print("Running {d} test(s)...\n\n", .{total_tests});
155 try s.output().print("Running {d} test(s)...\n\n", .{total_tests});
137156 }
138157 }
139158
140159 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
141160 const s = self(reporter);
142 try s.writer.print("\n", .{});
161 try s.output().print("\n", .{});
143162
144163 // Print failed tests details
145164 if (results.failed > 0) {
146165 if (reporter.use_colors) {
147 try s.writer.print("{s}Failed Tests:{s}\n\n", .{ Colors.bold ++ Colors.red, Colors.reset });
166 try s.output().print("{s}Failed Tests:{s}\n\n", .{ Colors.bold ++ Colors.red, Colors.reset });
148167 } else {
149 try s.writer.print("Failed Tests:\n\n", .{});
168 try s.output().print("Failed Tests:\n\n", .{});
150169 }
151170
152171 for (results.failed_tests.items) |test_case| {
153172 if (reporter.use_colors) {
154 try s.writer.print(" {s}✗{s} {s}\n", .{ Colors.red, Colors.reset, test_case.name });
173 try s.output().print(" {s}✗{s} {s}\n", .{ Colors.red, Colors.reset, test_case.name });
155174 } else {
156 try s.writer.print(" ✗ {s}\n", .{test_case.name});
175 try s.output().print(" ✗ {s}\n", .{test_case.name});
157176 }
158177 if (test_case.error_message) |msg| {
159 try s.writer.print(" {s}\n", .{msg});
178 try s.output().print(" {s}\n", .{msg});
160179 }
161180 }
162 try s.writer.print("\n", .{});
181 try s.output().print("\n", .{});
163182 }
164183
165184 // Print summary
166185 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
167186
168187 if (reporter.use_colors) {
169 try s.writer.print("{s}Test Summary:{s}\n", .{ Colors.bold, Colors.reset });
170 try s.writer.print(" Total: {d}\n", .{results.total});
171 try s.writer.print(" {s}Passed: {d}{s}\n", .{ Colors.green, results.passed, Colors.reset });
188 try s.output().print("{s}Test Summary:{s}\n", .{ Colors.bold, Colors.reset });
189 try s.output().print(" Total: {d}\n", .{results.total});
190 try s.output().print(" {s}Passed: {d}{s}\n", .{ Colors.green, results.passed, Colors.reset });
172191 if (results.failed > 0) {
173 try s.writer.print(" {s}Failed: {d}{s}\n", .{ Colors.red, results.failed, Colors.reset });
192 try s.output().print(" {s}Failed: {d}{s}\n", .{ Colors.red, results.failed, Colors.reset });
174193 }
175194 if (results.skipped > 0) {
176 try s.writer.print(" {s}Skipped: {d}{s}\n", .{ Colors.yellow, results.skipped, Colors.reset });
195 try s.output().print(" {s}Skipped: {d}{s}\n", .{ Colors.yellow, results.skipped, Colors.reset });
177196 }
178 try s.writer.print(" Time: {d:.2}ms\n", .{total_time_ms});
197 try s.output().print(" Time: {d:.2}ms\n", .{total_time_ms});
179198 } else {
180 try s.writer.print("Test Summary:\n", .{});
181 try s.writer.print(" Total: {d}\n", .{results.total});
182 try s.writer.print(" Passed: {d}\n", .{results.passed});
199 try s.output().print("Test Summary:\n", .{});
200 try s.output().print(" Total: {d}\n", .{results.total});
201 try s.output().print(" Passed: {d}\n", .{results.passed});
183202 if (results.failed > 0) {
184 try s.writer.print(" Failed: {d}\n", .{results.failed});
203 try s.output().print(" Failed: {d}\n", .{results.failed});
185204 }
186205 if (results.skipped > 0) {
187 try s.writer.print(" Skipped: {d}\n", .{results.skipped});
206 try s.output().print(" Skipped: {d}\n", .{results.skipped});
188207 }
189 try s.writer.print(" Time: {d:.2}ms\n", .{total_time_ms});
208 try s.output().print(" Time: {d:.2}ms\n", .{total_time_ms});
190209 }
191210 }
192211
@@ -196,13 +215,13 @@ pub const SpecReporter = struct {
196215 // Print indent
197216 var i: usize = 0;
198217 while (i < s.indent_level) : (i += 1) {
199 try s.writer.print(" ", .{});
218 try s.output().print(" ", .{});
200219 }
201220
202221 if (reporter.use_colors) {
203 try s.writer.print("{s}{s}{s}\n", .{ Colors.bold, suite_name, Colors.reset });
222 try s.output().print("{s}{s}{s}\n", .{ Colors.bold, suite_name, Colors.reset });
204223 } else {
205 try s.writer.print("{s}\n", .{suite_name});
224 try s.output().print("{s}\n", .{suite_name});
206225 }
207226 s.indent_level += 1;
208227 }
@@ -226,7 +245,7 @@ pub const SpecReporter = struct {
226245 // Print indent
227246 var i: usize = 0;
228247 while (i < s.indent_level) : (i += 1) {
229 try s.writer.print(" ", .{});
248 try s.output().print(" ", .{});
230249 }
231250
232251 const time_ms = @as(f64, @floatFromInt(test_case.execution_time_ns)) / 1_000_000.0;
@@ -234,7 +253,7 @@ pub const SpecReporter = struct {
234253 switch (test_case.status) {
235254 .passed => {
236255 if (reporter.use_colors) {
237 try s.writer.print("{s}✓{s} {s} {s}({d:.2}ms){s}\n", .{
256 try s.output().print("{s}✓{s} {s} {s}({d:.2}ms){s}\n", .{
238257 Colors.green,
239258 Colors.reset,
240259 test_case.name,
@@ -243,12 +262,12 @@ pub const SpecReporter = struct {
243262 Colors.reset,
244263 });
245264 } else {
246 try s.writer.print("✓ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
265 try s.output().print("✓ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
247266 }
248267 },
249268 .failed => {
250269 if (reporter.use_colors) {
251 try s.writer.print("{s}✗{s} {s} {s}({d:.2}ms){s}\n", .{
270 try s.output().print("{s}✗{s} {s} {s}({d:.2}ms){s}\n", .{
252271 Colors.red,
253272 Colors.reset,
254273 test_case.name,
@@ -257,12 +276,12 @@ pub const SpecReporter = struct {
257276 Colors.reset,
258277 });
259278 } else {
260 try s.writer.print("✗ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
279 try s.output().print("✗ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
261280 }
262281 },
263282 .skipped => {
264283 if (reporter.use_colors) {
265 try s.writer.print("{s}⊘{s} {s} {s}(skipped){s}\n", .{
284 try s.output().print("{s}⊘{s} {s} {s}(skipped){s}\n", .{
266285 Colors.yellow,
267286 Colors.reset,
268287 test_case.name,
@@ -270,7 +289,7 @@ pub const SpecReporter = struct {
270289 Colors.reset,
271290 });
272291 } else {
273 try s.writer.print("⊘ {s} (skipped)\n", .{test_case.name});
292 try s.output().print("⊘ {s} (skipped)\n", .{test_case.name});
274293 }
275294 },
276295 else => {},
@@ -282,6 +301,7 @@ pub const SpecReporter = struct {
282301pub const DotReporter = struct {
283302 reporter: Reporter,
284303 writer: std.Io.Writer,
304 writer_ref: ?*std.Io.Writer = null,
285305 tests_per_line: usize = 80,
286306 current_line_count: usize = 0,
287307
@@ -297,6 +317,16 @@ pub const DotReporter = struct {
297317 };
298318 }
299319
320 pub fn initRef(allocator: std.mem.Allocator, writer: *std.Io.Writer) Self {
321 var result = init(allocator, writer.*);
322 result.writer_ref = writer;
323 return result;
324 }
325
326 fn output(instance: *Self) *std.Io.Writer {
327 return instance.writer_ref orelse &instance.writer;
328 }
329
300330 const vtable = Reporter.VTable{
301331 .onRunStart = onRunStart,
302332 .onRunEnd = onRunEnd,
@@ -312,21 +342,21 @@ pub const DotReporter = struct {
312342
313343 fn onRunStart(reporter: *Reporter, total_tests: usize) !void {
314344 const s = self(reporter);
315 try s.writer.print("\nRunning {d} tests:\n", .{total_tests});
345 try s.output().print("\nRunning {d} tests:\n", .{total_tests});
316346 }
317347
318348 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
319349 const s = self(reporter);
320 try s.writer.print("\n\n", .{});
350 try s.output().print("\n\n", .{});
321351
322352 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
323353
324354 if (reporter.use_colors) {
325 try s.writer.print("{s}Passed: {d}{s}, ", .{ Colors.green, results.passed, Colors.reset });
326 try s.writer.print("{s}Failed: {d}{s}, ", .{ Colors.red, results.failed, Colors.reset });
327 try s.writer.print("Total: {d} ({d:.2}ms)\n", .{ results.total, total_time_ms });
355 try s.output().print("{s}Passed: {d}{s}, ", .{ Colors.green, results.passed, Colors.reset });
356 try s.output().print("{s}Failed: {d}{s}, ", .{ Colors.red, results.failed, Colors.reset });
357 try s.output().print("Total: {d} ({d:.2}ms)\n", .{ results.total, total_time_ms });
328358 } else {
329 try s.writer.print("Passed: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
359 try s.output().print("Passed: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
330360 results.passed,
331361 results.failed,
332362 results.total,
@@ -356,23 +386,23 @@ pub const DotReporter = struct {
356386 switch (test_case.status) {
357387 .passed => {
358388 if (reporter.use_colors) {
359 try s.writer.print("{s}.{s}", .{ Colors.green, Colors.reset });
389 try s.output().print("{s}.{s}", .{ Colors.green, Colors.reset });
360390 } else {
361 try s.writer.print(".", .{});
391 try s.output().print(".", .{});
362392 }
363393 },
364394 .failed => {
365395 if (reporter.use_colors) {
366 try s.writer.print("{s}F{s}", .{ Colors.red, Colors.reset });
396 try s.output().print("{s}F{s}", .{ Colors.red, Colors.reset });
367397 } else {
368 try s.writer.print("F", .{});
398 try s.output().print("F", .{});
369399 }
370400 },
371401 .skipped => {
372402 if (reporter.use_colors) {
373 try s.writer.print("{s}S{s}", .{ Colors.yellow, Colors.reset });
403 try s.output().print("{s}S{s}", .{ Colors.yellow, Colors.reset });
374404 } else {
375 try s.writer.print("S", .{});
405 try s.output().print("S", .{});
376406 }
377407 },
378408 else => {},
@@ -380,7 +410,7 @@ pub const DotReporter = struct {
380410
381411 s.current_line_count += 1;
382412 if (s.current_line_count >= s.tests_per_line) {
383 try s.writer.print("\n", .{});
413 try s.output().print("\n", .{});
384414 s.current_line_count = 0;
385415 }
386416 }
@@ -390,6 +420,7 @@ pub const DotReporter = struct {
390420pub const JsonReporter = struct {
391421 reporter: Reporter,
392422 writer: std.Io.Writer,
423 writer_ref: ?*std.Io.Writer = null,
393424 suites: std.ArrayList([]const u8),
394425
395426 const Self = @This();
@@ -405,6 +436,16 @@ pub const JsonReporter = struct {
405436 };
406437 }
407438
439 pub fn initRef(allocator: std.mem.Allocator, writer: *std.Io.Writer) Self {
440 var result = init(allocator, writer.*);
441 result.writer_ref = writer;
442 return result;
443 }
444
445 fn output(instance: *Self) *std.Io.Writer {
446 return instance.writer_ref orelse &instance.writer;
447 }
448
408449 pub fn deinit(s: *Self) void {
409450 s.suites.deinit(s.reporter.allocator);
410451 }
@@ -424,14 +465,14 @@ pub const JsonReporter = struct {
424465
425466 fn onRunStart(reporter: *Reporter, total_tests: usize) !void {
426467 const s = self(reporter);
427 try s.writer.print("{{\"totalTests\":{d},\"tests\":[\n", .{total_tests});
468 try s.output().print("{{\"totalTests\":{d},\"tests\":[\n", .{total_tests});
428469 }
429470
430471 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
431472 const s = self(reporter);
432473 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
433474
434 try s.writer.print("],\"summary\":{{\"total\":{d},\"passed\":{d},\"failed\":{d},\"skipped\":{d},\"time\":{d:.2}}}}}\n", .{
475 try s.output().print("],\"summary\":{{\"total\":{d},\"passed\":{d},\"failed\":{d},\"skipped\":{d},\"time\":{d:.2}}}}}\n", .{
435476 results.total,
436477 results.passed,
437478 results.failed,
@@ -470,17 +511,17 @@ pub const JsonReporter = struct {
470511 };
471512
472513 // Note: In a real implementation, you'd want to properly escape JSON strings
473 try s.writer.print(" {{\"name\":\"{s}\",\"status\":\"{s}\",\"time\":{d:.2}", .{
514 try s.output().print(" {{\"name\":\"{s}\",\"status\":\"{s}\",\"time\":{d:.2}", .{
474515 test_case.name,
475516 status_str,
476517 time_ms,
477518 });
478519
479520 if (test_case.error_message) |msg| {
480 try s.writer.print(",\"error\":\"{s}\"", .{msg});
521 try s.output().print(",\"error\":\"{s}\"", .{msg});
481522 }
482523
483 try s.writer.print("}},\n", .{});
524 try s.output().print("}},\n", .{});
484525 }
485526};
486527
@@ -488,6 +529,7 @@ pub const JsonReporter = struct {
488529pub const TAPReporter = struct {
489530 reporter: Reporter,
490531 writer: std.Io.Writer,
532 writer_ref: ?*std.Io.Writer = null,
491533 test_count: usize = 0,
492534
493535 const Self = @This();
@@ -510,9 +552,19 @@ pub const TAPReporter = struct {
510552 };
511553 }
512554
555 pub fn initRef(allocator: std.mem.Allocator, writer: *std.Io.Writer) Self {
556 var result = init(allocator, writer.*);
557 result.writer_ref = writer;
558 return result;
559 }
560
561 fn output(instance: *Self) *std.Io.Writer {
562 return instance.writer_ref orelse &instance.writer;
563 }
564
513565 fn onRunStart(reporter: *Reporter, total: usize) !void {
514566 const self: *Self = @fieldParentPtr("reporter", reporter);
515 try self.writer.print("TAP version 14\n1..{d}\n", .{total});
567 try self.output().print("TAP version 14\n1..{d}\n", .{total});
516568 }
517569
518570 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
@@ -522,7 +574,7 @@ pub const TAPReporter = struct {
522574
523575 fn onSuiteStart(reporter: *Reporter, suite_name: []const u8) !void {
524576 const self: *Self = @fieldParentPtr("reporter", reporter);
525 try self.writer.print("# Subtest: {s}\n", .{suite_name});
577 try self.output().print("# Subtest: {s}\n", .{suite_name});
526578 }
527579
528580 fn onSuiteEnd(reporter: *Reporter, suite_name: []const u8) !void {
@@ -546,14 +598,14 @@ pub const TAPReporter = struct {
546598 else => "not ok",
547599 };
548600
549 try self.writer.print("{s} {d} - {s}", .{ status, self.test_count, test_case.name });
601 try self.output().print("{s} {d} - {s}", .{ status, self.test_count, test_case.name });
550602
551603 if (test_case.status == .skipped) {
552 try self.writer.print(" # SKIP\n", .{});
604 try self.output().print(" # SKIP\n", .{});
553605 } else if (test_case.status == .failed and test_case.error_message != null) {
554 try self.writer.print("\n ---\n message: {s}\n ...\n", .{test_case.error_message.?});
606 try self.output().print("\n ---\n message: {s}\n ...\n", .{test_case.error_message.?});
555607 } else {
556 try self.writer.print("\n", .{});
608 try self.output().print("\n", .{});
557609 }
558610 }
559611};
@@ -735,6 +787,80 @@ pub const JUnitReporter = struct {
735787 }
736788};
737789
790/// Owns every built-in reporter and selects one through a shared interface.
791/// Keeping construction here lets every executor expose identical reporters.
792pub const ReporterSet = struct {
793 kind: ReporterType,
794 spec: SpecReporter,
795 dot: DotReporter,
796 json: JsonReporter,
797 tap: TAPReporter,
798 junit: JUnitReporter,
799
800 pub fn init(
801 allocator: std.mem.Allocator,
802 writer: std.Io.Writer,
803 kind: ReporterType,
804 junit_output: []const u8,
805 use_colors: bool,
806 ) ReporterSet {
807 var reporters = ReporterSet{
808 .kind = kind,
809 .spec = SpecReporter.init(allocator, writer),
810 .dot = DotReporter.init(allocator, writer),
811 .json = JsonReporter.init(allocator, writer),
812 .tap = TAPReporter.init(allocator, writer),
813 .junit = JUnitReporter.init(allocator, junit_output),
814 };
815 reporters.selected().use_colors = use_colors;
816 return reporters;
817 }
818
819 pub fn initRef(
820 allocator: std.mem.Allocator,
821 writer: *std.Io.Writer,
822 kind: ReporterType,
823 junit_output: []const u8,
824 use_colors: bool,
825 ) ReporterSet {
826 var reporters = ReporterSet{
827 .kind = kind,
828 .spec = SpecReporter.initRef(allocator, writer),
829 .dot = DotReporter.initRef(allocator, writer),
830 .json = JsonReporter.initRef(allocator, writer),
831 .tap = TAPReporter.initRef(allocator, writer),
832 .junit = JUnitReporter.init(allocator, junit_output),
833 };
834 reporters.selected().use_colors = use_colors;
835 return reporters;
836 }
837
838 pub fn deinit(self: *ReporterSet) void {
839 self.json.deinit();
840 self.junit.deinit();
841 }
842
843 pub fn selected(self: *ReporterSet) *Reporter {
844 return switch (self.kind) {
845 .spec => &self.spec.reporter,
846 .dot => &self.dot.reporter,
847 .json => &self.json.reporter,
848 .tap => &self.tap.reporter,
849 .junit => &self.junit.reporter,
850 };
851 }
852
853 pub fn flush(self: *ReporterSet) !void {
854 switch (self.kind) {
855 .spec => try self.spec.output().flush(),
856 .dot => try self.dot.output().flush(),
857 .json => try self.json.output().flush(),
858 .tap => try self.tap.output().flush(),
859 .junit => {},
860 }
861 }
862};
863
738864// Tests
739865test "Colors constants exist" {
740866 // Just verify the constants are defined
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);
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" {
tests/reporter_test.zigmodified+16-0
Changes to tests/reporter_test.zig
@@ -69,3 +69,19 @@ test "reporter json output" {
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);
7171}
72
73test "reporter set selects the same built-ins for every executor" {
74 var buffer: [1024]u8 = undefined;
75 const writer: std.Io.Writer = .fixed(&buffer);
76 var reporters = ztf.ReporterSet.init(
77 std.testing.allocator,
78 writer,
79 .tap,
80 "test-results.xml",
81 false,
82 );
83 defer reporters.deinit();
84
85 try std.testing.expect(reporters.selected() == &reporters.tap.reporter);
86 try std.testing.expect(!reporters.selected().use_colors);
87}