Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -820,28 +820,33 @@ Passed: 12, Failed: 0, Total: 12 (1.23ms)

### JSON Reporter

Machine-readable output for tooling:
Machine-readable output uses the versioned event protocol shared with the live
UI and test history:

```json
{
"totalTests": 12,
"tests": [
{
[
{
"protocol_version": 1,
"type": "run_start",
"data": { "total": 12 }
},
{
"protocol_version": 1,
"type": "test_end",
"data": {
"name": "should add two numbers",
"suite": "Math operations",
"status": "passed",
"time": 0.05
"duration_ns": 50000,
"error_message": null
}
],
"summary": {
"total": 12,
"passed": 12,
"failed": 0,
"skipped": 0,
"time": 1.23
}
}
]
```

See [docs/json-event-protocol.md](docs/json-event-protocol.md) for every event
type and the compatibility policy.

## API Reference

See [docs/api.md](docs/api.md) for complete API documentation.
Expand Down
7 changes: 4 additions & 3 deletions docs/execution-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ New plan producers should append `PlanItem` values. New executors should emit
implement `Reporter.VTable`; `EventStream` adapts the typed lifecycle to that
stable callback interface.

The event types are intentionally internal Zig structures. The separately
versioned JSON wire protocol is built on this layer rather than making this API
depend on one serialization format.
The lifecycle structures remain format-independent. The JSON reporter, live UI,
and test history adapt them to the public, versioned
[JSON event protocol](json-event-protocol.md), so integrations share one wire
model without coupling the execution API to JSON.

## Migration

Expand Down
71 changes: 71 additions & 0 deletions docs/json-event-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# JSON event protocol

The framework exposes one versioned event model for the JSON reporter, live UI,
and saved test history. Protocol version 1 uses this envelope for every event:

```json
{
"protocol_version": 1,
"type": "test_end",
"data": {
"name": "adds two numbers",
"suite": "math",
"status": "passed",
"duration_ns": 42000,
"error_message": null
}
}
```

All strings are encoded by Zig's standard JSON serializer. Consumers should
decode JSON rather than depending on whitespace or object-field order.

## Transports

- The JSON reporter writes an ordered JSON array of event envelopes.
- The live UI sends each envelope as a server-sent event. The SSE event name is
the same as the envelope's `type`.
- Test-history files contain an ordered JSON array using the same envelopes.

Events preserve execution order. A run begins with `run_start` and finishes
with `run_end`; suite and test start/end events are nested in between. Retry
events are emitted in attempt order immediately before their `test_end`.

## Version 1 events

| Type | Data fields |
| --- | --- |
| `run_start` | `total` |
| `run_end` | `total`, `passed`, `flaky`, `failed`, `skipped`, `duration_ns` |
| `suite_start`, `suite_end` | `name` |
| `test_start` | `name`, optional `suite` |
| `test_end` | `name`, optional `suite`, `status`, `duration_ns`, optional `error_message` |
| `hook` | `kind`, optional `suite`, optional `test_name`, `status`, `duration_ns`, optional `error_message` |
| `output` | `stream`, `text`, optional `test_name` |
| `retry` | `name`, `attempt`, `repetition`, `status`, `duration_ns`, optional `error_message` |
| `coverage` | optional `line_percent`, `function_percent`, `branch_percent`, `report_path` |

Statuses are `pending`, `running`, `passed`, `flaky`, `failed`, or `skipped`.
Hook kinds are `before_all`, `before_each`, `after_each`, or `after_all`.
Output streams are `stdout` or `stderr`.

Optional fields are serialized as `null` when no value is available. Durations
are unsigned integer nanoseconds. Percentages are numbers from 0 through 100.

## Compatibility policy

`protocol_version` is the major wire-format version. Version 1 may gain new
event types, enum values, and optional data fields without changing the version.
Consumers must ignore event types and fields they do not understand. Existing
fields will not change meaning or type within a major version.

Removing or renaming an event or field, changing a field's type or semantics,
or changing the envelope shape requires a new major version. Producers emit one
major version per stream. Consumers should reject an unsupported major version
with a clear error instead of interpreting it as an older version.

## Zig API

`event_protocol.Event` is the tagged union for all event types, and
`event_protocol.encodeAlloc` returns a standards-compliant encoded envelope.
The root module also exports these as `ProtocolEvent` and `protocol_version`.
136 changes: 136 additions & 0 deletions src/event_protocol.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const std = @import("std");

/// Major version of the public JSON event protocol.
pub const version: u16 = 1;

pub const Status = enum { pending, running, passed, flaky, failed, skipped };
pub const HookKind = enum { before_all, before_each, after_each, after_all };
pub const OutputStream = enum { stdout, stderr };

