also looking at this
Implement the web UI server with Zig I/O
#26
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.
| @@ -105,12 +105,13 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d | ||
| 105 | 105 | - `--shard-index <N>` - Run one one-based file shard |
| 106 | 106 | - `--shard-count <N>` - Set the total number of file shards |
| 107 | 107 | - `--no-color` - Disable color in child Zig test processes |
| 108 | - `--ui` - Serve the live test UI (configure it with `--ui-host` and `--ui-port`) | |
| 108 | 109 | - `--verbose` - Show detailed output |
| 109 | 110 | |
| 110 | 111 | Discovery and programmatic execution share the same test-plan, lifecycle, |
| 111 | 112 | result, and reporter model. Discovery still rejects options it cannot honor |
| 112 | 113 | instead of silently ignoring them: quiet output, parallel jobs, snapshot |
| 113 | updates, memory profiling, and the web UI remain programmatic-only. See | |
| 114 | updates, and memory profiling remain programmatic-only. See | |
| 114 | 115 | [the execution pipeline architecture](docs/execution-pipeline.md) for extension |
| 115 | 116 | points, timeout semantics, and migration guidance. |
| 116 | 117 | |
| @@ -129,6 +130,9 @@ zig-test --test-dir tests --no-recursive | ||
| 129 | 130 | # Stop on first failure |
| 130 | 131 | zig-test --test-dir tests --bail |
| 131 | 132 | |
| 133 | # Watch tests in the live web UI | |
| 134 | zig-test --test-dir tests --ui --watch | |
| 135 | ||
| 132 | 136 | # Retry failures twice and fail CI if any test is flaky |
| 133 | 137 | zig-test --test-dir tests --retry 2 --fail-on-flaky |
| 134 | 138 | |
| @@ -367,7 +367,6 @@ pub const CLI = struct { | ||
| 367 | 367 | if (self.options.profile_memory) return "--profile-memory"; |
| 368 | 368 | if (self.options.memory_threshold != 0) return "--memory-threshold"; |
| 369 | 369 | if (self.options.fail_on_leak) return "--fail-on-leak"; |
| 370 | if (self.options.ui) return "--ui"; | |
| 371 | 370 | return null; |
| 372 | 371 | } |
| 373 | 372 | |
| @@ -767,6 +766,13 @@ test "discovery accepts reporter selection" { | ||
| 767 | 766 | try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption()); |
| 768 | 767 | } |
| 769 | 768 | |
| 769 | test "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 | ||
| 770 | 776 | test "discovery accepts timeout and reports parallel as unsupported" { |
| 771 | 777 | var cli = CLI.init(std.testing.allocator); |
| 772 | 778 | |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const builtin = ; |
| 3 | 3 | const lib = ; |
| 4 | const compat = lib.compat; | |
| 5 | 4 | |
| 6 | // Global signal handler state | |
| 7 | var 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. | |
| 7 | var keep_running = std.atomic.Value(bool).init(true); | |
| 8 | 8 | |
| 9 | 9 | /// Install signal handlers |
| 10 | 10 | fn installSignalHandlers() !void { |
| @@ -16,7 +16,7 @@ fn installSignalHandlers() !void { | ||
| 16 | 16 | const Handler = struct { |
| 17 | 17 | fn handle(sig: std.posix.SIG) callconv(.c) void { |
| 18 | 18 | _ = sig; |
| 19 | shutdown_requested.store(true, .monotonic); | |
| 19 | keep_running.store(false, .monotonic); | |
| 20 | 20 | std.debug.print("\n\nShutdown requested... cleaning up\n", .{}); |
| 21 | 21 | } |
| 22 | 22 | }; |
| @@ -107,20 +107,14 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 107 | 107 | |
| 108 | 108 | // Start UI server if requested |
| 109 | 109 | var ui_server: ?lib.UIServer = null; |
| 110 | var ui_thread: ?std.Thread = null; | |
| 111 | var server_running = std.atomic.Value(bool).init(false); | |
| 112 | 110 | |
| 113 | 111 | defer { |
| 114 | if (shutdown_requested.load(.monotonic)) { | |
| 112 | if (!keep_running.load(.monotonic)) { | |
| 115 | 113 | std.debug.print("Cleanup complete.\n", .{}); |
| 116 | 114 | } |
| 117 | 115 | if (ui_server) |*server| { |
| 118 | server_running.store(false, .monotonic); | |
| 119 | 116 | server.deinit(); |
| 120 | 117 | } |
| 121 | if (ui_thread) |thread| { | |
| 122 | thread.detach(); | |
| 123 | } | |
| 124 | 118 | } |
| 125 | 119 | |
| 126 | 120 | if (cli_parser.options.ui) { |
| @@ -133,38 +127,8 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 133 | 127 | ui_server = lib.UIServer.init(allocator, ui_options); |
| 134 | 128 | try ui_server.?.start(); |
| 135 | 129 | |
| 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() }); | |
| 137 | 131 | 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); | |
| 168 | 132 | } |
| 169 | 133 | |
| 170 | 134 | var all_passed: bool = undefined; |
| @@ -197,8 +161,7 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 197 | 161 | .verbose = cli_parser.options.verbose, |
| 198 | 162 | }; |
| 199 | 163 | |
| 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); | |
| 202 | 165 | |
| 203 | 166 | // Create coverage options if coverage is enabled |
| 204 | 167 | const cov_opts = if (cli_parser.options.coverage) lib.CoverageOptions{ |
| @@ -303,9 +266,11 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 303 | 266 | } |
| 304 | 267 | |
| 305 | 268 | // 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); | |
| 311 | 276 | } |
| @@ -41,6 +41,12 @@ pub const LoaderOptions = struct { | ||
| 41 | 41 | ui_server: ?*ui_server.UIServer = null, |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | fn 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 | ||
| 44 | 50 | /// Run all discovered test files |
| 45 | 51 | pub fn runDiscoveredTests( |
| 46 | 52 | allocator: std.mem.Allocator, |
| @@ -101,9 +107,7 @@ pub fn runDiscoveredTests( | ||
| 101 | 107 | |
| 102 | 108 | // Notify UI of run start |
| 103 | 109 | 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 }); | |
| 107 | 111 | } |
| 108 | 112 | |
| 109 | 113 | var files_run: usize = 0; |
| @@ -125,10 +129,8 @@ pub fn runDiscoveredTests( | ||
| 125 | 129 | |
| 126 | 130 | // Notify UI of test file start |
| 127 | 131 | 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 }); | |
| 132 | 134 | } |
| 133 | 135 | |
| 134 | 136 | files_run += 1; |
| @@ -180,15 +182,14 @@ pub fn runDiscoveredTests( | ||
| 180 | 182 | |
| 181 | 183 | // Notify UI of test file end |
| 182 | 184 | 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 = "", | |
| 189 | 191 | }); |
| 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 }); | |
| 192 | 193 | } |
| 193 | 194 | |
| 194 | 195 | if (test_case.status == .passed) { |
| @@ -218,9 +219,13 @@ pub fn runDiscoveredTests( | ||
| 218 | 219 | |
| 219 | 220 | // Notify UI of run end |
| 220 | 221 | 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 | }); | |
| 224 | 229 | } |
| 225 | 230 | |
| 226 | 231 | if (options.shard) |shard| { |
| @@ -84,6 +84,7 @@ | ||
| 84 | 84 | |
| 85 | 85 | .stat.total .stat-value { color: #58a6ff; } |
| 86 | 86 | .stat.passed .stat-value { color: #3fb950; } |
| 87 | .stat.flaky .stat-value { color: #dbab09; } | |
| 87 | 88 | .stat.failed .stat-value { color: #f85149; } |
| 88 | 89 | .stat.skipped .stat-value { color: #d29922; } |
| 89 | 90 | |
| @@ -148,6 +149,11 @@ | ||
| 148 | 149 | background: #1c2128; |
| 149 | 150 | } |
| 150 | 151 | |
| 152 | .test.flaky { | |
| 153 | border-left-color: #dbab09; | |
| 154 | background: #1c2128; | |
| 155 | } | |
| 156 | ||
| 151 | 157 | .test.skipped { |
| 152 | 158 | border-left-color: #d29922; |
| 153 | 159 | opacity: 0.6; |
| @@ -181,6 +187,11 @@ | ||
| 181 | 187 | color: #fff; |
| 182 | 188 | } |
| 183 | 189 | |
| 190 | .test-status.flaky { | |
| 191 | background: #9e6a03; | |
| 192 | color: #fff; | |
| 193 | } | |
| 194 | ||
| 184 | 195 | .test-status.skipped { |
| 185 | 196 | background: #9e6a03; |
| 186 | 197 | color: #fff; |
| @@ -263,6 +274,10 @@ | ||
| 263 | 274 | <div class="stat-value" id="passed-tests">0</div> |
| 264 | 275 | <div class="stat-label">Passed</div> |
| 265 | 276 | </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> | |
| 266 | 281 | <div class="failed stat"> |
| 267 | 282 | <div class="stat-value" id="failed-tests">0</div> |
| 268 | 283 | <div class="stat-label">Failed</div> |
| @@ -290,6 +305,7 @@ | ||
| 290 | 305 | const state = { |
| 291 | 306 | total: 0, |
| 292 | 307 | passed: 0, |
| 308 | flaky: 0, | |
| 293 | 309 | failed: 0, |
| 294 | 310 | skipped: 0, |
| 295 | 311 | suites: new Map(), |
| @@ -300,6 +316,7 @@ | ||
| 300 | 316 | connectionStatus: document.getElementById('connection-status'), |
| 301 | 317 | totalTests: document.getElementById('total-tests'), |
| 302 | 318 | passedTests: document.getElementById('passed-tests'), |
| 319 | flakyTests: document.getElementById('flaky-tests'), | |
| 303 | 320 | failedTests: document.getElementById('failed-tests'), |
| 304 | 321 | skippedTests: document.getElementById('skipped-tests'), |
| 305 | 322 | progress: document.getElementById('progress'), |
| @@ -316,9 +333,9 @@ formatting only | ||
| 316 | 333 | |
| 317 | 334 | eventSource.addEventListener('run_start', (e) => { |
| 318 | 335 | const data = JSON.parse(e.data); |
| 336 | clearTests(); | |
| 319 | 337 | state.total = data.total; |
| 320 | 338 | updateStats(); |
| 321 | clearTests(); | |
| 322 | 339 | }); |
| 323 | 340 | |
| 324 | 341 | eventSource.addEventListener('suite_start', (e) => { |
| @@ -337,6 +354,7 @@ | ||
| 337 | 354 | updateTest(state.currentSuite, data.name, data.status, data.execution_time_ns, data.error_message); |
| 338 | 355 | |
| 339 | 356 | if (data.status === 'passed') state.passed++; |
| 357 | else if (data.status === 'flaky') state.flaky++; | |
| 340 | 358 | else if (data.status === 'failed') state.failed++; |
| 341 | 359 | else if (data.status === 'skipped') state.skipped++; |
| 342 | 360 | |
| @@ -347,6 +365,7 @@ | ||
| 347 | 365 | const data = JSON.parse(e.data); |
| 348 | 366 | state.total = data.total; |
| 349 | 367 | state.passed = data.passed; |
| 368 | state.flaky = data.flaky; | |
| 350 | 369 | state.failed = data.failed; |
| 351 | 370 | state.skipped = data.skipped; |
| 352 | 371 | updateStats(); |
| @@ -364,10 +383,11 @@ | ||
| 364 | 383 | function updateStats() { |
| 365 | 384 | elements.totalTests.textContent = state.total; |
| 366 | 385 | elements.passedTests.textContent = state.passed; |
| 386 | elements.flakyTests.textContent = state.flaky; | |
| 367 | 387 | elements.failedTests.textContent = state.failed; |
| 368 | 388 | elements.skippedTests.textContent = state.skipped; |
| 369 | 389 | |
| 370 | const completed = state.passed + state.failed + state.skipped; | |
| 390 | const completed = state.passed + state.flaky + state.failed + state.skipped; | |
| 371 | 391 | const progress = state.total > 0 ? (completed / state.total) * 100 : 0; |
| 372 | 392 | elements.progress.style.width = `${progress}%`; |
| 373 | 393 | } |
| @@ -375,6 +395,7 @@ | ||
| 375 | 395 | function clearTests() { |
| 376 | 396 | state.suites.clear(); |
| 377 | 397 | state.passed = 0; |
| 398 | state.flaky = 0; | |
| 378 | 399 | state.failed = 0; |
| 379 | 400 | state.skipped = 0; |
| 380 | 401 | elements.tests.innerHTML = ''; |