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 } finally {
1129 await page.close();
1130 }
1131 });
1132});
1133
1134describe("repo upload", () => {
1135 const CLONE_TOML = `
1136image = "debian:latest"
1137work_dir = "/ci/build"
1138clone_project_to = "/ci/build/project"
1139
1140[on]
1141manual = true
1142
1143[[steps]]
1144name = "hello"
1145run_sh = "echo hi"
1146`;
1147
1148 let cloneSha: string;
1149
1150 beforeAll(() => {
1151 cloneSha = seedCiToml("ci-repo", CLONE_TOML);
1152 });
1153
1154 function trigger(): Promise<number> {
1155 return triggerRun("ci-repo", {
1156 triggerSource: "manual",
1157 commitSha: cloneSha,
1158 commitBranch: "main",
1159 triggeredBy: adminUserId,
1160 });
1161 }
1162
1163 test("the checkout is uploaded, not bind-mounted", async () => {
1164 const runId = await trigger();
1165 expect(await waitForRun(runId)).toBe("success");
1166
1167 expect(uploads.map((u) => u.path)).toContain("/ci/build/project");
1168 expect(uploads[0]!.bytes).toBeGreaterThan(0);
1169
1170 const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []);
1171 expect(binds).not.toContain(DATA_DIR);
1172 });
1173
1174 test("the container never runs git", async () => {
1175 const runId = await trigger();
1176 expect(await waitForRun(runId)).toBe("success");
1177
1178 const ran = execCmds.flat().join(" ");
1179 expect(ran).not.toContain("git");
1180 });
1181
1182 test("a failing checkout fails the run before any step runs", async () => {
1183 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
1184 queueExec({ output: "mkdir: read-only\n", exitCode: 1 }); // mkdir dest
1185
1186 const runId = await trigger();
1187 expect(await waitForRun(runId)).toBe("failure");
1188
1189 const step = await db
1190 .selectFrom("ci_steps")
1191 .select("status")
1192 .where("run_id", "=", runId)
1193 .where("name", "=", "hello")
1194 .executeTakeFirst();
1195 expect(step?.status).toBe("skipped");
1196
1197 const setup = await db
1198 .selectFrom("ci_steps")
1199 .select(["status", "log"])
1200 .where("run_id", "=", runId)
1201 .where("name", "=", "pipeline setup")
1202 .executeTakeFirst();
1203 expect(setup?.status).toBe("failure");
1204 expect(setup?.log).toContain("mkdir: read-only");
1205 });
1206
1207 test("a cache path inside the clone directory is rejected", async () => {
1208 const badSha = seedCiToml(
1209 "ci-repo",
1210 `
1211image = "debian:latest"
1212clone_project_to = "/ci/build/project"
1213cache = ["/ci/build/project/target"]
1214
1215[on]
1216manual = true
1217
1218[[steps]]
1219name = "hello"
1220run_sh = "echo hi"
1221`,
1222 );
1223 const runId = await triggerRun("ci-repo", {
1224 triggerSource: "manual",
1225 commitSha: badSha,
1226 commitBranch: "main",
1227 triggeredBy: adminUserId,
1228 });
1229 expect(await waitForRun(runId)).toBe("failure");
1230 expect(uploads).toHaveLength(0);
1231
1232 const setup = await db
1233 .selectFrom("ci_steps")
1234 .select("log")
1235 .where("run_id", "=", runId)
1236 .where("name", "=", "pipeline setup")
1237 .executeTakeFirst();
1238 expect(setup?.log).toContain("overlaps clone_project_to");
1239 });
1240
1241 test("a cache path above the clone directory is rejected", async () => {
1242 const badSha = seedCiToml(
1243 "ci-repo",
1244 `
1245image = "debian:latest"
1246clone_project_to = "/ci/build/project"
1247cache = ["/ci/build"]
1248
1249[on]
1250manual = true
1251
1252[[steps]]
1253name = "hello"
1254run_sh = "echo hi"
1255`,
1256 );
1257 const runId = await triggerRun("ci-repo", {
1258 triggerSource: "manual",
1259 commitSha: badSha,
1260 commitBranch: "main",
1261 triggeredBy: adminUserId,
1262 });
1263 expect(await waitForRun(runId)).toBe("failure");
1264 expect(uploads).toHaveLength(0);
1265 });
1266
1267 test("a relative clone_project_to is rejected", async () => {
1268 const badSha = seedCiToml(
1269 "ci-repo",
1270 `
1271image = "debian:latest"
1272work_dir = "/ci/build"
1273clone_project_to = "project"
1274
1275[on]
1276manual = true
1277
1278[[steps]]
1279name = "hello"
1280run_sh = "echo hi"
1281`,
1282 );
1283 const runId = await triggerRun("ci-repo", {
1284 triggerSource: "manual",
1285 commitSha: badSha,
1286 commitBranch: "main",
1287 triggeredBy: adminUserId,
1288 });
1289 expect(await waitForRun(runId)).toBe("failure");
1290
1291 const setup = await db
1292 .selectFrom("ci_steps")
1293 .select("log")
1294 .where("run_id", "=", runId)
1295 .where("name", "=", "pipeline setup")
1296 .executeTakeFirst();
1297 expect(setup?.log).toContain("must be an absolute path");
1298 });
1299
1300 test("the upload carries the requested commit", async () => {
1301 const runId = await trigger();
1302 expect(await waitForRun(runId)).toBe("success");
1303
1304 const upload = uploads.find((u) => u.path === "/ci/build/project");
1305 expect(upload).toBeDefined();
1306
1307 // The archive must hold the CI config at the triggered commit, and no
1308 // .git. A dropped commit argument would still produce a valid tar.
1309 const names = tarEntryNames(upload!.body);
1310 expect(names).toContain(".hearthforge-ci.toml");
1311 expect(names.some((n) => n.startsWith(".git/"))).toBe(false);
1312
1313 // git archive writes uid 0 and no entry for the archive root, so the
1314 // destination keeps the mode the container gave it.
1315 for (const h of tarHeaders(upload!.body)) {
1316 expect(h.uid).toBe(0);
1317 expect(h.name).not.toBe("./");
1318 }
1319 });
1320});
1321
1322describe("copy from another image", () => {
1323 const COPY_TOML = `
1324image = "debian:latest"
1325
1326[on]
1327manual = true
1328
1329[[copy]]
1330image = "docker.io/oven/bun:1.4.0-alpine"
1331from = "/usr/local/bin/bun"
1332to = "/usr/local/bin"
1333
1334[[steps]]
1335name = "hello"
1336run_sh = "bun --version"
1337`;
1338
1339 test("pulls the source image and uploads its files", async () => {
1340 const sha = seedCiToml("ci-repo", COPY_TOML);
1341 queueExec({ output: "", exitCode: 0 }); // mkdir of the copy target
1342 queueExec({ output: "1.4.0\n", exitCode: 0 }); // the step
1343
1344 const runId = await triggerRun("ci-repo", {
1345 triggerSource: "manual",
1346 commitSha: sha,
1347 commitBranch: "main",
1348 triggeredBy: adminUserId,
1349 });
1350 expect(await waitForRun(runId)).toBe("success");
1351
1352 expect(pulls).toContain("docker.io/oven/bun");
1353 expect(uploads.map((u) => u.path)).toContain("/usr/local/bin");
1354 });
1355});
1356
1357describe("always and warn_on_fail", () => {
1358 const FLAGS_TOML = `
1359image = "debian:latest"
1360
1361[on]
1362manual = true
1363
1364[[steps]]
1365name = "lint"
1366run_sh = "make lint"
1367warn_on_fail = true
1368
1369[[steps]]
1370name = "build"
1371run_sh = "make"
1372
1373[[steps]]
1374name = "cleanup"
1375run_sh = "rm -rf /scratch"
1376always = true
1377`;
1378
1379 function status(runId: number, name: string) {
1380 return db
1381 .selectFrom("ci_steps")
1382 .select(["status", "log"])
1383 .where("run_id", "=", runId)
1384 .where("name", "=", name)
1385 .executeTakeFirst();
1386 }
1387
1388 async function run(sha: string): Promise<number> {
1389 const runId = await triggerRun("ci-repo", {
1390 triggerSource: "manual",
1391 commitSha: sha,
1392 commitBranch: "main",
1393 triggeredBy: adminUserId,
1394 });
1395 await waitForRun(runId);
1396 return runId;
1397 }
1398
1399 test("warn_on_fail marks the step and lets the run continue", async () => {
1400 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1401 queueExec({ output: "style nit\n", exitCode: 1 }); // lint
1402 queueExec({ output: "built\n", exitCode: 0 }); // build
1403 queueExec({ output: "", exitCode: 0 }); // cleanup
1404
1405 const runId = await run(sha);
1406
1407 expect((await status(runId, "lint"))?.status).toBe("warning");
1408 expect((await status(runId, "lint"))?.log).toContain("style nit");
1409 expect((await status(runId, "build"))?.status).toBe("success");
1410
1411 const runRow = await db
1412 .selectFrom("ci_runs")
1413 .select("status")
1414 .where("id", "=", runId)
1415 .executeTakeFirst();
1416 expect(runRow?.status).toBe("warning");
1417 });
1418
1419 test("always runs after a failure, other steps stay skipped", async () => {
1420 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1421 queueExec({ output: "ok\n", exitCode: 0 }); // lint
1422 queueExec({ output: "boom\n", exitCode: 1 }); // build fails
1423 queueExec({ output: "cleaned\n", exitCode: 0 }); // cleanup, always
1424
1425 const runId = await run(sha);
1426
1427 expect((await status(runId, "build"))?.status).toBe("failure");
1428 expect((await status(runId, "cleanup"))?.status).toBe("success");
1429 expect((await status(runId, "cleanup"))?.log).toContain("cleaned");
1430
1431 const runRow = await db
1432 .selectFrom("ci_runs")
1433 .select("status")
1434 .where("id", "=", runId)
1435 .executeTakeFirst();
1436 expect(runRow?.status).toBe("failure");
1437 });
1438
1439 test("a failing always step keeps the run failed", async () => {
1440 const sha = seedCiToml("ci-repo", FLAGS_TOML);
1441 queueExec({ output: "ok\n", exitCode: 0 }); // lint
1442 queueExec({ output: "boom\n", exitCode: 1 }); // build fails
1443 queueExec({ output: "no\n", exitCode: 1 }); // cleanup also fails
1444
1445 const runId = await run(sha);
1446
1447 expect((await status(runId, "cleanup"))?.status).toBe("failure");
1448 const runRow = await db
1449 .selectFrom("ci_runs")
1450 .select("status")
1451 .where("id", "=", runId)
1452 .executeTakeFirst();
1453 expect(runRow?.status).toBe("failure");
1454 });
1455});
1456
1457describe("duplicate step names", () => {
1458 const DUPES_TOML = `
1459image = "debian:latest"
1460
1461[on]
1462manual = true
1463
1464[[steps]]
1465name = "check"
1466run_sh = "echo one"
1467
1468[[steps]]
1469name = "check"
1470run_sh = "echo two"
1471`;
1472
1473 test("each occurrence gets its own row, in file order", async () => {
1474 const sha = seedCiToml("ci-repo", DUPES_TOML);
1475 queueExec({ output: "one\n", exitCode: 0 });
1476 queueExec({ output: "two\n", exitCode: 0 });
1477
1478 const runId = await triggerRun("ci-repo", {
1479 triggerSource: "manual",
1480 commitSha: sha,
1481 commitBranch: "main",
1482 triggeredBy: adminUserId,
1483 });
1484 expect(await waitForRun(runId)).toBe("success");
1485
1486 const rows = await db
1487 .selectFrom("ci_steps")
1488 .select(["status", "log"])
1489 .where("run_id", "=", runId)
1490 .where("name", "=", "check")
1491 .orderBy("id", "asc")
1492 .execute();
1493
1494 expect(rows).toHaveLength(2);
1495 expect(rows[0]!.log).toContain("one");
1496 expect(rows[1]!.log).toContain("two");
1497 expect(rows.every((r) => r.status === "success")).toBe(true);
1498 });
1499
1500 test("the second occurrence can fail on its own", async () => {
1501 const sha = seedCiToml("ci-repo", DUPES_TOML);
1502 queueExec({ output: "one\n", exitCode: 0 });
1503 queueExec({ output: "boom\n", exitCode: 1 });
1504
1505 const runId = await triggerRun("ci-repo", {
1506 triggerSource: "manual",
1507 commitSha: sha,
1508 commitBranch: "main",
1509 triggeredBy: adminUserId,
1510 });
1511 expect(await waitForRun(runId)).toBe("failure");
1512
1513 const rows = await db
1514 .selectFrom("ci_steps")
1515 .select("status")
1516 .where("run_id", "=", runId)
1517 .where("name", "=", "check")
1518 .orderBy("id", "asc")
1519 .execute();
1520
1521 expect(rows.map((r) => r.status)).toEqual(["success", "failure"]);
1522 });
1523});
1524
1525describe("timeouts override warn_on_fail", () => {
1526 const TIMEOUT_TOML = `
1527image = "debian:latest"
1528
1529[on]
1530manual = true
1531
1532[[steps]]
1533name = "lint"
1534run_sh = "make lint"
1535warn_on_fail = true
1536timeout = 1
1537
1538[[steps]]
1539name = "build"
1540run_sh = "make"
1541`;
1542
1543 test("a timed-out warn_on_fail step fails the run", async () => {
1544 const sha = seedCiToml("ci-repo", TIMEOUT_TOML);
1545 queueExec({ output: "", exitCode: 0, delayMs: 3000 });
1546
1547 const runId = await triggerRun("ci-repo", {
1548 triggerSource: "manual",
1549 commitSha: sha,
1550 commitBranch: "main",
1551 triggeredBy: adminUserId,
1552 });
1553 expect(await waitForRun(runId, 20_000)).toBe("failure");
1554
1555 const lint = await db
1556 .selectFrom("ci_steps")
1557 .select(["status", "log"])
1558 .where("run_id", "=", runId)
1559 .where("name", "=", "lint")
1560 .executeTakeFirst();
1561 // A timeout destroys the container, so nothing after it can run.
1562 // Reporting that as a warning would hide a dead pipeline.
1563 expect(lint?.status).toBe("failure");
1564 expect(lint?.log).toContain("timed out");
1565 }, 30_000);
1566});
1567
1568describe("clear failures are recorded", () => {
1569 const CLEAR_TOML = `
1570image = "debian:latest"
1571work_dir = "/ci/build"
1572clone_project_to = "/ci/build/project"
1573
1574[on]
1575manual = true
1576
1577[[steps]]
1578name = "first"
1579run_sh = "false"
1580
1581[[steps]]
1582name = "second"
1583always = true
1584clear = true
1585run_sh = "echo hi"
1586`;
1587
1588 test("a clear failure lands on the step, not the console", async () => {
1589 const sha = seedCiToml("ci-repo", CLEAR_TOML);
1590 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
1591 queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to
1592 queueExec({ output: "boom\n", exitCode: 1 }); // first, fails
1593 queueExec({ output: "rm: device busy\n", exitCode: 1 }); // clear
1594
1595 const runId = await triggerRun("ci-repo", {
1596 triggerSource: "manual",
1597 commitSha: sha,
1598 commitBranch: "main",
1599 triggeredBy: adminUserId,
1600 });
1601 expect(await waitForRun(runId)).toBe("failure");
1602
1603 const second = await db
1604 .selectFrom("ci_steps")
1605 .select(["status", "log"])
1606 .where("run_id", "=", runId)
1607 .where("name", "=", "second")
1608 .executeTakeFirst();
1609 expect(second?.status).toBe("failure");
1610 expect(second?.log).toContain("Failed to reset");
1611 expect(second?.log).toContain("device busy");
1612 });
1613
1614 test("a clear step re-extracts the checkout", async () => {
1615 const sha = seedCiToml("ci-repo", CLEAR_TOML);
1616 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
1617 queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to
1618 queueExec({ output: "ok\n", exitCode: 0 }); // first
1619 queueExec({ output: "", exitCode: 0 }); // clear: rm -rf
1620 queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to again
1621 queueExec({ output: "hi\n", exitCode: 0 }); // second
1622
1623 const runId = await triggerRun("ci-repo", {
1624 triggerSource: "manual",
1625 commitSha: sha,
1626 commitBranch: "main",
1627 triggeredBy: adminUserId,
1628 });
1629 expect(await waitForRun(runId)).toBe("success");
1630
1631 const toProject = uploads.filter((u) => u.path === "/ci/build/project");
1632 expect(toProject.length).toBe(2);
1633 expect(execCmds.flat().join(" ")).not.toContain("git");
1634 });
1635
1636 test("clear removes and recreates the directory in one exec", async () => {
1637 // `rm -rf` can delete the container's WorkingDir. A second exec would
1638 // then fail to chdir before its command starts, with exit 127 and an
1639 // opaque OCI message. Splitting these is the regression.
1640 const sha = seedCiToml(
1641 "ci-repo",
1642 `
1643image = "debian:latest"
1644work_dir = "/ci/build"
1645clone_project_to = "/ci/build"
1646
1647[on]
1648manual = true
1649
1650[[steps]]
1651name = "first"
1652run_sh = "true"
1653
1654[[steps]]
1655name = "second"
1656clear = true
1657run_sh = "echo hi"
1658`,
1659 );
1660 const runId = await triggerRun("ci-repo", {
1661 triggerSource: "manual",
1662 commitSha: sha,
1663 commitBranch: "main",
1664 triggeredBy: adminUserId,
1665 });
1666 expect(await waitForRun(runId)).toBe("success");
1667
1668 const removals = execCmds.filter((c) => c.join(" ").includes("rm -rf"));
1669 expect(removals).toHaveLength(1);
1670 expect(removals[0]!.join(" ")).toContain("mkdir -p");
1671 });
1672});
1673
1674describe("cache volumes", () => {
1675 const CACHE_TOML = `
1676image = "debian:latest"
1677cache = ["/ci/cache/target", "/ci/cache/registry"]
1678
1679[on]
1680manual = true
1681push = ["main"]
1682
1683[[steps]]
1684name = "hello"
1685run_sh = "echo hi"
1686`;
1687
1688 async function run(sha: string, branch: string): Promise<number> {
1689 const runId = await triggerRun("ci-repo", {
1690 triggerSource: "manual",
1691 commitSha: sha,
1692 commitBranch: branch,
1693 triggeredBy: adminUserId,
1694 });
1695 await waitForRun(runId);
1696 return runId;
1697 }
1698
1699 test("two cache paths sharing a prefix get distinct volumes", async () => {
1700 const sha = seedCiToml("ci-repo", CACHE_TOML);
1701 await run(sha, "main");
1702
1703 const names = volumesCreated.map((v) => v.name);
1704 expect(names).toHaveLength(2);
1705 expect(new Set(names).size).toBe(2);
1706 // The path is otherwise unrecoverable from a digest.
1707 expect(volumesCreated.map((v) => v.labels["com.hearthforge.cache-path"]))
1708 .toEqual(["/ci/cache/target", "/ci/cache/registry"]);
1709 });
1710
1711 test("a volume the config no longer names is pruned", async () => {
1712 const sha = seedCiToml("ci-repo", CACHE_TOML);
1713 resetMock();
1714 volumesOnHost = ["hearthforge-ci-cache-leftover-from-an-old-config"];
1715
1716 await run(sha, "main");
1717
1718 expect(volumesDeleted).toEqual([
1719 "hearthforge-ci-cache-leftover-from-an-old-config",
1720 ]);
1721 });
1722
1723 test("volumes still in the config survive", async () => {
1724 const sha = seedCiToml("ci-repo", CACHE_TOML);
1725 resetMock();
1726 // Prime the host list with the names this config will create.
1727 await run(sha, "main");
1728 const inUse = volumesCreated.map((v) => v.name);
1729
1730 resetMock();
1731 volumesOnHost = inUse;
1732 await run(sha, "main");
1733
1734 expect(volumesDeleted).toEqual([]);
1735 });
1736
1737 test("a run off the default branch prunes nothing", async () => {
1738 const sha = seedCiToml("ci-repo", CACHE_TOML);
1739 resetMock();
1740 volumesOnHost = ["hearthforge-ci-cache-belongs-to-the-default-branch"];
1741
1742 // The config is read per commit, so pruning from a feature branch
1743 // would delete the default branch's caches.
1744 await run(sha, "some-feature");
1745
1746 expect(volumesDeleted).toEqual([]);
1747 });
1748});
1749
1750describe("cache size caps", () => {
1751 const CAPPED_TOML = `
1752image = "debian:latest"
1753cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
1754
1755[on]
1756manual = true
1757
1758[[steps]]
1759name = "hello"
1760run_sh = "echo hi"
1761`;
1762
1763 async function run(sha: string): Promise<number> {
1764 const runId = await triggerRun("ci-repo", {
1765 triggerSource: "manual",
1766 commitSha: sha,
1767 commitBranch: "main",
1768 triggeredBy: adminUserId,
1769 });
1770 await waitForRun(runId);
1771 return runId;
1772 }
1773
1774 /** Volume names the config produces, in declaration order. */
1775 async function names(sha: string): Promise<string[]> {
1776 resetMock();
1777 await run(sha);
1778 return volumesCreated.map((v) => v.name);
1779 }
1780
1781 test("an oversized cache is dropped and reported on the run", async () => {
1782 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1783 const [target, registry] = await names(sha);
1784
1785 resetMock();
1786 volumesOnHost = [target!, registry!];
1787 volumeUsage = {
1788 [target!]: { Size: 2 * 1024 ** 3, RefCount: 0 },
1789 [registry!]: { Size: 9 * 1024 ** 3, RefCount: 0 },
1790 };
1791 const runId = await run(sha);
1792
1793 // Only the capped one goes, however large the uncapped one grows.
1794 expect(volumesDeleted).toEqual([target!]);
1795
1796 const step = await db
1797 .selectFrom("ci_steps")
1798 .select("log")
1799 .where("run_id", "=", runId)
1800 .where("name", "=", "cache")
1801 .executeTakeFirst();
1802 expect(step?.log).toContain("/ci/cache/target");
1803 expect(step?.log).toContain("2.0G");
1804 });
1805
1806 test("a cache under its cap survives", async () => {
1807 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1808 const [target, registry] = await names(sha);
1809
1810 resetMock();
1811 volumesOnHost = [target!, registry!];
1812 volumeUsage = { [target!]: { Size: 100, RefCount: 0 } };
1813 await run(sha);
1814
1815 expect(volumesDeleted).toEqual([]);
1816 });
1817
1818 test("a cache a concurrent run holds is left alone", async () => {
1819 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1820 const [target, registry] = await names(sha);
1821
1822 resetMock();
1823 volumesOnHost = [target!, registry!];
1824 volumeUsage = { [target!]: { Size: 9 * 1024 ** 3, RefCount: 1 } };
1825 await run(sha);
1826
1827 expect(volumesDeleted).toEqual([]);
1828 });
1829
1830 test("an unmeasured cache is never dropped", async () => {
1831 const sha = seedCiToml("ci-repo", CAPPED_TOML);
1832 const [target, registry] = await names(sha);
1833
1834 resetMock();
1835 volumesOnHost = [target!, registry!];
1836 // Docker reports -1 for a size it has not computed.
1837 volumeUsage = { [target!]: { Size: -1, RefCount: 0 } };
1838 const runId = await run(sha);
1839
1840 expect(volumesDeleted).toEqual([]);
1841 const step = await db
1842 .selectFrom("ci_steps")
1843 .select("id")
1844 .where("run_id", "=", runId)
1845 .where("name", "=", "cache")
1846 .executeTakeFirst();
1847 expect(step).toBeUndefined();
1848 });
1849});
1850