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