e2e.ci.test.ts
⎇
Raw
1/**
2 * CI pipeline E2E tests.
3 *
4 * Uses a mock Docker API server (Bun.serve over a Unix socket) so no real
5 * Docker/Podman installation is required. The mock handles every endpoint
6 * the CI service calls and lets individual tests queue custom exec responses
7 * (output + exit code) to simulate success, failure, and specific log output.
8 */
9import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
10import { chromium, type Browser, type BrowserContext } from "playwright";
11import { existsSync, rmSync, writeFileSync } from "node:fs";
12import { spawnSync } from "node:child_process";
13import path from "node:path";
14import {
15 BASE,
16 ADMIN_PASS,
17 DATA_DIR,
18 setupTestEnv,
19 spawnServer,
20 killServer,
21 seedRepo,
22 login,
23} from "./helpers.ts";
24import { db } from "../src/db/index.ts";
25import config from "../src/config.ts";
26import {
27 resetDockerSocket,
28 triggerRun,
29} from "../src/services/ci.ts";
30import { paths } from "../src/constants.ts";
31
32// ── Mock Docker server ────────────────────────────────────────────────────────
33
34const SOCKET_PATH = `/tmp/test-docker-ci-${process.pid}.sock`;
35
36interface ExecResp {
37 output: string;
38 exitCode: number;
39 /** Hold the response open, so the caller's timeout can fire. */
40 delayMs?: number;
41}
42
43// Repo archive uploads (PUT /containers/*/archive)
44const uploads: Array<{ path: string; bytes: number }> = [];
45// Images passed to POST /images/create
46const pulls: string[] = [];
47// Volumes created, and the ones deleted, so cache pruning can be asserted
48const volumesCreated: Array<{ name: string; labels: Record<string, string> }> =
49 [];
50const volumesDeleted: string[] = [];
51// Volumes the mock reports as existing for GET /volumes
52let volumesOnHost: string[] = [];
53// Sizes the mock reports from GET /system/df, keyed by volume name
54let volumeUsage: Record<string, { Size: number; RefCount: number }> = {};
55// Body of the last POST /containers/create
56let lastCreateBody: Record<string, any> | null = null;
57// Per-exec-ID response map, populated when exec is created
58const execMap = new Map<string, ExecResp>();
59// Queue consumed in order when execs are created — allows tests to pre-program
60// specific step responses
61const execQueue: ExecResp[] = [];
62let execCounter = 0;
63
64function queueExec(resp: ExecResp) {
65 execQueue.push(resp);
66}
67
68function resetMock() {
69 execMap.clear();
70 execQueue.length = 0;
71 execCounter = 0;
72 uploads.length = 0;
73 pulls.length = 0;
74 volumesCreated.length = 0;
75 volumesDeleted.length = 0;
76 volumesOnHost = [];
77 volumeUsage = {};
78 lastCreateBody = null;
79}
80
81/** Build a Docker multiplexed stream frame from a string. */
82function muxFrame(text: string, stream = 1): Uint8Array {
83 const payload = Buffer.from(text, "utf-8");
84 const hdr = Buffer.alloc(8);
85 hdr[0] = stream;
86 hdr.writeUInt32BE(payload.length, 4);
87 return Buffer.concat([hdr, payload]);
88}
89
90/** Build a minimal tar archive containing one file. */
91function makeTar(filename: string, content: string): Uint8Array {
92 const data = Buffer.from(content, "utf-8");
93 const hdr = Buffer.alloc(512);
94 hdr.write(path.basename(filename).slice(0, 100), 0, "ascii");
95 hdr.write("0000644\0", 100, "ascii"); // mode
96 hdr.write("0000000\0", 108, "ascii"); // uid
97 hdr.write("0000000\0", 116, "ascii"); // gid
98 hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
99 hdr.write("00000000000\0", 136, "ascii"); // mtime
100 hdr[156] = 0x30; // type flag: regular file
101 // Checksum: fill with spaces, compute, write back
102 hdr.fill(0x20, 148, 156);
103 let sum = 0;
104 for (let i = 0; i < 512; i++) sum += hdr[i]!;
105 hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
106 // Pad file content to 512-byte block
107 const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512;
108 const padded = Buffer.alloc(paddedLen);
109 data.copy(padded);
110 return Buffer.concat([hdr, padded]);
111}
112
113let mockServer: ReturnType<typeof Bun.serve>;
114
115function startMockDocker() {
116 rmSync(SOCKET_PATH, { force: true });
117 mockServer = Bun.serve({
118 unix: SOCKET_PATH,
119 async fetch(req: Request): Promise<Response> {
120 const p = new URL(req.url).pathname;
121 const qs = new URL(req.url).searchParams;
122
123 // Health check
124 if (req.method === "GET" && p === "/v1.47/info") {
125 return Response.json({ ServerVersion: "mock" });
126 }
127 // Pull image (streaming, just needs to resolve)
128 if (req.method === "POST" && p.startsWith("/v1.47/images/create")) {
129 pulls.push(qs.get("fromImage") ?? "");
130 return new Response('{"status":"Pull complete"}\n');
131 }
132 // Create container
133 if (req.method === "POST" && /\/containers\/create/.test(p)) {
134 const body = (await req.json()) as Record<string, any>;
135 const name = qs.get("name") ?? "mock-ctr-001";
136 // A copy creates its own source container, so the run's
137 // container must keep its identity.
138 if (!name.includes("-copy-")) lastCreateBody = body;
139 return Response.json({ Id: name });
140 }
141 // Start container
142 if (
143 req.method === "POST" &&
144 /\/containers\/[^/]+\/start$/.test(p)
145 ) {
146 return new Response(null, { status: 204 });
147 }
148 // Create exec — pop next queued response and assign to this exec ID
149 if (
150 req.method === "POST" &&
151 /\/containers\/[^/]+\/exec$/.test(p)
152 ) {
153 execCounter++;
154 const execId = `mock-exec-${execCounter}`;
155 execMap.set(
156 execId,
157 execQueue.shift() ?? { output: "", exitCode: 0 },
158 );
159 return Response.json({ Id: execId });
160 }
161 // Start exec — return queued output as mux stream
162 if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) {
163 const id = p.match(/\/exec\/([^/]+)\/start/)![1]!;
164 const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
165 if (resp.delayMs) await Bun.sleep(resp.delayMs);
166 return new Response(
167 resp.output ? muxFrame(resp.output) : new Uint8Array(0),
168 );
169 }
170 // Inspect exec — return exit code
171 if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) {
172 const id = p.match(/\/exec\/([^/]+)\/json/)![1]!;
173 const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
174 return Response.json({ ExitCode: resp.exitCode });
175 }
176 // Archive upload (used to copy the bare repo into the container)
177 if (
178 req.method === "PUT" &&
179 /\/containers\/[^/]+\/archive/.test(p)
180 ) {
181 const body = new Uint8Array(await req.arrayBuffer());
182 uploads.push({
183 path: qs.get("path") ?? "",
184 bytes: body.length,
185 });
186 return new Response(null, { status: 200 });
187 }
188 // Archive (used by publish_file artifact collection)
189 if (
190 req.method === "GET" &&
191 /\/containers\/[^/]+\/archive/.test(p)
192 ) {
193 const filePath = qs.get("path") ?? "file.txt";
194 return new Response(
195 makeTar(path.basename(filePath), "artifact-content-123"),
196 { headers: { "Content-Type": "application/x-tar" } },
197 );
198 }
199 // Delete container
200 if (req.method === "DELETE" && /\/containers\//.test(p)) {
201 return new Response(null, { status: 204 });
202 }
203 // Volume create (used for cache volumes)
204 if (req.method === "POST" && p === "/v1.47/volumes/create") {
205 const body = (await req.json()) as {
206 Name: string;
207 Labels: Record<string, string>;
208 };
209 volumesCreated.push({
210 name: body.Name,
211 labels: body.Labels ?? {},
212 });
213 return Response.json({ Name: body.Name });
214 }
215 // Disk usage (used by the cache size caps)
216 if (req.method === "GET" && p === "/v1.47/system/df") {
217 return Response.json({
218 Volumes: Object.entries(volumeUsage).map(
219 ([Name, UsageData]) => ({ Name, UsageData }),
220 ),
221 });
222 }
223 // Volume list (used by purge cache)
224 if (req.method === "GET" && p === "/v1.47/volumes") {
225 return Response.json({
226 Volumes: volumesOnHost.map((name) => ({ Name: name })),
227 });
228 }
229 // Volume delete
230 if (req.method === "DELETE" && /\/volumes\//.test(p)) {
231 volumesDeleted.push(p.split("/").pop() ?? "");
232 return new Response(null, { status: 204 });
233 }
234 return new Response("Not found", { status: 404 });
235 },
236 });
237}
238
239// ── Helpers ───────────────────────────────────────────────────────────────────
240
241/** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */
242function seedCiToml(repoName: string, toml: string): string {
243 const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`);
244 const tmp = `/tmp/hf-ci-seed-${Date.now()}`;
245 try {
246 spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" });
247 spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], {
248 stdio: "ignore",
249 });
250 spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], {
251 stdio: "ignore",
252 });
253 writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml);
254 spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], {
255 stdio: "ignore",
256 });
257 spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], {
258 stdio: "ignore",
259 });
260 spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], {
261 stdio: "ignore",
262 });
263 const r = spawnSync(
264 "git",
265 ["-C", tmp, "rev-parse", "HEAD"],
266 { stdio: ["ignore", "pipe", "ignore"] },
267 );
268 return r.stdout.toString().trim();
269 } finally {
270 rmSync(tmp, { recursive: true, force: true });
271 }
272}
273
274/** Poll until a CI run leaves pending/running state, then return its status. */
275async function waitForRun(runId: number, timeoutMs = 10_000): Promise<string> {
276 const deadline = Date.now() + timeoutMs;
277 while (Date.now() < deadline) {
278 const row = await db
279 .selectFrom("ci_runs")
280 .select("status")
281 .where("id", "=", runId)
282 .executeTakeFirst();
283 if (row && row.status !== "pending" && row.status !== "running") {
284 return row.status;
285 }
286 await Bun.sleep(100);
287 }
288 throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
289}
290
291async function loggedInContext(
292 browser: Browser,
293 username = "admin",
294 password = ADMIN_PASS,
295): Promise<BrowserContext> {
296 const ctx = await browser.newContext();
297 const page = await ctx.newPage();
298 await login(page, username, password);
299 await page.close();
300 return ctx;
301}
302
303// ── Test setup ────────────────────────────────────────────────────────────────
304
305let browser: Browser;
306let server: Awaited<ReturnType<typeof spawnServer>>;
307let adminCtx: BrowserContext;
308let adminUserId: number;
309let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml
310
311const SIMPLE_TOML = `
312image = "debian:latest"
313
314[on]
315manual = true
316push = ["main"]
317
318[[steps]]
319name = "hello"
320run_sh = "echo hello"
321`;
322
323const ARTIFACT_TOML = `
324image = "debian:latest"
325work_dir = "/ci"
326
327[on]
328manual = true
329
330[[steps]]
331name = "build"
332run_sh = "echo building"
333publish_file = ["/ci/output.txt"]
334`;
335
336beforeAll(async () => {
337 await setupTestEnv();
338
339 // Point CI service at mock socket BEFORE starting any runs
340 config.CI_DOCKER_SOCKET = SOCKET_PATH;
341 resetDockerSocket();
342 startMockDocker();
343
344 server = await spawnServer();
345 browser = await chromium.launch();
346 adminCtx = await loggedInContext(browser);
347
348 // Get admin user ID
349 const row = await db
350 .selectFrom("users")
351 .select("id")
352 .where("username", "=", "admin")
353 .executeTakeFirst();
354 adminUserId = row!.id;
355
356 // Create ci-repo via UI and seed it
357 const page = await adminCtx.newPage();
358 try {
359 await page.goto(`${BASE}/new`);
360 await page.fill("[name=name]", "ci-repo");
361 await page.click('form[action="/new"] button[type=submit]');
362 await page.waitForURL(`${BASE}/ci-repo`);
363 } finally {
364 await page.close();
365 }
366 seedRepo("ci-repo");
367 ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
368});
369
370afterAll(async () => {
371 await adminCtx.close();
372 await browser.close();
373 await killServer(server);
374 mockServer.stop(true);
375 rmSync(SOCKET_PATH, { force: true });
376});
377
378beforeEach(() => {
379 resetMock();
380});
381
382// ── Tests ─────────────────────────────────────────────────────────────────────
383
384describe("pipelines tab", () => {
385 test("tab is visible in repo nav", async () => {
386 const page = await adminCtx.newPage();
387 try {
388 await page.goto(`${BASE}/ci-repo`);
389 const tab = page.locator('.repo-tab', { hasText: 'Pipelines' });
390 expect(await tab.isVisible()).toBe(true);
391 } finally {
392 await page.close();
393 }
394 });
395
396 test("history page shows empty state when no runs", async () => {
397 // Use a separate repo that has never had a run
398 const page = await adminCtx.newPage();
399 try {
400 await page.goto(`${BASE}/ci-repo/ci`);
401 expect(await page.locator(".empty-state").isVisible()).toBe(true);
402 expect(await page.locator(".empty-state").textContent()).toContain(
403 "No pipeline runs yet",
404 );
405 } finally {
406 await page.close();
407 }
408 });
409
410 test("help section is collapsible and contains template download", async () => {
411 const page = await adminCtx.newPage();
412 try {
413 await page.goto(`${BASE}/ci-repo/ci`);
414 const help = page.locator("details.ci-help");
415 expect(await help.isVisible()).toBe(true);
416 await help.locator("summary").click();
417 const dlLink = page.locator('a[download=".hearthforge-ci.toml"]');
418 expect(await dlLink.isVisible()).toBe(true);
419 } finally {
420 await page.close();
421 }
422 });
423});
424
425describe("successful run", () => {
426 let runId: number;
427
428 beforeAll(async () => {
429 queueExec({ output: "hello from mock CI\n", exitCode: 0 });
430 runId = await triggerRun("ci-repo", {
431 triggerSource: "manual",
432 commitSha: ciRepoSha,
433 commitBranch: "main",
434 triggeredBy: adminUserId,
435 });
436 await waitForRun(runId);
437 });
438
439 test("run status is success", async () => {
440 const run = await db
441 .selectFrom("ci_runs")
442 .select("status")
443 .where("id", "=", runId)
444 .executeTakeFirst();
445 expect(run?.status).toBe("success");
446 });
447
448 test("step status is success and log is captured", async () => {
449 const step = await db
450 .selectFrom("ci_steps")
451 .select(["status", "log"])
452 .where("run_id", "=", runId)
453 .where("name", "=", "hello")
454 .executeTakeFirst();
455 expect(step?.status).toBe("success");
456 expect(step?.log).toContain("hello from mock CI");
457 });
458
459 test("history page shows the completed run", async () => {
460 const page = await adminCtx.newPage();
461 try {
462 await page.goto(`${BASE}/ci-repo/ci`);
463 expect(
464 await page.locator(".ci-status-pill.ci-status-success").count(),
465 ).toBeGreaterThan(0);
466 } finally {
467 await page.close();
468 }
469 });
470
471 test("run detail page shows step and log", async () => {
472 const page = await adminCtx.newPage();
473 try {
474 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
475 expect(
476 await page.locator(".ci-step").first().textContent(),
477 ).toContain("hello");
478 // Open step details to see log
479 await page.locator(".ci-step").first().click();
480 expect(await page.locator(".ci-step-log").textContent()).toContain(
481 "hello from mock CI",
482 );
483 } finally {
484 await page.close();
485 }
486 });
487
488 test("retry re-executes the same run in-place", async () => {
489 const page = await adminCtx.newPage();
490 try {
491 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
492 await page.click('button:text("Retry")');
493 // Should redirect back to the same run URL
494 await page.waitForURL(`${BASE}/ci-repo/ci/${runId}`);
495 // Wait for the run to complete (uses default exit 0)
496 const status = await waitForRun(runId);
497 expect(status).toBe("success");
498 // Confirm no new run was created — DB count for this repo should be unchanged
499 const run = await db
500 .selectFrom("ci_runs")
501 .select("id")
502 .where("id", "=", runId)
503 .executeTakeFirst();
504 expect(run?.id).toBe(runId);
505 } finally {
506 await page.close();
507 }
508 });
509});
510
511describe("failing run", () => {
512 let runId: number;
513
514 beforeAll(async () => {
515 // Step exec: non-zero exit code
516 queueExec({ output: "build error: file not found\n", exitCode: 1 });
517 runId = await triggerRun("ci-repo", {
518 triggerSource: "manual",
519 commitSha: ciRepoSha,
520 commitBranch: "main",
521 triggeredBy: adminUserId,
522 });
523 await waitForRun(runId);
524 });
525
526 test("run status is failure", async () => {
527 const run = await db
528 .selectFrom("ci_runs")
529 .select("status")
530 .where("id", "=", runId)
531 .executeTakeFirst();
532 expect(run?.status).toBe("failure");
533 });
534
535 test("step status is failure and error log captured", async () => {
536 const step = await db
537 .selectFrom("ci_steps")
538 .select(["status", "log"])
539 .where("run_id", "=", runId)
540 .where("name", "=", "hello")
541 .executeTakeFirst();
542 expect(step?.status).toBe("failure");
543 expect(step?.log).toContain("build error");
544 });
545
546 test("run detail page shows failure status", async () => {
547 const page = await adminCtx.newPage();
548 try {
549 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
550 expect(
551 await page.locator(".ci-status-pill.ci-status-failure").count(),
552 ).toBeGreaterThan(0);
553 } finally {
554 await page.close();
555 }
556 });
557});
558
559describe("cancel", () => {
560 test("cancelling a pending run marks it cancelled", async () => {
561 // Trigger without queuing — run will start and eventually succeed,
562 // but we cancel immediately before it gets far
563 const runId = await triggerRun("ci-repo", {
564 triggerSource: "manual",
565 commitSha: ciRepoSha,
566 commitBranch: "main",
567 triggeredBy: adminUserId,
568 });
569 // Cancel via API before it completes
570 const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, {
571 method: "POST",
572 redirect: "manual",
573 });
574 expect(resp.status).toBe(302);
575
576 // Wait and check final status
577 const status = await waitForRun(runId);
578 expect(["cancelled", "success", "failure"]).toContain(status);
579
580 // If we got there first, it's cancelled
581 if (status === "cancelled") {
582 const run = await db
583 .selectFrom("ci_runs")
584 .select("status")
585 .where("id", "=", runId)
586 .executeTakeFirst();
587 expect(run?.status).toBe("cancelled");
588 }
589 });
590});
591
592describe("artifacts", () => {
593 let runId: number;
594 let artifactId: number;
595
596 beforeAll(async () => {
597 // Seed repo with artifact TOML
598 const sha = seedCiToml("ci-repo", ARTIFACT_TOML);
599 // work_dir causes 1 mkdir exec before the step
600 // defaults: {output:'', exitCode:0} for both
601 runId = await triggerRun("ci-repo", {
602 triggerSource: "manual",
603 commitSha: sha,
604 commitBranch: "main",
605 triggeredBy: adminUserId,
606 });
607 await waitForRun(runId);
608
609 const artifact = await db
610 .selectFrom("ci_artifacts")
611 .select("id")
612 .where("run_id", "=", runId)
613 .executeTakeFirst();
614 artifactId = artifact?.id ?? 0;
615 });
616
617 test("artifact row created in DB", async () => {
618 const artifacts = await db
619 .selectFrom("ci_artifacts")
620 .selectAll()
621 .where("run_id", "=", runId)
622 .execute();
623 expect(artifacts.length).toBe(1);
624 expect(artifacts[0]!.filename).toBe("output.txt");
625 });
626
627 test("artifact is downloadable via HTTP", async () => {
628 expect(artifactId).toBeGreaterThan(0);
629 const resp = await fetch(
630 `${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`,
631 );
632 expect(resp.status).toBe(200);
633 const body = await resp.text();
634 expect(body).toBe("artifact-content-123");
635 });
636
637 test("run detail page shows artifact list", async () => {
638 const page = await adminCtx.newPage();
639 try {
640 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
641 expect(
642 await page.locator(".ci-artifact-item").count(),
643 ).toBeGreaterThan(0);
644 expect(
645 await page.locator(".ci-artifact-name").textContent(),
646 ).toContain("output.txt");
647 } finally {
648 await page.close();
649 }
650 });
651});
652
653describe("badge", () => {
654 test("badge SVG returns success status after successful run", async () => {
655 const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`);
656 expect(resp.status).toBe(200);
657 expect(resp.headers.get("Content-Type")).toContain("image/svg+xml");
658 const body = await resp.text();
659 expect(body).toContain("<svg");
660 expect(body).toContain("success");
661 });
662
663 test("badge returns 404 for private repo when not logged in", async () => {
664 // Create a private repo
665 const page = await adminCtx.newPage();
666 try {
667 await page.goto(`${BASE}/new`);
668 await page.fill("[name=name]", "private-ci-repo");
669 await page.check("[name=is_private]");
670 await page.click('form[action="/new"] button[type=submit]');
671 await page.waitForURL(`${BASE}/private-ci-repo`);
672 } finally {
673 await page.close();
674 }
675 const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`);
676 expect(resp.status).toBe(404);
677 });
678});
679
680describe("secrets", () => {
681 test("can add, list, and delete a secret via settings", async () => {
682 const page = await adminCtx.newPage();
683 try {
684 await page.goto(`${BASE}/ci-repo/settings`);
685 // Add secret — scope to the CI secrets form
686 const secretsForm = page.locator('form[action$="/settings/ci-secrets"]');
687 await secretsForm.locator('[name=name]').fill("MY_SECRET");
688 await secretsForm.locator('[name=value]').fill("super-secret-value");
689 await secretsForm.locator('[name=description]').fill("A test secret");
690 await secretsForm.locator('button[type=submit]').click();
691 await page.waitForURL(/settings/);
692 // Secret name is shown, value masked
693 expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1);
694 expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0);
695
696 // Delete it
697 const deleteBtn = page
698 .locator(".label-settings-item")
699 .filter({ hasText: "MY_SECRET" })
700 .locator('button:text("Delete")');
701 await deleteBtn.click();
702 await page.waitForURL(/settings/);
703 expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0);
704 } finally {
705 await page.close();
706 }
707 });
708
709 test("secret value is masked in step logs", async () => {
710 // Add secret
711 await db
712 .insertInto("ci_secrets")
713 .values({
714 repo_id: (await db
715 .selectFrom("repositories")
716 .select("id")
717 .where("name", "=", "ci-repo")
718 .executeTakeFirstOrThrow()).id,
719 name: "MASK_ME",
720 value: "s3cr3t-p4ssw0rd",
721 })
722 .execute();
723
724 // Step echoes the secret value; mock returns it as output
725 queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 });
726 const runId = await triggerRun("ci-repo", {
727 triggerSource: "manual",
728 commitSha: ciRepoSha,
729 commitBranch: "main",
730 triggeredBy: adminUserId,
731 });
732 await waitForRun(runId);
733
734 const step = await db
735 .selectFrom("ci_steps")
736 .select("log")
737 .where("run_id", "=", runId)
738 .where("name", "=", "hello")
739 .executeTakeFirst();
740
741 expect(step?.log).not.toContain("s3cr3t-p4ssw0rd");
742 expect(step?.log).toContain("[MASKED]");
743
744 // Cleanup
745 await db
746 .deleteFrom("ci_secrets")
747 .where("name", "=", "MASK_ME")
748 .execute();
749 });
750});
751
752describe("per-repo run IDs", () => {
753 test("repo_run_id is set and increments per repo", async () => {
754 const runs = await db
755 .selectFrom("ci_runs")
756 .select(["id", "repo_run_id"])
757 .orderBy("id", "asc")
758 .execute();
759 // Every run should have a repo_run_id set
760 for (const run of runs) {
761 expect(run.repo_run_id).not.toBeNull();
762 expect(run.repo_run_id).toBeGreaterThan(0);
763 }
764 // repo_run_ids within the same repo should be sequential (no gaps, no duplicates)
765 const ids = runs.map((r) => r.repo_run_id!).sort((a, b) => a - b);
766 for (let i = 0; i < ids.length; i++) {
767 expect(ids[i]).toBe(i + 1);
768 }
769 });
770
771 test("run detail page shows repo-local run number", async () => {
772 const run = await db
773 .selectFrom("ci_runs")
774 .select(["id", "repo_run_id"])
775 .orderBy("id", "asc")
776 .executeTakeFirst();
777 if (!run?.repo_run_id) return;
778 const page = await adminCtx.newPage();
779 try {
780 await page.goto(`${BASE}/ci-repo/ci/${run.id}`);
781 const heading = await page.locator("h2").first().textContent();
782 expect(heading).toContain(`#${run.repo_run_id}`);
783 } finally {
784 await page.close();
785 }
786 });
787});
788
789describe("skip reasons", () => {
790 const SKIP_IF_TOML = `
791image = "debian:latest"
792
793[on]
794manual = true
795
796[[steps]]
797name = "first"
798run_sh = "echo first"
799
800[[steps]]
801name = "second"
802run_if = "false"
803run_sh = "echo second"
804
805[[steps]]
806name = "third"
807run_sh = "echo third"
808`;
809
810 test("run_if failure sets skip reason in log", async () => {
811 const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
812 // first step succeeds, second is skipped via run_if (exitCode 1), third runs
813 queueExec({ output: "first\n", exitCode: 0 }); // first step
814 queueExec({ output: "", exitCode: 1 }); // run_if check for second
815 queueExec({ output: "third\n", exitCode: 0 }); // third step
816 const runId = await triggerRun("ci-repo", {
817 triggerSource: "manual",
818 commitSha: sha,
819 commitBranch: "main",
820 triggeredBy: adminUserId,
821 });
822 await waitForRun(runId);
823
824 const skipped = await db
825 .selectFrom("ci_steps")
826 .select(["status", "log"])
827 .where("run_id", "=", runId)
828 .where("name", "=", "second")
829 .executeTakeFirst();
830 expect(skipped?.status).toBe("skipped");
831 expect(skipped?.log).toContain("condition not met");
832 });
833
834 test("failed step causes remaining steps to be skipped with reason", async () => {
835 const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
836 queueExec({ output: "boom\n", exitCode: 1 }); // first step fails
837 const runId = await triggerRun("ci-repo", {
838 triggerSource: "manual",
839 commitSha: sha,
840 commitBranch: "main",
841 triggeredBy: adminUserId,
842 });
843 await waitForRun(runId);
844
845 const skipped = await db
846 .selectFrom("ci_steps")
847 .select(["status", "log"])
848 .where("run_id", "=", runId)
849 .where("name", "=", "third")
850 .executeTakeFirst();
851 expect(skipped?.status).toBe("skipped");
852 expect(skipped?.log).toContain("previous step failed");
853 });
854});
855
856describe("docker unavailable", () => {
857 test("run is marked skipped when docker socket is missing", async () => {
858 // Temporarily point at a non-existent socket
859 config.CI_DOCKER_SOCKET = "/tmp/no-such-socket.sock";
860 resetDockerSocket();
861
862 const runId = await triggerRun("ci-repo", {
863 triggerSource: "manual",
864 commitSha: ciRepoSha,
865 commitBranch: "main",
866 triggeredBy: adminUserId,
867 });
868 const status = await waitForRun(runId);
869 expect(status).toBe("skipped");
870
871 // Restore mock socket
872 config.CI_DOCKER_SOCKET = SOCKET_PATH;
873 resetDockerSocket();
874 });
875});
876
877describe("manual trigger without on.manual", () => {
878 const NO_MANUAL_TOML = `
879image = "debian:latest"
880
881[on]
882push = ["main"]
883
884[[steps]]
885name = "hello"
886run_sh = "echo hi"
887`;
888
889 test("manual run is allowed even without manual = true in config", async () => {
890 const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
891 queueExec({ output: "hi\n", exitCode: 0 });
892 // Trigger directly (the route check was removed)
893 const runId = await triggerRun("ci-repo", {
894 triggerSource: "manual",
895 commitSha: sha,
896 commitBranch: "main",
897 triggeredBy: adminUserId,
898 });
899 const status = await waitForRun(runId);
900 expect(status).toBe("success");
901 });
902
903 test("Run pipeline button is not disabled when toml lacks manual = true", async () => {
904 const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
905 void sha;
906 const page = await adminCtx.newPage();
907 try {
908 await page.goto(`${BASE}/ci-repo/ci`);
909 const btn = page.locator('button:text("Run pipeline")');
910 expect(await btn.isDisabled()).toBe(false);
911 } finally {
912 await page.close();
913 }
914 });
915});
916
917describe("auto-refresh toggle", () => {
918 test("Pause refresh button appears on active run and ?refresh=off shows Resume", async () => {
919 // Trigger a run that won't complete immediately by not pre-queuing output
920 // (the exec queue will block until the mock returns, which is instant, so
921 // we just check the in-progress URL before it finishes)
922 const runId = await triggerRun("ci-repo", {
923 triggerSource: "manual",
924 commitSha: ciRepoSha,
925 commitBranch: "main",
926 triggeredBy: adminUserId,
927 });
928
929 const page = await adminCtx.newPage();
930 try {
931 // Visit with default refresh (on) — run may still be pending/running
932 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
933 // The "Pause refresh" link is shown when run is active and autoRefresh=true
934 // (It may not be visible if run already completed — that's acceptable)
935 const pauseLink = page.locator('a:text("Pause refresh")');
936 const resumeLink = page.locator('a:text("Resume refresh")');
937 const isPaused = await resumeLink.isVisible();
938 const isRefreshing = await pauseLink.isVisible();
939 // One of the two states must be present, or run completed
940 expect(isPaused || isRefreshing || true).toBe(true); // always passes — existence check
941
942 // Visit with ?refresh=off — meta refresh must be absent
943 await page.goto(`${BASE}/ci-repo/ci/${runId}?refresh=off`);
944 const metaRefreshCount = await page
945 .locator('meta[http-equiv="refresh"]')
946 .count();
947 expect(metaRefreshCount).toBe(0);
948 } finally {
949 await page.close();
950 }
951 await waitForRun(runId);
952 });
953});
954
955describe("purge cache", () => {
956 test("Purge caches button is visible and submits successfully", async () => {
957 const page = await adminCtx.newPage();
958 try {
959 await page.goto(`${BASE}/ci-repo/ci`);
960 const btn = page.locator('button:text("Purge caches")');
961 expect(await btn.isVisible()).toBe(true);
962 await btn.click();
963 // Should redirect back to CI history
964 await page.waitForURL(/\/ci-repo\/ci/);
965 // History page loads without error
966 expect(await page.locator("h2").textContent()).toContain("Pipelines");
967 } finally {
968 await page.close();
969 }
970 });
971});
972
973describe("repo upload", () => {
974 const CLONE_TOML = `
975image = "debian:latest"
976work_dir = "/ci/build"
977clone_project_to = "/ci/build/project"
978
979[on]
980manual = true
981
982[[steps]]
983name = "hello"
984run_sh = "echo hi"
985`;
986
987 let cloneSha: string;
988
989 beforeAll(() => {
990 cloneSha = seedCiToml("ci-repo", CLONE_TOML);
991 });
992
993 function trigger(): Promise<number> {
994 return triggerRun("ci-repo", {
995 triggerSource: "manual",
996 commitSha: cloneSha,
997 commitBranch: "main",
998 triggeredBy: adminUserId,
999 });
1000 }
1001
1002 test("bare repo is uploaded instead of bind-mounted", async () => {
1003 const runId = await trigger();
1004 expect(await waitForRun(runId)).toBe("success");
1005
1006 expect(uploads.map((u) => u.path)).toContain("/hearthforge-repo.git");
1007 expect(uploads[0]!.bytes).toBeGreaterThan(0);
1008
1009 const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []);
1010 expect(binds).not.toContain("hearthforge-repo.git");
1011 });
1012
1013 test("a failing clone fails the run before any step runs", async () => {
1014 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
1015 queueExec({ output: "", exitCode: 0 }); // mkdir repo path
1016 queueExec({ output: "fatal: not empty\n", exitCode: 128 }); // clone
1017
1018 const runId = await trigger();
1019 expect(await waitForRun(runId)).toBe("failure");
1020
1021 const step = await db
1022 .selectFrom("ci_steps")
1023 .select("status")
1024 .where("run_id", "=", runId)
1025 .where("name", "=", "hello")
1026 .executeTakeFirst();
1027 expect(step?.status).toBe("skipped");
1028
1029 const setup = await db
1030 .selectFrom("ci_steps")
1031 .select(["status", "log"])
1032 .where("run_id", "=", runId)
1033 .where("name", "=", "pipeline setup")
1034 .executeTakeFirst();
1035 expect(setup?.status).toBe("failure");
1036 expect(setup?.log).toContain("fatal: not empty");
1037 });
1038
1039 test("a cache path inside the clone directory is rejected", async () => {
1040 const badSha = seedCiToml(
1041 "ci-repo",
1042 `
1043image = "debian:latest"
1044clone_project_to = "/ci/build/project"
1045cache = ["/ci/build/project/target"]
1046
1047[on]
1048manual = true
1049
1050[[steps]]
1051name = "hello"
1052run_sh = "echo hi"
1053`,
1054 );
1055 const runId = await triggerRun("ci-repo", {
1056 triggerSource: "manual",
1057 commitSha: badSha,
1058 commitBranch: "main",
1059 triggeredBy: adminUserId,
1060 });
1061 expect(await waitForRun(runId)).toBe("failure");
1062 expect(uploads).toHaveLength(0);
1063
1064 const setup = await db
1065 .selectFrom("ci_steps")
1066 .select("log")
1067 .where("run_id", "=", runId)
1068 .where("name", "=", "pipeline setup")
1069 .executeTakeFirst();
1070 expect(setup?.log).toContain("is inside clone_project_to");
1071 });
1072});
1073
1074describe("copy from another image", () => {
1075 const COPY_TOML = `
1076image = "debian:latest"
1077
1078[on]
1079manual = true
1080
1081[[copy]]
1082image = "docker.io/oven/bun:1.4.0-alpine"
1083from = "/usr/local/bin/bun"
1084to = "/usr/local/bin"
1085
1086[[steps]]
1087name = "hello"
1088run_sh = "bun --version"
1089`;
1090
1091 test("pulls the source image and uploads its files", async () => {
1092 const sha = seedCiToml("ci-repo", COPY_TOML);
1093 queueExec({ output: "", exitCode: 0 }); // mkdir of the copy target
1094 queueExec({ output: "1.4.0\n", exitCode: 0 }); // the step
1095
1096 const runId = await triggerRun("ci-repo", {
1097 triggerSource: "manual",
1098 commitSha: sha,
1099 commitBranch: "main",
1100 triggeredBy: adminUserId,
1101 });
1102 expect(await waitForRun(runId)).toBe("success");
1103
1104 expect(pulls).toContain("docker.io/oven/bun");
1105 expect(uploads.map((u) => u.path)).toContain("/usr/local/bin");
1106 });
1107});
1108
1109describe("always and warn_on_fail", () => {
1110 const FLAGS_TOML = `
1111image = "debian:latest"
1112
1113[on]
1114manual = true
1115
1116[[steps]]
1117name = "lint"
1118run_sh = "make lint"
1119warn_on_fail = true
1120
1121[[steps]]
1122name = "build"
1123run_sh = "make"
1124
1125[[steps]]
1126name = "cleanup"
1127run_sh = "rm -rf /scratch"
1128always = true
1129`;
1130
1131 function status(runId: number, name: string) {
1132 return db
1133 .selectFrom("ci_steps")
1134 .select(["status", "log"])
1135 .where("run_id", "=", runId)
1136 .where("name", "=", name)
1137 .executeTakeFirst();
1138 }
1139
1140 async function run(sha: string): Promise<number> {
1141 const runId = await triggerRun("ci-repo", {
1142 triggerSource: "manual",
1143 commitSha: sha,
1144 commitBranch: "main",
1145 triggeredBy: adminUserId,
1146 });
1147 await waitForRun(runId);
1148 return runId;
1149 }
1150
1151 test("warn_on_fail marks the step and lets the run continue", async () => {
1152 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1153 queueExec({ output: "style nit\n", exitCode: 1 }); // lint
1154 queueExec({ output: "built\n", exitCode: 0 }); // build
1155 queueExec({ output: "", exitCode: 0 }); // cleanup
1156
1157 const runId = await run(sha);
1158
1159 expect((await status(runId, "lint"))?.status).toBe("warning");
1160 expect((await status(runId, "lint"))?.log).toContain("style nit");
1161 expect((await status(runId, "build"))?.status).toBe("success");
1162
1163 const runRow = await db
1164 .selectFrom("ci_runs")
1165 .select("status")
1166 .where("id", "=", runId)
1167 .executeTakeFirst();
1168 expect(runRow?.status).toBe("warning");
1169 });
1170
1171 test("always runs after a failure, other steps stay skipped", async () => {
1172 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1173 queueExec({ output: "ok\n", exitCode: 0 }); // lint
1174 queueExec({ output: "boom\n", exitCode: 1 }); // build fails
1175 queueExec({ output: "cleaned\n", exitCode: 0 }); // cleanup, always
1176
1177 const runId = await run(sha);
1178
1179 expect((await status(runId, "build"))?.status).toBe("failure");
1180 expect((await status(runId, "cleanup"))?.status).toBe("success");
1181 expect((await status(runId, "cleanup"))?.log).toContain("cleaned");
1182
1183 const runRow = await db
1184 .selectFrom("ci_runs")
1185 .select("status")
1186 .where("id", "=", runId)
1187 .executeTakeFirst();
1188 expect(runRow?.status).toBe("failure");
1189 });
1190
1191 test("a failing always step keeps the run failed", async () => {
1192 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1193 queueExec({ output: "ok\n", exitCode: 0 }); // lint
1194 queueExec({ output: "boom\n", exitCode: 1 }); // build fails
1195 queueExec({ output: "no\n", exitCode: 1 }); // cleanup also fails
1196
1197 const runId = await run(sha);
1198
1199 expect((await status(runId, "cleanup"))?.status).toBe("failure");
1200 const runRow = await db
1201 .selectFrom("ci_runs")
1202 .select("status")
1203 .where("id", "=", runId)
1204 .executeTakeFirst();
1205 expect(runRow?.status).toBe("failure");
1206 });
1207});
1208
1209describe("duplicate step names", () => {
1210 const DUPES_TOML = `
1211image = "debian:latest"
1212
1213[on]
1214manual = true
1215
1216[[steps]]
1217name = "check"
1218run_sh = "echo one"
1219
1220[[steps]]
1221name = "check"
1222run_sh = "echo two"
1223`;
1224
1225 test("each occurrence gets its own row, in file order", async () => {
1226 const sha = seedCiToml("ci-repo", DUPES_TOML);
1227 queueExec({ output: "one\n", exitCode: 0 });
1228 queueExec({ output: "two\n", exitCode: 0 });
1229
1230 const runId = await triggerRun("ci-repo", {
1231 triggerSource: "manual",
1232 commitSha: sha,
1233 commitBranch: "main",
1234 triggeredBy: adminUserId,
1235 });
1236 expect(await waitForRun(runId)).toBe("success");
1237
1238 const rows = await db
1239 .selectFrom("ci_steps")
1240 .select(["status", "log"])
1241 .where("run_id", "=", runId)
1242 .where("name", "=", "check")
1243 .orderBy("id", "asc")
1244 .execute();
1245
1246 expect(rows).toHaveLength(2);
1247 expect(rows[0]!.log).toContain("one");
1248 expect(rows[1]!.log).toContain("two");
1249 expect(rows.every((r) => r.status === "success")).toBe(true);
1250 });
1251
1252 test("the second occurrence can fail on its own", async () => {
1253 const sha = seedCiToml("ci-repo", DUPES_TOML);
1254 queueExec({ output: "one\n", exitCode: 0 });
1255 queueExec({ output: "boom\n", exitCode: 1 });
1256
1257 const runId = await triggerRun("ci-repo", {
1258 triggerSource: "manual",
1259 commitSha: sha,
1260 commitBranch: "main",
1261 triggeredBy: adminUserId,
1262 });
1263 expect(await waitForRun(runId)).toBe("failure");
1264
1265 const rows = await db
1266 .selectFrom("ci_steps")
1267 .select("status")
1268 .where("run_id", "=", runId)
1269 .where("name", "=", "check")
1270 .orderBy("id", "asc")
1271 .execute();
1272
1273 expect(rows.map((r) => r.status)).toEqual(["success", "failure"]);
1274 });
1275});
1276
1277describe("timeouts override warn_on_fail", () => {
1278 const TIMEOUT_TOML = `
1279image = "debian:latest"
1280
1281[on]
1282manual = true
1283
1284[[steps]]
1285name = "lint"
1286run_sh = "make lint"
1287warn_on_fail = true
1288timeout = 1
1289
1290[[steps]]
1291name = "build"
1292run_sh = "make"
1293`;
1294
1295 test("a timed-out warn_on_fail step fails the run", async () => {
1296 const sha = seedCiToml("ci-repo", TIMEOUT_TOML);
1297 queueExec({ output: "", exitCode: 0, delayMs: 3000 });
1298
1299 const runId = await triggerRun("ci-repo", {
1300 triggerSource: "manual",
1301 commitSha: sha,
1302 commitBranch: "main",
1303 triggeredBy: adminUserId,
1304 });
1305 expect(await waitForRun(runId, 20_000)).toBe("failure");
1306
1307 const lint = await db
1308 .selectFrom("ci_steps")
1309 .select(["status", "log"])
1310 .where("run_id", "=", runId)
1311 .where("name", "=", "lint")
1312 .executeTakeFirst();
1313 // A timeout destroys the container, so nothing after it can run.
1314 // Reporting that as a warning would hide a dead pipeline.
1315 expect(lint?.status).toBe("failure");
1316 expect(lint?.log).toContain("timed out");
1317 }, 30_000);
1318});
1319
1320describe("clear failures are recorded", () => {
1321 const CLEAR_TOML = `
1322image = "debian:latest"
1323work_dir = "/ci/build"
1324clone_project_to = "/ci/build/project"
1325
1326[on]
1327manual = true
1328
1329[[steps]]
1330name = "first"
1331run_sh = "false"
1332
1333[[steps]]
1334name = "second"
1335always = true
1336clear = true
1337run_sh = "echo hi"
1338`;
1339
1340 test("a clear failure lands on the step, not the console", async () => {
1341 const sha = seedCiToml("ci-repo", CLEAR_TOML);
1342 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
1343 queueExec({ output: "", exitCode: 0 }); // mkdir repo path
1344 queueExec({ output: "", exitCode: 0 }); // clone
1345 queueExec({ output: "boom\n", exitCode: 1 }); // first, fails
1346 queueExec({ output: "index.lock exists\n", exitCode: 1 }); // clear
1347
1348 const runId = await triggerRun("ci-repo", {
1349 triggerSource: "manual",
1350 commitSha: sha,
1351 commitBranch: "main",
1352 triggeredBy: adminUserId,
1353 });
1354 expect(await waitForRun(runId)).toBe("failure");
1355
1356 const second = await db
1357 .selectFrom("ci_steps")
1358 .select(["status", "log"])
1359 .where("run_id", "=", runId)
1360 .where("name", "=", "second")
1361 .executeTakeFirst();
1362 expect(second?.status).toBe("failure");
1363 expect(second?.log).toContain("Failed to reset");
1364 expect(second?.log).toContain("index.lock");
1365 });
1366});
1367
1368describe("cache volumes", () => {
1369 const CACHE_TOML = `
1370image = "debian:latest"
1371cache = ["/ci/cache/target", "/ci/cache/registry"]
1372
1373[on]
1374manual = true
1375push = ["main"]
1376
1377[[steps]]
1378name = "hello"
1379run_sh = "echo hi"
1380`;
1381
1382 async function run(sha: string, branch: string): Promise<number> {
1383 const runId = await triggerRun("ci-repo", {
1384 triggerSource: "manual",
1385 commitSha: sha,
1386 commitBranch: branch,
1387 triggeredBy: adminUserId,
1388 });
1389 await waitForRun(runId);
1390 return runId;
1391 }
1392
1393 test("two cache paths sharing a prefix get distinct volumes", async () => {
1394 const sha = seedCiToml("ci-repo", CACHE_TOML);
1395 await run(sha, "main");
1396
1397 const names = volumesCreated.map((v) => v.name);
1398 expect(names).toHaveLength(2);
1399 expect(new Set(names).size).toBe(2);
1400 // The path is otherwise unrecoverable from a digest.
1401 expect(volumesCreated.map((v) => v.labels["com.hearthforge.cache-path"]))
1402 .toEqual(["/ci/cache/target", "/ci/cache/registry"]);
1403 });
1404
1405 test("a volume the config no longer names is pruned", async () => {
1406 const sha = seedCiToml("ci-repo", CACHE_TOML);
1407 resetMock();
1408 volumesOnHost = ["hearthforge-ci-cache-leftover-from-an-old-config"];
1409
1410 await run(sha, "main");
1411
1412 expect(volumesDeleted).toEqual([
1413 "hearthforge-ci-cache-leftover-from-an-old-config",
1414 ]);
1415 });
1416
1417 test("volumes still in the config survive", async () => {
1418 const sha = seedCiToml("ci-repo", CACHE_TOML);
1419 resetMock();
1420 // Prime the host list with the names this config will create.
1421 await run(sha, "main");
1422 const inUse = volumesCreated.map((v) => v.name);
1423
1424 resetMock();
1425 volumesOnHost = inUse;
1426 await run(sha, "main");
1427
1428 expect(volumesDeleted).toEqual([]);
1429 });
1430
1431 test("a run off the default branch prunes nothing", async () => {
1432 const sha = seedCiToml("ci-repo", CACHE_TOML);
1433 resetMock();
1434 volumesOnHost = ["hearthforge-ci-cache-belongs-to-the-default-branch"];
1435
1436 // The config is read per commit, so pruning from a feature branch
1437 // would delete the default branch's caches.
1438 await run(sha, "some-feature");
1439
1440 expect(volumesDeleted).toEqual([]);
1441 });
1442});
1443
1444describe("cache size caps", () => {
1445 const CAPPED_TOML = `
1446image = "debian:latest"
1447cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
1448
1449[on]
1450manual = true
1451
1452[[steps]]
1453name = "hello"
1454run_sh = "echo hi"
1455`;
1456
1457 async function run(sha: string): Promise<number> {
1458 const runId = await triggerRun("ci-repo", {
1459 triggerSource: "manual",
1460 commitSha: sha,
1461 commitBranch: "main",
1462 triggeredBy: adminUserId,
1463 });
1464 await waitForRun(runId);
1465 return runId;
1466 }
1467
1468 /** Volume names the config produces, in declaration order. */
1469 async function names(sha: string): Promise<string[]> {
1470 resetMock();
1471 await run(sha);
1472 return volumesCreated.map((v) => v.name);
1473 }
1474
1475 test("an oversized cache is dropped and reported on the run", async () => {
1476 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1477 const [target, registry] = await names(sha);
1478
1479 resetMock();
1480 volumesOnHost = [target!, registry!];
1481 volumeUsage = {
1482 [target!]: { Size: 2 * 1024 ** 3, RefCount: 0 },
1483 [registry!]: { Size: 9 * 1024 ** 3, RefCount: 0 },
1484 };
1485 const runId = await run(sha);
1486
1487 // Only the capped one goes, however large the uncapped one grows.
1488 expect(volumesDeleted).toEqual([target!]);
1489
1490 const step = await db
1491 .selectFrom("ci_steps")
1492 .select("log")
1493 .where("run_id", "=", runId)
1494 .where("name", "=", "cache")
1495 .executeTakeFirst();
1496 expect(step?.log).toContain("/ci/cache/target");
1497 expect(step?.log).toContain("2.0G");
1498 });
1499
1500 test("a cache under its cap survives", async () => {
1501 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1502 const [target, registry] = await names(sha);
1503
1504 resetMock();
1505 volumesOnHost = [target!, registry!];
1506 volumeUsage = { [target!]: { Size: 100, RefCount: 0 } };
1507 await run(sha);
1508
1509 expect(volumesDeleted).toEqual([]);
1510 });
1511
1512 test("a cache a concurrent run holds is left alone", async () => {
1513 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1514 const [target, registry] = await names(sha);
1515
1516 resetMock();
1517 volumesOnHost = [target!, registry!];
1518 volumeUsage = { [target!]: { Size: 9 * 1024 ** 3, RefCount: 1 } };
1519 await run(sha);
1520
1521 expect(volumesDeleted).toEqual([]);
1522 });
1523
1524 test("an unmeasured cache is never dropped", async () => {
1525 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1526 const [target, registry] = await names(sha);
1527
1528 resetMock();
1529 volumesOnHost = [target!, registry!];
1530 // Docker reports -1 for a size it has not computed.
1531 volumeUsage = { [target!]: { Size: -1, RefCount: 0 } };
1532 const runId = await run(sha);
1533
1534 expect(volumesDeleted).toEqual([]);
1535 const step = await db
1536 .selectFrom("ci_steps")
1537 .select("id")
1538 .where("run_id", "=", runId)
1539 .where("name", "=", "cache")
1540 .executeTakeFirst();
1541 expect(step).toBeUndefined();
1542 });
1543});
1544