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

dev-env.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.

dev-env.test.tsBlame533 lines · 1 contributor
9b3a183Claude1/**
3646bfeClaude2 * Tests for src/lib/dev-env.ts and src/routes/dev-env.tsx — cloud dev
3 * environments (migration 0072).
9b3a183Claude4 *
3646bfeClaude5 * Three layers:
9b3a183Claude6 *
3646bfeClaude7 * 1. Route smoke tests — module loads without error; unauthenticated
8 * visitors on a public repo are redirected to login.
9 *
10 * 2. Pure helpers — URL building, status label mapping, machine-size
9b3a183Claude11 * validation. No DB, no network. Always run.
12 *
3646bfeClaude13 * 3. DB-backed pipeline — gated on HAS_DB so the suite stays green on
9b3a183Claude14 * machines without Postgres. Covers:
15 * - startDevEnv reads committed dev.yml when present
16 * - startDevEnv generates a default when no file is committed
17 * - per-repo opt-in gate (refuses when devEnvsEnabled=false)
18 * - expireIdleEnvs only touches idle ones
19 * - stop / restart upsert keeps the same row
20 */
21
22import { afterEach, describe, expect, it } from "bun:test";
23import { eq } from "drizzle-orm";
3646bfeClaude24import app from "../app";
9b3a183Claude25import {
26 buildDevEnvUrl,
27 DEFAULT_IDLE_MINUTES,
28 devEnvStatusLabel,
29 expireIdleEnvs,
30 generateDevYml,
31 getDevEnv,
32 getDevEnvForOwner,
33 markFailed,
34 markReady,
35 normalizeMachineSize,
36 recordActivity,
37 startDevEnv,
38 stopDevEnv,
39} from "../lib/dev-env";
40import { db } from "../db";
41import { devEnvs, repositories, users } from "../db/schema";
42
43const HAS_DB = Boolean(process.env.DATABASE_URL);
44
45// ---------------------------------------------------------------------------
3646bfeClaude46// 1. Route smoke tests
47// ---------------------------------------------------------------------------
48
49describe("dev-env — route module", () => {
50 it("loads without error and exports a Hono app", async () => {
51 // Dynamic import to verify the module resolves cleanly at runtime.
52 const mod = await import("../routes/dev-env");
53 expect(mod.default).toBeDefined();
54 // Hono instances expose a `fetch` method.
55 expect(typeof mod.default.fetch).toBe("function");
56 });
57});
58
59describe("dev-env — unauthenticated redirect", () => {
60 it.skipIf(!HAS_DB)(
61 "GET /:owner/:repo/dev redirects unauthenticated visitor on a public repo to /login",
62 async () => {
63 // Seed a minimal public repo so resolveRepoForUser resolves.
64 const { db: database } = await import("../db");
65 const { repositories: repos, users: usersTable } = await import(
66 "../db/schema"
67 );
68 const username =
69 "devenvsmoke_" + Math.random().toString(36).slice(2, 10);
70 const [user] = await database
71 .insert(usersTable)
72 .values({
73 username,
74 email: `${username}@test.local`,
75 passwordHash: "$2b$10$" + "x".repeat(53),
76 })
77 .returning();
78 const repoName = "pub-" + Math.random().toString(36).slice(2, 8);
79 await database
80 .insert(repos)
81 .values({
82 name: repoName,
83 ownerId: user!.id,
84 diskPath: `/tmp/devenvsmoke-${repoName}`,
85 isPrivate: false,
86 devEnvsEnabled: true,
87 })
88 .returning();
89
90 try {
91 // No session cookie → softAuth sets user=null → redirect to /login.
92 const res = await app.request(
93 `/${username}/${repoName}/dev`,
94 { redirect: "manual" }
95 );
96 expect(res.status).toBe(302);
97 const location = res.headers.get("location") ?? "";
98 expect(location).toContain("/login");
99 } finally {
100 // Clean up seeded rows.
101 try {
102 const { eq: eqFn } = await import("drizzle-orm");
103 await database
104 .delete(repos)
105 .where(eqFn(repos.ownerId, user!.id));
106 await database
107 .delete(usersTable)
108 .where(eqFn(usersTable.id, user!.id));
109 } catch {
110 /* best effort */
111 }
112 }
113 }
114 );
115
116 it("GET /:owner/:repo/dev returns 404 for non-existent repo (no DB or absent repo)", async () => {
117 // Without a real repo row the route cannot resolve the repo and returns 404.
118 // This test always runs (no DB required) because resolveRepoForUser
119 // returns null when the DB is unreachable or the repo doesn't exist.
120 const res = await app.request(
121 "/no-such-owner-xyzzy/no-such-repo-xyzzy/dev",
122 { redirect: "manual" }
123 );
124 // 404 (repo not found) or 302 (redirect if somehow auth redirects first).
125 expect([404, 302].includes(res.status)).toBe(true);
126 });
127});
128
129// ---------------------------------------------------------------------------
130// 2. Pure helpers
9b3a183Claude131// ---------------------------------------------------------------------------
132
133describe("dev-env — buildDevEnvUrl", () => {
134 it("produces a wildcard subdomain with default domain", () => {
135 const prev = process.env.DEV_ENV_DOMAIN;
136 delete process.env.DEV_ENV_DOMAIN;
137 try {
138 const url = buildDevEnvUrl("abcdef-1234");
139 expect(url.startsWith("https://dev-")).toBe(true);
140 expect(url.endsWith(".gluecron.com")).toBe(true);
141 } finally {
142 if (prev !== undefined) process.env.DEV_ENV_DOMAIN = prev;
143 }
144 });
145
146 it("honours DEV_ENV_DOMAIN env var", () => {
147 const prev = process.env.DEV_ENV_DOMAIN;
148 process.env.DEV_ENV_DOMAIN = "dev.acme.dev";
149 try {
150 const url = buildDevEnvUrl("abc");
151 expect(url).toBe("https://dev-abc.dev.acme.dev");
152 } finally {
153 if (prev === undefined) delete process.env.DEV_ENV_DOMAIN;
154 else process.env.DEV_ENV_DOMAIN = prev;
155 }
156 });
157
158 it("strips scheme from DEV_ENV_DOMAIN if accidentally included", () => {
159 const prev = process.env.DEV_ENV_DOMAIN;
160 process.env.DEV_ENV_DOMAIN = "https://dev.foo.com";
161 try {
162 const url = buildDevEnvUrl("xyz");
163 expect(url).toBe("https://dev-xyz.dev.foo.com");
164 } finally {
165 if (prev === undefined) delete process.env.DEV_ENV_DOMAIN;
166 else process.env.DEV_ENV_DOMAIN = prev;
167 }
168 });
169
170 it("slugifies the env id so a UUID lands cleanly", () => {
171 // UUID with dashes is still a valid DNS label after slugify.
172 const url = buildDevEnvUrl("11111111-2222-3333-4444-555555555555");
173 expect(url).toContain("dev-11111111-2222-3333-4444-555555555555.");
174 });
175
176 it("handles empty / missing ids gracefully", () => {
177 const url = buildDevEnvUrl("");
178 expect(url.startsWith("https://dev-")).toBe(true);
179 });
180});
181
182describe("dev-env — devEnvStatusLabel", () => {
183 it("maps known statuses to human labels", () => {
184 expect(devEnvStatusLabel("cold")).toBe("Cold");
185 expect(devEnvStatusLabel("warming")).toBe("Warming up");
186 expect(devEnvStatusLabel("ready")).toBe("Ready");
187 expect(devEnvStatusLabel("failed")).toBe("Failed");
188 expect(devEnvStatusLabel("stopped")).toBe("Stopped");
189 });
190
191 it("passes through unknown statuses unchanged", () => {
192 expect(devEnvStatusLabel("mystery")).toBe("mystery");
193 });
194});
195
196describe("dev-env — normalizeMachineSize", () => {
197 it("accepts the three valid sizes", () => {
198 expect(normalizeMachineSize("small")).toBe("small");
199 expect(normalizeMachineSize("medium")).toBe("medium");
200 expect(normalizeMachineSize("large")).toBe("large");
201 });
202
203 it("defaults to 'small' for unknown / empty", () => {
204 expect(normalizeMachineSize("")).toBe("small");
205 expect(normalizeMachineSize(undefined)).toBe("small");
206 expect(normalizeMachineSize(null)).toBe("small");
207 expect(normalizeMachineSize("xlarge")).toBe("small");
208 });
209});
210
211describe("dev-env — DEFAULT_IDLE_MINUTES constant", () => {
212 it("is 30", () => {
213 expect(DEFAULT_IDLE_MINUTES).toBe(30);
214 });
215});
216
217// ---------------------------------------------------------------------------
218// 2. Graceful no-ops — must not throw on empty inputs
219// ---------------------------------------------------------------------------
220
221describe("dev-env — graceful no-ops", () => {
222 it("getDevEnv returns null for empty id", async () => {
223 expect(await getDevEnv("")).toBeNull();
224 });
225
226 it("getDevEnvForOwner returns null for empty args", async () => {
227 expect(await getDevEnvForOwner("", "")).toBeNull();
228 });
229
230 it("markReady / markFailed / stopDevEnv / recordActivity swallow empty ids", async () => {
231 await markReady("");
232 await markFailed("", "err");
233 await stopDevEnv("");
234 await recordActivity("");
235 expect(true).toBe(true);
236 });
237
238 it("startDevEnv refuses invalid input", async () => {
239 const r1 = await startDevEnv({
240 repositoryId: "",
241 ownerUserId: "user",
242 });
243 expect(r1.ok).toBe(false);
244 if (!r1.ok) expect(r1.reason).toBe("invalid_input");
245 const r2 = await startDevEnv({
246 repositoryId: "repo",
247 ownerUserId: "",
248 });
249 expect(r2.ok).toBe(false);
250 if (!r2.ok) expect(r2.reason).toBe("invalid_input");
251 });
252
253 it("generateDevYml returns the default when AI is unavailable", async () => {
254 // Without ANTHROPIC_API_KEY the helper falls back to the bundled
255 // default YAML — never throws.
256 const prev = process.env.ANTHROPIC_API_KEY;
257 delete process.env.ANTHROPIC_API_KEY;
258 try {
259 const yml = await generateDevYml("alice/foo");
260 expect(typeof yml).toBe("string");
261 expect(yml.includes("image:")).toBe(true);
262 } finally {
263 if (prev !== undefined) process.env.ANTHROPIC_API_KEY = prev;
264 }
265 });
266});
267
268// ---------------------------------------------------------------------------
269// 3. DB-backed pipeline
270// ---------------------------------------------------------------------------
271
272const TEST_USER_PREFIX = "devenvtest_";
273
274async function seedRepo(opts?: {
275 devEnvsEnabled?: boolean;
276}): Promise<{
277 userId: string;
278 repoId: string;
279 ownerName: string;
280 repoName: string;
281}> {
282 const username =
283 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
284 const [user] = await db
285 .insert(users)
286 .values({
287 username,
288 email: `${username}@devenvtest.local`,
289 passwordHash: "$2b$10$" + "x".repeat(53),
290 })
291 .returning();
292 const repoName = "dev-test-" + Math.random().toString(36).slice(2, 8);
293 const [repo] = await db
294 .insert(repositories)
295 .values({
296 name: repoName,
297 ownerId: user!.id,
298 diskPath: "/tmp/dev-test-" + Math.random().toString(36).slice(2, 8),
299 devEnvsEnabled: opts?.devEnvsEnabled ?? true,
300 })
301 .returning();
302 return {
303 userId: user!.id,
304 repoId: repo!.id,
305 ownerName: user!.username,
306 repoName: repo!.name,
307 };
308}
309
310async function cleanupUser(userId: string): Promise<void> {
311 try {
312 await db.delete(repositories).where(eq(repositories.ownerId, userId));
313 await db.delete(users).where(eq(users.id, userId));
314 } catch {
315 /* best effort */
316 }
317}
318
319describe.skipIf(!HAS_DB)("dev-env — DB pipeline", () => {
320 let userId = "";
321
322 afterEach(async () => {
323 if (userId) await cleanupUser(userId);
324 userId = "";
325 });
326
327 it("startDevEnv uses the provided dev.yml override (skips AI)", async () => {
328 const seeded = await seedRepo();
329 userId = seeded.userId;
330
331 const yml = "image: node:20-alpine\nports: [3000]\n";
332 const result = await startDevEnv({
333 repositoryId: seeded.repoId,
334 ownerUserId: seeded.userId,
335 devYml: yml,
336 });
337 expect(result.ok).toBe(true);
338 if (result.ok) {
339 expect(result.env.status).toBe("warming");
340 expect(result.env.devYml).toBe(yml);
341 expect(result.env.machineSize).toBe("small");
342 expect(result.env.idleMinutes).toBe(30);
343 expect(result.url.startsWith("https://dev-")).toBe(true);
344 expect(result.env.previewUrl).toBe(result.url);
345 }
346 });
347
348 it("startDevEnv generates a default when no override + no AI key", async () => {
349 const seeded = await seedRepo();
350 userId = seeded.userId;
351
352 const prev = process.env.ANTHROPIC_API_KEY;
353 delete process.env.ANTHROPIC_API_KEY;
354 try {
355 const result = await startDevEnv({
356 repositoryId: seeded.repoId,
357 ownerUserId: seeded.userId,
358 });
359 expect(result.ok).toBe(true);
360 if (result.ok) {
361 // Default YAML contains an `image:` key.
362 expect(result.env.devYml).toBeTruthy();
363 expect(result.env.devYml!.toLowerCase()).toContain("image:");
364 }
365 } finally {
366 if (prev !== undefined) process.env.ANTHROPIC_API_KEY = prev;
367 }
368 });
369
370 it("startDevEnv refuses if the repo hasn't opted in", async () => {
371 const seeded = await seedRepo({ devEnvsEnabled: false });
372 userId = seeded.userId;
373
374 const result = await startDevEnv({
375 repositoryId: seeded.repoId,
376 ownerUserId: seeded.userId,
377 devYml: "image: node:20\n",
378 });
379 expect(result.ok).toBe(false);
380 if (!result.ok) {
381 expect(result.reason).toBe("not_opted_in");
382 }
383
384 // And no row was inserted.
385 const after = await getDevEnvForOwner(seeded.repoId, seeded.userId);
386 expect(after).toBeNull();
387 });
388
389 it("startDevEnv is idempotent: restart reuses the same row + URL", async () => {
390 const seeded = await seedRepo();
391 userId = seeded.userId;
392
393 const first = await startDevEnv({
394 repositoryId: seeded.repoId,
395 ownerUserId: seeded.userId,
396 devYml: "image: node:20\n# v1",
397 });
398 expect(first.ok).toBe(true);
399 if (!first.ok) return;
400
401 await markReady(first.env.id, "ctr-1");
402 await stopDevEnv(first.env.id);
403
404 const second = await startDevEnv({
405 repositoryId: seeded.repoId,
406 ownerUserId: seeded.userId,
407 devYml: "image: node:20\n# v2",
408 });
409 expect(second.ok).toBe(true);
410 if (!second.ok) return;
411
412 // Same row, status flipped back to warming, dev.yml refreshed.
413 expect(second.env.id).toBe(first.env.id);
414 expect(second.env.status).toBe("warming");
415 expect(second.env.devYml).toContain("v2");
416 expect(second.url).toBe(first.url);
417
418 // Unique constraint enforces single row per (repo, user).
419 const all = await db
420 .select()
421 .from(devEnvs)
422 .where(eq(devEnvs.repositoryId, seeded.repoId));
423 expect(all.length).toBe(1);
424 });
425
426 it("expireIdleEnvs only touches idle ready/warming rows", async () => {
427 const seeded = await seedRepo();
428 userId = seeded.userId;
429
430 const result = await startDevEnv({
431 repositoryId: seeded.repoId,
432 ownerUserId: seeded.userId,
433 devYml: "image: node:20\n",
434 });
435 expect(result.ok).toBe(true);
436 if (!result.ok) return;
437
438 await markReady(result.env.id, "ctr-x");
439
440 // Fresh — sweep should leave it alone.
441 const beforeFlip = await expireIdleEnvs();
442 expect(beforeFlip).toBe(0);
443 const stillReady = await getDevEnv(result.env.id);
444 expect(stillReady!.status).toBe("ready");
445
446 // Force last_active_at into the past beyond idle_minutes.
447 await db
448 .update(devEnvs)
449 .set({
450 lastActiveAt: new Date(Date.now() - 60 * 60_000), // 60min ago
451 idleMinutes: 5,
452 })
453 .where(eq(devEnvs.id, result.env.id));
454
455 const swept = await expireIdleEnvs();
456 expect(swept).toBeGreaterThanOrEqual(1);
457
458 const after = await getDevEnv(result.env.id);
459 expect(after!.status).toBe("stopped");
460 });
461
462 it("expireIdleEnvs leaves stopped + failed rows alone", async () => {
463 const seeded = await seedRepo();
464 userId = seeded.userId;
465
466 const result = await startDevEnv({
467 repositoryId: seeded.repoId,
468 ownerUserId: seeded.userId,
469 devYml: "image: node:20\n",
470 });
471 expect(result.ok).toBe(true);
472 if (!result.ok) return;
473
474 await markFailed(result.env.id, "boom");
475 // Pretend it's been failed for an hour.
476 await db
477 .update(devEnvs)
478 .set({
479 lastActiveAt: new Date(Date.now() - 60 * 60_000),
480 idleMinutes: 5,
481 })
482 .where(eq(devEnvs.id, result.env.id));
483
484 await expireIdleEnvs();
485 const after = await getDevEnv(result.env.id);
486 // Status is unchanged — only 'ready'/'warming' get swept.
487 expect(after!.status).toBe("failed");
488 });
489
490 it("recordActivity bumps last_active_at", async () => {
491 const seeded = await seedRepo();
492 userId = seeded.userId;
493
494 const result = await startDevEnv({
495 repositoryId: seeded.repoId,
496 ownerUserId: seeded.userId,
497 devYml: "image: node:20\n",
498 });
499 expect(result.ok).toBe(true);
500 if (!result.ok) return;
501
502 // Force last_active_at into the past.
503 const oldDate = new Date(Date.now() - 60 * 60_000);
504 await db
505 .update(devEnvs)
506 .set({ lastActiveAt: oldDate })
507 .where(eq(devEnvs.id, result.env.id));
508
509 await recordActivity(result.env.id);
510 const after = await getDevEnv(result.env.id);
511 expect(after!.lastActiveAt.getTime()).toBeGreaterThan(oldDate.getTime());
512 });
513
514 it("markFailed truncates absurdly long error messages", async () => {
515 const seeded = await seedRepo();
516 userId = seeded.userId;
517
518 const result = await startDevEnv({
519 repositoryId: seeded.repoId,
520 ownerUserId: seeded.userId,
521 devYml: "image: node:20\n",
522 });
523 expect(result.ok).toBe(true);
524 if (!result.ok) return;
525
526 const longError = "boom\n".repeat(10_000);
527 await markFailed(result.env.id, longError);
528 const after = await getDevEnv(result.env.id);
529 expect(after!.status).toBe("failed");
530 expect(after!.errorMessage).not.toBeNull();
531 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
532 });
533});