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
README.mdmodified+12-5
Changes to README.md
@@ -95,15 +95,20 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
9595- `--no-recursive` - Disable recursive directory search
9696- `--bail` - Stop on first failure
9797- `--filter <text>` / `--grep <text>` - Run only Zig tests whose names contain the text
98- `--reporter <name>` - Use spec, dot, JSON, TAP, or JUnit output
99- `--junit-output <file>` - Set the JUnit XML path
100- `--timeout <ms>` - Mark files that exceed the global time budget as failed
98101- `--shard-index <N>` - Run one one-based file shard
99102- `--shard-count <N>` - Set the total number of file shards
100103- `--no-color` - Disable color in child Zig test processes
101104- `--verbose` - Show detailed output
102105
103Discovery mode rejects options that it cannot honor yet instead of silently
104ignoring them. Reporter selection, quiet output, timeouts, parallel jobs,
105snapshot updates, memory profiling, JUnit output, and the web UI require the
106shared execution pipeline planned for future releases.
106Discovery and programmatic execution share the same test-plan, lifecycle,
107result, and reporter model. Discovery still rejects options it cannot honor
108instead of silently ignoring them: quiet output, parallel jobs, snapshot
109updates, memory profiling, and the web UI remain programmatic-only. See
110[the execution pipeline architecture](docs/execution-pipeline.md) for extension
111points, timeout semantics, and migration guidance.
107112
108113**Examples:**
109114
@@ -634,7 +639,9 @@ zig-test --no-color
634639| `--version` | `-v` | Show version information |
635640| `--bail` | `-b` | Stop on first failure |
636641| `--filter <pattern>` | | Run only tests matching pattern |
637| `--reporter <name>` | | Set reporter (spec, dot, json) |
642| `--reporter <name>` | | Set reporter (spec, dot, json, tap, junit) |
643| `--junit-output <file>` | | Set the JUnit XML output path |
644| `--timeout <ms>` | | Set the global test timeout |
638645| `--verbose` | | Enable verbose output |
639646| `--quiet` | `-q` | Minimal output |
640647| `--no-color` | | Disable colored output |
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}));
docs/execution-pipeline.mdadded+62-0
Changes to docs/execution-pipeline.md
@@ -0,0 +1,62 @@
1# Unified execution pipeline
2
3The framework has two plan producers and two executor implementations, but one
4execution contract.
5
61. Programmatic registration or file discovery produces a `TestPlan`.
72. A registered-test or external-process executor consumes that plan.
83. The executor emits typed `LifecycleEvent` values through `EventStream`.
94. A selected reporter consumes the events and a shared `TestResults` summary.
10
11`PlanItem.registered` retains the suite and test-case pointers needed for hooks
12and in-process execution. `PlanItem.discovered` retains the discovered file used
13by the external `zig test` executor. `PlanItem.identity()` gives extensions a
14common stable identity without erasing executor-specific information.
15
16## Shared lifecycle and results
17
18Both executors emit run, suite, and test start/end events. Discovered files are
19represented as one synthetic suite and test result per external process. This
20means spec, dot, JSON, TAP, and JUnit reporters receive the same lifecycle and
21summary model in either mode. `ReporterSet` is the single factory for built-in
22reporters, including the configured JUnit output path.
23
24`ExecutionPolicy` owns common filtering, bail, and timeout decisions:
25
26- Registered tests match filters directly by test name. Discovery forwards the
27 same filter to `zig test`, where Zig applies it to names inside each file.
28- Bail stops scheduling after the shared result model records a failure. Work
29 already started by a parallel batch cannot be cancelled.
30- A global timeout compares elapsed execution time with the same policy in both
31 executors. It marks an over-budget result failed after control returns; it
32 does not yet terminate an in-process function or external child process.
33- Summaries always come from `TestResults`, rather than executor-local counters.
34
35## Extension points
36
37New plan producers should append `PlanItem` values. New executors should emit
38`LifecycleEvent` values and update `TestResults`. New reporters can continue to
39implement `Reporter.VTable`; `EventStream` adapts the typed lifecycle to that
40stable callback interface.
41
42The event types are intentionally internal Zig structures. The separately
43versioned JSON wire protocol is built on this layer rather than making this API
44depend on one serialization format.
45
46## Migration
47
48Existing programmatic APIs remain source compatible:
49
50- `TestRunner.init`, `runTests`, and `runTestsWithOptions` are unchanged.
51- `RunnerOptions` only gains optional `junit_output`, `timeout_ms`, and
52 `reporter_writer` fields.
53- `runDiscoveredTests` and `LoaderOptions` remain available; optional reporter,
54 JUnit path, timeout, and writer fields default to the previous spec behavior.
55
56CLI hosts can inject a stdout writer with `reporter_writer`. Embedded callers
57may leave it unset; reporter output then uses stderr so it cannot corrupt Zig's
58stdout-based test protocol.
59
60Code that implements a custom execution path can migrate incrementally by first
61building a `TestPlan`, then routing callbacks through `EventStream`. Existing
62custom reporters require no migration.
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}