//! Shared scenario harness — the assertion helpers, rig lifecycle, and //! subprocess drivers the scenario executables (`scenario_space.zig`, //! `scenario_serve.zig`) are built on. //! //! Everything here is deliberately rig-agnostic: binaries and ports come //! from environment variables (`LINJI_ZDS_BIN`, `LINJI_BIN`, //! `LINJI_SERVE_BIN`, `LINJI_ZDS_PORT`, `LINJI_API_PORT`), so the same //! code runs against the real zds/linji or against scripted fakes (see //! `zig build scenario-self`). //! //! The `test` blocks at the bottom are the L1 self-tests: they prove the //! gate's own machinery — every assertion helper in both polarities, the //! parsers, and the curl API driver against an in-process stub server. //! No rig, no network, milliseconds. const std = @import("std"); const builtin = @import("builtin"); const zat = @import("zat"); const httpz = @import("httpz"); var app_threaded_io: std.Io.Threaded = undefined; pub const std_options_debug_threaded_io: ?*std.Io.Threaded = &app_threaded_io; /// Scenario output goes to stdout (fd 1 via libc — no Io dependency, so /// it is safe in unit tests where the global threaded io is not yet /// initialized). std.debug.print would go to stderr, which the build /// runner captures and echoes as a bogus "failed command" on green runs. /// In test binaries fd 1 is the `--listen=-` protocol channel, so there /// the output is suppressed entirely. fn scenarioOut(comptime fmt: []const u8, args: anytype) void { if (builtin.is_test) return; var buf: [8192]u8 = undefined; const s = std.fmt.bufPrint(&buf, fmt, args) catch return; _ = std.c.write(std.c.STDOUT_FILENO, s.ptr, s.len); } pub const ApiResult = struct { code: u16, body: []const u8, }; // ---------------------------------------------------------------- env /// Env override with a fallback. The self-tests point the harness at the /// fakes through these. pub fn envOr(name: [*:0]const u8, fallback: []const u8) []const u8 { return if (std.c.getenv(name)) |v| std.mem.span(v) else fallback; } /// Port from `LINJI_ZDS_PORT` / `LINJI_API_PORT`; defaults keep the /// historical fixed ports, overrides let concurrent runs avoid collision. pub fn envPort(allocator: std.mem.Allocator, name: [*:0]const u8, fallback: u16) !u16 { _ = allocator; const v = std.c.getenv(name) orelse return fallback; return std.fmt.parseInt(u16, std.mem.span(v), 10) catch fallback; } /// Milliseconds before a harness subprocess call is killed, from /// `LINJI_CMD_TIMEOUT_MS` (default 30s). The self-test hang case sets it /// short to prove a stuck CLI is reaped, not wedged. fn cmdTimeoutMs() u32 { const v = std.c.getenv("LINJI_CMD_TIMEOUT_MS") orelse return 30_000; return std.fmt.parseInt(u32, std.mem.span(v), 10) catch 30_000; } // ---------------------------------------------------------------- fs /// mkdir -p with mode 0700. pub fn mkdirs(path: []const u8) !void { var buf: [4096]u8 = undefined; var index: usize = 0; while (index < path.len) : (index += 1) { if (path[index] != std.fs.path.sep or index == 0) continue; try mkdirOne(path[0..index], &buf); } try mkdirOne(path, &buf); } fn mkdirOne(path: []const u8, buf: []u8) !void { if (path.len == 0 or path.len >= buf.len) return error.PathTooLong; @memcpy(buf[0..path.len], path); buf[path.len] = 0; const path_z: [:0]const u8 = buf[0..path.len :0]; if (std.c.mkdir(path_z.ptr, 0o700) == 0) return; const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); if (err == .EXIST) return; return error.CreateDirFailed; } pub fn writeFile(io: std.Io, path: []const u8, content: []const u8) !void { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = content }); } // ---------------------------------------------------------------- http /// GET, returning whatever status the server answered (or transport error). pub fn httpAny(allocator: std.mem.Allocator, io: std.Io, url: []const u8) !std.http.Status { var transport = zat.HttpTransport.init(io, allocator); defer transport.deinit(); var result = try transport.fetch(.{ .url = url, .accept = "application/json" }); defer result.deinit(allocator); return result.status; } /// Poll `/{base}/xrpc/_health` until it answers 200 (zds boot wait). pub fn waitForHealth(allocator: std.mem.Allocator, io: std.Io, base: []const u8) !void { var i: usize = 0; while (i < 200) : (i += 1) { const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/_health", .{base}); if (httpAny(allocator, io, url)) |status| { if (status == .ok) return; } else |_| {} io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; } return error.ZdsDidNotStart; } /// Poll until any HTTP response (server boot wait). pub fn waitForHttp(allocator: std.mem.Allocator, io: std.Io, base: []const u8) !void { var i: usize = 0; while (i < 200) : (i += 1) { if (httpAny(allocator, io, base)) |_| return else |_| {} io.sleep(std.Io.Duration.fromMilliseconds(150), .awake) catch {}; } return error.ServeDidNotStart; } /// One API call through curl: cookie jar in/out, JSON body for POSTs. /// `base` is the API origin; `path` may already be a full URL. pub fn api(allocator: std.mem.Allocator, io: std.Io, base: []const u8, jar: ?[]const u8, method: []const u8, path: []const u8, body: ?[]const u8) !ApiResult { var argv: std.ArrayList([]const u8) = .empty; try argv.appendSlice(allocator, &.{ "curl", "-s", "-X", method, "-w", "\n%{http_code}" }); if (jar) |j| try argv.appendSlice(allocator, &.{ "-b", j, "-c", j }); if (body) |b| try argv.appendSlice(allocator, &.{ "-H", "content-type: application/json", "-d", b }); const url = if (std.mem.startsWith(u8, path, "http")) path else try std.fmt.allocPrint(allocator, "{s}{s}", .{ base, path }); try argv.append(allocator, url); const r = try std.process.run(allocator, io, .{ .argv = argv.items }); try expectExited(r, "curl"); const trimmed = std.mem.trimEnd(u8, r.stdout, "\n"); const nl = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return error.ScenarioFailed; return .{ .code = try std.fmt.parseInt(u16, trimmed[nl + 1 ..], 10), .body = trimmed[0..nl], }; } // ---------------------------------------------------------------- assertions /// Checkpoint that must pass: the process exited 0. pub fn expectExited(r: std.process.RunResult, label: []const u8) !void { switch (r.term) { .exited => |code| { if (code != 0) { scenarioOut("FAIL {s}: exit {d}\nstderr: {s}\n", .{ label, code, r.stderr }); return error.ScenarioFailed; } }, else => { scenarioOut("FAIL {s}: abnormal term\n", .{label}); return error.ScenarioFailed; }, } } /// Checkpoint that must pass: `needle` present in `haystack`. pub fn expectContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { if (std.mem.indexOf(u8, haystack, needle) == null) { scenarioOut("FAIL {s}: missing {s} in:\n{s}\n", .{ label, needle, haystack }); return error.ScenarioFailed; } scenarioOut(" ok {s}\n", .{label}); } /// Checkpoint that must pass: `needle` absent from `haystack`. pub fn expectNotContains(haystack: []const u8, needle: []const u8, label: []const u8) !void { if (std.mem.indexOf(u8, haystack, needle) != null) { scenarioOut("FAIL {s}: unexpected {s} in:\n{s}\n", .{ label, needle, haystack }); return error.ScenarioFailed; } scenarioOut(" ok {s}\n", .{label}); } /// Checkpoint that must pass: `first` appears strictly before `second`. pub fn expectOrder(haystack: []const u8, first: []const u8, second: []const u8, label: []const u8) !void { const a = std.mem.indexOf(u8, haystack, first) orelse { scenarioOut("FAIL {s}: missing first text {s}\n{s}\n", .{ label, first, haystack }); return error.ScenarioFailed; }; const b = std.mem.indexOf(u8, haystack, second) orelse { scenarioOut("FAIL {s}: missing second text {s}\n{s}\n", .{ label, second, haystack }); return error.ScenarioFailed; }; if (a >= b) { scenarioOut("FAIL {s}: wrong order in:\n{s}\n", .{ label, haystack }); return error.ScenarioFailed; } scenarioOut(" ok {s}\n", .{label}); } /// Checkpoint that must pass: HTTP status matches. pub fn expectCode(actual: u16, expected: u16, label: []const u8) !void { if (actual != expected) { scenarioOut("FAIL {s}: http {d}, expected {d}\n", .{ label, actual, expected }); return error.ScenarioFailed; } scenarioOut(" ok {s}\n", .{label}); } // ---------------------------------------------------------------- linji driver /// Run the linji CLI with LINJI_HOME set and a bounded wait; returns the /// captured result (stdout/stderr are caller-owned — arena in the /// scenario mains). pub fn runLinji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8) !std.process.RunResult { var env = std.process.Environ.Map.init(allocator); try env.put("LINJI_HOME", home); // Passthrough for the fake-CLI fixture selector (self-tests only). if (std.c.getenv("LINJI_FAKE_FIXTURE")) |f| try env.put("LINJI_FAKE_FIXTURE", std.mem.span(f)); var full: std.ArrayList([]const u8) = .empty; try full.append(allocator, bin); try full.appendSlice(allocator, argv); return std.process.run(allocator, io, .{ .argv = full.items, .environ_map = &env, .timeout = .{ .deadline = std.Io.Clock.Timestamp.fromNow(io, .{ .raw = std.Io.Duration.fromMilliseconds(cmdTimeoutMs()), .clock = .real, }) }, }); } /// Checkpoint driver: the CLI must exit 0. pub fn linji(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !std.process.RunResult { const r = runLinji(allocator, io, home, bin, argv) catch |err| { if (err == error.Timeout) { scenarioOut("FAIL {s}: timed out after {d}ms\n", .{ label, cmdTimeoutMs() }); return error.ScenarioFailed; } return err; }; try expectExited(r, label); scenarioOut(" ok {s}\n", .{label}); return r; } /// Checkpoint driver: the CLI must exit NON-zero (server refusal). pub fn linjiFails(allocator: std.mem.Allocator, io: std.Io, home: []const u8, bin: []const u8, argv: []const []const u8, label: []const u8) !void { const r = runLinji(allocator, io, home, bin, argv) catch |err| { if (err == error.Timeout) { scenarioOut("FAIL {s}: timed out after {d}ms\n", .{ label, cmdTimeoutMs() }); return error.ScenarioFailed; } return err; }; switch (r.term) { .exited => |code| { if (code == 0) { scenarioOut("FAIL {s}: expected failure but exited 0\nstdout: {s}\n", .{ label, r.stdout }); return error.ScenarioFailed; } }, else => { scenarioOut("FAIL {s}: abnormal term\nstderr: {s}\n", .{ label, r.stderr }); return error.ScenarioFailed; }, } scenarioOut(" ok {s}\n", .{label}); } // ---------------------------------------------------------------- parsing /// Extract the value of a flat string field from a small JSON object. pub fn takeJsonString(allocator: std.mem.Allocator, json: []const u8, key: []const u8) ![]const u8 { const needle = try std.fmt.allocPrint(allocator, "\"{s}\":\"", .{key}); const start = (std.mem.indexOf(u8, json, needle) orelse return error.ScenarioFailed) + needle.len; const rest = json[start..]; const end = std.mem.indexOfScalar(u8, rest, '"') orelse return error.ScenarioFailed; return rest[0..end]; } /// The space URI is the whole stdout of `space create` (no prefix). pub fn takeSpaceUri(allocator: std.mem.Allocator, out: []const u8) ![]const u8 { const trimmed = std.mem.trim(u8, out, "\r\n "); if (!std.mem.startsWith(u8, trimmed, "at://")) { scenarioOut("FAIL: no at-uri in:\n{s}\n", .{out}); return error.ScenarioFailed; } return allocator.dupe(u8, trimmed); } // ---------------------------------------------------------------- rig pub const ZdsOpts = struct { port: u16, base: []const u8, db_path: []const u8, blobs_dir: []const u8, }; /// Boot a fresh zds with dev tools + permissioned data. The caller owns /// the child and must kill it (scenarios `defer` that). pub fn spawnZds(allocator: std.mem.Allocator, io: std.Io, bin: []const u8, opts: ZdsOpts) !std.process.Child { const port_str = try std.fmt.allocPrint(allocator, "{d}", .{opts.port}); var env = std.process.Environ.Map.init(allocator); try env.put("ZDS_DEV_TOOLS", "true"); try env.put("ZDS_PERMISSIONED_DATA", "true"); return std.process.spawn(io, .{ .argv = &.{ bin, "--host", "127.0.0.1", "--port", port_str, "--db", opts.db_path, "--blobstore-path", opts.blobs_dir, "--public-url", opts.base, "--server-did", "did:web:localhost", "--handle-domains", ".test", "--admin-token", "scenario-admin", }, .environ_map = &env, .stdout = .ignore, .stderr = .ignore, }); } /// Seed accounts directly in the zds sqlite db (the sandbox pattern — /// createAccount needs the live PLC directory). pub fn seedAccounts(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, names: []const []const u8) !void { for (names) |name| { const sql = try std.fmt.allocPrint(allocator, "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:{s}test', '{s}.test', '{s}@example.com', '{s}-password', unixepoch(), unixepoch())", .{ name, name, name, name }); const r = try std.process.run(allocator, io, .{ .argv = &.{ "sqlite3", db_path, sql } }); try expectExited(r, "sqlite seed"); } } // ================================================================ L1 self-tests // // The scenario suite is the gate for zds upgrades; these tests prove the // gate's own machinery. The regress terminates here: fixtures are // hand-verified oracles, never products of more tests. test "expectContains accepts and rejects" { try expectContains("hello world", "world", "present"); try std.testing.expectError(error.ScenarioFailed, expectContains("hello world", "nope", "absent")); } test "expectNotContains accepts and rejects" { try expectNotContains("hello world", "nope", "absent"); try std.testing.expectError(error.ScenarioFailed, expectNotContains("hello world", "world", "present")); } test "expectOrder accepts order and rejects all three failure shapes" { try expectOrder("a then b", "a", "b", "ordered"); try std.testing.expectError(error.ScenarioFailed, expectOrder("b then a", "a", "b", "reversed")); try std.testing.expectError(error.ScenarioFailed, expectOrder("only b", "a", "b", "missing first")); try std.testing.expectError(error.ScenarioFailed, expectOrder("only a", "a", "b", "missing second")); // strictly ordered: identical texts are rejected try std.testing.expectError(error.ScenarioFailed, expectOrder("x x", "x", "x", "identical")); } test "expectCode matches and rejects" { try expectCode(200, 200, "match"); try std.testing.expectError(error.ScenarioFailed, expectCode(403, 200, "mismatch")); } test "expectExited passes on exit 0 and fails on non-zero" { const ok = std.process.RunResult{ .term = .{ .exited = 0 }, .stdout = &.{}, .stderr = &.{} }; try expectExited(ok, "exit 0"); const bad = std.process.RunResult{ .term = .{ .exited = 1 }, .stdout = &.{}, .stderr = &.{} }; try std.testing.expectError(error.ScenarioFailed, expectExited(bad, "exit 1")); } test "takeJsonString extracts flat string fields" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); try std.testing.expectEqualStrings("did:plc:alicetest", try takeJsonString(a, "{\"did\":\"did:plc:alicetest\",\"handle\":\"alice.test\"}", "did")); try std.testing.expectError(error.ScenarioFailed, takeJsonString(a, "{\"did\":123}", "did")); try std.testing.expectError(error.ScenarioFailed, takeJsonString(a, "{}", "missing")); } test "takeSpaceUri trims and validates" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); try std.testing.expectEqualStrings( "at://did:plc:communitytest/space/at.linji.space/general", try takeSpaceUri(a, " at://did:plc:communitytest/space/at.linji.space/general\n"), ); try std.testing.expectError(error.ScenarioFailed, takeSpaceUri(a, "not a uri")); } const Stub = struct { fn handler(_: *Stub, req: *httpz.Request, res: *httpz.Response) !void { if (req.method == .POST) { res.status = 201; res.body = "{\"uri\":\"at://did:plc:alicetest/at.linji.post/x\"}"; } else { res.status = 401; res.body = "nope"; } } }; fn serveThread(server: *httpz.Server(*Stub)) void { server.listen() catch {}; } test "api() drives curl and splits http code from body" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var threaded_io = std.Io.Threaded.init(allocator, .{ .async_limit = .limited(4), .concurrent_limit = .limited(4), }); const io = threaded_io.io(); app_threaded_io = threaded_io; var stub: Stub = .{}; var server = try httpz.Server(*Stub).init(io, allocator, .{ .address = .{ .ip = .{ .ip4 = try std.Io.net.Ip4Address.parse("127.0.0.1", 2599) } }, }, &stub); const router = try server.router(.{}); router.get("/x", Stub.handler, .{}); router.post("/x", Stub.handler, .{}); const serve = try std.Thread.spawn(.{}, serveThread, .{&server}); // Poll until the stub answers (a fixed sleep raced the build runner // under load — first-run flake). const base = "http://127.0.0.1:2599"; var up = false; var i: usize = 0; while (i < 40) : (i += 1) { if (httpAny(allocator, io, base ++ "/x")) |_| { up = true; break; } else |_| {} io.sleep(std.Io.Duration.fromMilliseconds(50), .awake) catch {}; } try std.testing.expect(up); const got = try api(allocator, io, base, null, "GET", "/x", null); try std.testing.expectEqual(@as(u16, 401), got.code); try std.testing.expectEqualStrings("nope", got.body); const posted = try api(allocator, io, base, null, "POST", "/x", "{}"); try std.testing.expectEqual(@as(u16, 201), posted.code); try std.testing.expectEqualStrings("{\"uri\":\"at://did:plc:alicetest/at.linji.post/x\"}", posted.body); server.stop(); serve.join(); server.deinit(); threaded_io.deinit(); }