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

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.

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
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'),
@@ -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) => {
@@ -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 = '';