diff --git a/README.md b/README.md index 152bb55..ac03ae1 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,13 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d - `--shard-index ` - Run one one-based file shard - `--shard-count ` - Set the total number of file shards - `--no-color` - Disable color in child Zig test processes +- `--ui` - Serve the live test UI (configure it with `--ui-host` and `--ui-port`) - `--verbose` - Show detailed output Discovery and programmatic execution share the same test-plan, lifecycle, result, and reporter model. Discovery still rejects options it cannot honor instead of silently ignoring them: quiet output, parallel jobs, snapshot -updates, memory profiling, and the web UI remain programmatic-only. See +updates, and memory profiling remain programmatic-only. See [the execution pipeline architecture](docs/execution-pipeline.md) for extension points, timeout semantics, and migration guidance. @@ -129,6 +130,9 @@ zig-test --test-dir tests --no-recursive # Stop on first failure zig-test --test-dir tests --bail +# Watch tests in the live web UI +zig-test --test-dir tests --ui --watch + # Retry failures twice and fail CI if any test is flaky zig-test --test-dir tests --retry 2 --fail-on-flaky diff --git a/build.zig b/build.zig index fbeed4a..0af457c 100644 --- a/build.zig +++ b/build.zig @@ -56,6 +56,13 @@ pub fn build(b: *std.Build) void { // Unit tests for the framework itself const lib_unit_tests = b.addTest(.{ .root_module = lib_module, + // The Zig server-mode test runner can deadlock with Threaded network + // I/O on Windows. Run this networking-heavy test artifact through the + // normal process protocol and use its exit status instead. + .test_runner = if (target.result.os.tag == .windows) .{ + .path = b.path("tests/simple_test_runner.zig"), + .mode = .simple, + } else null, }); const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); diff --git a/src/cli.zig b/src/cli.zig index 8f3a59f..2a596e0 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -367,7 +367,6 @@ pub const CLI = struct { if (self.options.profile_memory) return "--profile-memory"; if (self.options.memory_threshold != 0) return "--memory-threshold"; if (self.options.fail_on_leak) return "--fail-on-leak"; - if (self.options.ui) return "--ui"; return null; } @@ -767,6 +766,13 @@ test "discovery accepts reporter selection" { try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption()); } +test "discovery accepts the live UI" { + var cli = CLI.init(std.testing.allocator); + const args = [_][]const u8{ "zig-test", "--test-dir", "tests", "--ui", "--ui-port", "0" }; + try cli.parse(&args); + try std.testing.expectEqual(@as(?[]const u8, null), cli.unsupportedDiscoveryOption()); +} + test "discovery accepts timeout and reports parallel as unsupported" { var cli = CLI.init(std.testing.allocator); diff --git a/src/main.zig b/src/main.zig index 8a98efe..d92be88 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,10 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); const lib = @import("zig_test_framework"); -const compat = lib.compat; -// Global signal handler state -var shutdown_requested = std.atomic.Value(bool).init(false); +// Global signal handler state. Watch mode shares this flag so Ctrl+C can +// unwind through normal cleanup instead of bypassing the UI server shutdown. +var keep_running = std.atomic.Value(bool).init(true); /// Install signal handlers fn installSignalHandlers() !void { @@ -16,7 +16,7 @@ fn installSignalHandlers() !void { const Handler = struct { fn handle(sig: std.posix.SIG) callconv(.c) void { _ = sig; - shutdown_requested.store(true, .monotonic); + keep_running.store(false, .monotonic); std.debug.print("\n\nShutdown requested... cleaning up\n", .{}); } }; @@ -107,20 +107,14 @@ pub fn main(init: std.process.Init.Minimal) !void { // Start UI server if requested var ui_server: ?lib.UIServer = null; - var ui_thread: ?std.Thread = null; - var server_running = std.atomic.Value(bool).init(false); defer { - if (shutdown_requested.load(.monotonic)) { + if (!keep_running.load(.monotonic)) { std.debug.print("Cleanup complete.\n", .{}); } if (ui_server) |*server| { - server_running.store(false, .monotonic); server.deinit(); } - if (ui_thread) |thread| { - thread.detach(); - } } if (cli_parser.options.ui) { @@ -133,38 +127,8 @@ pub fn main(init: std.process.Init.Minimal) !void { ui_server = lib.UIServer.init(allocator, ui_options); try ui_server.?.start(); - std.debug.print("UI Server started at http://{s}:{d}\n", .{ ui_options.host, ui_options.port }); + std.debug.print("UI Server started at http://{s}:{d}\n", .{ ui_options.host, ui_server.?.port() }); std.debug.print("Open this URL in your browser to view test results.\n\n", .{}); - - // Start server in background thread - const ServerContext = struct { - server: *lib.UIServer, - running: *std.atomic.Value(bool), - verbose: bool, - - fn run(ctx: @This()) void { - ctx.running.store(true, .monotonic); - while (ctx.running.load(.monotonic)) { - ctx.server.acceptClient() catch |err| { - if (ctx.verbose) { - std.debug.print("UI Server error: {any}\n", .{err}); - } - // Small delay to avoid tight loop on errors - compat.sleep(100 * std.time.ns_per_ms); - }; - } - } - }; - - const server_ctx = ServerContext{ - .server = &ui_server.?, - .running = &server_running, - .verbose = cli_parser.options.verbose, - }; - ui_thread = try std.Thread.spawn(.{}, ServerContext.run, .{server_ctx}); - - // Give server time to start - compat.sleep(100 * std.time.ns_per_ms); } var all_passed: bool = undefined; @@ -197,8 +161,7 @@ pub fn main(init: std.process.Init.Minimal) !void { .verbose = cli_parser.options.verbose, }; - var watch_running = std.atomic.Value(bool).init(true); - var watcher = lib.TestWatcher.init(allocator, watch_options, &watch_running); + var watcher = lib.TestWatcher.init(allocator, watch_options, &keep_running); // Create coverage options if coverage is enabled const cov_opts = if (cli_parser.options.coverage) lib.CoverageOptions{ @@ -303,9 +266,11 @@ pub fn main(init: std.process.Init.Minimal) !void { } // Exit with appropriate code - if (all_passed) { - std.process.exit(0); - } else { - std.process.exit(1); - } + if (all_passed) return; + + // `std.process.exit` does not run defers, so explicitly stop the server + // before returning the unsuccessful process status. + if (ui_server) |*server| server.deinit(); + ui_server = null; + std.process.exit(1); } diff --git a/src/test_loader.zig b/src/test_loader.zig index 67f2727..bb44757 100644 --- a/src/test_loader.zig +++ b/src/test_loader.zig @@ -41,6 +41,12 @@ pub const LoaderOptions = struct { ui_server: ?*ui_server.UIServer = null, }; +fn broadcastJson(server: *ui_server.UIServer, allocator: std.mem.Allocator, event: []const u8, value: anytype) !void { + const json = try std.json.Stringify.valueAlloc(allocator, value, .{}); + defer allocator.free(json); + try server.broadcast(event, json); +} + /// Run all discovered test files pub fn runDiscoveredTests( allocator: std.mem.Allocator, @@ -101,9 +107,7 @@ pub fn runDiscoveredTests( // Notify UI of run start if (options.ui_server) |server| { - var buffer: [256]u8 = undefined; - const json = try std.fmt.bufPrint(&buffer, "{{\"total\":{d}}}", .{selected_files}); - try server.broadcast("run_start", json); + try broadcastJson(server, allocator, "run_start", .{ .total = selected_files }); } var files_run: usize = 0; @@ -125,10 +129,8 @@ pub fn runDiscoveredTests( // Notify UI of test file start if (options.ui_server) |server| { - var buffer: [512]u8 = undefined; - const json = try std.fmt.bufPrint(&buffer, "{{\"name\":\"{s}\"}}", .{file.name}); - try server.broadcast("suite_start", json); - try server.broadcast("test_start", json); + try broadcastJson(server, allocator, "suite_start", .{ .name = file.name }); + try broadcastJson(server, allocator, "test_start", .{ .name = file.name }); } files_run += 1; @@ -180,15 +182,14 @@ pub fn runDiscoveredTests( // Notify UI of test file end if (options.ui_server) |server| { - var buffer: [512]u8 = undefined; - const json = try std.fmt.bufPrint(&buffer, "{{\"name\":\"{s}\",\"status\":\"{s}\",\"execution_time_ns\":{d},\"attempts\":{d},\"error_message\":\"\"}}", .{ - file.name, - @tagName(test_case.status), - test_case.execution_time_ns, - test_case.attempts.items.len, + try broadcastJson(server, allocator, "test_end", .{ + .name = file.name, + .status = @tagName(test_case.status), + .execution_time_ns = test_case.execution_time_ns, + .attempts = test_case.attempts.items.len, + .error_message = "", }); - try server.broadcast("test_end", json); - try server.broadcast("suite_end", try std.fmt.bufPrint(&buffer, "{{\"name\":\"{s}\"}}", .{file.name})); + try broadcastJson(server, allocator, "suite_end", .{ .name = file.name }); } if (test_case.status == .passed) { @@ -218,9 +219,13 @@ pub fn runDiscoveredTests( // Notify UI of run end if (options.ui_server) |server| { - var buffer: [512]u8 = undefined; - const json = try std.fmt.bufPrint(&buffer, "{{\"total\":{d},\"passed\":{d},\"failed\":{d},\"skipped\":{d}}}", .{ results.total, results.passed, results.failed, results.skipped }); - try server.broadcast("run_end", json); + try broadcastJson(server, allocator, "run_end", .{ + .total = results.total, + .passed = results.passed, + .flaky = results.flaky, + .failed = results.failed, + .skipped = results.skipped, + }); } if (options.shard) |shard| { diff --git a/src/ui/index.html b/src/ui/index.html index 424ec8c..e46f81d 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -84,6 +84,7 @@ .stat.total .stat-value { color: #58a6ff; } .stat.passed .stat-value { color: #3fb950; } + .stat.flaky .stat-value { color: #dbab09; } .stat.failed .stat-value { color: #f85149; } .stat.skipped .stat-value { color: #d29922; } @@ -148,6 +149,11 @@ background: #1c2128; } + .test.flaky { + border-left-color: #dbab09; + background: #1c2128; + } + .test.skipped { border-left-color: #d29922; opacity: 0.6; @@ -181,6 +187,11 @@ color: #fff; } + .test-status.flaky { + background: #9e6a03; + color: #fff; + } + .test-status.skipped { background: #9e6a03; color: #fff; @@ -263,6 +274,10 @@

