ReviewOS

also looking at this

zig-utils/zig-test-framework

Define stable JSON event protocol

#27
Merged glennmichael123 wants to merge codex/issue-10-json-events into main
11 files +442 -177

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.

src/event_protocol.zigadded+136-0
Changes to src/event_protocol.zig
@@ -0,0 +1,136 @@
1const std = ;
2
3/// Major version of the public JSON event protocol.
4pub const version: u16 = 1;
5
6pub const Status = enum { pending, running, passed, flaky, failed, skipped };
7pub const HookKind = enum { before_all, before_each, after_each, after_all };
8pub const OutputStream = enum { stdout, stderr };
9
10pub const RunStart = struct { total: usize };
11pub const RunEnd = struct {
12 total: usize,
13 passed: usize,
14 flaky: usize,
15 failed: usize,
16 skipped: usize,
17 duration_ns: u64 = 0,
18};
19pub const Suite = struct { name: []const u8 };
20pub const TestStart = struct { name: []const u8, suite: ?[]const u8 = null };
21pub const TestEnd = struct {
22 name: []const u8,
23 suite: ?[]const u8 = null,
24 status: Status,
25 duration_ns: u64,
26 error_message: ?[]const u8 = null,
27};
28pub const Hook = struct {
29 kind: HookKind,
30 suite: ?[]const u8 = null,
31 test_name: ?[]const u8 = null,
32 status: Status,
33 duration_ns: u64 = 0,
34 error_message: ?[]const u8 = null,
35};
36pub const Output = struct {
37 stream: OutputStream,
38 text: []const u8,
39 test_name: ?[]const u8 = null,
40};
41pub const Retry = struct {
42 name: []const u8,
43 attempt: usize,
44 repetition: usize,
45 status: Status,
46 duration_ns: u64,
47 error_message: ?[]const u8 = null,
48};
49pub const Coverage = struct {
50 line_percent: ?f64 = null,
51 function_percent: ?f64 = null,
52 branch_percent: ?f64 = null,
53 report_path: ?[]const u8 = null,
54};
55
56/// Every serialized value is an envelope with the same version/type/data
57/// shape. Unknown fields and event types are intentionally safe to ignore.
58pub const Event = union(enum) {
59 run_start: RunStart,
60 run_end: RunEnd,
61 suite_start: Suite,
62 suite_end: Suite,
63 test_start: TestStart,
64 test_end: TestEnd,
65 hook: Hook,
66 output: Output,
67 retry: Retry,
68 coverage: Coverage,
69
70 pub fn eventName(self: Event) []const u8 {
71 return @tagName(std.meta.activeTag(self));
72 }
73
74 pub fn jsonStringify(self: Event, json: anytype) !void {
75 try json.beginObject();
76 try json.objectField("protocol_version");
77 try json.write(version);
78 try json.objectField("type");
79 try json.write(self.eventName());
80 try json.objectField("data");
81 switch (self) {
82 inline else => |payload| try json.write(payload),
83 }
84 try json.endObject();
85 }
86};
87
88pub fn status(value: anytype) Status {
89 return std.meta.stringToEnum(Status, @tagName(value)) orelse unreachable;
90}
91
92pub fn encodeAlloc(allocator: std.mem.Allocator, event: Event) ![]u8 {
93 return std.json.Stringify.valueAlloc(allocator, event, .{});
94}
95
96test "every event type follows the versioned envelope contract" {
97 const events = [_]Event{
98 .{ .run_start = .{ .total = 2 } },
99 .{ .run_end = .{ .total = 2, .passed = 1, .flaky = 0, .failed = 1, .skipped = 0 } },
100 .{ .suite_start = .{ .name = "suite \"one\"" } },
101 .{ .suite_end = .{ .name = "suite \"one\"" } },
102 .{ .test_start = .{ .name = "line\nbreak", .suite = "suite" } },
103 .{ .test_end = .{ .name = "line\nbreak", .status = .failed, .duration_ns = 42, .error_message = "bad \"value\"" } },
104 .{ .hook = .{ .kind = .before_each, .test_name = "case", .status = .passed } },
105 .{ .output = .{ .stream = .stderr, .text = "hello\nworld", .test_name = "case" } },
106 .{ .retry = .{ .name = "case", .attempt = 2, .repetition = 1, .status = .passed, .duration_ns = 10 } },
107 .{ .coverage = .{ .line_percent = 90.5, .report_path = "coverage/index.html" } },
108 };
109
110 inline for (events) |event| {
111 const encoded = try encodeAlloc(std.testing.allocator, event);
112 defer std.testing.allocator.free(encoded);
113 var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, encoded, .{});
114 defer parsed.deinit();
115 const object = parsed.value.object;
116 try std.testing.expectEqual(@as(i64, version), object.get("protocol_version").?.integer);
117 try std.testing.expectEqualStrings(event.eventName(), object.get("type").?.string);
118 try std.testing.expect(object.get("data") != null);
119 }
120}
121
122test "event payload strings use standard JSON escaping" {
123 const encoded = try encodeAlloc(std.testing.allocator, .{ .test_end = .{
124 .name = "case\tname",
125 .status = .failed,
126 .duration_ns = 1,
127 .error_message = "line one\n\"quoted\" and \\escaped",
128 } });
129 defer std.testing.allocator.free(encoded);
130
131 var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, encoded, .{});
132 defer parsed.deinit();
133 const data = parsed.value.object.get("data").?.object;
134 try std.testing.expectEqualStrings("case\tname", data.get("name").?.string);
135 try std.testing.expectEqualStrings("line one\n\"quoted\" and \\escaped", data.get("error_message").?.string);
136}
src/lib.zigmodified+3-0
Changes to src/lib.zig
@@ -6,6 +6,7 @@ pub const suite = ;
66pub const test_runner = ;
77pub const reporter = ;
88pub const pipeline = ;
9pub const event_protocol = ;
910pub const matchers = ;
1011pub const mock = ;
1112pub const cli = ;
@@ -90,6 +91,8 @@ pub const PlanOrigin = pipeline.PlanOrigin;
9091pub const ExecutionPolicy = pipeline.ExecutionPolicy;
9192pub const LifecycleEvent = pipeline.LifecycleEvent;
9293pub const EventStream = pipeline.EventStream;
94pub const ProtocolEvent = event_protocol.Event;
95pub const protocol_version = event_protocol.version;
9396
9497// Matchers
9598pub const Matchers = matchers.Matchers;
src/reporter.zigmodified+44-58
Changes to src/reporter.zig
@@ -1,6 +1,7 @@
11const std = ;
22const suite = ;
33const compat = ;
4const protocol = ;
45
56pub const ReporterType = enum {
67 spec,
@@ -464,7 +465,7 @@ pub const JsonReporter = struct {
464465 writer: std.Io.Writer,
465466 writer_ref: ?*std.Io.Writer = null,
466467 suites: std.ArrayList([]const u8),
467 emitted_tests: bool = false,
468 emitted_events: bool = false,
468469
469470 const Self = @This();
470471
@@ -508,87 +509,72 @@ pub const JsonReporter = struct {
508509
509510 fn onRunStart(reporter: *Reporter, total_tests: usize) !void {
510511 const s = self(reporter);
511 try s.output().print("{{\"totalTests\":{d},\"tests\":[\n", .{total_tests});
512 s.emitted_events = false;
513 s.suites.clearRetainingCapacity();
514 try s.output().writeAll("[\n");
515 try s.emit(.{ .run_start = .{ .total = total_tests } });
512516 }
513517
514518 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
515519 const s = self(reporter);
516 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
517
518 try s.output().print("\n],\"summary\":{{\"total\":{d},\"passed\":{d},\"flaky\":{d},\"failed\":{d},\"skipped\":{d},\"time\":{d:.2}}}}}\n", .{
519 results.total,
520 results.passed,
521 results.flaky,
522 results.failed,
523 results.skipped,
524 total_time_ms,
525 });
520 try s.emit(.{ .run_end = .{
521 .total = results.total,
522 .passed = results.passed,
523 .flaky = results.flaky,
524 .failed = results.failed,
525 .skipped = results.skipped,
526 .duration_ns = results.total_time_ns,
527 } });
528 try s.output().writeAll("\n]\n");
526529 }
527530
528531 fn onSuiteStart(reporter: *Reporter, suite_name: []const u8) !void {
529532 const s = self(reporter);
530533 try s.suites.append(reporter.allocator, suite_name);
534 try s.emit(.{ .suite_start = .{ .name = suite_name } });
531535 }
532536
533537 fn onSuiteEnd(reporter: *Reporter, suite_name: []const u8) !void {
534 _ = suite_name;
535538 const s = self(reporter);
539 try s.emit(.{ .suite_end = .{ .name = suite_name } });
536540 if (s.suites.items.len > 0) {
537541 _ = s.suites.pop();
538542 }
539543 }
540544
541545 fn onTestStart(reporter: *Reporter, test_name: []const u8) !void {
542 _ = reporter;
543 _ = test_name;
546 const s = self(reporter);
547 try s.emit(.{ .test_start = .{
548 .name = test_name,
549 .suite = if (s.suites.getLastOrNull()) |name| name else null,
550 } });
544551 }
545552
546553 fn onTestEnd(reporter: *Reporter, test_case: *const suite.TestCase) !void {
547554 const s = self(reporter);
548 const time_ms = @as(f64, @floatFromInt(test_case.execution_time_ns)) / 1_000_000.0;
549
550 const status_str = switch (test_case.status) {
551 .passed => "passed",
552 .flaky => "flaky",
553 .failed => "failed",
554 .skipped => "skipped",
555 else => "unknown",
556 };
557
558 if (s.emitted_tests) try s.output().print(",\n", .{});
559 s.emitted_tests = true;
560
561 // Note: In a real implementation, you'd want to properly escape JSON strings
562 try s.output().print(" {{\"name\":\"{s}\",\"status\":\"{s}\",\"time\":{d:.2}", .{
563 test_case.name,
564 status_str,
565 time_ms,
566 });
567
568 if (test_case.error_message) |msg| {
569 try s.output().print(",\"error\":\"{s}\"", .{msg});
570 }
571
572 try s.output().print(",\"attempts\":[", .{});
573 for (test_case.attempts.items, 0..) |attempt, index| {
574 if (index > 0) try s.output().print(",", .{});
575 const attempt_status = switch (attempt.status) {
576 .passed => "passed",
577 .flaky => "flaky",
578 .failed => "failed",
579 .skipped => "skipped",
580 else => "unknown",
581 };
582 try s.output().print(
583 "{{\"number\":{d},\"repetition\":{d},\"status\":\"{s}\",\"durationNs\":{d}",
584 .{ attempt.number, attempt.repetition, attempt_status, attempt.duration_ns },
585 );
586 if (attempt.error_message) |message| {
587 try s.output().print(",\"error\":\"{s}\"", .{message});
588 }
589 try s.output().print("}}", .{});
555 for (test_case.attempts.items) |attempt| {
556 try s.emit(.{ .retry = .{
557 .name = test_case.name,
558 .attempt = attempt.number,
559 .repetition = attempt.repetition,
560 .status = protocol.status(attempt.status),
561 .duration_ns = attempt.duration_ns,
562 .error_message = attempt.error_message,
563 } });
590564 }
591 try s.output().print("]}}", .{});
565 try s.emit(.{ .test_end = .{
566 .name = test_case.name,
567 .suite = if (s.suites.getLastOrNull()) |name| name else null,
568 .status = protocol.status(test_case.status),
569 .duration_ns = test_case.execution_time_ns,
570 .error_message = test_case.error_message,
571 } });
572 }
573
574 fn emit(s: *Self, event: protocol.Event) !void {
575 if (s.emitted_events) try s.output().writeAll(",\n");
576 s.emitted_events = true;
577 try std.json.Stringify.value(event, .{}, s.output());
592578 }
593579};
594580
src/test_history.zigmodified+68-33
Changes to src/test_history.zig
@@ -2,12 +2,14 @@ const std = ;
22const reporter_mod = ;
33const suite = ;
44const compat = ;
5const protocol = ;
56
67/// Test history entry
78pub const HistoryEntry = struct {
89 timestamp: i64,
910 total: usize,
1011 passed: usize,
12 flaky: usize = 0,
1113 failed: usize,
1214 skipped: usize,
1315 duration_ns: u64,
@@ -69,6 +71,7 @@ pub const TestHistory = struct {
6971 .timestamp = self.start_time,
7072 .total = total,
7173 .passed = 0,
74 .flaky = 0,
7275 .failed = 0,
7376 .skipped = 0,
7477 .duration_ns = 0,
@@ -111,6 +114,7 @@ pub const TestHistory = struct {
111114 const end_time = compat.milliTimestamp();
112115 self.current_entry.?.duration_ns = @intCast((end_time - self.start_time) * std.time.ns_per_ms);
113116 self.current_entry.?.passed = results.passed;
117 self.current_entry.?.flaky = results.flaky;
114118 self.current_entry.?.failed = results.failed;
115119 self.current_entry.?.skipped = results.skipped;
116120
@@ -128,42 +132,36 @@ pub const TestHistory = struct {
128132 var filename_buf: [256]u8 = undefined;
129133 const filename = try std.fmt.bufPrint(&filename_buf, "{s}/test-run-{d}.json", .{ self.history_dir, self.current_entry.?.timestamp });
130134
131 // Write JSON
135 // Store the same versioned events consumed by reporters and the UI.
132136 var buffer = std.ArrayList(u8).empty;
133137 defer buffer.deinit(self.allocator);
134
135 try buffer.appendSlice(self.allocator, "{\n");
136 try buffer.print(self.allocator, " \"timestamp\": {d},\n", .{self.current_entry.?.timestamp});
137 try buffer.print(self.allocator, " \"total\": {d},\n", .{self.current_entry.?.total});
138 try buffer.print(self.allocator, " \"passed\": {d},\n", .{self.current_entry.?.passed});
139 try buffer.print(self.allocator, " \"failed\": {d},\n", .{self.current_entry.?.failed});
140 try buffer.print(self.allocator, " \"skipped\": {d},\n", .{self.current_entry.?.skipped});
141 try buffer.print(self.allocator, " \"duration_ns\": {d},\n", .{self.current_entry.?.duration_ns});
142 try buffer.appendSlice(self.allocator, " \"tests\": [\n");
143
144 for (self.current_entry.?.tests.items, 0..) |test_record, i| {
145 try buffer.appendSlice(self.allocator, " {\n");
146 try buffer.print(self.allocator, " \"name\": \"{s}\",\n", .{test_record.name});
147 try buffer.print(self.allocator, " \"suite_name\": \"{s}\",\n", .{test_record.suite_name});
148 try buffer.print(self.allocator, " \"status\": \"{s}\",\n", .{test_record.status});
149 try buffer.print(self.allocator, " \"execution_time_ns\": {d}", .{test_record.execution_time_ns});
150
151 if (test_record.error_message) |msg| {
152 try buffer.appendSlice(self.allocator, ",\n");
153 try buffer.print(self.allocator, " \"error_message\": \"{s}\"\n", .{msg});
154 } else {
155 try buffer.appendSlice(self.allocator, "\n");
156 }
157
158 if (i < self.current_entry.?.tests.items.len - 1) {
159 try buffer.appendSlice(self.allocator, " },\n");
160 } else {
161 try buffer.appendSlice(self.allocator, " }\n");
162 }
138 var allocating: std.Io.Writer.Allocating = .fromArrayList(self.allocator, &buffer);
139 errdefer buffer = allocating.toArrayList();
140 const writer = &allocating.writer;
141 try writer.writeAll("[\n");
142 try std.json.Stringify.value(protocol.Event{ .run_start = .{ .total = self.current_entry.?.total } }, .{}, writer);
143 for (self.current_entry.?.tests.items) |test_record| {
144 try writer.writeAll(",\n");
145 try std.json.Stringify.value(protocol.Event{ .test_end = .{
146 .name = test_record.name,
147 .suite = test_record.suite_name,
148 .status = std.meta.stringToEnum(protocol.Status, test_record.status) orelse .failed,
149 .duration_ns = test_record.execution_time_ns,
150 .error_message = test_record.error_message,
151 } }, .{}, writer);
163152 }
164
165 try buffer.appendSlice(self.allocator, " ]\n");
166 try buffer.appendSlice(self.allocator, "}\n");
153 try writer.writeAll(",\n");
154 try std.json.Stringify.value(protocol.Event{ .run_end = .{
155 .total = self.current_entry.?.total,
156 .passed = self.current_entry.?.passed,
157 .flaky = self.current_entry.?.flaky,
158 .failed = self.current_entry.?.failed,
159 .skipped = self.current_entry.?.skipped,
160 .duration_ns = self.current_entry.?.duration_ns,
161 } }, .{}, writer);
162 try writer.writeAll("\n]\n");
163 try writer.flush();
164 buffer = allocating.toArrayList();
167165
168166 try compat.writeFile(self.allocator, filename, buffer.items);
169167 }
@@ -190,6 +188,7 @@ pub const TestHistory = struct {
190188 .timestamp = 0,
191189 .total = 0,
192190 .passed = 0,
191 .flaky = 0,
193192 .failed = 0,
194193 .skipped = 0,
195194 .duration_ns = 0,
@@ -249,3 +248,39 @@ test "TestHistory start and record" {
249248 try history.recordTest("test1", "suite1", .passed, 1000000, null);
250249 try std.testing.expectEqual(@as(usize, 1), history.current_entry.?.tests.items.len);
251250}
251
252test "TestHistory saves versioned protocol events" {
253 const allocator = std.testing.allocator;
254 var tmp = std.testing.tmpDir(.{});
255 defer tmp.cleanup();
256 const history_dir = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path});
257 defer allocator.free(history_dir);
258
259 var history = TestHistory.init(allocator, history_dir);
260 defer history.deinit();
261 try history.startRun(1);
262 try history.recordTest("quoted \"test\"", "suite\nname", .passed, 42, null);
263 var results = reporter_mod.TestResults.init(allocator);
264 defer results.deinit();
265 results.total = 1;
266 results.passed = 1;
267 results.total_time_ns = 42;
268 try history.finishRun(&results);
269
270 const filename = try std.fmt.allocPrint(allocator, "{s}/test-run-{d}.json", .{ history_dir, history.current_entry.?.timestamp });
271 defer allocator.free(filename);
272 const content = try compat.readFileAlloc(allocator, filename);
273 defer allocator.free(content);
274 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
275 defer parsed.deinit();
276
277 const events = parsed.value.array.items;
278 try std.testing.expectEqual(@as(usize, 3), events.len);
279 try std.testing.expectEqualStrings("run_start", events[0].object.get("type").?.string);
280 try std.testing.expectEqualStrings("test_end", events[1].object.get("type").?.string);
281 try std.testing.expectEqualStrings("quoted \"test\"", events[1].object.get("data").?.object.get("name").?.string);
282 try std.testing.expectEqualStrings("run_end", events[2].object.get("type").?.string);
283 for (events) |event| {
284 try std.testing.expectEqual(@as(i64, protocol.version), event.object.get("protocol_version").?.integer);
285 }
286}
src/test_loader.zigmodified+35-19
Changes to src/test_loader.zig
@@ -7,6 +7,7 @@ const suite = ;
77const coverage = ;
88const ui_server = ;
99const compat = ;
10const protocol = ;
1011
1112/// Options for running discovered tests
1213pub const LoaderOptions = struct {
@@ -41,12 +42,6 @@ pub const LoaderOptions = struct {
4142 ui_server: ?*ui_server.UIServer = null,
4243};
4344
44fn broadcastJson(server: *ui_server.UIServer, allocator: std.mem.Allocator, event: []const u8, value: anytype) !void {
45 const json = try std.json.Stringify.valueAlloc(allocator, value, .{});
46 defer allocator.free(json);
47 try server.broadcast(event, json);
48}
49
5045/// Run all discovered test files
5146pub fn runDiscoveredTests(
5247 allocator: std.mem.Allocator,
@@ -107,7 +102,7 @@ pub fn runDiscoveredTests(
107102
108103 // Notify UI of run start
109104 if (options.ui_server) |server| {
110 try broadcastJson(server, allocator, "run_start", .{ .total = selected_files });
105 try server.broadcastEvent(allocator, .{ .run_start = .{ .total = selected_files } });
111106 }
112107
113108 var files_run: usize = 0;
@@ -129,8 +124,8 @@ pub fn runDiscoveredTests(
129124
130125 // Notify UI of test file start
131126 if (options.ui_server) |server| {
132 try broadcastJson(server, allocator, "suite_start", .{ .name = file.name });
133 try broadcastJson(server, allocator, "test_start", .{ .name = file.name });
127 try server.broadcastEvent(allocator, .{ .suite_start = .{ .name = file.name } });
128 try server.broadcastEvent(allocator, .{ .test_start = .{ .name = file.name, .suite = file.name } });
134129 }
135130
136131 files_run += 1;
@@ -182,14 +177,24 @@ pub fn runDiscoveredTests(
182177
183178 // Notify UI of test file end
184179 if (options.ui_server) |server| {
185 try broadcastJson(server, allocator, "test_end", .{
180 for (test_case.attempts.items) |attempt| {
181 try server.broadcastEvent(allocator, .{ .retry = .{
182 .name = file.name,
183 .attempt = attempt.number,
184 .repetition = attempt.repetition,
185 .status = protocol.status(attempt.status),
186 .duration_ns = attempt.duration_ns,
187 .error_message = attempt.error_message,
188 } });
189 }
190 try server.broadcastEvent(allocator, .{ .test_end = .{
186191 .name = file.name,
187 .status = @tagName(test_case.status),
188 .execution_time_ns = test_case.execution_time_ns,
189 .attempts = test_case.attempts.items.len,
190 .error_message = "",
191 });
192 try broadcastJson(server, allocator, "suite_end", .{ .name = file.name });
192 .suite = file.name,
193 .status = protocol.status(test_case.status),
194 .duration_ns = test_case.execution_time_ns,
195 .error_message = test_case.error_message,
196 } });
197 try server.broadcastEvent(allocator, .{ .suite_end = .{ .name = file.name } });
193198 }
194199
195200 if (test_case.status == .passed) {
@@ -213,19 +218,30 @@ pub fn runDiscoveredTests(
213218 std.debug.print("Warning: Could not parse coverage report: {any}\n", .{err});
214219 break :result null;
215220 };
216 if (cov_result) |summary| coverage.printCoverageSummary(summary);
221 if (cov_result) |summary| {
222 coverage.printCoverageSummary(summary);
223 if (options.ui_server) |server| {
224 try server.broadcastEvent(allocator, .{ .coverage = .{
225 .line_percent = summary.linePercentage(),
226 .function_percent = summary.functionPercentage(),
227 .branch_percent = summary.branchPercentage(),
228 .report_path = summary.report_dir,
229 } });
230 }
231 }
217232 }
218233 }
219234
220235 // Notify UI of run end
221236 if (options.ui_server) |server| {
222 try broadcastJson(server, allocator, "run_end", .{
237 try server.broadcastEvent(allocator, .{ .run_end = .{
223238 .total = results.total,
224239 .passed = results.passed,
225240 .flaky = results.flaky,
226241 .failed = results.failed,
227242 .skipped = results.skipped,
228 });
243 .duration_ns = results.total_time_ns,
244 } });
229245 }
230246
231247 if (options.shard) |shard| {
src/ui/index.htmlmodified+7-7
Changes to src/ui/index.html
@@ -332,26 +332,26 @@
332332 });
333333
334334 eventSource.addEventListener('run_start', (e) => {
335 const data = JSON.parse(e.data);
335 const data = JSON.parse(e.data).data;
336336 clearTests();
337337 state.total = data.total;
338338 updateStats();
339339 });
340340
341341 eventSource.addEventListener('suite_start', (e) => {
342 const data = JSON.parse(e.data);
342 const data = JSON.parse(e.data).data;
343343 state.currentSuite = data.name;
344344 addSuite(data.name);
345345 });
346346
347347 eventSource.addEventListener('test_start', (e) => {
348 const data = JSON.parse(e.data);
349 addTest(state.currentSuite, data.name, 'running');
348 const data = JSON.parse(e.data).data;
349 addTest(data.suite || state.currentSuite, data.name, 'running');
350350 });
351351
352352 eventSource.addEventListener('test_end', (e) => {
353 const data = JSON.parse(e.data);
354 updateTest(state.currentSuite, data.name, data.status, data.execution_time_ns, data.error_message);
353 const data = JSON.parse(e.data).data;
354 updateTest(data.suite || state.currentSuite, data.name, data.status, data.duration_ns, data.error_message || '');
355355
356356 if (data.status === 'passed') state.passed++;
357357 else if (data.status === 'flaky') state.flaky++;
@@ -362,7 +362,7 @@
362362 });
363363
364364 eventSource.addEventListener('run_end', (e) => {
365 const data = JSON.parse(e.data);
365 const data = JSON.parse(e.data).data;
366366 state.total = data.total;
367367 state.passed = data.passed;
368368 state.flaky = data.flaky;
src/ui_server.zigmodified+33-34
Changes to src/ui_server.zig
@@ -3,6 +3,7 @@ const reporter_mod = ;
33const suite = ;
44const test_history = ;
55const compat = ;
6const protocol = ;
67
78/// Options for the UI server
89pub const UIServerOptions = struct {
@@ -282,13 +283,19 @@ pub const UIServer = struct {
282283 index += 1;
283284 }
284285 }
286
287 /// Serialize and broadcast one versioned protocol event.
288 pub fn broadcastEvent(self: *Self, allocator: std.mem.Allocator, event: protocol.Event) !void {
289 const encoded = try protocol.encodeAlloc(allocator, event);
290 defer allocator.free(encoded);
291 try self.broadcast(event.eventName(), encoded);
292 }
285293};
286294
287295/// UI Reporter - sends test events to the UI server
288296pub const UIReporter = struct {
289297 reporter: reporter_mod.Reporter,
290298 server: *UIServer,
291 buffer: std.ArrayList(u8),
292299
293300 const Self = @This();
294301
@@ -307,71 +314,66 @@ pub const UIReporter = struct {
307314 .use_colors = false,
308315 },
309316 .server = server,
310 .buffer = .empty,
311317 };
312318 }
313319
314 pub fn deinit(self: *Self) void {
315 self.buffer.deinit(self.reporter.allocator);
316 }
317
318 fn encode(self: *Self, value: anytype) !void {
319 self.buffer.clearRetainingCapacity();
320 var writer: std.Io.Writer.Allocating = .fromArrayList(self.reporter.allocator, &self.buffer);
321 defer self.buffer = writer.toArrayList();
322 try std.json.Stringify.value(value, .{}, &writer.writer);
323 }
320 pub fn deinit(_: *Self) void {}
324321
325322 fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void {
326323 const self: *Self = @fieldParentPtr("reporter", reporter);
327 try self.encode(.{ .total = total });
328 try self.server.broadcast("run_start", self.buffer.items);
324 try self.server.broadcastEvent(reporter.allocator, .{ .run_start = .{ .total = total } });
329325 }
330326
331327 fn onRunEnd(reporter: *reporter_mod.Reporter, results: *reporter_mod.TestResults) !void {
332328 const self: *Self = @fieldParentPtr("reporter", reporter);
333329
334 try self.encode(.{
330 try self.server.broadcastEvent(reporter.allocator, .{ .run_end = .{
335331 .total = results.total,
336332 .passed = results.passed,
337333 .flaky = results.flaky,
338334 .failed = results.failed,
339335 .skipped = results.skipped,
340 });
341 try self.server.broadcast("run_end", self.buffer.items);
336 .duration_ns = results.total_time_ns,
337 } });
342338 }
343339
344340 fn onSuiteStart(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void {
345341 const self: *Self = @fieldParentPtr("reporter", reporter);
346342
347 try self.encode(.{ .name = suite_name });
348 try self.server.broadcast("suite_start", self.buffer.items);
343 try self.server.broadcastEvent(reporter.allocator, .{ .suite_start = .{ .name = suite_name } });
349344 }
350345
351346 fn onSuiteEnd(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void {
352347 const self: *Self = @fieldParentPtr("reporter", reporter);
353348
354 try self.encode(.{ .name = suite_name });
355 try self.server.broadcast("suite_end", self.buffer.items);
349 try self.server.broadcastEvent(reporter.allocator, .{ .suite_end = .{ .name = suite_name } });
356350 }
357351
358352 fn onTestStart(reporter: *reporter_mod.Reporter, test_name: []const u8) !void {
359353 const self: *Self = @fieldParentPtr("reporter", reporter);
360354
361 try self.encode(.{ .name = test_name });
362 try self.server.broadcast("test_start", self.buffer.items);
355 try self.server.broadcastEvent(reporter.allocator, .{ .test_start = .{ .name = test_name } });
363356 }
364357
365358 fn onTestEnd(reporter: *reporter_mod.Reporter, test_case: *const suite.TestCase) !void {
366359 const self: *Self = @fieldParentPtr("reporter", reporter);
367360
368 try self.encode(.{
361 for (test_case.attempts.items) |attempt| {
362 try self.server.broadcastEvent(reporter.allocator, .{ .retry = .{
363 .name = test_case.name,
364 .attempt = attempt.number,
365 .repetition = attempt.repetition,
366 .status = protocol.status(attempt.status),
367 .duration_ns = attempt.duration_ns,
368 .error_message = attempt.error_message,
369 } });
370 }
371 try self.server.broadcastEvent(reporter.allocator, .{ .test_end = .{
369372 .name = test_case.name,
370 .status = @tagName(test_case.status),
371 .execution_time_ns = test_case.execution_time_ns,
372 .error_message = test_case.error_message orelse "",
373 });
374 try self.server.broadcast("test_end", self.buffer.items);
373 .status = protocol.status(test_case.status),
374 .duration_ns = test_case.execution_time_ns,
375 .error_message = test_case.error_message,
376 } });
375377 }
376378};
377379
@@ -471,10 +473,7 @@ test "UIReporter initialization" {
471473 defer ui_reporter.deinit();
472474
473475 try std.testing.expectEqual(false, ui_reporter.reporter.use_colors);
474 try std.testing.expectEqual(@as(usize, 0), ui_reporter.buffer.items.len);
475
476476 try ui_reporter.reporter.onSuiteStart("quoted \"suite\"\nname");
477 try std.testing.expectEqualStrings("{\"name\":\"quoted \\\"suite\\\"\\nname\"}", ui_reporter.buffer.items);
478477}
479478
480479fn writeTestRequest(io: std.Io, stream: std.Io.net.Stream, target: []const u8) !void {
@@ -535,9 +534,9 @@ test "UI server streams broadcast events over SSE" {
535534 try expectLine(&stream_reader.interface, "data: {}");
536535 try expectLine(&stream_reader.interface, "");
537536
538 try server.broadcast("run_start", "{\"total\":2}");
537 try server.broadcastEvent(std.testing.allocator, .{ .run_start = .{ .total = 2 } });
539538 try expectLine(&stream_reader.interface, "event: run_start");
540 try expectLine(&stream_reader.interface, "data: {\"total\":2}");
539 try expectLine(&stream_reader.interface, "data: {\"protocol_version\":1,\"type\":\"run_start\",\"data\":{\"total\":2}}");
541540 try expectLine(&stream_reader.interface, "");
542541}
543542
tests/reporter_test.zigmodified+22-9
Changes to tests/reporter_test.zig
@@ -47,8 +47,8 @@ test "reporter dot output" {
4747 try std.testing.expect(std.mem.indexOf(u8, output, "Passed: 1") != null);
4848}
4949
50test "reporter json output" {
51 var buffer: [2048]u8 = undefined;
50test "reporter json output uses versioned event envelopes" {
51 var buffer: [4096]u8 = undefined;
5252 const writer: std.Io.Writer = .fixed(&buffer);
5353 var reporter = ztf.JsonReporter.init(std.testing.allocator, writer);
5454 defer reporter.deinit();
@@ -56,20 +56,29 @@ test "reporter json output" {
5656 defer results.deinit();
5757 results.total = 1;
5858 results.passed = 1;
59 var test_case = ztf.TestCase.init("passes", passingTest);
59 var test_case = ztf.TestCase.init("passes \"quoted\"\nname", passingTest);
6060 test_case.status = .passed;
6161
6262 try reporter.reporter.onRunStart(1);
63 try reporter.reporter.onSuiteStart("suite");
63 try reporter.reporter.onSuiteStart("suite\tone");
64 try reporter.reporter.onTestStart(test_case.name);
6465 try reporter.reporter.onTestEnd(&test_case);
65 try reporter.reporter.onSuiteEnd("suite");
66 try reporter.reporter.onSuiteEnd("suite\tone");
6667 try reporter.reporter.onRunEnd(&results);
6768
6869 const output = buffer[0..reporter.writer.end];
69 try std.testing.expect(std.mem.indexOf(u8, output, "\"name\":\"passes\"") != null);
70 try std.testing.expect(std.mem.indexOf(u8, output, "\"passed\":1") != null);
7170 var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, output, .{});
7271 defer parsed.deinit();
72 const events = parsed.value.array.items;
73 try std.testing.expectEqual(@as(usize, 6), events.len);
74 try std.testing.expectEqual(@as(i64, ztf.protocol_version), events[0].object.get("protocol_version").?.integer);
75 try std.testing.expectEqualStrings("run_start", events[0].object.get("type").?.string);
76 try std.testing.expectEqualStrings("test_start", events[2].object.get("type").?.string);
77 const test_data = events[3].object.get("data").?.object;
78 try std.testing.expectEqualStrings(test_case.name, test_data.get("name").?.string);
79 try std.testing.expectEqualStrings("suite\tone", test_data.get("suite").?.string);
80 try std.testing.expectEqualStrings("run_end", events[5].object.get("type").?.string);
81 try std.testing.expectEqual(@as(i64, 1), events[5].object.get("data").?.object.get("passed").?.integer);
7382}
7483
7584test "reporter json output includes flaky attempt history" {
@@ -106,10 +115,14 @@ test "reporter json output includes flaky attempt history" {
106115
107116 const output = buffer[0..reporter.writer.end];
108117 try std.testing.expect(std.mem.indexOf(u8, output, "\"status\":\"flaky\"") != null);
109 try std.testing.expect(std.mem.indexOf(u8, output, "\"attempts\":[") != null);
110 try std.testing.expect(std.mem.indexOf(u8, output, "\"durationNs\":10") != null);
118 try std.testing.expect(std.mem.indexOf(u8, output, "\"type\":\"retry\"") != null);
119 try std.testing.expect(std.mem.indexOf(u8, output, "\"duration_ns\":10") != null);
111120 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, output, .{});
112121 defer parsed.deinit();
122 const events = parsed.value.array.items;
123 try std.testing.expectEqualStrings("retry", events[2].object.get("type").?.string);
124 try std.testing.expectEqualStrings("retry", events[3].object.get("type").?.string);
125 try std.testing.expectEqualStrings("test_end", events[4].object.get("type").?.string);
113126}
114127
115128test "reporter set selects the same built-ins for every executor" {