Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
9.6 kB · 254 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255// End-to-end through papyrus's real HTTP routes, driven in-process via Hono's test// client against a real silverwood binary + a temp forest. Asserts that canvas CUJs// (create / edit / delete a node, session metadata) round-trip through silverwood —// the delegation the server exists to do. Ground truth is re-read from silverwood, not// server memory. Skip-mode only (no clone), so it runs in the sandbox.
import { describe, test, expect, beforeEach, afterEach } from "bun:test";import { api, jsonInit, spawnCalls } from "./helpers/app"; // stubs bun-pty, then loads the routesimport * as sw from "../server/services/silverwood";import { sessions } from "../server/services/sessionManager";import { newForest, cleanupForest, cli, SKIP_SOURCE, SILVERWOOD_BIN,} from "./helpers/forest";
function createNode(name: string, extra: Record<string, unknown> = {}) { return api( "/sessions", jsonInit("POST", { name, path: ["basic", "jj-colocated"], args: [SKIP_SOURCE], ...extra, }), );}
async function nodes(): Promise<any[]> { const { body } = await api("/state"); return body.nodes;}
describe("papyrus routes → silverwood (in-process, skip-mode)", () => { let dir: string; beforeEach(() => { dir = newForest(); spawnCalls.length = 0; }); afterEach(() => cleanupForest(dir));
test("GET /config echoes the active forest", async () => { const { status, body } = await api("/config"); expect(status).toBe(200); expect(body.forest).toBe(dir); });
test("GET /agents and GET /new-schema (delegates to silverwood)", async () => { const agents = await api("/agents"); expect(agents.body[0].id).toBe("claude"); const schema = await api("/new-schema"); expect(schema.status).toBe(200); expect(schema.body.name).toBe("new"); });
test("empty forest → no nodes", async () => { expect(await nodes()).toEqual([]); expect((await api("/sessions")).body).toEqual([]); });
test("create node registers a workstream and stores its position", async () => { const { status, body } = await createNode("web", { position: { x: 10, y: 20 } }); expect(status).toBe(200); expect(body.checkoutState).toBe("initialized-without-checkout");
const ns = await nodes(); expect(ns).toHaveLength(1); expect(ns[0].customName).toBe("web"); expect(ns[0].position).toEqual({ x: 10, y: 20 }); // Ground truth: it's a real workstream in silverwood. expect((cli(["ls"]).json as any[]).length).toBe(1); });
// A checkout-less `local-blank` node: the route creates it with no checkout mode and // skips the basic-only background `checkout` (which would error on a non-basic kind). test("create a local-blank node (no checkout, no background provisioning)", async () => { const { status, body } = await api( "/sessions", jsonInit("POST", { name: "blank", path: ["local-blank"], args: [] }), ); expect(status).toBe(200); // Checkout state is Basic-only, so a checkout-less kind reports none (undefined, omitted). expect(body.checkoutState).toBeUndefined();
const ns = await nodes(); expect(ns).toHaveLength(1); // Ground truth: a real local-blank workstream in silverwood. expect(cli(["workstream", body.nodeId, "show"]).json.kind).toBe("local-blank"); });
test("edit node: name/color/notes/position round-trip through silverwood", async () => { const { body } = await createNode("orig"); const id = body.nodeId;
const res = await api( `/sessions/${id}`, jsonInit("PATCH", { customName: "renamed", customColor: "#123456", notes: "some notes", position: { x: 3, y: 4 }, }), ); expect(res.status).toBe(200);
const node = (await nodes())[0]; expect(node.customName).toBe("renamed"); expect(node.customColor).toBe("#123456"); expect(node.notes).toBe("some notes"); expect(node.position).toEqual({ x: 3, y: 4 }); // Ground truth: the durable name lives in silverwood. expect(cli(["workstream", id, "show"]).json.name).toBe("renamed"); });
test("delete node archives the workstream", async () => { const { body } = await createNode("temp"); const id = body.nodeId;
const res = await api(`/sessions/${id}`, { method: "DELETE" }); expect(res.status).toBe(200);
expect(await nodes()).toEqual([]); // hidden from the active canvas const all = cli(["ls", "--all"]).json as Array<{ id: string; status: string }>; expect(all.find((w) => w.id === id)?.status).toBe("archived"); });
test("session metadata: a seeded tab renames and removes via routes", async () => { const { body } = await createNode("with-session"); const id = body.nodeId; const sid = crypto.randomUUID(); // Seed a durable session record directly (no PTY needed to test its metadata). await sw.sessionCreate("plain-shell", id, sid, "shell");
const tab = (n: any[]) => n[0].sessions.find((t: any) => t.sessionId === sid); expect(tab(await nodes())?.name).toBe("shell");
const r1 = await api(`/sessions/${id}/sessions/${sid}`, jsonInit("PATCH", { name: "renamed-tab" })); expect(r1.status).toBe(200); expect(tab(await nodes())?.name).toBe("renamed-tab");
const r2 = await api(`/sessions/${id}/sessions/${sid}`, { method: "DELETE" }); expect(r2.status).toBe(200); expect(tab(await nodes())).toBeUndefined(); });
test("fresh codex registers and keeps its title after reconnect", async () => { const created = await api( "/sessions", jsonInit("POST", { name: "codex-ws", path: ["local-blank"], args: [] }), ); const id = created.body.nodeId;
const started = await api( `/sessions/${id}/sessions`, jsonInit("POST", { kind: "codex", options: {} }), ); expect(started.status).toBe(200); expect(started.body).toMatchObject({ connected: true, kind: "codex", name: "codex" }); const runtimeId = started.body.sessionId; expect(spawnCalls.at(-1)).toEqual([ "session", "create", "codex", id, "--name", runtimeId, ]); // The hook, not Papyrus, creates the record under Codex's generated UUID. expect(await sw.sessionLs(id)).toEqual({}); const node = (await nodes()).find((n) => n.nodeId === id); expect(node.sessions).toEqual([ expect.objectContaining({ sessionId: runtimeId, kind: "codex", connected: true }), ]);
// Simulate Codex's SessionStart hook. Reconciliation keeps the live runtime key // stable for its WebSocket while mapping metadata operations to the real UUID. const durableId = "019f9d07-c030-7f22-bd1b-46088aa1a707"; const hookInput = `${dir}/codex-session-start.json`; await Bun.write( hookInput, JSON.stringify({ session_id: durableId, hook_event_name: "SessionStart", source: "startup", }), ); const registered = Bun.spawnSync([SILVERWOOD_BIN, "__register-codex-session"], { env: { ...process.env, SILVERWOOD_CODEX_REGISTER_WORKSTREAM: id, SILVERWOOD_CODEX_REGISTER_NAME: runtimeId, }, stdin: Bun.file(hookInput), }); expect(registered.exitCode).toBe(0);
const reconciled = (await nodes()).find((n) => n.nodeId === id); expect(reconciled.sessions).toEqual([ expect.objectContaining({ sessionId: runtimeId, name: "codex", kind: "codex", connected: true, lock: expect.objectContaining({ mine: true }), }), ]); expect((await sw.sessionLs(id))[durableId]).toMatchObject({ kind: "codex", name: "codex", });
const renamed = await api( `/sessions/${id}/sessions/${runtimeId}`, jsonInit("PATCH", { name: "codex-renamed" }), ); expect(renamed.status).toBe(200); expect((await sw.sessionLs(id))[durableId]?.name).toBe("codex-renamed");
// Reopening uses the durable id as the runtime key. The projection must not // add a second temporary Codex tab that hides the saved title. const disconnected = await api(`/sessions/${id}/sessions/${runtimeId}/disconnect`, { method: "POST", }); expect(disconnected.status).toBe(200); const reconnected = await api( `/sessions/${id}/sessions/connect`, jsonInit("POST", { sessionId: durableId }), ); expect(reconnected.status).toBe(200); expect(sessions.get(durableId)?.durableSessionId).toBe(durableId); const resumed = (await nodes()).find((n) => n.nodeId === id); expect(resumed.sessions).toEqual([ expect.objectContaining({ sessionId: durableId, name: "codex-renamed", connected: true }), ]);
// Even if a future reconnect path omits the marker, an existing durable // record still takes precedence over the temporary-tab fallback. sessions.get(durableId)!.durableSessionId = undefined; const guarded = (await nodes()).find((n) => n.nodeId === id); expect(guarded.sessions).toEqual([ expect.objectContaining({ sessionId: durableId, name: "codex-renamed", connected: true }), ]); expect((await sw.sessionLs(id))[durableId]?.name).toBe("codex-renamed"); await api(`/sessions/${id}/sessions/${durableId}/disconnect`, { method: "POST" }); });
test("mutating an absent workstream surfaces silverwood's error as 400", async () => { const res = await api(`/sessions/not-a-uuid`, jsonInit("PATCH", { customName: "x" })); expect(res.status).toBe(400); expect(res.body.error).toBeTruthy(); });});