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
README.mdmodified+19-14
Changes to README.md
@@ -820,28 +820,33 @@ Passed: 12, Failed: 0, Total: 12 (1.23ms)
820820
821821### JSON Reporter
822822
823Machine-readable output for tooling:
823Machine-readable output uses the versioned event protocol shared with the live
824UI and test history:
824825
825826```json
826{
827 "totalTests": 12,
828 "tests": [
829 {
827[
828 {
829 "protocol_version": 1,
830 "type": "run_start",
831 "data": { "total": 12 }
832 },
833 {
834 "protocol_version": 1,
835 "type": "test_end",
836 "data": {
830837 "name": "should add two numbers",
838 "suite": "Math operations",
831839 "status": "passed",
832 "time": 0.05
840 "duration_ns": 50000,
841 "error_message": null
833842 }
834 ],
835 "summary": {
836 "total": 12,
837 "passed": 12,
838 "failed": 0,
839 "skipped": 0,
840 "time": 1.23
841843 }
842}
844]
843845```
844846
847See [docs/json-event-protocol.md](docs/json-event-protocol.md) for every event
848type and the compatibility policy.
849
845850## API Reference
846851
847852See [docs/api.md](docs/api.md) for complete API documentation.
docs/execution-pipeline.mdmodified+4-3
Changes to docs/execution-pipeline.md
@@ -43,9 +43,10 @@ New plan producers should append `PlanItem` values. New executors should emit
4343implement `Reporter.VTable`; `EventStream` adapts the typed lifecycle to that
4444stable callback interface.
4545
46The event types are intentionally internal Zig structures. The separately
47versioned JSON wire protocol is built on this layer rather than making this API
48depend on one serialization format.
46The lifecycle structures remain format-independent. The JSON reporter, live UI,
47and test history adapt them to the public, versioned
48[JSON event protocol](json-event-protocol.md), so integrations share one wire
49model without coupling the execution API to JSON.
4950
5051## Migration
5152
docs/json-event-protocol.mdadded+71-0
Changes to docs/json-event-protocol.md
@@ -0,0 +1,71 @@
1# JSON event protocol
2
3The framework exposes one versioned event model for the JSON reporter, live UI,
4and saved test history. Protocol version 1 uses this envelope for every event:
5
6```json
7{
8 "protocol_version": 1,
9 "type": "test_end",
10 "data": {
11 "name": "adds two numbers",
12 "suite": "math",
13 "status": "passed",
14 "duration_ns": 42000,
15 "error_message": null
16 }
17}
18```
19
20All strings are encoded by Zig's standard JSON serializer. Consumers should
21decode JSON rather than depending on whitespace or object-field order.
22
23## Transports
24
25- The JSON reporter writes an ordered JSON array of event envelopes.
26- The live UI sends each envelope as a server-sent event. The SSE event name is
27 the same as the envelope's `type`.
28- Test-history files contain an ordered JSON array using the same envelopes.
29
30Events preserve execution order. A run begins with `run_start` and finishes
31with `run_end`; suite and test start/end events are nested in between. Retry
32events are emitted in attempt order immediately before their `test_end`.
33
34## Version 1 events
35
36| Type | Data fields |
37| --- | --- |
38| `run_start` | `total` |
39| `run_end` | `total`, `passed`, `flaky`, `failed`, `skipped`, `duration_ns` |
40| `suite_start`, `suite_end` | `name` |
41| `test_start` | `name`, optional `suite` |
42| `test_end` | `name`, optional `suite`, `status`, `duration_ns`, optional `error_message` |
43| `hook` | `kind`, optional `suite`, optional `test_name`, `status`, `duration_ns`, optional `error_message` |
44| `output` | `stream`, `text`, optional `test_name` |
45| `retry` | `name`, `attempt`, `repetition`, `status`, `duration_ns`, optional `error_message` |
46| `coverage` | optional `line_percent`, `function_percent`, `branch_percent`, `report_path` |
47
48Statuses are `pending`, `running`, `passed`, `flaky`, `failed`, or `skipped`.
49Hook kinds are `before_all`, `before_each`, `after_each`, or `after_all`.
50Output streams are `stdout` or `stderr`.
51
52Optional fields are serialized as `null` when no value is available. Durations
53are unsigned integer nanoseconds. Percentages are numbers from 0 through 100.
54
55## Compatibility policy
56
57`protocol_version` is the major wire-format version. Version 1 may gain new
58event types, enum values, and optional data fields without changing the version.
59Consumers must ignore event types and fields they do not understand. Existing
60fields will not change meaning or type within a major version.
61
62Removing or renaming an event or field, changing a field's type or semantics,
63or changing the envelope shape requires a new major version. Producers emit one
64major version per stream. Consumers should reject an unsupported major version
65with a clear error instead of interpreting it as an older version.
66
67## Zig API
68
69`event_protocol.Event` is the tagged union for all event types, and
70`event_protocol.encodeAlloc` returns a standards-compliant encoded envelope.
71The root module also exports these as `ProtocolEvent` and `protocol_version`.
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" {