pub const RunStart = struct { total: usize };
pub const RunEnd = struct {
total: usize,
passed: usize,
flaky: usize,
failed: usize,
skipped: usize,
duration_ns: u64 = 0,
};
pub const Suite = struct { name: []const u8 };
pub const TestStart = struct { name: []const u8, suite: ?[]const u8 = null };
pub const TestEnd = struct {
name: []const u8,
suite: ?[]const u8 = null,
status: Status,
duration_ns: u64,
error_message: ?[]const u8 = null,
};
pub const Hook = struct {
kind: HookKind,
suite: ?[]const u8 = null,
test_name: ?[]const u8 = null,
status: Status,
duration_ns: u64 = 0,
error_message: ?[]const u8 = null,
};
pub const Output = struct {
stream: OutputStream,
text: []const u8,
test_name: ?[]const u8 = null,
};
pub const Retry = struct {
name: []const u8,
attempt: usize,
repetition: usize,
status: Status,
duration_ns: u64,
error_message: ?[]const u8 = null,
};
pub const Coverage = struct {
line_percent: ?f64 = null,
function_percent: ?f64 = null,
branch_percent: ?f64 = null,
report_path: ?[]const u8 = null,
};

/// Every serialized value is an envelope with the same version/type/data
/// shape. Unknown fields and event types are intentionally safe to ignore.
pub const Event = union(enum) {
run_start: RunStart,
run_end: RunEnd,
suite_start: Suite,
suite_end: Suite,
test_start: TestStart,
test_end: TestEnd,
hook: Hook,
output: Output,
retry: Retry,
coverage: Coverage,

pub fn eventName(self: Event) []const u8 {
return @tagName(std.meta.activeTag(self));
}

pub fn jsonStringify(self: Event, json: anytype) !void {
try json.beginObject();
try json.objectField("protocol_version");
try json.write(version);
try json.objectField("type");
try json.write(self.eventName());
try json.objectField("data");
switch (self) {
inline else => |payload| try json.write(payload),
}
try json.endObject();
}
};

pub fn status(value: anytype) Status {
return std.meta.stringToEnum(Status, @tagName(value)) orelse unreachable;
}

pub fn encodeAlloc(allocator: std.mem.Allocator, event: Event) ![]u8 {
return std.json.Stringify.valueAlloc(allocator, event, .{});
}

test "every event type follows the versioned envelope contract" {
const events = [_]Event{
.{ .run_start = .{ .total = 2 } },
.{ .run_end = .{ .total = 2, .passed = 1, .flaky = 0, .failed = 1, .skipped = 0 } },
.{ .suite_start = .{ .name = "suite \"one\"" } },
.{ .suite_end = .{ .name = "suite \"one\"" } },
.{ .test_start = .{ .name = "line\nbreak", .suite = "suite" } },
.{ .test_end = .{ .name = "line\nbreak", .status = .failed, .duration_ns = 42, .error_message = "bad \"value\"" } },
.{ .hook = .{ .kind = .before_each, .test_name = "case", .status = .passed } },
.{ .output = .{ .stream = .stderr, .text = "hello\nworld", .test_name = "case" } },
.{ .retry = .{ .name = "case", .attempt = 2, .repetition = 1, .status = .passed, .duration_ns = 10 } },
.{ .coverage = .{ .line_percent = 90.5, .report_path = "coverage/index.html" } },
};

inline for (events) |event| {
const encoded = try encodeAlloc(std.testing.allocator, event);
defer std.testing.allocator.free(encoded);
var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, encoded, .{});
defer parsed.deinit();
const object = parsed.value.object;
try std.testing.expectEqual(@as(i64, version), object.get("protocol_version").?.integer);
try std.testing.expectEqualStrings(event.eventName(), object.get("type").?.string);
try std.testing.expect(object.get("data") != null);
}
}

test "event payload strings use standard JSON escaping" {
const encoded = try encodeAlloc(std.testing.allocator, .{ .test_end = .{
.name = "case\tname",
.status = .failed,
.duration_ns = 1,
.error_message = "line one\n\"quoted\" and \\escaped",
} });
defer std.testing.allocator.free(encoded);

var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, encoded, .{});
defer parsed.deinit();
const data = parsed.value.object.get("data").?.object;
try std.testing.expectEqualStrings("case\tname", data.get("name").?.string);
try std.testing.expectEqualStrings("line one\n\"quoted\" and \\escaped", data.get("error_message").?.string);
}
3 changes: 3 additions & 0 deletions src/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub const suite = @import("suite.zig");
pub const test_runner = @import("test_runner.zig");
pub const reporter = @import("reporter.zig");
pub const pipeline = @import("pipeline.zig");
pub const event_protocol = @import("event_protocol.zig");
pub const matchers = @import("matchers.zig");
pub const mock = @import("mock.zig");
pub const cli = @import("cli.zig");
Expand Down Expand Up @@ -90,6 +91,8 @@ pub const PlanOrigin = pipeline.PlanOrigin;
pub const ExecutionPolicy = pipeline.ExecutionPolicy;
pub const LifecycleEvent = pipeline.LifecycleEvent;
pub const EventStream = pipeline.EventStream;
pub const ProtocolEvent = event_protocol.Event;
pub const protocol_version = event_protocol.version;

// Matchers
pub const Matchers = matchers.Matchers;
Expand Down
Loading
Loading