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.
| 79ed944 | 1 | /** |
| 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 | ||
| 17 | import { afterEach, describe, expect, it } from "bun:test"; | |
| 18 | import { | |
| 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"; | |
| 30 | import { db } from "../db"; | |
| 31 | import { | |
| 32 | prSandboxes, | |
| 33 | pullRequests, | |
| 34 | repositories, | |
| 35 | users, | |
| 36 | } from "../db/schema"; | |
| 37 | import { eq } from "drizzle-orm"; | |
| 38 | ||
| 39 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 40 | ||
| 41 | // --------------------------------------------------------------------------- | |
| 42 | // 1. Pure helpers | |
| 43 | // --------------------------------------------------------------------------- | |
| 44 | ||
| 45 | describe("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 | ||
| 92 | describe("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 | ||
| 105 | describe("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 | ||
| 135 | describe("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 | ||
| 145 | describe("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 | ||
| 166 | const TEST_USER_PREFIX = "sandboxtest_"; | |
| 167 | ||
| 168 | async 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 | ||
| 211 | async 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 | ||
| 221 | describe.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 | }); |