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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
- `--shard-index <N>` - Run one one-based file shard
- `--shard-count <N>` - 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.

Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);

Expand Down
63 changes: 14 additions & 49 deletions src/main.zig
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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", .{});
}
};
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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);
}
41 changes: 23 additions & 18 deletions src/test_loader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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| {
Expand Down
25 changes: 23 additions & 2 deletions src/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down Expand Up @@ -148,6 +149,11 @@
background: #1c2128;
}

.test.flaky {
border-left-color: #dbab09;
background: #1c2128;
}

.test.skipped {
border-left-color: #d29922;
opacity: 0.6;
Expand Down Expand Up @@ -181,6 +187,11 @@
color: #fff;
}

.test-status.flaky {
background: #9e6a03;
color: #fff;
}

.test-status.skipped {
background: #9e6a03;
color: #fff;
Expand Down Expand Up @@ -263,6 +274,10 @@ <h1>⚡ Zig Test Framework</h1>
<div class="stat-value" id="passed-tests">0</div>
<div class="stat-label">Passed</div>
</div>
<div class="flaky stat">
<div class="stat-value" id="flaky-tests">0</div>
<div class="stat-label">Flaky</div>
</div>
<div class="failed stat">
<div class="stat-value" id="failed-tests">0</div>
<div class="stat-label">Failed</div>
Expand Down Expand Up @@ -290,6 +305,7 @@ <h2>Waiting for tests...</h2>
const state = {
total: 0,
passed: 0,
flaky: 0,
failed: 0,
skipped: 0,
suites: new Map(),
Expand All @@ -300,6 +316,7 @@ <h2>Waiting for tests...</h2>
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'),
Expand All @@ -316,9 +333,9 @@ <h2>Waiting for tests...</h2>

eventSource.addEventListener('run_start', (e) => {
const data = JSON.parse(e.data);
clearTests();
state.total = data.total;
updateStats();
clearTests();
});

eventSource.addEventListener('suite_start', (e) => {
Expand All @@ -337,6 +354,7 @@ <h2>Waiting for tests...</h2>
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++;

Expand All @@ -347,6 +365,7 @@ <h2>Waiting for tests...</h2>
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();
Expand All @@ -364,17 +383,19 @@ <h2>Waiting for tests...</h2>
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}%`;
}

function clearTests() {
state.suites.clear();
state.passed = 0;
state.flaky = 0;
state.failed = 0;
state.skipped = 0;
elements.tests.innerHTML = '';
Expand Down
Loading
Loading