diff --git a/experimental/papyrus/TESTING.md b/experimental/papyrus/TESTING.md index c030597..554d8ae 100644 --- a/experimental/papyrus/TESTING.md +++ b/experimental/papyrus/TESTING.md @@ -66,7 +66,7 @@ these red, where the client suite stays green. - `server-tests/silverwood-contract.test.ts` — the wrapper (`services/silverwood.ts`) argv contract: create/get/list/rename/archive, kv, and the full session lifecycle. Native-free. - `server-tests/api.e2e.test.ts` — the canvas CUJs through the routes: create / edit / - delete a node, session-tab metadata, and error propagation. + delete a node, session-tab metadata, session creation/reconnect launch argv, and errors. - `server-tests/helpers/` — `forest.ts` (temp forest + `cli()` runner, the TS analog of silverwood's `tests/common/mod.rs`) and `app.ts` (stubs the native `bun-pty` — pulled in transitively via `sessionManager` — before loading the routes, the same trick the client @@ -84,8 +84,9 @@ SILVERWOOD_BIN=../silverwood/target/debug/silverwood OPENUI_QUIET=1 bun test ser the package root (the "tests" substring would also match `client/tests/`, whose happy-dom preload only loads under `client/`). -**Scope / constraints.** Every journey uses silverwood `--checkout-extent skip`, so nothing -clones — the suite is network-free and runs in the `nix flake check` sandbox via the +**Scope / constraints.** Basic workstreams use `--checkout-extent skip`; session launch +journeys use `local-blank` with mocked PTYs, so no agent runs. The suite is network-free +and runs in the `nix flake check` sandbox via the `server-tests` derivation (`flake.nix`), which sets `SILVERWOOD_BIN` to the flake's wrapped silverwood. The full create→checkout→live-terminal journey needs network + jj and is **out of scope** here, matching silverwood's own `#[ignore]`d e2e split. diff --git a/experimental/papyrus/server-tests/api.e2e.test.ts b/experimental/papyrus/server-tests/api.e2e.test.ts index fe0f865..de377cb 100644 --- a/experimental/papyrus/server-tests/api.e2e.test.ts +++ b/experimental/papyrus/server-tests/api.e2e.test.ts @@ -145,6 +145,54 @@ describe("papyrus routes → silverwood (in-process, skip-mode)", () => { expect(tab(await nodes())).toBeUndefined(); }); + test.each([ + "claude-code", + "claude-code-noninteractiveshell", + "plain-shell", + "disk-space", + ])("fresh %s session and reconnect use the saved-session command", async (kind) => { + const created = await api( + "/sessions", + jsonInit("POST", { name: "session-ws", path: ["local-blank"], args: [] }), + ); + expect(created.status).toBe(200); + const id = created.body.nodeId; + const options = kind === "claude-code-noninteractiveshell" + ? { "run-direnv-exec": "false" } + : {}; + + const started = await api( + `/sessions/${id}/sessions`, + jsonInit("POST", { kind, options }), + ); + expect(started.status).toBe(200); + expect(started.body).toMatchObject({ connected: true, kind }); + const sid = started.body.sessionId; + expect((await sw.sessionLs(id))[sid]?.kind).toBe(kind); + expect(spawnCalls).toEqual([["session", "run", id, sid]]); + expect(sessions.get(sid)?.cwd).toBe(created.body.cwd); + + const disconnected = await api(`/sessions/${id}/sessions/${sid}/disconnect`, { + method: "POST", + }); + expect(disconnected.status).toBe(200); + expect(sessions.has(sid)).toBe(false); + expect((await sw.sessionLs(id))[sid]?.kind).toBe(kind); + + const reconnected = await api( + `/sessions/${id}/sessions/connect`, + jsonInit("POST", { sessionId: sid }), + ); + expect(reconnected.status).toBe(200); + expect(reconnected.body).toMatchObject({ sessionId: sid, connected: true }); + expect(spawnCalls).toEqual([ + ["session", "run", id, sid], + ["session", "run", id, sid], + ]); + expect(sessions.get(sid)?.cwd).toBe(created.body.cwd); + await api(`/sessions/${id}/sessions/${sid}/disconnect`, { method: "POST" }); + }); + test("fresh codex registers and keeps its title after reconnect", async () => { const created = await api( "/sessions", @@ -229,6 +277,10 @@ describe("papyrus routes → silverwood (in-process, skip-mode)", () => { jsonInit("POST", { sessionId: durableId }), ); expect(reconnected.status).toBe(200); + expect(spawnCalls).toEqual([ + ["session", "create", "codex", id, "--name", runtimeId], + ["session", "run", id, durableId], + ]); expect(sessions.get(durableId)?.durableSessionId).toBe(durableId); const resumed = (await nodes()).find((n) => n.nodeId === id); expect(resumed.sessions).toEqual([ diff --git a/experimental/papyrus/server-tests/helpers/app.ts b/experimental/papyrus/server-tests/helpers/app.ts index 45f7492..efecce1 100644 --- a/experimental/papyrus/server-tests/helpers/app.ts +++ b/experimental/papyrus/server-tests/helpers/app.ts @@ -1,9 +1,5 @@ -// Gateway to the real Hono routes for in-process e2e. `server/routes/api.ts` imports -// `sessionManager.ts`, which statically imports the native `bun-pty`. No sandbox CUJ -// spawns a PTY (the create route's terminal spawn lives in a background branch that -// never runs here), so we stub `bun-pty` BEFORE importing the routes — the same -// technique the client behavioral test uses for the `Terminal` component. This is the -// only module that loads the routes, so the mock is always registered first. +// Gateway to the real Hono routes for in-process e2e. Stub native `bun-pty` before +// importing the routes so session journeys can assert launch argv without running agents. import { mock } from "bun:test";