ReviewOS

also looking at this

zig-utils/zig-test-framework

Implement the web UI server with Zig I/O

#26
Merged glennmichael123 wants to merge codex/issue-9-ui-server into main
8 files +513 -114
README.mdmodified+5-1
Changes to README.md
@@ -105,12 +105,13 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
105105- `--shard-index <N>` - Run one one-based file shard
106106- `--shard-count <N>` - Set the total number of file shards
107107- `--no-color` - Disable color in child Zig test processes
108- `--ui` - Serve the live test UI (configure it with `--ui-host` and `--ui-port`)
108109- `--verbose` - Show detailed output
109110
110111Discovery and programmatic execution share the same test-plan, lifecycle,
111112result, and reporter model. Discovery still rejects options it cannot honor
112113instead of silently ignoring them: quiet output, parallel jobs, snapshot
113updates, memory profiling, and the web UI remain programmatic-only. See
114updates, and memory profiling remain programmatic-only. See
114115[the execution pipeline architecture](docs/execution-pipeline.md) for extension
115116points, timeout semantics, and migration guidance.
116117
@@ -129,6 +130,9 @@ zig-test --test-dir tests --no-recursive
129130# Stop on first failure
130131zig-test --test-dir tests --bail
131132
133# Watch tests in the live web UI
134zig-test --test-dir tests --ui --watch
135
132136# Retry failures twice and fail CI if any test is flaky
133137zig-test --test-dir tests --retry 2 --fail-on-flaky
134138
build.zigmodified+7-0
Changes to build.zig
@@ -56,6 +56,13 @@ pub fn build(b: *std.Build) void {
5656 // Unit tests for the framework itself
5757 const lib_unit_tests = b.addTest(.{
5858 .root_module = lib_module,
59 // The Zig server-mode test runner can deadlock with Threaded network
60 // I/O on Windows. Run this networking-heavy test artifact through the
61 // normal process protocol and use its exit status instead.
62 .test_runner = if (target.result.os.tag == .windows) .{
63 .path = b.path("tests/simple_test_runner.zig"),
64 .mode = .simple,
65 } else null,
5966 });
6067
6168 const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
src/cli.zigmodified+7-1
Changes to src/cli.zig
@@ -367,7 +367,6 @@ pub const CLI = struct {
367367 if (self.options.profile_memory) return "--profile-memory";
368368 if (self.options.memory_threshold != 0) return "--memory-threshold";
369369 if (self.options.fail_on_leak) return "--fail-on-leak";
370 if (self.options.ui) return "--ui";
371370 return null;
372371 }
373372
@@ -767,6 +766,13 @@ test "discovery accepts reporter selection" {
767766 try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption());
768767}
769768
769test "discovery accepts the live UI" {
770 var cli = CLI.init(std.testing.allocator);
771 const args = [_][]const u8{ "zig-test", "--test-dir", "tests", "--ui", "--ui-port", "0" };
772 try cli.parse(&args);
773 try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption());
774}
775
770776test "discovery accepts timeout and reports parallel as unsupported" {
771777 var cli = CLI.init(std.testing.allocator);
772778
src/main.zigmodified+14-49
Changes to src/main.zig
@@ -1,10 +1,10 @@
11const std = ;
22const builtin = ;
33const lib = ;
4const compat = lib.compat;
54
6// Global signal handler state
7var shutdown_requested = std.atomic.Value(bool).init(false);
5// Global signal handler state. Watch mode shares this flag so Ctrl+C can
6// unwind through normal cleanup instead of bypassing the UI server shutdown.
7var keep_running = std.atomic.Value(bool).init(true);
88
99/// Install signal handlers
1010fn installSignalHandlers() !void {
@@ -16,7 +16,7 @@ fn installSignalHandlers() !void {
1616 const Handler = struct {
1717 fn handle(sig: std.posix.SIG) callconv(.c) void {
1818 _ = sig;
19 shutdown_requested.store(true, .monotonic);
19 keep_running.store(false, .monotonic);
2020 std.debug.print("\n\nShutdown requested... cleaning up\n", .{});
2121 }
2222 };
@@ -107,20 +107,14 @@ pub fn main(init: std.process.Init.Minimal) !void {
107107
108108 // Start UI server if requested
109109 var ui_server: ?lib.UIServer = null;
110 var ui_thread: ?std.Thread = null;
111 var server_running = std.atomic.Value(bool).init(false);
112110
113111 defer {
114 if (shutdown_requested.load(.monotonic)) {
112 if (!keep_running.load(.monotonic)) {
115113 std.debug.print("Cleanup complete.\n", .{});
116114 }
117115 if (ui_server) |*server| {
118 server_running.store(false, .monotonic);
119116 server.deinit();
120117 }
121 if (ui_thread) |thread| {
122 thread.detach();
123 }
124118 }
125119
126120 if (cli_parser.options.ui) {
@@ -133,38 +127,8 @@ pub fn main(init: std.process.Init.Minimal) !void {
133127 ui_server = lib.UIServer.init(allocator, ui_options);
134128 try ui_server.?.start();
135129
136 std.debug.print("UI Server started at http://{s}:{d}\n", .{ ui_options.host, ui_options.port });
130 std.debug.print("UI Server started at http://{s}:{d}\n", .{ ui_options.host, ui_server.?.port() });
137131 std.debug.print("Open this URL in your browser to view test results.\n\n", .{});
138
139 // Start server in background thread
140 const ServerContext = struct {
141 server: *lib.UIServer,
142 running: *std.atomic.Value(bool),
143 verbose: bool,
144
145 fn run(ctx: @This()) void {
146 ctx.running.store(true, .monotonic);
147 while (ctx.running.load(.monotonic)) {
148 ctx.server.acceptClient() catch |err| {
149 if (ctx.verbose) {
150 std.debug.print("UI Server error: {any}\n", .{err});
151 }
152 // Small delay to avoid tight loop on errors
153 compat.sleep(100 * std.time.ns_per_ms);
154 };
155 }
156 }
157 };
158
159 const server_ctx = ServerContext{
160 .server = &ui_server.?,
161 .running = &server_running,
162 .verbose = cli_parser.options.verbose,
163 };
164 ui_thread = try std.Thread.spawn(.{}, ServerContext.run, .{server_ctx});
165
166 // Give server time to start
167 compat.sleep(100 * std.time.ns_per_ms);
168132 }
169133
170134 var all_passed: bool = undefined;
@@ -197,8 +161,7 @@ pub fn main(init: std.process.Init.Minimal) !void {
197161 .verbose = cli_parser.options.verbose,
198162 };
199163
200 var watch_running = std.atomic.Value(bool).init(true);
201 var watcher = lib.TestWatcher.init(allocator, watch_options, &watch_running);
164 var watcher = lib.TestWatcher.init(allocator, watch_options, &keep_running);
202165
203166 // Create coverage options if coverage is enabled
204167 const cov_opts = if (cli_parser.options.coverage) lib.CoverageOptions{
@@ -303,9 +266,11 @@ pub fn main(init: std.process.Init.Minimal) !void {
303266 }
304267
305268 // Exit with appropriate code
306 if (all_passed) {
307 std.process.exit(0);
308 } else {
309 std.process.exit(1);
310 }
269 if (all_passed) return;
270
271 // `std.process.exit` does not run defers, so explicitly stop the server
272 // before returning the unsuccessful process status.
273 if (ui_server) |*server| server.deinit();
274 ui_server = null;
275 std.process.exit(1);
311276}
src/test_loader.zigmodified+23-18
Changes to src/test_loader.zig
@@ -41,6 +41,12 @@ pub const LoaderOptions = struct {
4141 ui_server: ?*ui_server.UIServer = null,
4242};
4343
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
4450/// Run all discovered test files
4551pub fn runDiscoveredTests(
4652 allocator: std.mem.Allocator,
@@ -101,9 +107,7 @@ pub fn runDiscoveredTests(
101107
102108 // Notify UI of run start
103109 if (options.ui_server) |server| {
104 var buffer: [256]u8 = undefined;
105 const json = try std.fmt.bufPrint(&buffer, "", .{selected_files});
106 try server.broadcast("run_start", json);
110 try broadcastJson(server, allocator, "run_start", .{ .total = selected_files });
107111 }
108112
109113 var files_run: usize = 0;
@@ -125,10 +129,8 @@ pub fn runDiscoveredTests(
125129
126130 // Notify UI of test file start
127131 if (options.ui_server) |server| {
128 var buffer: [512]u8 = undefined;
129 const json = try std.fmt.bufPrint(&buffer, "", .{file.name});
130 try server.broadcast("suite_start", json);
131 try server.broadcast("test_start", json);
132 try broadcastJson(server, allocator, "suite_start", .{ .name = file.name });
133 try broadcastJson(server, allocator, "test_start", .{ .name = file.name });
132134 }
133135
134136 files_run += 1;
@@ -180,15 +182,14 @@ pub fn runDiscoveredTests(
180182
181183 // Notify UI of test file end
182184 if (options.ui_server) |server| {
183 var buffer: [512]u8 = undefined;
184 const json = try std.fmt.bufPrint(&buffer, "", .{
185 file.name,
186 @tagName(test_case.status),
187 test_case.execution_time_ns,
188 test_case.attempts.items.len,
185 try broadcastJson(server, allocator, "test_end", .{
186 .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 = "",
189191 });
190 try server.broadcast("test_end", json);
191 try server.broadcast("suite_end", try std.fmt.bufPrint(&buffer, "", .{file.name}));
192 try broadcastJson(server, allocator, "suite_end", .{ .name = file.name });
192193 }
193194
194195 if (test_case.status == .passed) {
@@ -218,9 +219,13 @@ pub fn runDiscoveredTests(
218219
219220 // Notify UI of run end
220221 if (options.ui_server) |server| {
221 var buffer: [512]u8 = undefined;
222 const json = try std.fmt.bufPrint(&buffer, "", .{ results.total, results.passed, results.failed, results.skipped });
223 try server.broadcast("run_end", json);
222 try broadcastJson(server, allocator, "run_end", .{
223 .total = results.total,
224 .passed = results.passed,
225 .flaky = results.flaky,
226 .failed = results.failed,
227 .skipped = results.skipped,
228 });
224229 }
225230
226231 if (options.shard) |shard| {
src/ui/index.htmlmodified+23-2
Changes to src/ui/index.html
@@ -84,6 +84,7 @@
8484
8585 .stat.total .stat-value { color: #58a6ff; }
8686 .stat.passed .stat-value { color: #3fb950; }
87 .stat.flaky .stat-value { color: #dbab09; }
8788 .stat.failed .stat-value { color: #f85149; }
8889 .stat.skipped .stat-value { color: #d29922; }
8990
@@ -148,6 +149,11 @@
148149 background: #1c2128;
149150 }
150151
152 .test.flaky {
153 border-left-color: #dbab09;
154 background: #1c2128;
155 }
156
151157 .test.skipped {
152158 border-left-color: #d29922;
153159 opacity: 0.6;
@@ -181,6 +187,11 @@
181187 color: #fff;
182188 }
183189
190 .test-status.flaky {
191 background: #9e6a03;
192 color: #fff;
193 }
194
184195 .test-status.skipped {
185196 background: #9e6a03;
186197 color: #fff;
@@ -263,6 +274,10 @@
263274 <div class="stat-value" id="passed-tests">0</div>
264275 <div class="stat-label">Passed</div>
265276 </div>
277 <div class="flaky stat">
278 <div class="stat-value" id="flaky-tests">0</div>
279 <div class="stat-label">Flaky</div>
280 </div>
266281 <div class="failed stat">
267282 <div class="stat-value" id="failed-tests">0</div>
268283 <div class="stat-label">Failed</div>
@@ -290,6 +305,7 @@
290305 const state = {
291306 total: 0,
292307 passed: 0,
308 flaky: 0,
293309 failed: 0,
294310 skipped: 0,
295311 suites: new Map(),
@@ -300,6 +316,7 @@
300316 connectionStatus: document.getElementById('connection-status'),
301317 totalTests: document.getElementById('total-tests'),
302318 passedTests: document.getElementById('passed-tests'),
319 flakyTests: document.getElementById('flaky-tests'),
303320 failedTests: document.getElementById('failed-tests'),
304321 skippedTests: document.getElementById('skipped-tests'),
305322 progress: document.getElementById('progress'),
formatting only · 10 hidden lines
Changes to src/ui/index.html
@@ -316,9 +333,9 @@ formatting only
316333
317334 eventSource.addEventListener('run_start', (e) => {
318335 const data = JSON.parse(e.data);
336 clearTests();
319337 state.total = data.total;
320338 updateStats();
321 clearTests();
322339 });
323340
324341 eventSource.addEventListener('suite_start', (e) => {
Changes to src/ui/index.html
@@ -337,6 +354,7 @@
337354 updateTest(state.currentSuite, data.name, data.status, data.execution_time_ns, data.error_message);
338355
339356 if (data.status === 'passed') state.passed++;
357 else if (data.status === 'flaky') state.flaky++;
340358 else if (data.status === 'failed') state.failed++;
341359 else if (data.status === 'skipped') state.skipped++;
342360
@@ -347,6 +365,7 @@
347365 const data = JSON.parse(e.data);
348366 state.total = data.total;
349367 state.passed = data.passed;
368 state.flaky = data.flaky;
350369 state.failed = data.failed;
351370 state.skipped = data.skipped;
352371 updateStats();
@@ -364,10 +383,11 @@
364383 function updateStats() {
365384 elements.totalTests.textContent = state.total;
366385 elements.passedTests.textContent = state.passed;
386 elements.flakyTests.textContent = state.flaky;
367387 elements.failedTests.textContent = state.failed;
368388 elements.skippedTests.textContent = state.skipped;
369389
370 const completed = state.passed + state.failed + state.skipped;
390 const completed = state.passed + state.flaky + state.failed + state.skipped;
371391 const progress = state.total > 0 ? (completed / state.total) * 100 : 0;
372392 elements.progress.style.width = `${progress}%`;
373393 }
@@ -375,6 +395,7 @@
375395 function clearTests() {
376396 state.suites.clear();
377397 state.passed = 0;
398 state.flaky = 0;
378399 state.failed = 0;
379400 state.skipped = 0;
380401 elements.tests.innerHTML = '';
src/ui_server.zigmodified+360-43
Changes to src/ui_server.zig
@@ -14,47 +14,273 @@ pub const UIServerOptions = struct {
1414 verbose: bool = false,
1515};
1616
17/// UI Server for web-based test visualization
18/// Note: std.net was removed in Zig 0.16. Server functionality is stubbed out
19/// until the new Io-based networking API stabilizes.
17/// UI Server for web-based test visualization.
2018pub const UIServer = struct {
2119 allocator: std.mem.Allocator,
2220 options: UIServerOptions,
2321 mutex: compat.Mutex = .{},
2422 history: ?*test_history.TestHistory = null,
23 threaded_io: std.Io.Threaded,
24 listener: ?std.Io.net.Server = null,
25 thread: ?std.Thread = null,
26 running: std.atomic.Value(bool) = .init(false),
27 active_client: ?std.Io.net.Stream = null,
28 clients: std.ArrayList(std.Io.net.Stream) = .empty,
2529
2630 const Self = @This();
31 const index_html = @embedFile("ui/index.html");
32
33 pub const Error = error{
34 AlreadyStarted,
35 InvalidRequest,
36 ServerStopped,
37 };
2738
2839 pub fn init(allocator: std.mem.Allocator, options: UIServerOptions) Self {
2940 return .{
3041 .allocator = allocator,
3142 .options = options,
43 .threaded_io = .init(allocator, .{ .environ = .empty }),
3244 };
3345 }
3446
3547 pub fn deinit(self: *Self) void {
36 _ = self;
48 self.stop();
49 self.clients.deinit(self.allocator);
50 self.threaded_io.deinit();
3751 }
3852
39 /// Start the UI server (stubbed - std.net removed in Zig 0.16)
53 /// Bind the configured address and start accepting HTTP clients.
4054 pub fn start(self: *Self) !void {
55 if (self.running.load(.acquire) or self.listener != null) return Error.AlreadyStarted;
56
57 const io = self.threaded_io.io();
58 const address = if (std.ascii.eqlIgnoreCase(self.options.host, "localhost"))
59 std.Io.net.IpAddress{ .ip4 = .loopback(self.options.port) }
60 else
61 try std.Io.net.IpAddress.parse(self.options.host, self.options.port);
62
63 self.listener = try address.listen(io, .{ .reuse_address = true });
64 self.options.port = self.listener.?.socket.address.getPort();
65 self.running.store(true, .release);
66 self.thread = std.Thread.spawn(.{}, serve, .{self}) catch |err| {
67 self.running.store(false, .release);
68 self.listener.?.deinit(io);
69 self.listener = null;
70 return err;
71 };
72
4173 if (self.options.verbose) {
42 std.debug.print("UI Server: networking stubbed out (std.net removed in Zig 0.16)\n", .{});
74 std.debug.print("UI Server: listening on http://{s}:{d}\n", .{ self.options.host, self.options.port });
75 }
76 }
77
78 /// Stop accepting connections and close every active event stream.
79 pub fn stop(self: *Self) void {
80 const was_running = self.running.swap(false, .acq_rel);
81 var wake_io: ?std.Io.Threaded = null;
82 var wake_stream: ?std.Io.net.Stream = null;
83
84 if (was_running) {
85 // A client may have connected without finishing its request
86 // headers. Shutdown unblocks the parser while leaving ownership of
87 // the socket with the accept thread.
88 self.mutex.lock();
89 if (self.active_client) |client| client.shutdown(self.threaded_io.io(), .both) catch {};
90 self.mutex.unlock();
91
92 // Closing a listening socket from another thread makes the current
93 // Zig Windows backend treat the canceled accept as unreachable.
94 // Wake it with a loopback connection on an independent I/O runtime:
95 // sharing the server runtime here can deadlock its blocking accept
96 // on Linux and Windows. Keep the connection alive until accept has
97 // returned so the peer cannot disappear from the listen queue.
98 wake_io = .init(self.allocator, .{ .environ = .empty });
99 if (wake_io) |*threaded| {
100 wake_stream = self.connectWake(threaded.io());
101 }
102 }
103
104 if (self.thread) |thread| {
105 thread.join();
106 self.thread = null;
107 }
108
109 if (wake_stream) |stream| {
110 if (wake_io) |*threaded| stream.close(threaded.io());
43111 }
112 if (wake_io) |*threaded| threaded.deinit();
113
114 if (self.listener) |*server| {
115 server.deinit(self.threaded_io.io());
116 self.listener = null;
117 }
118
119 self.mutex.lock();
120 defer self.mutex.unlock();
121 for (self.clients.items) |client| client.close(self.threaded_io.io());
122 self.clients.clearRetainingCapacity();
123 }
124
125 fn connectWake(self: *Self, io: std.Io) ?std.Io.net.Stream {
126 const server = self.listener orelse return null;
127 var address = server.socket.address;
128 switch (address) {
129 .ip4 => |ip4| {
130 if (std.mem.eql(u8, &ip4.bytes, &.{ 0, 0, 0, 0 })) {
131 address = .{ .ip4 = .loopback(ip4.port) };
132 }
133 },
134 .ip6 => |ip6| {
135 const unspecified: [16]u8 = @splat(0);
136 if (std.mem.eql(u8, &ip6.bytes, &unspecified)) {
137 address = .{ .ip6 = .loopback(ip6.port) };
138 }
139 },
140 }
141
142 return address.connect(io, .{ .mode = .stream }) catch null;
44143 }
45144
46 /// Accept a client connection (stubbed)
145 /// Return the actual bound port. This differs from the requested port when
146 /// port zero asks the operating system for an ephemeral port.
147 pub fn port(self: *const Self) u16 {
148 return self.options.port;
149 }
150
151 fn serve(self: *Self) void {
152 while (self.running.load(.acquire)) {
153 self.acceptClient() catch |err| {
154 if (!self.running.load(.acquire)) return;
155 if (self.options.verbose) {
156 std.debug.print("UI Server error: {s}\n", .{@errorName(err)});
157 }
158 compat.sleep(10 * std.time.ns_per_ms);
159 };
160 }
161 }
162
163 /// Accept and serve one HTTP client connection.
47164 pub fn acceptClient(self: *Self) !void {
48 _ = self;
49 // Stubbed: no networking available
50 compat.sleep(100 * std.time.ns_per_ms);
165 if (!self.running.load(.acquire)) return Error.ServerStopped;
166 const stream = if (self.listener) |*listener|
167 try listener.accept(self.threaded_io.io())
168 else
169 return Error.ServerStopped;
170 if (!self.running.load(.acquire)) {
171 stream.close(self.threaded_io.io());
172 return Error.ServerStopped;
173 }
174
175 self.mutex.lock();
176 self.active_client = stream;
177 self.mutex.unlock();
178
179 const disposition = self.handleClient(stream) catch |err| {
180 self.finishClient(false) catch {};
181 return err;
182 };
183 try self.finishClient(disposition == .event_stream);
51184 }
52185
53 /// Broadcast an event to all connected clients (stubbed)
186 const ClientDisposition = enum { close, event_stream };
187
188 fn handleClient(self: *Self, stream: std.Io.net.Stream) !ClientDisposition {
189 var read_buffer: [4096]u8 = undefined;
190 var stream_reader = stream.reader(self.threaded_io.io(), &read_buffer);
191 const request_line_raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return Error.InvalidRequest;
192 const request_line = std.mem.trimEnd(u8, request_line_raw, "\r");
193 var parts = std.mem.splitScalar(u8, request_line, ' ');
194 const method = parts.next() orelse return Error.InvalidRequest;
195 const target = parts.next() orelse return Error.InvalidRequest;
196 _ = parts.next() orelse return Error.InvalidRequest;
197
198 const is_get = std.mem.eql(u8, method, "GET");
199 const is_events = std.mem.eql(u8, target, "/events");
200 const is_index = std.mem.eql(u8, target, "/") or std.mem.eql(u8, target, "/index.html");
201
202 while (true) {
203 const header_raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return Error.InvalidRequest;
204 if (std.mem.trimEnd(u8, header_raw, "\r").len == 0) break;
205 }
206
207 if (!is_get) {
208 try self.respond(stream, "405 Method Not Allowed", "text/plain; charset=utf-8", "Method Not Allowed\n");
209 } else if (is_events) {
210 try self.openEventStream(stream);
211 return .event_stream;
212 } else if (is_index) {
213 try self.respond(stream, "200 OK", "text/html; charset=utf-8", index_html);
214 } else {
215 try self.respond(stream, "404 Not Found", "text/plain; charset=utf-8", "Not Found\n");
216 }
217 return .close;
218 }
219
220 fn finishClient(self: *Self, keep_open: bool) !void {
221 self.mutex.lock();
222 defer self.mutex.unlock();
223 const client = self.active_client orelse return;
224 self.active_client = null;
225
226 if (keep_open and self.running.load(.acquire)) {
227 self.clients.append(self.allocator, client) catch |err| {
228 client.close(self.threaded_io.io());
229 return err;
230 };
231 } else {
232 client.close(self.threaded_io.io());
233 }
234 }
235
236 fn respond(self: *Self, stream: std.Io.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void {
237 var write_buffer: [2048]u8 = undefined;
238 var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer);
239 try stream_writer.interface.print(
240 "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n",
241 .{ status, content_type, body.len },
242 );
243 try stream_writer.interface.writeAll(body);
244 try stream_writer.interface.flush();
245 }
246
247 fn openEventStream(self: *Self, stream: std.Io.net.Stream) !void {
248 var write_buffer: [512]u8 = undefined;
249 var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer);
250 try stream_writer.interface.writeAll(
251 "HTTP/1.1 200 OK\r\n" ++
252 "Content-Type: text/event-stream\r\n" ++
253 "Cache-Control: no-cache\r\n" ++
254 "Connection: keep-alive\r\n" ++
255 "X-Accel-Buffering: no\r\n\r\n" ++
256 "event: connected\ndata: {}\n\n",
257 );
258 try stream_writer.interface.flush();
259 }
260
261 fn sendEvent(self: *Self, stream: std.Io.net.Stream, event: []const u8, data: []const u8) !void {
262 var write_buffer: [1024]u8 = undefined;
263 var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer);
264 try stream_writer.interface.print("event: {s}\ndata: {s}\n\n", .{ event, data });
265 try stream_writer.interface.flush();
266 }
267
268 /// Broadcast an event to all connected clients. A disconnected browser is
269 /// removed without disrupting the test run or the remaining clients.
54270 pub fn broadcast(self: *Self, event: []const u8, data: []const u8) !void {
55 _ = self;
56 _ = event;
57 _ = data;
271 self.mutex.lock();
272 defer self.mutex.unlock();
273
274 var index: usize = 0;
275 while (index < self.clients.items.len) {
276 const client = self.clients.items[index];
277 self.sendEvent(client, event, data) catch {
278 client.close(self.threaded_io.io());
279 _ = self.clients.swapRemove(index);
280 continue;
281 };
282 index += 1;
283 }
58284 }
59285};
60286
@@ -89,69 +315,62 @@ pub const UIReporter = struct {
89315 self.buffer.deinit(self.reporter.allocator);
90316 }
91317
92 fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void {
93 const self: *Self = @fieldParentPtr("reporter", reporter);
94
318 fn encode(self: *Self, value: anytype) !void {
95319 self.buffer.clearRetainingCapacity();
96 try self.buffer.print(reporter.allocator, "", .{total});
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 }
97324
325 fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void {
326 const self: *Self = @fieldParentPtr("reporter", reporter);
327 try self.encode(.{ .total = total });
98328 try self.server.broadcast("run_start", self.buffer.items);
99329 }
100330
101331 fn onRunEnd(reporter: *reporter_mod.Reporter, results: *reporter_mod.TestResults) !void {
102332 const self: *Self = @fieldParentPtr("reporter", reporter);
103333
104 self.buffer.clearRetainingCapacity();
105 try self.buffer.print(reporter.allocator, "", .{
106 results.total,
107 results.passed,
108 results.failed,
109 results.skipped,
334 try self.encode(.{
335 .total = results.total,
336 .passed = results.passed,
337 .flaky = results.flaky,
338 .failed = results.failed,
339 .skipped = results.skipped,
110340 });
111
112341 try self.server.broadcast("run_end", self.buffer.items);
113342 }
114343
115344 fn onSuiteStart(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void {
116345 const self: *Self = @fieldParentPtr("reporter", reporter);
117346
118 self.buffer.clearRetainingCapacity();
119 try self.buffer.print(reporter.allocator, "", .{suite_name});
120
347 try self.encode(.{ .name = suite_name });
121348 try self.server.broadcast("suite_start", self.buffer.items);
122349 }
123350
124351 fn onSuiteEnd(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void {
125352 const self: *Self = @fieldParentPtr("reporter", reporter);
126353
127 self.buffer.clearRetainingCapacity();
128 try self.buffer.print(reporter.allocator, "", .{suite_name});
129
354 try self.encode(.{ .name = suite_name });
130355 try self.server.broadcast("suite_end", self.buffer.items);
131356 }
132357
133358 fn onTestStart(reporter: *reporter_mod.Reporter, test_name: []const u8) !void {
134359 const self: *Self = @fieldParentPtr("reporter", reporter);
135360
136 self.buffer.clearRetainingCapacity();
137 try self.buffer.print(reporter.allocator, "", .{test_name});
138
361 try self.encode(.{ .name = test_name });
139362 try self.server.broadcast("test_start", self.buffer.items);
140363 }
141364
142365 fn onTestEnd(reporter: *reporter_mod.Reporter, test_case: *const suite.TestCase) !void {
143366 const self: *Self = @fieldParentPtr("reporter", reporter);
144367
145 self.buffer.clearRetainingCapacity();
146
147 const error_msg = test_case.error_message orelse "";
148 try self.buffer.print(reporter.allocator, "", .{
149 test_case.name,
150 @tagName(test_case.status),
151 test_case.execution_time_ns,
152 error_msg,
368 try self.encode(.{
369 .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 "",
153373 });
154
155374 try self.server.broadcast("test_end", self.buffer.items);
156375 }
157376};
@@ -253,4 +472,102 @@ test "UIReporter initialization" {
253472
254473 try std.testing.expectEqual(false, ui_reporter.reporter.use_colors);
255474 try std.testing.expectEqual(@as(usize, 0), ui_reporter.buffer.items.len);
475
476 try ui_reporter.reporter.onSuiteStart("quoted \"suite\"\nname");
477 try std.testing.expectEqualStrings("{\"name\":\"quoted \\\"suite\\\"\\nname\"}", ui_reporter.buffer.items);
478}
479
480fn writeTestRequest(io: std.Io, stream: std.Io.net.Stream, target: []const u8) !void {
481 var write_buffer: [512]u8 = undefined;
482 var writer = stream.writer(io, &write_buffer);
483 try writer.interface.print("GET {s} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", .{target});
484 try writer.interface.flush();
485}
486
487fn expectLine(reader: *std.Io.Reader, expected: []const u8) !void {
488 const raw = (try reader.takeDelimiter('\n')) orelse return error.EndOfStream;
489 try std.testing.expectEqualStrings(expected, std.mem.trimEnd(u8, raw, "\r"));
490}
491
492test "UI server serves the embedded page on an ephemeral port" {
493 var server = UIServer.init(std.testing.allocator, .{ .port = 0 });
494 defer server.deinit();
495 try server.start();
496 try std.testing.expect(server.port() != 0);
497
498 var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty });
499 defer client_io.deinit();
500 const io = client_io.io();
501 const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) };
502 const stream = try address.connect(io, .{ .mode = .stream });
503 defer stream.close(io);
504 try writeTestRequest(io, stream, "/");
505
506 var read_buffer: [4096]u8 = undefined;
507 var stream_reader = stream.reader(io, &read_buffer);
508 const response = try stream_reader.interface.allocRemaining(std.testing.allocator, .limited(UIServer.index_html.len + 1024));
509 defer std.testing.allocator.free(response);
510 try std.testing.expect(std.mem.startsWith(u8, response, "HTTP/1.1 200 OK\r\n"));
511 try std.testing.expect(std.mem.indexOf(u8, response, "Zig Test Framework - Live Results") != null);
512}
513
514test "UI server streams broadcast events over SSE" {
515 var server = UIServer.init(std.testing.allocator, .{ .port = 0 });
516 defer server.deinit();
517 try server.start();
518
519 var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty });
520 defer client_io.deinit();
521 const io = client_io.io();
522 const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) };
523 const stream = try address.connect(io, .{ .mode = .stream });
524 defer stream.close(io);
525 try writeTestRequest(io, stream, "/events");
526
527 var read_buffer: [4096]u8 = undefined;
528 var stream_reader = stream.reader(io, &read_buffer);
529 try expectLine(&stream_reader.interface, "HTTP/1.1 200 OK");
530 while (true) {
531 const raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return error.EndOfStream;
532 if (std.mem.trimEnd(u8, raw, "\r").len == 0) break;
533 }
534 try expectLine(&stream_reader.interface, "event: connected");
535 try expectLine(&stream_reader.interface, "data: {}");
536 try expectLine(&stream_reader.interface, "");
537
538 try server.broadcast("run_start", "{\"total\":2}");
539 try expectLine(&stream_reader.interface, "event: run_start");
540 try expectLine(&stream_reader.interface, "data: {\"total\":2}");
541 try expectLine(&stream_reader.interface, "");
542}
543
544test "UI server shutdown cancels an incomplete HTTP request" {
545 var server = UIServer.init(std.testing.allocator, .{ .port = 0 });
546 defer server.deinit();
547 try server.start();
548
549 var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty });
550 defer client_io.deinit();
551 const io = client_io.io();
552 const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) };
553 const stream = try address.connect(io, .{ .mode = .stream });
554 defer stream.close(io);
555
556 var write_buffer: [128]u8 = undefined;
557 var writer = stream.writer(io, &write_buffer);
558 try writer.interface.writeAll("GET / HTTP/1.1\r\n");
559 try writer.interface.flush();
560
561 var request_is_active = false;
562 for (0..100) |_| {
563 server.mutex.lock();
564 request_is_active = server.active_client != null;
565 server.mutex.unlock();
566 if (request_is_active) break;
567 compat.sleep(std.time.ns_per_ms);
568 }
569 try std.testing.expect(request_is_active);
570
571 server.stop();
572 try std.testing.expect(!server.running.load(.acquire));
256573}
tests/simple_test_runner.zigadded+74-0
Changes to tests/simple_test_runner.zig
@@ -0,0 +1,74 @@
1//! Sequential test runner for test artifacts that exercise threaded network I/O.
2const builtin = ;
3const std = ;
4const testing = std.testing;
5
6pub const std_options: std.Options = .{
7 .logFn = log,
8};
9
10var log_error_count: usize = 0;
11
12pub fn main(init: std.process.Init.Minimal) void {
13 @disableInstrumentation();
14
15 var passed: usize = 0;
16 var skipped: usize = 0;
17 var failed: usize = 0;
18 var leaked: usize = 0;
19
20 for (builtin.test_functions, 0..) |test_fn, index| {
21 testing.allocator_instance = .init(std.heap.page_allocator, .{
22 .canary = 0xc3a701ba,
23 .check_write_after_free = true,
24 });
25 testing.io_instance = .init(testing.allocator, .{
26 .argv0 = .init(init.args),
27 .environ = init.environ,
28 });
29 testing.log_level = .warn;
30 testing.environ = init.environ;
31
32 std.debug.print("{d}/{d} {s}...", .{ index + 1, builtin.test_functions.len, test_fn.name });
33 if (test_fn.func()) |_| {
34 passed += 1;
35 std.debug.print("OK\n", .{});
36 } else |err| switch (err) {
37 error.SkipZigTest => {
38 skipped += 1;
39 std.debug.print("SKIP\n", .{});
40 },
41 else => {
42 failed += 1;
43 std.debug.print("FAIL ({t})\n", .{err});
44 if (@errorReturnTrace()) |trace| std.debug.dumpErrorReturnTrace(trace);
45 },
46 }
47
48 testing.io_instance.deinit();
49 leaked += @intFromBool(testing.allocator_instance.deinit() != 0);
50 }
51
52 std.debug.print("{d} passed; {d} skipped; {d} failed.\n", .{ passed, skipped, failed });
53 if (leaked != 0) std.debug.print("{d} tests leaked memory.\n", .{leaked});
54 if (log_error_count != 0) std.debug.print("{d} errors were logged.\n", .{log_error_count});
55 if (failed != 0 or leaked != 0 or log_error_count != 0) std.process.exit(1);
56}
57
58fn log(
59 comptime message_level: std.log.Level,
60 comptime scope: @EnumLiteral(),
61 comptime format: []const u8,
62 args: anytype,
63) void {
64 @disableInstrumentation();
65 if (@backingInt(message_level) <= @backingInt(std.log.Level.err)) {
66 log_error_count +|= 1;
67 }
68 if (@backingInt(message_level) <= @backingInt(testing.log_level)) {
69 std.debug.print(
70 "[" ++ @tagName(scope) ++ "] (" ++ @tagName(message_level) ++ "): " ++ format ++ "\n",
71 args,
72 );
73 }
74}