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