⚡ Zig Test Framework

0
Passed
+
+
0
+
Flaky
+
0
Failed
@@ -290,6 +305,7 @@

Waiting for tests...

const state = { total: 0, passed: 0, + flaky: 0, failed: 0, skipped: 0, suites: new Map(), @@ -300,6 +316,7 @@

Waiting for tests...

connectionStatus: document.getElementById('connection-status'), totalTests: document.getElementById('total-tests'), passedTests: document.getElementById('passed-tests'), + flakyTests: document.getElementById('flaky-tests'), failedTests: document.getElementById('failed-tests'), skippedTests: document.getElementById('skipped-tests'), progress: document.getElementById('progress'), @@ -316,9 +333,9 @@

Waiting for tests...

eventSource.addEventListener('run_start', (e) => { const data = JSON.parse(e.data); + clearTests(); state.total = data.total; updateStats(); - clearTests(); }); eventSource.addEventListener('suite_start', (e) => { @@ -337,6 +354,7 @@

Waiting for tests...

updateTest(state.currentSuite, data.name, data.status, data.execution_time_ns, data.error_message); if (data.status === 'passed') state.passed++; + else if (data.status === 'flaky') state.flaky++; else if (data.status === 'failed') state.failed++; else if (data.status === 'skipped') state.skipped++; @@ -347,6 +365,7 @@

Waiting for tests...

const data = JSON.parse(e.data); state.total = data.total; state.passed = data.passed; + state.flaky = data.flaky; state.failed = data.failed; state.skipped = data.skipped; updateStats(); @@ -364,10 +383,11 @@

Waiting for tests...

function updateStats() { elements.totalTests.textContent = state.total; elements.passedTests.textContent = state.passed; + elements.flakyTests.textContent = state.flaky; elements.failedTests.textContent = state.failed; elements.skippedTests.textContent = state.skipped; - const completed = state.passed + state.failed + state.skipped; + const completed = state.passed + state.flaky + state.failed + state.skipped; const progress = state.total > 0 ? (completed / state.total) * 100 : 0; elements.progress.style.width = `${progress}%`; } @@ -375,6 +395,7 @@

Waiting for tests...

function clearTests() { state.suites.clear(); state.passed = 0; + state.flaky = 0; state.failed = 0; state.skipped = 0; elements.tests.innerHTML = ''; diff --git a/src/ui_server.zig b/src/ui_server.zig index 57d9b80..1177c98 100644 --- a/src/ui_server.zig +++ b/src/ui_server.zig @@ -14,47 +14,273 @@ pub const UIServerOptions = struct { verbose: bool = false, }; -/// UI Server for web-based test visualization -/// Note: std.net was removed in Zig 0.16. Server functionality is stubbed out -/// until the new Io-based networking API stabilizes. +/// UI Server for web-based test visualization. pub const UIServer = struct { allocator: std.mem.Allocator, options: UIServerOptions, mutex: compat.Mutex = .{}, history: ?*test_history.TestHistory = null, + threaded_io: std.Io.Threaded, + listener: ?std.Io.net.Server = null, + thread: ?std.Thread = null, + running: std.atomic.Value(bool) = .init(false), + active_client: ?std.Io.net.Stream = null, + clients: std.ArrayList(std.Io.net.Stream) = .empty, const Self = @This(); + const index_html = @embedFile("ui/index.html"); + + pub const Error = error{ + AlreadyStarted, + InvalidRequest, + ServerStopped, + }; pub fn init(allocator: std.mem.Allocator, options: UIServerOptions) Self { return .{ .allocator = allocator, .options = options, + .threaded_io = .init(allocator, .{ .environ = .empty }), }; } pub fn deinit(self: *Self) void { - _ = self; + self.stop(); + self.clients.deinit(self.allocator); + self.threaded_io.deinit(); } - /// Start the UI server (stubbed - std.net removed in Zig 0.16) + /// Bind the configured address and start accepting HTTP clients. pub fn start(self: *Self) !void { + if (self.running.load(.acquire) or self.listener != null) return Error.AlreadyStarted; + + const io = self.threaded_io.io(); + const address = if (std.ascii.eqlIgnoreCase(self.options.host, "localhost")) + std.Io.net.IpAddress{ .ip4 = .loopback(self.options.port) } + else + try std.Io.net.IpAddress.parse(self.options.host, self.options.port); + + self.listener = try address.listen(io, .{ .reuse_address = true }); + self.options.port = self.listener.?.socket.address.getPort(); + self.running.store(true, .release); + self.thread = std.Thread.spawn(.{}, serve, .{self}) catch |err| { + self.running.store(false, .release); + self.listener.?.deinit(io); + self.listener = null; + return err; + }; + if (self.options.verbose) { - std.debug.print("UI Server: networking stubbed out (std.net removed in Zig 0.16)\n", .{}); + std.debug.print("UI Server: listening on http://{s}:{d}\n", .{ self.options.host, self.options.port }); + } + } + + /// Stop accepting connections and close every active event stream. + pub fn stop(self: *Self) void { + const was_running = self.running.swap(false, .acq_rel); + var wake_io: ?std.Io.Threaded = null; + var wake_stream: ?std.Io.net.Stream = null; + + if (was_running) { + // A client may have connected without finishing its request + // headers. Shutdown unblocks the parser while leaving ownership of + // the socket with the accept thread. + self.mutex.lock(); + if (self.active_client) |client| client.shutdown(self.threaded_io.io(), .both) catch {}; + self.mutex.unlock(); + + // Closing a listening socket from another thread makes the current + // Zig Windows backend treat the canceled accept as unreachable. + // Wake it with a loopback connection on an independent I/O runtime: + // sharing the server runtime here can deadlock its blocking accept + // on Linux and Windows. Keep the connection alive until accept has + // returned so the peer cannot disappear from the listen queue. + wake_io = .init(self.allocator, .{ .environ = .empty }); + if (wake_io) |*threaded| { + wake_stream = self.connectWake(threaded.io()); + } + } + + if (self.thread) |thread| { + thread.join(); + self.thread = null; + } + + if (wake_stream) |stream| { + if (wake_io) |*threaded| stream.close(threaded.io()); } + if (wake_io) |*threaded| threaded.deinit(); + + if (self.listener) |*server| { + server.deinit(self.threaded_io.io()); + self.listener = null; + } + + self.mutex.lock(); + defer self.mutex.unlock(); + for (self.clients.items) |client| client.close(self.threaded_io.io()); + self.clients.clearRetainingCapacity(); + } + + fn connectWake(self: *Self, io: std.Io) ?std.Io.net.Stream { + const server = self.listener orelse return null; + var address = server.socket.address; + switch (address) { + .ip4 => |ip4| { + if (std.mem.eql(u8, &ip4.bytes, &.{ 0, 0, 0, 0 })) { + address = .{ .ip4 = .loopback(ip4.port) }; + } + }, + .ip6 => |ip6| { + const unspecified: [16]u8 = @splat(0); + if (std.mem.eql(u8, &ip6.bytes, &unspecified)) { + address = .{ .ip6 = .loopback(ip6.port) }; + } + }, + } + + return address.connect(io, .{ .mode = .stream }) catch null; } - /// Accept a client connection (stubbed) + /// Return the actual bound port. This differs from the requested port when + /// port zero asks the operating system for an ephemeral port. + pub fn port(self: *const Self) u16 { + return self.options.port; + } + + fn serve(self: *Self) void { + while (self.running.load(.acquire)) { + self.acceptClient() catch |err| { + if (!self.running.load(.acquire)) return; + if (self.options.verbose) { + std.debug.print("UI Server error: {s}\n", .{@errorName(err)}); + } + compat.sleep(10 * std.time.ns_per_ms); + }; + } + } + + /// Accept and serve one HTTP client connection. pub fn acceptClient(self: *Self) !void { - _ = self; - // Stubbed: no networking available - compat.sleep(100 * std.time.ns_per_ms); + if (!self.running.load(.acquire)) return Error.ServerStopped; + const stream = if (self.listener) |*listener| + try listener.accept(self.threaded_io.io()) + else + return Error.ServerStopped; + if (!self.running.load(.acquire)) { + stream.close(self.threaded_io.io()); + return Error.ServerStopped; + } + + self.mutex.lock(); + self.active_client = stream; + self.mutex.unlock(); + + const disposition = self.handleClient(stream) catch |err| { + self.finishClient(false) catch {}; + return err; + }; + try self.finishClient(disposition == .event_stream); } - /// Broadcast an event to all connected clients (stubbed) + const ClientDisposition = enum { close, event_stream }; + + fn handleClient(self: *Self, stream: std.Io.net.Stream) !ClientDisposition { + var read_buffer: [4096]u8 = undefined; + var stream_reader = stream.reader(self.threaded_io.io(), &read_buffer); + const request_line_raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return Error.InvalidRequest; + const request_line = std.mem.trimEnd(u8, request_line_raw, "\r"); + var parts = std.mem.splitScalar(u8, request_line, ' '); + const method = parts.next() orelse return Error.InvalidRequest; + const target = parts.next() orelse return Error.InvalidRequest; + _ = parts.next() orelse return Error.InvalidRequest; + + const is_get = std.mem.eql(u8, method, "GET"); + const is_events = std.mem.eql(u8, target, "/events"); + const is_index = std.mem.eql(u8, target, "/") or std.mem.eql(u8, target, "/index.html"); + + while (true) { + const header_raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return Error.InvalidRequest; + if (std.mem.trimEnd(u8, header_raw, "\r").len == 0) break; + } + + if (!is_get) { + try self.respond(stream, "405 Method Not Allowed", "text/plain; charset=utf-8", "Method Not Allowed\n"); + } else if (is_events) { + try self.openEventStream(stream); + return .event_stream; + } else if (is_index) { + try self.respond(stream, "200 OK", "text/html; charset=utf-8", index_html); + } else { + try self.respond(stream, "404 Not Found", "text/plain; charset=utf-8", "Not Found\n"); + } + return .close; + } + + fn finishClient(self: *Self, keep_open: bool) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + const client = self.active_client orelse return; + self.active_client = null; + + if (keep_open and self.running.load(.acquire)) { + self.clients.append(self.allocator, client) catch |err| { + client.close(self.threaded_io.io()); + return err; + }; + } else { + client.close(self.threaded_io.io()); + } + } + + fn respond(self: *Self, stream: std.Io.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void { + var write_buffer: [2048]u8 = undefined; + var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer); + try stream_writer.interface.print( + "HTTP/1.1 {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ status, content_type, body.len }, + ); + try stream_writer.interface.writeAll(body); + try stream_writer.interface.flush(); + } + + fn openEventStream(self: *Self, stream: std.Io.net.Stream) !void { + var write_buffer: [512]u8 = undefined; + var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer); + try stream_writer.interface.writeAll( + "HTTP/1.1 200 OK\r\n" ++ + "Content-Type: text/event-stream\r\n" ++ + "Cache-Control: no-cache\r\n" ++ + "Connection: keep-alive\r\n" ++ + "X-Accel-Buffering: no\r\n\r\n" ++ + "event: connected\ndata: {}\n\n", + ); + try stream_writer.interface.flush(); + } + + fn sendEvent(self: *Self, stream: std.Io.net.Stream, event: []const u8, data: []const u8) !void { + var write_buffer: [1024]u8 = undefined; + var stream_writer = stream.writer(self.threaded_io.io(), &write_buffer); + try stream_writer.interface.print("event: {s}\ndata: {s}\n\n", .{ event, data }); + try stream_writer.interface.flush(); + } + + /// Broadcast an event to all connected clients. A disconnected browser is + /// removed without disrupting the test run or the remaining clients. pub fn broadcast(self: *Self, event: []const u8, data: []const u8) !void { - _ = self; - _ = event; - _ = data; + self.mutex.lock(); + defer self.mutex.unlock(); + + var index: usize = 0; + while (index < self.clients.items.len) { + const client = self.clients.items[index]; + self.sendEvent(client, event, data) catch { + client.close(self.threaded_io.io()); + _ = self.clients.swapRemove(index); + continue; + }; + index += 1; + } } }; @@ -89,69 +315,62 @@ pub const UIReporter = struct { self.buffer.deinit(self.reporter.allocator); } - fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void { - const self: *Self = @fieldParentPtr("reporter", reporter); - + fn encode(self: *Self, value: anytype) !void { self.buffer.clearRetainingCapacity(); - try self.buffer.print(reporter.allocator, "{{\"total\":{d}}}", .{total}); + var writer: std.Io.Writer.Allocating = .fromArrayList(self.reporter.allocator, &self.buffer); + defer self.buffer = writer.toArrayList(); + try std.json.Stringify.value(value, .{}, &writer.writer); + } + fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void { + const self: *Self = @fieldParentPtr("reporter", reporter); + try self.encode(.{ .total = total }); try self.server.broadcast("run_start", self.buffer.items); } fn onRunEnd(reporter: *reporter_mod.Reporter, results: *reporter_mod.TestResults) !void { const self: *Self = @fieldParentPtr("reporter", reporter); - self.buffer.clearRetainingCapacity(); - try self.buffer.print(reporter.allocator, "{{\"total\":{d},\"passed\":{d},\"failed\":{d},\"skipped\":{d}}}", .{ - results.total, - results.passed, - results.failed, - results.skipped, + try self.encode(.{ + .total = results.total, + .passed = results.passed, + .flaky = results.flaky, + .failed = results.failed, + .skipped = results.skipped, }); - try self.server.broadcast("run_end", self.buffer.items); } fn onSuiteStart(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void { const self: *Self = @fieldParentPtr("reporter", reporter); - self.buffer.clearRetainingCapacity(); - try self.buffer.print(reporter.allocator, "{{\"name\":\"{s}\"}}", .{suite_name}); - + try self.encode(.{ .name = suite_name }); try self.server.broadcast("suite_start", self.buffer.items); } fn onSuiteEnd(reporter: *reporter_mod.Reporter, suite_name: []const u8) !void { const self: *Self = @fieldParentPtr("reporter", reporter); - self.buffer.clearRetainingCapacity(); - try self.buffer.print(reporter.allocator, "{{\"name\":\"{s}\"}}", .{suite_name}); - + try self.encode(.{ .name = suite_name }); try self.server.broadcast("suite_end", self.buffer.items); } fn onTestStart(reporter: *reporter_mod.Reporter, test_name: []const u8) !void { const self: *Self = @fieldParentPtr("reporter", reporter); - self.buffer.clearRetainingCapacity(); - try self.buffer.print(reporter.allocator, "{{\"name\":\"{s}\"}}", .{test_name}); - + try self.encode(.{ .name = test_name }); try self.server.broadcast("test_start", self.buffer.items); } fn onTestEnd(reporter: *reporter_mod.Reporter, test_case: *const suite.TestCase) !void { const self: *Self = @fieldParentPtr("reporter", reporter); - self.buffer.clearRetainingCapacity(); - - const error_msg = test_case.error_message orelse ""; - try self.buffer.print(reporter.allocator, "{{\"name\":\"{s}\",\"status\":\"{s}\",\"execution_time_ns\":{d},\"error_message\":\"{s}\"}}", .{ - test_case.name, - @tagName(test_case.status), - test_case.execution_time_ns, - error_msg, + try self.encode(.{ + .name = test_case.name, + .status = @tagName(test_case.status), + .execution_time_ns = test_case.execution_time_ns, + .error_message = test_case.error_message orelse "", }); - try self.server.broadcast("test_end", self.buffer.items); } }; @@ -253,4 +472,102 @@ test "UIReporter initialization" { try std.testing.expectEqual(false, ui_reporter.reporter.use_colors); try std.testing.expectEqual(@as(usize, 0), ui_reporter.buffer.items.len); + + try ui_reporter.reporter.onSuiteStart("quoted \"suite\"\nname"); + try std.testing.expectEqualStrings("{\"name\":\"quoted \\\"suite\\\"\\nname\"}", ui_reporter.buffer.items); +} + +fn writeTestRequest(io: std.Io, stream: std.Io.net.Stream, target: []const u8) !void { + var write_buffer: [512]u8 = undefined; + var writer = stream.writer(io, &write_buffer); + try writer.interface.print("GET {s} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", .{target}); + try writer.interface.flush(); +} + +fn expectLine(reader: *std.Io.Reader, expected: []const u8) !void { + const raw = (try reader.takeDelimiter('\n')) orelse return error.EndOfStream; + try std.testing.expectEqualStrings(expected, std.mem.trimEnd(u8, raw, "\r")); +} + +test "UI server serves the embedded page on an ephemeral port" { + var server = UIServer.init(std.testing.allocator, .{ .port = 0 }); + defer server.deinit(); + try server.start(); + try std.testing.expect(server.port() != 0); + + var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty }); + defer client_io.deinit(); + const io = client_io.io(); + const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) }; + const stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + try writeTestRequest(io, stream, "/"); + + var read_buffer: [4096]u8 = undefined; + var stream_reader = stream.reader(io, &read_buffer); + const response = try stream_reader.interface.allocRemaining(std.testing.allocator, .limited(UIServer.index_html.len + 1024)); + defer std.testing.allocator.free(response); + try std.testing.expect(std.mem.startsWith(u8, response, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.indexOf(u8, response, "Zig Test Framework - Live Results") != null); +} + +test "UI server streams broadcast events over SSE" { + var server = UIServer.init(std.testing.allocator, .{ .port = 0 }); + defer server.deinit(); + try server.start(); + + var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty }); + defer client_io.deinit(); + const io = client_io.io(); + const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) }; + const stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + try writeTestRequest(io, stream, "/events"); + + var read_buffer: [4096]u8 = undefined; + var stream_reader = stream.reader(io, &read_buffer); + try expectLine(&stream_reader.interface, "HTTP/1.1 200 OK"); + while (true) { + const raw = (try stream_reader.interface.takeDelimiter('\n')) orelse return error.EndOfStream; + if (std.mem.trimEnd(u8, raw, "\r").len == 0) break; + } + try expectLine(&stream_reader.interface, "event: connected"); + try expectLine(&stream_reader.interface, "data: {}"); + try expectLine(&stream_reader.interface, ""); + + try server.broadcast("run_start", "{\"total\":2}"); + try expectLine(&stream_reader.interface, "event: run_start"); + try expectLine(&stream_reader.interface, "data: {\"total\":2}"); + try expectLine(&stream_reader.interface, ""); +} + +test "UI server shutdown cancels an incomplete HTTP request" { + var server = UIServer.init(std.testing.allocator, .{ .port = 0 }); + defer server.deinit(); + try server.start(); + + var client_io: std.Io.Threaded = .init(std.testing.allocator, .{ .environ = .empty }); + defer client_io.deinit(); + const io = client_io.io(); + const address = std.Io.net.IpAddress{ .ip4 = .loopback(server.port()) }; + const stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var write_buffer: [128]u8 = undefined; + var writer = stream.writer(io, &write_buffer); + try writer.interface.writeAll("GET / HTTP/1.1\r\n"); + try writer.interface.flush(); + + var request_is_active = false; + for (0..100) |_| { + server.mutex.lock(); + request_is_active = server.active_client != null; + server.mutex.unlock(); + if (request_is_active) break; + compat.sleep(std.time.ns_per_ms); + } + try std.testing.expect(request_is_active); + + server.stop(); + try std.testing.expect(!server.running.load(.acquire)); } diff --git a/tests/simple_test_runner.zig b/tests/simple_test_runner.zig new file mode 100644 index 0000000..b8ac325 --- /dev/null +++ b/tests/simple_test_runner.zig @@ -0,0 +1,74 @@ +//! Sequential test runner for test artifacts that exercise threaded network I/O. +const builtin = @import("builtin"); +const std = @import("std"); +const testing = std.testing; + +pub const std_options: std.Options = .{ + .logFn = log, +}; + +var log_error_count: usize = 0; + +pub fn main(init: std.process.Init.Minimal) void { + @disableInstrumentation(); + + var passed: usize = 0; + var skipped: usize = 0; + var failed: usize = 0; + var leaked: usize = 0; + + for (builtin.test_functions, 0..) |test_fn, index| { + testing.allocator_instance = .init(std.heap.page_allocator, .{ + .canary = 0xc3a701ba, + .check_write_after_free = true, + }); + testing.io_instance = .init(testing.allocator, .{ + .argv0 = .init(init.args), + .environ = init.environ, + }); + testing.log_level = .warn; + testing.environ = init.environ; + + std.debug.print("{d}/{d} {s}...", .{ index + 1, builtin.test_functions.len, test_fn.name }); + if (test_fn.func()) |_| { + passed += 1; + std.debug.print("OK\n", .{}); + } else |err| switch (err) { + error.SkipZigTest => { + skipped += 1; + std.debug.print("SKIP\n", .{}); + }, + else => { + failed += 1; + std.debug.print("FAIL ({t})\n", .{err}); + if (@errorReturnTrace()) |trace| std.debug.dumpErrorReturnTrace(trace); + }, + } + + testing.io_instance.deinit(); + leaked += @intFromBool(testing.allocator_instance.deinit() != 0); + } + + std.debug.print("{d} passed; {d} skipped; {d} failed.\n", .{ passed, skipped, failed }); + if (leaked != 0) std.debug.print("{d} tests leaked memory.\n", .{leaked}); + if (log_error_count != 0) std.debug.print("{d} errors were logged.\n", .{log_error_count}); + if (failed != 0 or leaked != 0 or log_error_count != 0) std.process.exit(1); +} + +fn log( + comptime message_level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime format: []const u8, + args: anytype, +) void { + @disableInstrumentation(); + if (@backingInt(message_level) <= @backingInt(std.log.Level.err)) { + log_error_count +|= 1; + } + if (@backingInt(message_level) <= @backingInt(testing.log_level)) { + std.debug.print( + "[" ++ @tagName(scope) ++ "] (" ++ @tagName(message_level) ++ "): " ++ format ++ "\n", + args, + ); + } +}