maybe a prototype, maybe it will go in production
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458//! 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();}