Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

claude-web-session.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

claude-web-session.test.tsBlame210 lines · 1 contributor
90c7531Claude1/**
2 * Unit tests for src/lib/claude-web-session.ts.
3 *
4 * We don't exercise the real `claude` CLI here — the spawn seam is
5 * overridden so we can verify the command shape, --resume passthrough,
6 * stream-json session-id parsing, and the env-scrubbing whitelist.
7 */
8
9import { describe, it, expect, afterEach, beforeEach } from "bun:test";
10import {
11 __setSpawnForTests,
12 __test,
13 claudeBinary,
14 claudeWebRoot,
15 runTurn,
16 sessionWorkdir,
17 type SpawnFn,
18} from "../lib/claude-web-session";
19import type { ClaudeWebSession } from "../db/schema";
20
21const originalRoot = process.env.CLAUDE_WEB_WORKDIR;
22const originalBin = process.env.CLAUDE_BIN;
23const originalApiKey = process.env.ANTHROPIC_API_KEY;
24
25beforeEach(() => {
26 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test";
27 process.env.CLAUDE_BIN = "claude-test";
28});
29
30afterEach(() => {
31 __setSpawnForTests(null);
32 for (const [k, v] of [
33 ["CLAUDE_WEB_WORKDIR", originalRoot],
34 ["CLAUDE_BIN", originalBin],
35 ["ANTHROPIC_API_KEY", originalApiKey],
36 ] as const) {
37 if (v === undefined) delete process.env[k];
38 else process.env[k] = v;
39 }
40});
41
42function fakeSession(overrides: Partial<ClaudeWebSession> = {}): ClaudeWebSession {
43 return {
44 id: "00000000-0000-0000-0000-000000000099",
45 repositoryId: "00000000-0000-0000-0000-000000000001",
46 ownerUserId: "00000000-0000-0000-0000-000000000002",
47 title: "test session",
48 branch: "main",
49 workdirPath: "/tmp/cw-test/00000000-0000-0000-0000-000000000099",
50 claudeSessionId: null,
51 status: "cold",
52 lastActiveAt: new Date(),
53 createdAt: new Date(),
54 ...overrides,
55 } as ClaudeWebSession;
56}
57
58describe("claudeWebRoot + sessionWorkdir + claudeBinary", () => {
59 it("reads from env at call time", () => {
60 expect(claudeWebRoot()).toBe("/tmp/cw-test");
61 expect(claudeBinary()).toBe("claude-test");
62 expect(sessionWorkdir("abc")).toBe("/tmp/cw-test/abc");
63 });
64
65 it("defaults sensibly when env unset", () => {
66 delete process.env.CLAUDE_WEB_WORKDIR;
67 delete process.env.CLAUDE_BIN;
68 expect(claudeWebRoot()).toBe("/var/lib/gluecron/claude-web");
69 expect(claudeBinary()).toBe("claude");
70 });
71
72 it("strips trailing slash from workdir root", () => {
73 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test/";
74 expect(claudeWebRoot()).toBe("/tmp/cw-test");
75 });
76});
77
78describe("runTurn", () => {
79 it("invokes the claude binary with --print and prompt, without --resume on first turn", async () => {
80 const captured: { cmd: string[]; cwd: string } = { cmd: [], cwd: "" };
81 const handle = makeHandle(['{"type":"text","text":"hi"}\n'], 0, "");
82 __setSpawnForTests((cmd, opts) => {
83 captured.cmd = cmd;
84 captured.cwd = opts.cwd;
85 return handle;
86 });
87
88 const events = await collect(runTurn({
89 session: fakeSession(),
90 ownerName: "you",
91 repoName: "repo",
92 prompt: "hello there",
93 }));
94
95 expect(captured.cmd[0]).toBe("claude-test");
96 expect(captured.cmd).toContain("--print");
97 expect(captured.cmd).toContain("--output-format");
98 expect(captured.cmd).toContain("stream-json");
99 expect(captured.cmd).not.toContain("--resume");
100 expect(captured.cmd[captured.cmd.length - 1]).toBe("hello there");
101 expect(captured.cwd).toBe("/tmp/cw-test/00000000-0000-0000-0000-000000000099");
102
103 const chunks = events.filter((e) => e.chunk).map((e) => e.chunk).join("");
104 expect(chunks).toContain("hi");
105 const done = events.find((e) => e.done);
106 expect(done?.done?.exitCode).toBe(0);
107 });
108
109 it("passes --resume <id> when the session already has a claudeSessionId", async () => {
110 const captured: { cmd: string[] } = { cmd: [] };
111 __setSpawnForTests((cmd) => {
112 captured.cmd = cmd;
113 return makeHandle(["ok\n"], 0, "");
114 });
115
116 await collect(runTurn({
117 session: fakeSession({
118 claudeSessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
119 }),
120 ownerName: "you",
121 repoName: "repo",
122 prompt: "next turn",
123 }));
124
125 const i = captured.cmd.indexOf("--resume");
126 expect(i).toBeGreaterThan(-1);
127 expect(captured.cmd[i + 1]).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
128 });
129
130 it("extracts the Claude session UUID from a stream-json init chunk", async () => {
131 __setSpawnForTests(() =>
132 makeHandle(
133 [
134 '{"type":"system","subtype":"init","session_id":"12345678-aaaa-bbbb-cccc-1234567890ab"}\n',
135 '{"type":"text","text":"hello"}\n',
136 ],
137 0,
138 ""
139 )
140 );
141 const events = await collect(runTurn({
142 session: fakeSession(),
143 ownerName: "you",
144 repoName: "repo",
145 prompt: "p",
146 }));
147 const done = events.find((e) => e.done);
148 expect(done?.done?.claudeSessionId).toBe(
149 "12345678-aaaa-bbbb-cccc-1234567890ab"
150 );
151 });
152
153 it("returns the subprocess exit code on the done event", async () => {
154 __setSpawnForTests(() => makeHandle(["x"], 7, "boom"));
155 const events = await collect(runTurn({
156 session: fakeSession(),
157 ownerName: "y",
158 repoName: "r",
159 prompt: "p",
160 }));
161 const done = events.find((e) => e.done);
162 expect(done?.done?.exitCode).toBe(7);
163 expect(done?.done?.stderr).toBe("boom");
164 });
165});
166
167describe("passthroughEnv", () => {
168 it("keeps PATH and ANTHROPIC_API_KEY when set", () => {
169 process.env.ANTHROPIC_API_KEY = "sk-test";
170 const env = __test.passthroughEnv();
171 expect(env.PATH).toBeDefined();
172 expect(env.ANTHROPIC_API_KEY).toBe("sk-test");
173 });
174
175 it("strips DATABASE_URL even if set", () => {
176 process.env.DATABASE_URL = "postgresql://x";
177 const env = __test.passthroughEnv();
178 expect(env.DATABASE_URL).toBeUndefined();
179 delete process.env.DATABASE_URL;
180 });
181
182 it("keeps any CLAUDE_* prefix var", () => {
183 process.env.CLAUDE_CUSTOM_FLAG = "yes";
184 const env = __test.passthroughEnv();
185 expect(env.CLAUDE_CUSTOM_FLAG).toBe("yes");
186 delete process.env.CLAUDE_CUSTOM_FLAG;
187 });
188});
189
190// ─── helpers ────────────────────────────────────────────────────────────────
191
192function makeHandle(
193 chunks: string[],
194 exitCode: number,
195 stderr: string
196): ReturnType<SpawnFn> {
197 return {
198 stdout: (async function* () {
199 for (const c of chunks) yield c;
200 })(),
201 done: Promise.resolve({ exitCode, stderr }),
202 kill: () => {},
203 };
204}
205
206async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
207 const out: T[] = [];
208 for await (const ev of gen) out.push(ev);
209 return out;
210}