Files
openrig/packages/cli/test/client.test.ts
T
mvschwarz ac75777a3c Release 0.4.4
Multi-host + Living Notes.

Multi-host foundation: shareable whole-topology staged spin-up brings
a rig topology up across multiple hosts in stages; VPS product-factory
multi-host hardening ships with rig host verbs (add / list / doctor,
capped at three; one built-in product-factory-vps posture, three-
valued per item); the transport posture is documented (ssh for pane
ops, http-bearer for daemon REST, no cross-transport fallback); the
For-You feed aggregates activity across every registered host; and
rig file moves files across the topology's registered hosts.

Living Notes: durable INTENT / PLAN / DELIVERED signal layer at
mission and slice altitude with agent authorship + timestamps; cheap
composer surfaces make it the first-class place agents record
decisions, plans, and delivered work; the one-structure review
contract reads left-to-right as a single vertical stack (INTENT above,
PLAN + mockup in the middle, DELIVERED with paired proof at the
bottom); a plan change deletes the old plan and writes a new one -
never demotes or stacks multiple competing plans.

Operationalize the SDLC control plane: conventions SSOT ships in
source at docs/reference/sdlc-conventions.md (Living Notes UI section
names, proof-contract format + plannedRef mockup pairing, staged-
approval locks, C1 proof header + closed sets, three role contracts,
curation rule, elastic-middle doctrine, advisory fail-open audit
posture); rig scope slice create scaffolds the convention sections +
proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every
template kind; rig scope audit + rig workspace doctor gain sdlc-
convention advisories (records-and-advises, gates on HIGH only); the
mission-slice-sop skill ships in canonical source with byte-parity
guard; rig proof / rig scope slice create / rig scope slice approve
help + cli-reference teach the SDLC flow.

BREAKING: rig ps default flips to consolidated all-active-rigs
compact projection (the v0.4.0 current-rig-only default is retired -
it hid running rigs from the operator's field of view). --json is
scope-not-shape (still a bare array with existing per-entry keys;
scope widens to all non-archived rigs including stopped ones; one-line
migration for the old fleet firehose: rig ps --nodes -A --full).
-A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener;
bare -A is a structured teaching error). --nodes names its scope
everywhere (session default local-only; --host <id> --nodes requires
--rig or -A; multi-host fan-out is rollup-only by default; the
explicit ladder --all-hosts --nodes -A --full fans out per-node with
hostId-stamped projected rows). --all-hosts / --hosts --json emits the
shared AggregatedPayload (items + hosts with closed-enum statuses:
ok | unreachable | unsupported-transport | auth-failed).

Operator UX: agent altitude coordination panel scopes multi-agent
state to the right altitude (workspace / mission / slice) so the
coordination surface reads without per-seat drowning; composes with
the workspace observability tabs from 0.4.1. Docs closeout catches
docs/as-built/architecture.md, docs/as-built/cli-reference.md, and
the codemaps up to shipped state.

Migrations: additive only. Existing v0.4.3 databases upgrade by
running rig daemon start.

Behavior change (rig ps default view): the compact all-active-rigs
projection replaces the current-rig-only default. If your call sites
relied on the v0.4.3 default shape, add --full for the rich per-node
projection or --rig <name> for the prior scope.

Public-safety scrub applied inline to packages/ui/twin/corrective/
fixtures-corrective.ts (one demo-fixture role identifier neutralized
to the demo-namespace baseline established at 0.4.1).

See CHANGELOG.md and docs/releases/v0.4.4.md.
2026-07-06 01:20:09 -07:00

