From 6bbb8e182e36ae4f28dccd0615ce70b2554f97a7 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Tue, 5 May 2026 15:41:52 +0800 Subject: [PATCH 1/2] test(integration): add structured HTTP smoke harness --- README.md | 4 + build.zig | 20 +++++ src/integration_tests.zig | 155 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/integration_tests.zig diff --git a/README.md b/README.md index 73f7c48..1f04b92 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ Backend: ```bash zig build test +zig build test-integration ``` Frontend: @@ -139,6 +140,9 @@ End-to-end: ./tests/test_e2e.sh ``` +`zig build test-integration` runs structured backend HTTP integration tests +against a real `nullhub` process started in a temporary home directory. + ## Tech Stack - Zig 0.16.0 diff --git a/build.zig b/build.zig index 54b11ac..8ee38eb 100644 --- a/build.zig +++ b/build.zig @@ -91,6 +91,26 @@ pub fn build(b: *std.Build) void { const run_tests = b.addRunArtifact(exe_unit_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); + + const integration_test_module = b.createModule(.{ + .root_source_file = b.path("src/integration_tests.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + integration_test_module.addImport("build_options", build_options_module); + integration_test_module.addImport("ui_assets", ui_assets_module); + integration_test_module.addImport("compat", compat_module); + + const integration_tests = b.addTest(.{ + .root_module = integration_test_module, + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + run_integration_tests.step.dependOn(b.getInstallStep()); + run_integration_tests.setCwd(b.path(".")); + run_integration_tests.setEnvironmentVariable("NULLHUB_INTEGRATION_BIN", "zig-out/bin/nullhub"); + const integration_test_step = b.step("test-integration", "Run integration tests"); + integration_test_step.dependOn(&run_integration_tests.step); } fn createUiAssetsModule(b: *std.Build, embed_ui: bool) *std.Build.Module { diff --git a/src/integration_tests.zig b/src/integration_tests.zig new file mode 100644 index 0000000..0047b0b --- /dev/null +++ b/src/integration_tests.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const std_compat = @import("compat"); +const builtin = @import("builtin"); + +const IntegrationServer = struct { + allocator: std.mem.Allocator, + tmp_dir: std.testing.TmpDir, + temp_root: []const u8, + home_dir: []const u8, + port: u16, + child: std_compat.process.Child, + env_map: std_compat.process.EnvMap, + + fn start(allocator: std.mem.Allocator) !IntegrationServer { + if (builtin.os.tag == .wasi) return error.SkipZigTest; + + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + const temp_root = try std_compat.fs.Dir.wrap(tmp_dir.dir).realpathAlloc(allocator, "."); + errdefer allocator.free(temp_root); + + const home_dir = try std.fs.path.join(allocator, &.{ temp_root, "home" }); + errdefer allocator.free(home_dir); + try std_compat.fs.makeDirAbsolute(home_dir); + + const port = try reservePort(); + const port_text = try std.fmt.allocPrint(allocator, "{d}", .{port}); + defer allocator.free(port_text); + + const exe_path = try std_compat.process.getEnvVarOwned(allocator, "NULLHUB_INTEGRATION_BIN"); + defer allocator.free(exe_path); + + var env_map = try std_compat.process.getEnvMap(allocator); + errdefer env_map.deinit(); + try env_map.put("HOME", home_dir); + + const argv = try allocator.dupe([]const u8, &.{ exe_path, "serve", "--port", port_text, "--no-open" }); + defer allocator.free(argv); + + var child = std_compat.process.Child.init(argv, allocator); + child.cwd = "."; + child.env_map = &env_map; + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Ignore; + child.stderr_behavior = .Ignore; + try child.spawn(); + errdefer { + _ = child.kill() catch {}; + _ = child.wait() catch {}; + } + + var server = IntegrationServer{ + .allocator = allocator, + .tmp_dir = tmp_dir, + .temp_root = temp_root, + .home_dir = home_dir, + .port = port, + .child = child, + .env_map = env_map, + }; + errdefer server.deinit(); + + try server.waitUntilReady(); + return server; + } + + fn deinit(self: *IntegrationServer) void { + _ = self.child.kill() catch {}; + _ = self.child.wait() catch {}; + self.env_map.deinit(); + self.allocator.free(self.home_dir); + self.allocator.free(self.temp_root); + self.tmp_dir.cleanup(); + self.* = undefined; + } + + fn waitUntilReady(self: *IntegrationServer) !void { + var attempt: usize = 0; + while (attempt < 40) : (attempt += 1) { + const result = self.fetch("/health"); + if (result) |resp| { + defer resp.deinit(self.allocator); + if (resp.status == .ok) return; + } else |_| {} + + std_compat.thread.sleep(250 * std.time.ns_per_ms); + } + + return error.ServerNotReady; + } + + fn fetch(self: *IntegrationServer, path: []const u8) !HttpResponse { + const url = try std.fmt.allocPrint(self.allocator, "http://127.0.0.1:{d}{s}", .{ self.port, path }); + defer self.allocator.free(url); + + var client: std.http.Client = .{ .allocator = self.allocator, .io = std_compat.io() }; + defer client.deinit(); + + var body: std.Io.Writer.Allocating = .init(self.allocator); + errdefer body.deinit(); + + const result = try client.fetch(.{ + .location = .{ .url = url }, + .method = .GET, + .response_writer = &body.writer, + }); + + return .{ + .status = result.status, + .body = try body.toOwnedSlice(), + }; + } +}; + +const HttpResponse = struct { + status: std.http.Status, + body: []u8, + + fn deinit(self: HttpResponse, allocator: std.mem.Allocator) void { + allocator.free(self.body); + } +}; + +fn reservePort() !u16 { + const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0); + var listener = try addr.listen(.{}); + defer listener.deinit(); + return listener.listen_address.in.getPort(); +} + +test "integration harness serves health and core api routes" { + var server = try IntegrationServer.start(std.testing.allocator); + defer server.deinit(); + + { + const resp = try server.fetch("/health"); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + } + + { + const resp = try server.fetch("/api/status"); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"hub\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"version\"") != null); + } + + { + const resp = try server.fetch("/api/nonexistent"); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.not_found, resp.status); + } +} From 91a1efa08eb6e09056cdef6d97acad42b1af9e95 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Tue, 5 May 2026 15:51:04 +0800 Subject: [PATCH 2/2] test(integration): cover settings, config, and orchestration flows --- src/integration_tests.zig | 220 ++++++++++++++++++++++++++++++++------ 1 file changed, 185 insertions(+), 35 deletions(-) diff --git a/src/integration_tests.zig b/src/integration_tests.zig index 0047b0b..8da8e02 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -1,6 +1,8 @@ const std = @import("std"); const std_compat = @import("compat"); const builtin = @import("builtin"); +const paths_mod = @import("core/paths.zig"); +const state_mod = @import("core/state.zig"); const IntegrationServer = struct { allocator: std.mem.Allocator, @@ -14,6 +16,34 @@ const IntegrationServer = struct { fn start(allocator: std.mem.Allocator) !IntegrationServer { if (builtin.os.tag == .wasi) return error.SkipZigTest; + return startWithSeed(allocator, struct { + fn call(_: *IntegrationServer) !void {} + }.call); + } + + fn startWithEnv( + allocator: std.mem.Allocator, + extra_env: []const EnvEntry, + ) !IntegrationServer { + return startWithSeedAndEnv(allocator, struct { + fn call(_: *IntegrationServer) !void {} + }.call, extra_env); + } + + fn startWithSeed( + allocator: std.mem.Allocator, + comptime seedFn: *const fn (*IntegrationServer) anyerror!void, + ) !IntegrationServer { + return startWithSeedAndEnv(allocator, seedFn, &.{}); + } + + fn startWithSeedAndEnv( + allocator: std.mem.Allocator, + comptime seedFn: *const fn (*IntegrationServer) anyerror!void, + extra_env: []const EnvEntry, + ) !IntegrationServer { + if (builtin.os.tag == .wasi) return error.SkipZigTest; + var tmp_dir = std.testing.tmpDir(.{}); errdefer tmp_dir.cleanup(); @@ -24,51 +54,53 @@ const IntegrationServer = struct { errdefer allocator.free(home_dir); try std_compat.fs.makeDirAbsolute(home_dir); + var server = IntegrationServer{ + .allocator = allocator, + .tmp_dir = tmp_dir, + .temp_root = temp_root, + .home_dir = home_dir, + .port = undefined, + .child = undefined, + .env_map = undefined, + }; + errdefer server.deinit(); + + try seedFn(&server); + const port = try reservePort(); + server.port = port; const port_text = try std.fmt.allocPrint(allocator, "{d}", .{port}); defer allocator.free(port_text); const exe_path = try std_compat.process.getEnvVarOwned(allocator, "NULLHUB_INTEGRATION_BIN"); defer allocator.free(exe_path); - var env_map = try std_compat.process.getEnvMap(allocator); - errdefer env_map.deinit(); - try env_map.put("HOME", home_dir); + server.env_map = try std_compat.process.getEnvMap(allocator); + errdefer server.env_map.deinit(); + try server.env_map.put("HOME", home_dir); + for (extra_env) |entry| try server.env_map.put(entry.key, entry.value); const argv = try allocator.dupe([]const u8, &.{ exe_path, "serve", "--port", port_text, "--no-open" }); defer allocator.free(argv); - var child = std_compat.process.Child.init(argv, allocator); - child.cwd = "."; - child.env_map = &env_map; - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - try child.spawn(); - errdefer { - _ = child.kill() catch {}; - _ = child.wait() catch {}; - } - - var server = IntegrationServer{ - .allocator = allocator, - .tmp_dir = tmp_dir, - .temp_root = temp_root, - .home_dir = home_dir, - .port = port, - .child = child, - .env_map = env_map, - }; - errdefer server.deinit(); + server.child = std_compat.process.Child.init(argv, allocator); + server.child.cwd = "."; + server.child.env_map = &server.env_map; + server.child.stdin_behavior = .Ignore; + server.child.stdout_behavior = .Ignore; + server.child.stderr_behavior = .Ignore; + try server.child.spawn(); try server.waitUntilReady(); return server; } fn deinit(self: *IntegrationServer) void { - _ = self.child.kill() catch {}; - _ = self.child.wait() catch {}; - self.env_map.deinit(); + if (@intFromPtr(self.child.argv.ptr) != 0) { + _ = self.child.kill() catch {}; + _ = self.child.wait() catch {}; + } + if (self.env_map.count() > 0) self.env_map.deinit(); self.allocator.free(self.home_dir); self.allocator.free(self.temp_root); self.tmp_dir.cleanup(); @@ -78,7 +110,7 @@ const IntegrationServer = struct { fn waitUntilReady(self: *IntegrationServer) !void { var attempt: usize = 0; while (attempt < 40) : (attempt += 1) { - const result = self.fetch("/health"); + const result = self.fetch(.{ .path = "/health" }); if (result) |resp| { defer resp.deinit(self.allocator); if (resp.status == .ok) return; @@ -90,8 +122,8 @@ const IntegrationServer = struct { return error.ServerNotReady; } - fn fetch(self: *IntegrationServer, path: []const u8) !HttpResponse { - const url = try std.fmt.allocPrint(self.allocator, "http://127.0.0.1:{d}{s}", .{ self.port, path }); + fn fetch(self: *IntegrationServer, req: Request) !HttpResponse { + const url = try std.fmt.allocPrint(self.allocator, "http://127.0.0.1:{d}{s}", .{ self.port, req.path }); defer self.allocator.free(url); var client: std.http.Client = .{ .allocator = self.allocator, .io = std_compat.io() }; @@ -100,10 +132,18 @@ const IntegrationServer = struct { var body: std.Io.Writer.Allocating = .init(self.allocator); errdefer body.deinit(); + var header_buf: [1]std.http.Header = undefined; + const extra_headers: []const std.http.Header = if (req.body.len > 0) blk: { + header_buf[0] = .{ .name = "Content-Type", .value = "application/json" }; + break :blk header_buf[0..1]; + } else &.{}; + const result = try client.fetch(.{ .location = .{ .url = url }, - .method = .GET, + .method = req.method, + .payload = if (req.body.len > 0 or req.method.requestHasBody()) req.body else null, .response_writer = &body.writer, + .extra_headers = extra_headers, }); return .{ @@ -111,6 +151,23 @@ const IntegrationServer = struct { .body = try body.toOwnedSlice(), }; } + + fn paths(self: *IntegrationServer) !paths_mod.Paths { + const root = try std.fs.path.join(self.allocator, &.{ self.home_dir, ".nullhub" }); + defer self.allocator.free(root); + return try paths_mod.Paths.init(self.allocator, root); + } +}; + +const EnvEntry = struct { + key: []const u8, + value: []const u8, +}; + +const Request = struct { + path: []const u8, + method: std.http.Method = .GET, + body: []const u8 = "", }; const HttpResponse = struct { @@ -129,18 +186,37 @@ fn reservePort() !u16 { return listener.listen_address.in.getPort(); } +fn seedManagedInstance(server: *IntegrationServer, component: []const u8, name: []const u8) !void { + var paths = try server.paths(); + defer paths.deinit(server.allocator); + try paths.ensureDirs(); + + const state_path = try paths.state(server.allocator); + defer server.allocator.free(state_path); + var state = state_mod.State.init(server.allocator, state_path); + defer state.deinit(); + + try state.addInstance(component, name, .{ + .version = "1.0.0", + .auto_start = false, + .launch_mode = "gateway", + .verbose = false, + }); + try state.save(); +} + test "integration harness serves health and core api routes" { var server = try IntegrationServer.start(std.testing.allocator); defer server.deinit(); { - const resp = try server.fetch("/health"); + const resp = try server.fetch(.{ .path = "/health" }); defer resp.deinit(std.testing.allocator); try std.testing.expectEqual(std.http.Status.ok, resp.status); } { - const resp = try server.fetch("/api/status"); + const resp = try server.fetch(.{ .path = "/api/status" }); defer resp.deinit(std.testing.allocator); try std.testing.expectEqual(std.http.Status.ok, resp.status); try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"hub\"") != null); @@ -148,8 +224,82 @@ test "integration harness serves health and core api routes" { } { - const resp = try server.fetch("/api/nonexistent"); + const resp = try server.fetch(.{ .path = "/api/nonexistent" }); defer resp.deinit(std.testing.allocator); try std.testing.expectEqual(std.http.Status.not_found, resp.status); } } + +test "integration harness covers settings and config round-trips" { + var server = try IntegrationServer.startWithSeed(std.testing.allocator, struct { + fn call(srv: *IntegrationServer) !void { + try seedManagedInstance(srv, "nullboiler", "demo"); + } + }.call); + defer server.deinit(); + + { + const resp = try server.fetch(.{ .path = "/api/settings" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"port\":") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"access\"") != null); + } + + { + const resp = try server.fetch(.{ + .path = "/api/settings", + .method = .PUT, + .body = "{\"port\":19901}", + }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"port\":19901") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"nullboiler\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"demo\"") != null); + } + + { + const resp = try server.fetch(.{ + .path = "/api/instances/nullboiler/demo/config", + .method = .PUT, + .body = "{\"gateway\":{\"port\":43123},\"provider\":\"openrouter\"}", + }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"saved\"") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances/nullboiler/demo/config" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"gateway\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "43123") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances/nullboiler/demo/config?path=gateway.port" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"path\":\"gateway.port\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"value\":43123") != null); + } +} + +test "integration harness covers orchestration proxy not configured" { + var server = try IntegrationServer.start(std.testing.allocator); + defer server.deinit(); + + const resp = try server.fetch(.{ .path = "/api/orchestration/runs" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.service_unavailable, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "NullBoiler not configured") != null); +}