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.tsBlame212 lines · 2 contributors
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");
032ae5fccanty labs62 // sessionWorkdir joins with the platform separator; on the Linux server
63 // it is POSIX. Normalize so the assertion is stable on a Windows checkout.
64 expect(sessionWorkdir("abc").replace(/\\/g, "/")).toBe("/tmp/cw-test/abc");
90c7531Claude65 });
66
67 it("defaults sensibly when env unset", () => {
68 delete process.env.CLAUDE_WEB_WORKDIR;
69 delete process.env.CLAUDE_BIN;
70 expect(claudeWebRoot()).toBe("/var/lib/gluecron/claude-web");
71 expect(claudeBinary()).toBe("claude");
72 });
73
74 it("strips trailing slash from workdir root", () => {
75 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test/";
76 expect(claudeWebRoot()).toBe("/tmp/cw-test");
77 });
78});
79
80describe("runTurn", () => {
81 it("invokes the claude binary with --print and prompt, without --resume on first turn", async () => {
82 const captured: { cmd: string[]; cwd: string } = { cmd: [], cwd: "" };
83 const handle = makeHandle(['{"type":"text","text":"hi"}\n'], 0, "");
84 __setSpawnForTests((cmd, opts) => {
85 captured.cmd = cmd;
86 captured.cwd = opts.cwd;
87 return handle;
88 });
89
90 const events = await collect(runTurn({
91 session: fakeSession(),
92 ownerName: "you",
93 repoName: "repo",
94 prompt: "hello there",
95 }));
96
97 expect(captured.cmd[0]).toBe("claude-test");
98 expect(captured.cmd).toContain("--print");
99 expect(captured.cmd).toContain("--output-format");
100 expect(captured.cmd).toContain("stream-json");
101 expect(captured.cmd).not.toContain("--resume");
102 expect(captured.cmd[captured.cmd.length - 1]).toBe("hello there");
103 expect(captured.cwd).toBe("/tmp/cw-test/00000000-0000-0000-0000-000000000099");
104
105 const chunks = events.filter((e) => e.chunk).map((e) => e.chunk).join("");
106 expect(chunks).toContain("hi");
107 const done = events.find((e) => e.done);
108 expect(done?.done?.exitCode).toBe(0);
109 });
110
111 it("passes --resume <id> when the session already has a claudeSessionId", async () => {
112 const captured: { cmd: string[] } = { cmd: [] };
113 __setSpawnForTests((cmd) => {
114 captured.cmd = cmd;
115 return makeHandle(["ok\n"], 0, "");
116 });
117
118 await collect(runTurn({
119 session: fakeSession({
120 claudeSessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
121 }),
122 ownerName: "you",
123 repoName: "repo",
124 prompt: "next turn",
125 }));
126
127 const i = captured.cmd.indexOf("--resume");
128 expect(i).toBeGreaterThan(-1);
129 expect(captured.cmd[i + 1]).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
130 });
131
132 it("extracts the Claude session UUID from a stream-json init chunk", async () => {
133 __setSpawnForTests(() =>
134 makeHandle(
135 [
136 '{"type":"system","subtype":"init","session_id":"12345678-aaaa-bbbb-cccc-1234567890ab"}\n',
137 '{"type":"text","text":"hello"}\n',
138 ],
139 0,
140 ""
141 )
142 );
143 const events = await collect(runTurn({
144 session: fakeSession(),
145 ownerName: "you",
146 repoName: "repo",
147 prompt: "p",
148 }));
149 const done = events.find((e) => e.done);
150 expect(done?.done?.claudeSessionId).toBe(
151 "12345678-aaaa-bbbb-cccc-1234567890ab"
152 );
153 });
154
155 it("returns the subprocess exit code on the done event", async () => {
156 __setSpawnForTests(() => makeHandle(["x"], 7, "boom"));
157 const events = await collect(runTurn({
158 session: fakeSession(),
159 ownerName: "y",
160 repoName: "r",
161 prompt: "p",
162 }));
163 const done = events.find((e) => e.done);
164 expect(done?.done?.exitCode).toBe(7);
165 expect(done?.done?.stderr).toBe("boom");
166 });
167});
168
169describe("passthroughEnv", () => {
170 it("keeps PATH and ANTHROPIC_API_KEY when set", () => {
171 process.env.ANTHROPIC_API_KEY = "sk-test";
172 const env = __test.passthroughEnv();
173 expect(env.PATH).toBeDefined();
174 expect(env.ANTHROPIC_API_KEY).toBe("sk-test");
175 });
176
177 it("strips DATABASE_URL even if set", () => {
178 process.env.DATABASE_URL = "postgresql://x";
179 const env = __test.passthroughEnv();
180 expect(env.DATABASE_URL).toBeUndefined();
181 delete process.env.DATABASE_URL;
182 });
183
184 it("keeps any CLAUDE_* prefix var", () => {
185 process.env.CLAUDE_CUSTOM_FLAG = "yes";
186 const env = __test.passthroughEnv();
187 expect(env.CLAUDE_CUSTOM_FLAG).toBe("yes");
188 delete process.env.CLAUDE_CUSTOM_FLAG;
189 });
190});
191
192// ─── helpers ────────────────────────────────────────────────────────────────
193
194function makeHandle(
195 chunks: string[],
196 exitCode: number,
197 stderr: string
198): ReturnType<SpawnFn> {
199 return {
200 stdout: (async function* () {
201 for (const c of chunks) yield c;
202 })(),
203 done: Promise.resolve({ exitCode, stderr }),
204 kill: () => {},
205 };
206}
207
208async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
209 const out: T[] = [];
210 for await (const ev of gen) out.push(ev);
211 return out;
212}