CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
hosted-claude-loop.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.
| ebbb527 | 1 | /** |
| 2 | * Hosted Claude loops — migration 0069 + src/lib/hosted-claude-loop.ts. | |
| 3 | * | |
| 4 | * Two layers: | |
| 5 | * | |
| 6 | * 1. Pure helpers (slugify, endpoint path build, usage extraction) — | |
| 7 | * no DB or AI key required, always run. | |
| 8 | * | |
| 9 | * 2. DB-backed flows (createLoop, invokeLoop via the executor seam, | |
| 10 | * budget cap enforcement) — gated on HAS_DB. The executor seam | |
| 11 | * avoids needing ANTHROPIC_API_KEY in CI, so HAS_AI just adds a | |
| 12 | * smoke check that the default template parses as JS. | |
| 13 | */ | |
| 14 | ||
| 15 | import { beforeAll, afterAll, describe, expect, it } from "bun:test"; | |
| 16 | import { eq } from "drizzle-orm"; | |
| 17 | import { randomBytes, createHash } from "crypto"; | |
| 18 | ||
| 19 | import { | |
| 20 | DEFAULT_LOOP_TEMPLATE, | |
| 21 | __setExecutorForTests, | |
| 22 | buildEndpointPath, | |
| 23 | capStream, | |
| 24 | createLoop, | |
| 25 | deleteLoop, | |
| 26 | extractUsageFromStdout, | |
| 27 | getLoop, | |
| 28 | invokeLoop, | |
| 29 | listLoopsForOwner, | |
| 30 | listRunsForLoop, | |
| 31 | pauseLoop, | |
| 32 | resumeLoop, | |
| 33 | slugifyLoopName, | |
| 34 | } from "../lib/hosted-claude-loop"; | |
| 35 | ||
| 36 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 37 | const HAS_AI = Boolean(process.env.ANTHROPIC_API_KEY); | |
| 38 | ||
| 39 | // Seed a unique test user per run when DB is available. | |
| 40 | async function seedTestUser(): Promise<string | null> { | |
| 41 | if (!HAS_DB) return null; | |
| 42 | const { db } = await import("../db"); | |
| 43 | const { users } = await import("../db/schema"); | |
| 44 | const username = `cldploy-${randomBytes(4).toString("hex")}`; | |
| 45 | try { | |
| 46 | const [row] = await db | |
| 47 | .insert(users) | |
| 48 | .values({ | |
| 49 | username, | |
| 50 | email: `${username}@test.local`, | |
| 51 | passwordHash: "x", | |
| 52 | }) | |
| 53 | .returning(); | |
| 54 | return row?.id ?? null; | |
| 55 | } catch { | |
| 56 | return null; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | async function cleanupTestUser(userId: string | null) { | |
| 61 | if (!userId || !HAS_DB) return; | |
| 62 | try { | |
| 63 | const { db } = await import("../db"); | |
| 64 | const { users } = await import("../db/schema"); | |
| 65 | await db.delete(users).where(eq(users.id, userId)); | |
| 66 | } catch { | |
| 67 | /* best-effort */ | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | let testUserId: string | null = null; | |
| 72 | ||
| 73 | beforeAll(async () => { | |
| 74 | testUserId = await seedTestUser(); | |
| 75 | }); | |
| 76 | ||
| 77 | afterAll(async () => { | |
| 78 | __setExecutorForTests(null); | |
| 79 | await cleanupTestUser(testUserId); | |
| 80 | }); | |
| 81 | ||
| 82 | // --------------------------------------------------------------------------- | |
| 83 | // Pure helpers | |
| 84 | // --------------------------------------------------------------------------- | |
| 85 | ||
| 86 | describe("hosted-claude-loop — pure helpers", () => { | |
| 87 | it("slugifyLoopName lowercases + dasherises + trims to 40 chars", () => { | |
| 88 | expect(slugifyLoopName("Hello World!")).toBe("hello-world"); | |
| 89 | expect(slugifyLoopName(" Foo BAR ")).toBe("foo-bar"); | |
| 90 | expect(slugifyLoopName("a".repeat(60)).length).toBe(40); | |
| 91 | expect(slugifyLoopName("")).toBe(""); | |
| 92 | expect(slugifyLoopName("!!!---")).toBe(""); | |
| 93 | }); | |
| 94 | ||
| 95 | it("buildEndpointPath always begins with /claude-loops/ and has a suffix", () => { | |
| 96 | const a = buildEndpointPath("my-loop"); | |
| 97 | const b = buildEndpointPath("my-loop"); | |
| 98 | expect(a.startsWith("/claude-loops/")).toBe(true); | |
| 99 | expect(b.startsWith("/claude-loops/")).toBe(true); | |
| 100 | // Suffix randomness should virtually never collide. | |
| 101 | expect(a).not.toBe(b); | |
| 102 | // Empty name falls back to `loop` prefix. | |
| 103 | expect(buildEndpointPath("").startsWith("/claude-loops/loop-")).toBe(true); | |
| 104 | }); | |
| 105 | ||
| 106 | it("capStream truncates at the cap and tags it", () => { | |
| 107 | const big = "x".repeat(50_000); | |
| 108 | const out = capStream(big, 100); | |
| 109 | expect(out.length).toBeLessThanOrEqual(200); | |
| 110 | expect(out.endsWith("[truncated]")).toBe(true); | |
| 111 | expect(capStream("short", 1000)).toBe("short"); | |
| 112 | expect(capStream("", 100)).toBe(""); | |
| 113 | }); | |
| 114 | ||
| 115 | it("extractUsageFromStdout pulls input/output tokens from JSON stdout", () => { | |
| 116 | const stdout = JSON.stringify({ | |
| 117 | ok: true, | |
| 118 | model: "claude-haiku-4-5", | |
| 119 | usage: { input_tokens: 120, output_tokens: 80 }, | |
| 120 | }); | |
| 121 | const got = extractUsageFromStdout(stdout); | |
| 122 | expect(got.inputTokens).toBe(120); | |
| 123 | expect(got.outputTokens).toBe(80); | |
| 124 | expect(got.model).toBe("claude-haiku-4-5"); | |
| 125 | }); | |
| 126 | ||
| 127 | it("extractUsageFromStdout returns zeros for non-JSON or no usage block", () => { | |
| 128 | expect(extractUsageFromStdout("plain text output")).toEqual({ | |
| 129 | inputTokens: 0, | |
| 130 | outputTokens: 0, | |
| 131 | model: null, | |
| 132 | }); | |
| 133 | expect(extractUsageFromStdout("")).toEqual({ | |
| 134 | inputTokens: 0, | |
| 135 | outputTokens: 0, | |
| 136 | model: null, | |
| 137 | }); | |
| 138 | expect( | |
| 139 | extractUsageFromStdout(JSON.stringify({ no_usage_here: true })) | |
| 140 | ).toEqual({ inputTokens: 0, outputTokens: 0, model: null }); | |
| 141 | }); | |
| 142 | ||
| 143 | it("extractUsageFromStdout scans line-by-line for embedded JSON lines", () => { | |
| 144 | const stdout = | |
| 145 | "preamble log\n" + | |
| 146 | JSON.stringify({ usage: { input_tokens: 5, output_tokens: 3 } }) + | |
| 147 | "\nepilogue text"; | |
| 148 | const got = extractUsageFromStdout(stdout); | |
| 149 | expect(got.inputTokens).toBe(5); | |
| 150 | expect(got.outputTokens).toBe(3); | |
| 151 | }); | |
| 152 | ||
| 153 | it("DEFAULT_LOOP_TEMPLATE references the SDK + prints JSON usage", () => { | |
| 154 | expect(DEFAULT_LOOP_TEMPLATE).toContain("@anthropic-ai/sdk"); | |
| 155 | expect(DEFAULT_LOOP_TEMPLATE).toContain("usage"); | |
| 156 | expect(DEFAULT_LOOP_TEMPLATE).toContain("process.env.INPUT"); | |
| 157 | }); | |
| 158 | }); | |
| 159 | ||
| 160 | // --------------------------------------------------------------------------- | |
| 161 | // DB-backed flows | |
| 162 | // --------------------------------------------------------------------------- | |
| 163 | ||
| 164 | describe.skipIf(!HAS_DB)("hosted-claude-loop — DB flows", () => { | |
| 165 | it("createLoop persists a row + mints an agent token", async () => { | |
| 166 | if (!testUserId) return; | |
| 167 | const r = await createLoop({ | |
| 168 | ownerUserId: testUserId, | |
| 169 | name: "test-loop-create", | |
| 170 | sourceCode: "console.log(JSON.stringify({ok:true}));", | |
| 171 | monthlyBudgetCents: 500, | |
| 172 | }); | |
| 173 | expect(r).not.toBeNull(); | |
| 174 | expect(r!.loop.name).toBe("test-loop-create"); | |
| 175 | expect(r!.loop.endpointPath.startsWith("/claude-loops/")).toBe(true); | |
| 176 | expect(r!.loop.status).toBe("paused"); | |
| 177 | expect(r!.loop.monthlyBudgetCents).toBe(500); | |
| 178 | // Agent token is plaintext, shown once. | |
| 179 | if (r!.agentToken) { | |
| 180 | expect(/^agt_[0-9a-f]{64}$/.test(r!.agentToken)).toBe(true); | |
| 181 | } | |
| 182 | const back = await getLoop(r!.loop.id); | |
| 183 | expect(back?.id).toBe(r!.loop.id); | |
| 184 | await deleteLoop(r!.loop.id, testUserId); | |
| 185 | }); | |
| 186 | ||
| 187 | it("listLoopsForOwner only returns loops owned by the user", async () => { | |
| 188 | if (!testUserId) return; | |
| 189 | const otherUserId = await seedTestUser(); | |
| 190 | try { | |
| 191 | const mine = await createLoop({ | |
| 192 | ownerUserId: testUserId, | |
| 193 | name: "mine-list-test", | |
| 194 | sourceCode: "console.log('hi');", | |
| 195 | }); | |
| 196 | const theirs = otherUserId | |
| 197 | ? await createLoop({ | |
| 198 | ownerUserId: otherUserId, | |
| 199 | name: "theirs-list-test", | |
| 200 | sourceCode: "console.log('hi');", | |
| 201 | }) | |
| 202 | : null; | |
| 203 | const mineList = await listLoopsForOwner(testUserId); | |
| 204 | expect(mineList.some((l) => l.id === mine?.loop.id)).toBe(true); | |
| 205 | expect(mineList.some((l) => l.id === theirs?.loop.id)).toBe(false); | |
| 206 | if (mine) await deleteLoop(mine.loop.id, testUserId); | |
| 207 | if (theirs && otherUserId) await deleteLoop(theirs.loop.id, otherUserId); | |
| 208 | } finally { | |
| 209 | await cleanupTestUser(otherUserId); | |
| 210 | } | |
| 211 | }); | |
| 212 | ||
| 213 | it("invokeLoop runs the snippet via the executor seam + records a run", async () => { | |
| 214 | if (!testUserId) return; | |
| 215 | const created = await createLoop({ | |
| 216 | ownerUserId: testUserId, | |
| 217 | name: "test-loop-invoke", | |
| 218 | sourceCode: "console.log('seeded');", | |
| 219 | monthlyBudgetCents: 5000, | |
| 220 | }); | |
| 221 | expect(created).not.toBeNull(); | |
| 222 | ||
| 223 | // Resume so the owner-side invoke runs without an extra hop. | |
| 224 | await resumeLoop(created!.loop.id, testUserId); | |
| 225 | ||
| 226 | __setExecutorForTests(async (args) => ({ | |
| 227 | stdout: JSON.stringify({ | |
| 228 | echo: args.inputPayload, | |
| 229 | usage: { input_tokens: 100, output_tokens: 50 }, | |
| 230 | model: "claude-haiku-4-5", | |
| 231 | }), | |
| 232 | stderr: "", | |
| 233 | exitCode: 0, | |
| 234 | durationMs: 5, | |
| 235 | timedOut: false, | |
| 236 | })); | |
| 237 | const result = await invokeLoop({ | |
| 238 | loopId: created!.loop.id, | |
| 239 | inputPayload: { repo: "demo" }, | |
| 240 | }); | |
| 241 | expect(result.status).toBe("ok"); | |
| 242 | expect(result.centsCharged).toBeGreaterThan(0); | |
| 243 | expect(result.run).not.toBeNull(); | |
| 244 | expect(result.run?.status).toBe("ok"); | |
| 245 | expect(result.run?.claudeInputTokens).toBe(100); | |
| 246 | expect(result.run?.claudeOutputTokens).toBe(50); | |
| 247 | // Output parsed back from the stdout JSON. | |
| 248 | expect((result.output as { echo: { repo: string } }).echo.repo).toBe("demo"); | |
| 249 | ||
| 250 | const runs = await listRunsForLoop(created!.loop.id, 10); | |
| 251 | expect(runs.length).toBeGreaterThanOrEqual(1); | |
| 252 | ||
| 253 | // Totals on the loop row should have bumped. | |
| 254 | const after = await getLoop(created!.loop.id); | |
| 255 | expect(after?.totalInvocations).toBeGreaterThan(0); | |
| 256 | expect(after?.totalCentsSpent).toBeGreaterThan(0); | |
| 257 | expect(after?.lastRunAt).not.toBeNull(); | |
| 258 | ||
| 259 | __setExecutorForTests(null); | |
| 260 | await deleteLoop(created!.loop.id, testUserId); | |
| 261 | }); | |
| 262 | ||
| 263 | it("invokeLoop returns budget_exceeded once the cap is hit", async () => { | |
| 264 | if (!testUserId) return; | |
| 265 | const created = await createLoop({ | |
| 266 | ownerUserId: testUserId, | |
| 267 | name: "test-loop-budget", | |
| 268 | sourceCode: "console.log('seeded');", | |
| 269 | // 1¢ budget — first invocation should land at the cap. | |
| 270 | monthlyBudgetCents: 1, | |
| 271 | }); | |
| 272 | expect(created).not.toBeNull(); | |
| 273 | await resumeLoop(created!.loop.id, testUserId); | |
| 274 | ||
| 275 | // Stub an executor that reports 10k input + 10k output tokens so | |
| 276 | // computeCentsForCall lands well over the 1¢ cap. | |
| 277 | __setExecutorForTests(async () => ({ | |
| 278 | stdout: JSON.stringify({ | |
| 279 | usage: { input_tokens: 10_000, output_tokens: 10_000 }, | |
| 280 | model: "claude-haiku-4-5", | |
| 281 | }), | |
| 282 | stderr: "", | |
| 283 | exitCode: 0, | |
| 284 | durationMs: 5, | |
| 285 | timedOut: false, | |
| 286 | })); | |
| 287 | ||
| 288 | // First call lands OK (no spend yet → cap not exceeded at the | |
| 289 | // pre-flight check) and bumps the spend counter over cap. | |
| 290 | const first = await invokeLoop({ | |
| 291 | loopId: created!.loop.id, | |
| 292 | inputPayload: {}, | |
| 293 | }); | |
| 294 | expect(["ok", "budget_exceeded"].includes(first.status)).toBe(true); | |
| 295 | ||
| 296 | // Second call MUST short-circuit on budget. | |
| 297 | const second = await invokeLoop({ | |
| 298 | loopId: created!.loop.id, | |
| 299 | inputPayload: {}, | |
| 300 | }); | |
| 301 | expect(second.status).toBe("budget_exceeded"); | |
| 302 | expect(second.centsCharged).toBe(0); | |
| 303 | ||
| 304 | __setExecutorForTests(null); | |
| 305 | await deleteLoop(created!.loop.id, testUserId); | |
| 306 | }); | |
| 307 | ||
| 308 | it("pauseLoop + invokeLoop with isPublicInvocation=true returns disabled", async () => { | |
| 309 | if (!testUserId) return; | |
| 310 | const created = await createLoop({ | |
| 311 | ownerUserId: testUserId, | |
| 312 | name: "test-loop-paused", | |
| 313 | sourceCode: "console.log('seeded');", | |
| 314 | }); | |
| 315 | expect(created).not.toBeNull(); | |
| 316 | await pauseLoop(created!.loop.id, testUserId); | |
| 317 | const r = await invokeLoop({ | |
| 318 | loopId: created!.loop.id, | |
| 319 | inputPayload: {}, | |
| 320 | isPublicInvocation: true, | |
| 321 | }); | |
| 322 | expect(r.status).toBe("disabled"); | |
| 323 | await deleteLoop(created!.loop.id, testUserId); | |
| 324 | }); | |
| 325 | }); | |
| 326 | ||
| 327 | // --------------------------------------------------------------------------- | |
| 328 | // HAS_AI smoke: just verify the template is syntactically importable as | |
| 329 | // JS (parses) when an AI key is present — we don't actually hit Claude. | |
| 330 | // --------------------------------------------------------------------------- | |
| 331 | describe.skipIf(!HAS_AI)("hosted-claude-loop — HAS_AI surface", () => { | |
| 332 | it("DEFAULT_LOOP_TEMPLATE is non-empty + hashes deterministically", () => { | |
| 333 | expect(DEFAULT_LOOP_TEMPLATE.length).toBeGreaterThan(50); | |
| 334 | const h = createHash("sha256").update(DEFAULT_LOOP_TEMPLATE).digest("hex"); | |
| 335 | expect(h.length).toBe(64); | |
| 336 | }); | |
| 337 | }); |