215 lines
7.9 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { DaemonClient, DaemonConnectionError } from "../src/client.js";
// Lightweight test server that echoes request info as JSON
function createEchoServer(): { server: http.Server; port: number; close: () => Promise<void> } {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, `http://localhost`);
// POST /api/conflict -> 409 (for non-2xx test)
if (req.method === "POST" && url.pathname === "/api/conflict") {
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "already exists" }));
return;
}
// GET /api/rigs/:id/spec -> text/yaml (for getText test)
if (req.method === "GET" && url.pathname.endsWith("/spec")) {
res.writeHead(200, { "Content-Type": "text/yaml" });
res.end("schema_version: 1\nname: test-rig\n");
return;
}
// POST /api/echo-raw -> echoes content-type and raw body (for postText test)
if (req.method === "POST" && url.pathname === "/api/echo-raw") {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ contentType: req.headers["content-type"], body }));
});
return;
}
// Collect body for POST
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
method: req.method,
path: url.pathname,
body: body || null,
}));
});
});
return {
server,
port: 0,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
describe("DaemonClient", () => {
let echoServer: ReturnType<typeof createEchoServer>;
let baseUrl: string;
beforeAll(async () => {
echoServer = createEchoServer();
await new Promise<void>((resolve) => {
echoServer.server.listen(0, () => {
const addr = echoServer.server.address();
if (addr && typeof addr === "object") {
echoServer.port = addr.port;
baseUrl = `http://localhost:${addr.port}`;
}
resolve();
});
});
});
afterAll(async () => {
await echoServer.close();
});
// Test 1: Client constructs correct URLs from base
it("constructs correct URLs from base", () => {
const client = new DaemonClient("http://localhost:9999");
expect(client.baseUrl).toBe("http://localhost:9999");
});
// Test 2: Client GET returns { status, data } with parsed JSON
it("GET returns { status, data } with parsed JSON", async () => {
const client = new DaemonClient(baseUrl);
const res = await client.get("/api/rigs");
expect(res.status).toBe(200);
expect(res.data).toEqual({
method: "GET",
path: "/api/rigs",
body: null,
});
});
// Test 3: Client POST sends body, returns { status, data }
it("POST sends body and returns { status, data }", async () => {
const client = new DaemonClient(baseUrl);
const res = await client.post("/api/rigs", { name: "test-rig" });
expect(res.status).toBe(200);
expect(res.data).toEqual({
method: "POST",
path: "/api/rigs",
body: JSON.stringify({ name: "test-rig" }),
});
});
// Test 4: Client handles connection refused -> throws DaemonConnectionError
it("connection refused throws DaemonConnectionError", async () => {
const client = new DaemonClient("http://localhost:1");
await expect(client.get("/api/rigs")).rejects.toThrow(DaemonConnectionError);
});
it("bounded timeout throws DaemonConnectionError instead of hanging forever", async () => {
const neverFetch: typeof fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(init.signal?.reason ?? new Error("aborted"));
}, { once: true });
})) as typeof fetch;
const client = new DaemonClient("http://localhost:9999", { fetchImpl: neverFetch, timeoutMs: 20 });
await expect(client.get("/api/rigs")).rejects.toThrow(DaemonConnectionError);
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
});
it("per-request timeout override can extend a long-running call", async () => {
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
}, 40);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(init.signal?.reason ?? new Error("aborted"));
}, { once: true });
})) as typeof fetch;
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
expect(res.status).toBe(200);
expect(res.data.ok).toBe(true);
expect(res.data.url).toContain("/api/rigs");
});
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
// Default (no env)
const saved = process.env["OPENRIG_URL"];
delete process.env["OPENRIG_URL"];
const defaultClient = new DaemonClient();
expect(defaultClient.baseUrl).toBe("http://127.0.0.1:7433");
// With env
process.env["OPENRIG_URL"] = "http://custom:9000";
const envClient = new DaemonClient();
expect(envClient.baseUrl).toBe("http://custom:9000");
// Cleanup
if (saved !== undefined) {
process.env["OPENRIG_URL"] = saved;
} else {
delete process.env["OPENRIG_URL"];
}
});
// Test 6: CLI --version prints version string
it("CLI --version prints version string", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
const { resolve } = await import("node:path");
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
const result = await execFileAsync("node", [cliEntry, "--version"], {
cwd: resolve(import.meta.dirname, ".."),
});
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
});
// Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody }
it("non-2xx response returns { status, data } without throwing", async () => {
const client = new DaemonClient(baseUrl);
const res = await client.post("/api/conflict", {});
expect(res.status).toBe(409);
expect(res.data).toEqual({ error: "already exists" });
});
// Test 8: postText sends raw text body with correct Content-Type
it("postText sends raw text body with text/yaml Content-Type", async () => {
const client = new DaemonClient(baseUrl);
const yaml = "schema_version: 1\nname: test\n";
const res = await client.postText<{ contentType: string; body: string }>("/api/echo-raw", yaml);
expect(res.status).toBe(200);
expect(res.data.contentType).toBe("text/yaml");
expect(res.data.body).toBe(yaml); // Raw text, NOT JSON-stringified
});
// Test 9: getText returns raw text body for non-JSON content (YAML export)
it("getText returns raw text body for text/yaml response", async () => {
const client = new DaemonClient(baseUrl);
const res = await client.getText("/api/rigs/r1/spec");
expect(res.status).toBe(200);
expect(res.data).toBe("schema_version: 1\nname: test-rig\n");
});
});