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

pr-sandbox.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.

pr-sandbox.test.tsBlame381 lines · 1 contributor
79ed944Claude1/**
2 * Tests for src/lib/pr-sandbox.ts — per-PR runnable sandboxes (migration 0067).
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — sandbox URL building, status label mapping,
7 * expires-in formatting. No DB, no network. Always run.
8 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green
10 * on machines without Postgres. Covers:
11 * - provisioning lifecycle (status transitions)
12 * - auto-expire after the TTL
13 * - idempotent re-provision on the same PR replaces the row
14 * - destroy flips status
15 */
16
17import { afterEach, describe, expect, it } from "bun:test";
18import {
19 buildSandboxUrl,
20 destroySandbox,
21 expireOldSandboxes,
22 formatSandboxExpiresIn,
23 getSandboxForPr,
24 markSandboxFailed,
25 markSandboxReady,
26 provisionSandbox,
27 sandboxStatusLabel,
28 SANDBOX_TTL_MS,
29} from "../lib/pr-sandbox";
30import { db } from "../db";
31import {
32 prSandboxes,
33 pullRequests,
34 repositories,
35 users,
36} from "../db/schema";
37import { eq } from "drizzle-orm";
38
39const HAS_DB = Boolean(process.env.DATABASE_URL);
40
41// ---------------------------------------------------------------------------
42// 1. Pure helpers
43// ---------------------------------------------------------------------------
44
45describe("pr-sandbox — buildSandboxUrl", () => {
46 it("produces a wildcard subdomain URL with default domain", () => {
47 const prev = process.env.PR_SANDBOX_DOMAIN;
48 delete process.env.PR_SANDBOX_DOMAIN;
49 try {
50 const url = buildSandboxUrl(42, "alice", "site");
51 expect(url).toBe("https://pr-42-alice-site.sandbox.gluecron.com");
52 } finally {
53 if (prev !== undefined) process.env.PR_SANDBOX_DOMAIN = prev;
54 }
55 });
56
57 it("honours a custom PR_SANDBOX_DOMAIN env var", () => {
58 const prev = process.env.PR_SANDBOX_DOMAIN;
59 process.env.PR_SANDBOX_DOMAIN = "sandbox.acme.dev";
60 try {
61 const url = buildSandboxUrl(1, "a", "b");
62 expect(url).toBe("https://pr-1-a-b.sandbox.acme.dev");
63 } finally {
64 if (prev === undefined) delete process.env.PR_SANDBOX_DOMAIN;
65 else process.env.PR_SANDBOX_DOMAIN = prev;
66 }
67 });
68
69 it("strips scheme from PR_SANDBOX_DOMAIN if accidentally included", () => {
70 const prev = process.env.PR_SANDBOX_DOMAIN;
71 process.env.PR_SANDBOX_DOMAIN = "https://sandbox.foo.com";
72 try {
73 const url = buildSandboxUrl(7, "a", "b");
74 expect(url).toBe("https://pr-7-a-b.sandbox.foo.com");
75 } finally {
76 if (prev === undefined) delete process.env.PR_SANDBOX_DOMAIN;
77 else process.env.PR_SANDBOX_DOMAIN = prev;
78 }
79 });
80
81 it("clamps negative / NaN PR numbers to 0", () => {
82 expect(buildSandboxUrl(-3, "a", "b")).toContain("pr-0-");
83 expect(buildSandboxUrl(NaN, "a", "b")).toContain("pr-0-");
84 });
85
86 it("slugifies owner/repo into one DNS label", () => {
87 const url = buildSandboxUrl(2, "Big Org", "My_Repo");
88 expect(url).toContain("pr-2-big-org-my-repo.");
89 });
90});
91
92describe("pr-sandbox — sandboxStatusLabel", () => {
93 it("maps known statuses to human labels", () => {
94 expect(sandboxStatusLabel("provisioning")).toBe("Provisioning");
95 expect(sandboxStatusLabel("ready")).toBe("Ready");
96 expect(sandboxStatusLabel("failed")).toBe("Failed");
97 expect(sandboxStatusLabel("destroyed")).toBe("Destroyed");
98 });
99
100 it("passes through unknown statuses unchanged", () => {
101 expect(sandboxStatusLabel("unknown")).toBe("unknown");
102 });
103});
104
105describe("pr-sandbox — formatSandboxExpiresIn", () => {
106 it("returns 'expired' for past timestamps", () => {
107 const now = new Date("2026-01-01T12:00:00Z");
108 const past = new Date("2026-01-01T11:00:00Z");
109 expect(formatSandboxExpiresIn(past, now)).toBe("expired");
110 });
111
112 it("returns 'less than a minute' for sub-minute futures", () => {
113 const now = new Date("2026-01-01T12:00:00Z");
114 const soon = new Date("2026-01-01T12:00:30Z");
115 expect(formatSandboxExpiresIn(soon, now)).toBe("less than a minute");
116 });
117
118 it("returns just minutes when under an hour away", () => {
119 const now = new Date("2026-01-01T12:00:00Z");
120 const future = new Date("2026-01-01T12:42:00Z");
121 expect(formatSandboxExpiresIn(future, now)).toBe("42m");
122 });
123
124 it("returns hours + minutes when over an hour away", () => {
125 const now = new Date("2026-01-01T12:00:00Z");
126 const future = new Date("2026-01-01T14:30:00Z");
127 expect(formatSandboxExpiresIn(future, now)).toBe("2h 30m");
128 });
129
130 it("returns em-dash for null", () => {
131 expect(formatSandboxExpiresIn(null)).toBe("—");
132 });
133});
134
135describe("pr-sandbox — TTL constant matches 4h", () => {
136 it("SANDBOX_TTL_MS is exactly 4 hours", () => {
137 expect(SANDBOX_TTL_MS).toBe(4 * 60 * 60 * 1000);
138 });
139});
140
141// ---------------------------------------------------------------------------
142// 2. Graceful no-ops without DB — must not throw on empty inputs
143// ---------------------------------------------------------------------------
144
145describe("pr-sandbox — graceful no-ops", () => {
146 it("provisionSandbox returns null for empty PR id", async () => {
147 expect(await provisionSandbox({ prId: "" })).toBeNull();
148 });
149
150 it("getSandboxForPr returns null for empty PR id", async () => {
151 expect(await getSandboxForPr("")).toBeNull();
152 });
153
154 it("markSandboxReady / markSandboxFailed / destroySandbox swallow empty ids", async () => {
155 await markSandboxReady("");
156 await markSandboxFailed("", "err");
157 await destroySandbox("");
158 expect(true).toBe(true);
159 });
160});
161
162// ---------------------------------------------------------------------------
163// 3. DB-backed pipeline
164// ---------------------------------------------------------------------------
165
166const TEST_USER_PREFIX = "sandboxtest_";
167
168async function seedPr(): Promise<{
169 userId: string;
170 repoId: string;
171 prId: string;
172 prNumber: number;
173}> {
174 const username =
175 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
176 const [user] = await db
177 .insert(users)
178 .values({
179 username,
180 email: `${username}@sandboxtest.local`,
181 passwordHash: "$2b$10$" + "x".repeat(53),
182 })
183 .returning();
184 const [repo] = await db
185 .insert(repositories)
186 .values({
187 name: "sandbox-test-" + Math.random().toString(36).slice(2, 8),
188 ownerId: user!.id,
189 diskPath: "/tmp/sandbox-test-" + Math.random().toString(36).slice(2, 8),
190 })
191 .returning();
192 const [pr] = await db
193 .insert(pullRequests)
194 .values({
195 repositoryId: repo!.id,
196 authorId: user!.id,
197 title: "Test PR",
198 body: "Test body",
199 baseBranch: "main",
200 headBranch: "feature/x",
201 })
202 .returning();
203 return {
204 userId: user!.id,
205 repoId: repo!.id,
206 prId: pr!.id,
207 prNumber: pr!.number,
208 };
209}
210
211async function cleanupUser(userId: string): Promise<void> {
212 try {
213 // Repositories CASCADE PRs which CASCADE pr_sandboxes — single delete OK.
214 await db.delete(repositories).where(eq(repositories.ownerId, userId));
215 await db.delete(users).where(eq(users.id, userId));
216 } catch {
217 /* best effort */
218 }
219}
220
221describe.skipIf(!HAS_DB)("pr-sandbox — DB pipeline", () => {
222 let userId = "";
223
224 afterEach(async () => {
225 if (userId) await cleanupUser(userId);
226 userId = "";
227 });
228
229 it("provisioning lifecycle: provisioning → ready", async () => {
230 const seeded = await seedPr();
231 userId = seeded.userId;
232
233 const row = await provisionSandbox({
234 prId: seeded.prId,
235 // Skip the AI generator path in tests.
236 playgroundYml: "runtime: docker\nimage: node:20-alpine\n",
237 });
238 expect(row).not.toBeNull();
239 expect(row!.status).toBe("provisioning");
240 expect(row!.sandboxUrl).toContain(`pr-${seeded.prNumber}-`);
241 expect(row!.sandboxUrl.startsWith("https://")).toBe(true);
242 expect(row!.expiresAt instanceof Date).toBe(true);
243 expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
244 expect(row!.playgroundYml).toContain("docker");
245
246 await markSandboxReady(row!.id, "ctr-abc123");
247 const ready = await getSandboxForPr(seeded.prId);
248 expect(ready!.status).toBe("ready");
249 expect(ready!.containerId).toBe("ctr-abc123");
250 });
251
252 it("provisioning lifecycle: provisioning → failed records error", async () => {
253 const seeded = await seedPr();
254 userId = seeded.userId;
255
256 const row = await provisionSandbox({
257 prId: seeded.prId,
258 playgroundYml: "runtime: docker\n",
259 });
260 expect(row).not.toBeNull();
261
262 const longError = "boom\n".repeat(10_000);
263 await markSandboxFailed(row!.id, longError);
264 const after = await getSandboxForPr(seeded.prId);
265 expect(after!.status).toBe("failed");
266 expect(after!.errorMessage).not.toBeNull();
267 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
268 });
269
270 it("idempotent: re-provisioning on same PR replaces the row", async () => {
271 const seeded = await seedPr();
272 userId = seeded.userId;
273
274 const first = await provisionSandbox({
275 prId: seeded.prId,
276 playgroundYml: "runtime: docker # v1\n",
277 });
278 expect(first).not.toBeNull();
279
280 // Flip to ready so we can verify the upsert resets it.
281 await markSandboxReady(first!.id);
282 let row = await getSandboxForPr(seeded.prId);
283 expect(row!.status).toBe("ready");
284
285 // Re-provision (force-push scenario).
286 const second = await provisionSandbox({
287 prId: seeded.prId,
288 playgroundYml: "runtime: docker # v2\n",
289 });
290 expect(second).not.toBeNull();
291 expect(second!.id).toBe(first!.id); // same row
292 expect(second!.status).toBe("provisioning"); // reset
293 expect(second!.playgroundYml).toContain("v2");
294 expect(second!.errorMessage).toBeNull();
295 expect(second!.destroyedAt).toBeNull();
296
297 // And only ONE row exists per PR (unique constraint).
298 const all = await db
299 .select()
300 .from(prSandboxes)
301 .where(eq(prSandboxes.prId, seeded.prId));
302 expect(all.length).toBe(1);
303 });
304
305 it("destroySandbox flips status + records destroyed_at", async () => {
306 const seeded = await seedPr();
307 userId = seeded.userId;
308
309 const row = await provisionSandbox({
310 prId: seeded.prId,
311 playgroundYml: "runtime: docker\n",
312 });
313 await markSandboxReady(row!.id);
314
315 await destroySandbox(row!.id);
316 const after = await getSandboxForPr(seeded.prId);
317 expect(after!.status).toBe("destroyed");
318 expect(after!.destroyedAt).not.toBeNull();
319 });
320
321 it("expireOldSandboxes transitions ready rows past expires_at to 'destroyed'", async () => {
322 const seeded = await seedPr();
323 userId = seeded.userId;
324
325 const row = await provisionSandbox({
326 prId: seeded.prId,
327 playgroundYml: "runtime: docker\n",
328 });
329 await markSandboxReady(row!.id);
330
331 // Force expires_at into the past.
332 await db
333 .update(prSandboxes)
334 .set({ expiresAt: new Date(Date.now() - 60_000) })
335 .where(eq(prSandboxes.id, row!.id));
336
337 const flipped = await expireOldSandboxes();
338 expect(flipped).toBeGreaterThanOrEqual(1);
339
340 const after = await getSandboxForPr(seeded.prId);
341 expect(after!.status).toBe("destroyed");
342 expect(after!.destroyedAt).not.toBeNull();
343 });
344
345 it("expireOldSandboxes leaves fresh rows alone", async () => {
346 const seeded = await seedPr();
347 userId = seeded.userId;
348
349 const row = await provisionSandbox({
350 prId: seeded.prId,
351 playgroundYml: "runtime: docker\n",
352 });
353 await markSandboxReady(row!.id);
354
355 // Default expiresAt is now+4h → expire pass should ignore it.
356 await expireOldSandboxes();
357 const after = await getSandboxForPr(seeded.prId);
358 expect(after!.status).toBe("ready");
359 });
360
361 it("expireOldSandboxes also sweeps stuck 'provisioning' rows", async () => {
362 const seeded = await seedPr();
363 userId = seeded.userId;
364
365 const row = await provisionSandbox({
366 prId: seeded.prId,
367 playgroundYml: "runtime: docker\n",
368 });
369 // Leave status='provisioning' but force expires_at into the past.
370 await db
371 .update(prSandboxes)
372 .set({ expiresAt: new Date(Date.now() - 60_000) })
373 .where(eq(prSandboxes.id, row!.id));
374
375 const flipped = await expireOldSandboxes();
376 expect(flipped).toBeGreaterThanOrEqual(1);
377
378 const after = await getSandboxForPr(seeded.prId);
379 expect(after!.status).toBe("destroyed");
380 });
381});