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
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}