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}
40
41// Repo archive uploads (PUT /containers/*/archive)
42const uploads: Array<{ path: string; bytes: number }> = [];
43// Body of the last POST /containers/create
44let lastCreateBody: Record<string, any> | null = null;
45// Per-exec-ID response map, populated when exec is created
46const execMap = new Map<string, ExecResp>();
47// Queue consumed in order when execs are created — allows tests to pre-program
48// specific step responses
49const execQueue: ExecResp[] = [];
50let execCounter = 0;
51
52function queueExec(resp: ExecResp) {
53 execQueue.push(resp);
54}
55
56function resetMock() {
57 execMap.clear();
58 execQueue.length = 0;
59 execCounter = 0;
60 uploads.length = 0;
61 lastCreateBody = null;
62}
63
64/** Build a Docker multiplexed stream frame from a string. */
65function muxFrame(text: string, stream = 1): Uint8Array {
66 const payload = Buffer.from(text, "utf-8");
67 const hdr = Buffer.alloc(8);
68 hdr[0] = stream;
69 hdr.writeUInt32BE(payload.length, 4);
70 return Buffer.concat([hdr, payload]);
71}
72
73/** Build a minimal tar archive containing one file. */
74function makeTar(filename: string, content: string): Uint8Array {
75 const data = Buffer.from(content, "utf-8");
76 const hdr = Buffer.alloc(512);
77 hdr.write(path.basename(filename).slice(0, 100), 0, "ascii");
78 hdr.write("0000644\0", 100, "ascii"); // mode
79 hdr.write("0000000\0", 108, "ascii"); // uid
80 hdr.write("0000000\0", 116, "ascii"); // gid
81 hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
82 hdr.write("00000000000\0", 136, "ascii"); // mtime
83 hdr[156] = 0x30; // type flag: regular file
84 // Checksum: fill with spaces, compute, write back
85 hdr.fill(0x20, 148, 156);
86 let sum = 0;
87 for (let i = 0; i < 512; i++) sum += hdr[i]!;
88 hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
89 // Pad file content to 512-byte block
90 const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512;
91 const padded = Buffer.alloc(paddedLen);
92 data.copy(padded);
93 return Buffer.concat([hdr, padded]);
94}
95
96let mockServer: ReturnType<typeof Bun.serve>;
97
98function startMockDocker() {
99 rmSync(SOCKET_PATH, { force: true });
100 mockServer = Bun.serve({
101 unix: SOCKET_PATH,
102 async fetch(req: Request): Promise<Response> {
103 const p = new URL(req.url).pathname;
104 const qs = new URL(req.url).searchParams;
105
106 // Health check
107 if (req.method === "GET" && p === "/v1.47/info") {
108 return Response.json({ ServerVersion: "mock" });
109 }
110 // Pull image (streaming, just needs to resolve)
111 if (req.method === "POST" && p.startsWith("/v1.47/images/create")) {
112 return new Response('{"status":"Pull complete"}\n');
113 }
114 // Create container
115 if (req.method === "POST" && /\/containers\/create/.test(p)) {
116 lastCreateBody = (await req.json()) as Record<string, any>;
117 return Response.json({ Id: "mock-ctr-001" });
118 }
119 // Start container
120 if (
121 req.method === "POST" &&
122 /\/containers\/[^/]+\/start$/.test(p)
123 ) {
124 return new Response(null, { status: 204 });
125 }
126 // Create exec — pop next queued response and assign to this exec ID
127 if (
128 req.method === "POST" &&
129 /\/containers\/[^/]+\/exec$/.test(p)
130 ) {
131 execCounter++;
132 const execId = `mock-exec-${execCounter}`;
133 execMap.set(
134 execId,
135 execQueue.shift() ?? { output: "", exitCode: 0 },
136 );
137 return Response.json({ Id: execId });
138 }
139 // Start exec — return queued output as mux stream
140 if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) {
141 const id = p.match(/\/exec\/([^/]+)\/start/)![1]!;
142 const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
143 return new Response(
144 resp.output ? muxFrame(resp.output) : new Uint8Array(0),
145 );
146 }
147 // Inspect exec — return exit code
148 if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) {
149 const id = p.match(/\/exec\/([^/]+)\/json/)![1]!;
150 const resp = execMap.get(id) ?? { output: "", exitCode: 0 };
151 return Response.json({ ExitCode: resp.exitCode });
152 }
153 // Archive upload (used to copy the bare repo into the container)
154 if (
155 req.method === "PUT" &&
156 /\/containers\/[^/]+\/archive/.test(p)
157 ) {
158 const body = new Uint8Array(await req.arrayBuffer());
159 uploads.push({
160 path: qs.get("path") ?? "",
161 bytes: body.length,
162 });
163 return new Response(null, { status: 200 });
164 }
165 // Archive (used by publish_file artifact collection)
166 if (
167 req.method === "GET" &&
168 /\/containers\/[^/]+\/archive/.test(p)
169 ) {
170 const filePath = qs.get("path") ?? "file.txt";
171 return new Response(
172 makeTar(path.basename(filePath), "artifact-content-123"),
173 { headers: { "Content-Type": "application/x-tar" } },
174 );
175 }
176 // Delete container
177 if (req.method === "DELETE" && /\/containers\//.test(p)) {
178 return new Response(null, { status: 204 });
179 }
180 // Volume create (used for cache volumes)
181 if (req.method === "POST" && p === "/v1.47/volumes/create") {
182 return Response.json({ Name: "mock-volume" });
183 }
184 // Volume list (used by purge cache)
185 if (req.method === "GET" && p === "/v1.47/volumes") {
186 return Response.json({ Volumes: [] });
187 }
188 // Volume delete
189 if (req.method === "DELETE" && /\/volumes\//.test(p)) {
190 return new Response(null, { status: 204 });
191 }
192 return new Response("Not found", { status: 404 });
193 },
194 });
195}
196
197// ── Helpers ───────────────────────────────────────────────────────────────────
198
199/** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */
200function seedCiToml(repoName: string, toml: string): string {
201 const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`);
202 const tmp = `/tmp/hf-ci-seed-${Date.now()}`;
203 try {
204 spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" });
205 spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], {
206 stdio: "ignore",
207 });
208 spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], {
209 stdio: "ignore",
210 });
211 writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml);
212 spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], {
213 stdio: "ignore",
214 });
215 spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], {
216 stdio: "ignore",
217 });
218 spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], {
219 stdio: "ignore",
220 });
221 const r = spawnSync(
222 "git",
223 ["-C", tmp, "rev-parse", "HEAD"],
224 { stdio: ["ignore", "pipe", "ignore"] },
225 );
226 return r.stdout.toString().trim();
227 } finally {
228 rmSync(tmp, { recursive: true, force: true });
229 }
230}
231
232/** Poll until a CI run leaves pending/running state, then return its status. */
233async function waitForRun(runId: number, timeoutMs = 10_000): Promise<string> {
234 const deadline = Date.now() + timeoutMs;
235 while (Date.now() < deadline) {
236 const row = await db
237 .selectFrom("ci_runs")
238 .select("status")
239 .where("id", "=", runId)
240 .executeTakeFirst();
241 if (row && row.status !== "pending" && row.status !== "running") {
242 return row.status;
243 }
244 await Bun.sleep(100);
245 }
246 throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`);
247}
248
249async function loggedInContext(
250 browser: Browser,
251 username = "admin",
252 password = ADMIN_PASS,
253): Promise<BrowserContext> {
254 const ctx = await browser.newContext();
255 const page = await ctx.newPage();
256 await login(page, username, password);
257 await page.close();
258 return ctx;
259}
260
261// ── Test setup ────────────────────────────────────────────────────────────────
262
263let browser: Browser;
264let server: Awaited<ReturnType<typeof spawnServer>>;
265let adminCtx: BrowserContext;
266let adminUserId: number;
267let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml
268
269const SIMPLE_TOML = `
270image = "debian:latest"
271
272[on]
273manual = true
274push = ["main"]
275
276[hello]
277run_sh = "echo hello"
278`;
279
280const ARTIFACT_TOML = `
281image = "debian:latest"
282work_dir = "/ci"
283
284[on]
285manual = true
286
287[build]
288run_sh = "echo building"
289publish_file = ["/ci/output.txt"]
290`;
291
292beforeAll(async () => {
293 await setupTestEnv();
294
295 // Point CI service at mock socket BEFORE starting any runs
296 config.CI_DOCKER_SOCKET = SOCKET_PATH;
297 resetDockerSocket();
298 startMockDocker();
299
300 server = await spawnServer();
301 browser = await chromium.launch();
302 adminCtx = await loggedInContext(browser);
303
304 // Get admin user ID
305 const row = await db
306 .selectFrom("users")
307 .select("id")
308 .where("username", "=", "admin")
309 .executeTakeFirst();
310 adminUserId = row!.id;
311
312 // Create ci-repo via UI and seed it
313 const page = await adminCtx.newPage();
314 try {
315 await page.goto(`${BASE}/new`);
316 await page.fill("[name=name]", "ci-repo");
317 await page.click('form[action="/new"] button[type=submit]');
318 await page.waitForURL(`${BASE}/ci-repo`);
319 } finally {
320 await page.close();
321 }
322 seedRepo("ci-repo");
323 ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML);
324});
325
326afterAll(async () => {
327 await adminCtx.close();
328 await browser.close();
329 await killServer(server);
330 mockServer.stop(true);
331 rmSync(SOCKET_PATH, { force: true });
332});
333
334beforeEach(() => {
335 resetMock();
336});
337
338// ── Tests ─────────────────────────────────────────────────────────────────────
339
340describe("pipelines tab", () => {
341 test("tab is visible in repo nav", async () => {
342 const page = await adminCtx.newPage();
343 try {
344 await page.goto(`${BASE}/ci-repo`);
345 const tab = page.locator('.repo-tab', { hasText: 'Pipelines' });
346 expect(await tab.isVisible()).toBe(true);
347 } finally {
348 await page.close();
349 }
350 });
351
352 test("history page shows empty state when no runs", async () => {
353 // Use a separate repo that has never had a run
354 const page = await adminCtx.newPage();
355 try {
356 await page.goto(`${BASE}/ci-repo/ci`);
357 expect(await page.locator(".empty-state").isVisible()).toBe(true);
358 expect(await page.locator(".empty-state").textContent()).toContain(
359 "No pipeline runs yet",
360 );
361 } finally {
362 await page.close();
363 }
364 });
365
366 test("help section is collapsible and contains template download", async () => {
367 const page = await adminCtx.newPage();
368 try {
369 await page.goto(`${BASE}/ci-repo/ci`);
370 const help = page.locator("details.ci-help");
371 expect(await help.isVisible()).toBe(true);
372 await help.locator("summary").click();
373 const dlLink = page.locator('a[download=".hearthforge-ci.toml"]');
374 expect(await dlLink.isVisible()).toBe(true);
375 } finally {
376 await page.close();
377 }
378 });
379});
380
381describe("successful run", () => {
382 let runId: number;
383
384 beforeAll(async () => {
385 queueExec({ output: "hello from mock CI\n", exitCode: 0 });
386 runId = await triggerRun("ci-repo", {
387 triggerSource: "manual",
388 commitSha: ciRepoSha,
389 commitBranch: "main",
390 triggeredBy: adminUserId,
391 });
392 await waitForRun(runId);
393 });
394
395 test("run status is success", async () => {
396 const run = await db
397 .selectFrom("ci_runs")
398 .select("status")
399 .where("id", "=", runId)
400 .executeTakeFirst();
401 expect(run?.status).toBe("success");
402 });
403
404 test("step status is success and log is captured", async () => {
405 const step = await db
406 .selectFrom("ci_steps")
407 .select(["status", "log"])
408 .where("run_id", "=", runId)
409 .where("name", "=", "hello")
410 .executeTakeFirst();
411 expect(step?.status).toBe("success");
412 expect(step?.log).toContain("hello from mock CI");
413 });
414
415 test("history page shows the completed run", async () => {
416 const page = await adminCtx.newPage();
417 try {
418 await page.goto(`${BASE}/ci-repo/ci`);
419 expect(
420 await page.locator(".ci-status-pill.ci-status-success").count(),
421 ).toBeGreaterThan(0);
422 } finally {
423 await page.close();
424 }
425 });
426
427 test("run detail page shows step and log", async () => {
428 const page = await adminCtx.newPage();
429 try {
430 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
431 expect(
432 await page.locator(".ci-step").first().textContent(),
433 ).toContain("hello");
434 // Open step details to see log
435 await page.locator(".ci-step").first().click();
436 expect(await page.locator(".ci-step-log").textContent()).toContain(
437 "hello from mock CI",
438 );
439 } finally {
440 await page.close();
441 }
442 });
443
444 test("retry re-executes the same run in-place", async () => {
445 const page = await adminCtx.newPage();
446 try {
447 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
448 await page.click('button:text("Retry")');
449 // Should redirect back to the same run URL
450 await page.waitForURL(`${BASE}/ci-repo/ci/${runId}`);
451 // Wait for the run to complete (uses default exit 0)
452 const status = await waitForRun(runId);
453 expect(status).toBe("success");
454 // Confirm no new run was created — DB count for this repo should be unchanged
455 const run = await db
456 .selectFrom("ci_runs")
457 .select("id")
458 .where("id", "=", runId)
459 .executeTakeFirst();
460 expect(run?.id).toBe(runId);
461 } finally {
462 await page.close();
463 }
464 });
465});
466
467describe("failing run", () => {
468 let runId: number;
469
470 beforeAll(async () => {
471 // Step exec: non-zero exit code
472 queueExec({ output: "build error: file not found\n", exitCode: 1 });
473 runId = await triggerRun("ci-repo", {
474 triggerSource: "manual",
475 commitSha: ciRepoSha,
476 commitBranch: "main",
477 triggeredBy: adminUserId,
478 });
479 await waitForRun(runId);
480 });
481
482 test("run status is failure", async () => {
483 const run = await db
484 .selectFrom("ci_runs")
485 .select("status")
486 .where("id", "=", runId)
487 .executeTakeFirst();
488 expect(run?.status).toBe("failure");
489 });
490
491 test("step status is failure and error log captured", async () => {
492 const step = await db
493 .selectFrom("ci_steps")
494 .select(["status", "log"])
495 .where("run_id", "=", runId)
496 .where("name", "=", "hello")
497 .executeTakeFirst();
498 expect(step?.status).toBe("failure");
499 expect(step?.log).toContain("build error");
500 });
501
502 test("run detail page shows failure status", async () => {
503 const page = await adminCtx.newPage();
504 try {
505 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
506 expect(
507 await page.locator(".ci-status-pill.ci-status-failure").count(),
508 ).toBeGreaterThan(0);
509 } finally {
510 await page.close();
511 }
512 });
513});
514
515describe("cancel", () => {
516 test("cancelling a pending run marks it cancelled", async () => {
517 // Trigger without queuing — run will start and eventually succeed,
518 // but we cancel immediately before it gets far
519 const runId = await triggerRun("ci-repo", {
520 triggerSource: "manual",
521 commitSha: ciRepoSha,
522 commitBranch: "main",
523 triggeredBy: adminUserId,
524 });
525 // Cancel via API before it completes
526 const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, {
527 method: "POST",
528 redirect: "manual",
529 });
530 expect(resp.status).toBe(302);
531
532 // Wait and check final status
533 const status = await waitForRun(runId);
534 expect(["cancelled", "success", "failure"]).toContain(status);
535
536 // If we got there first, it's cancelled
537 if (status === "cancelled") {
538 const run = await db
539 .selectFrom("ci_runs")
540 .select("status")
541 .where("id", "=", runId)
542 .executeTakeFirst();
543 expect(run?.status).toBe("cancelled");
544 }
545 });
546});
547
548describe("artifacts", () => {
549 let runId: number;
550 let artifactId: number;
551
552 beforeAll(async () => {
553 // Seed repo with artifact TOML
554 const sha = seedCiToml("ci-repo", ARTIFACT_TOML);
555 // work_dir causes 1 mkdir exec before the step
556 // defaults: {output:'', exitCode:0} for both
557 runId = await triggerRun("ci-repo", {
558 triggerSource: "manual",
559 commitSha: sha,
560 commitBranch: "main",
561 triggeredBy: adminUserId,
562 });
563 await waitForRun(runId);
564
565 const artifact = await db
566 .selectFrom("ci_artifacts")
567 .select("id")
568 .where("run_id", "=", runId)
569 .executeTakeFirst();
570 artifactId = artifact?.id ?? 0;
571 });
572
573 test("artifact row created in DB", async () => {
574 const artifacts = await db
575 .selectFrom("ci_artifacts")
576 .selectAll()
577 .where("run_id", "=", runId)
578 .execute();
579 expect(artifacts.length).toBe(1);
580 expect(artifacts[0]!.filename).toBe("output.txt");
581 });
582
583 test("artifact is downloadable via HTTP", async () => {
584 expect(artifactId).toBeGreaterThan(0);
585 const resp = await fetch(
586 `${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`,
587 );
588 expect(resp.status).toBe(200);
589 const body = await resp.text();
590 expect(body).toBe("artifact-content-123");
591 });
592
593 test("run detail page shows artifact list", async () => {
594 const page = await adminCtx.newPage();
595 try {
596 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
597 expect(
598 await page.locator(".ci-artifact-item").count(),
599 ).toBeGreaterThan(0);
600 expect(
601 await page.locator(".ci-artifact-name").textContent(),
602 ).toContain("output.txt");
603 } finally {
604 await page.close();
605 }
606 });
607});
608
609describe("badge", () => {
610 test("badge SVG returns success status after successful run", async () => {
611 const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`);
612 expect(resp.status).toBe(200);
613 expect(resp.headers.get("Content-Type")).toContain("image/svg+xml");
614 const body = await resp.text();
615 expect(body).toContain("<svg");
616 expect(body).toContain("success");
617 });
618
619 test("badge returns 404 for private repo when not logged in", async () => {
620 // Create a private repo
621 const page = await adminCtx.newPage();
622 try {
623 await page.goto(`${BASE}/new`);
624 await page.fill("[name=name]", "private-ci-repo");
625 await page.check("[name=is_private]");
626 await page.click('form[action="/new"] button[type=submit]');
627 await page.waitForURL(`${BASE}/private-ci-repo`);
628 } finally {
629 await page.close();
630 }
631 const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`);
632 expect(resp.status).toBe(404);
633 });
634});
635
636describe("secrets", () => {
637 test("can add, list, and delete a secret via settings", async () => {
638 const page = await adminCtx.newPage();
639 try {
640 await page.goto(`${BASE}/ci-repo/settings`);
641 // Add secret — scope to the CI secrets form
642 const secretsForm = page.locator('form[action$="/settings/ci-secrets"]');
643 await secretsForm.locator('[name=name]').fill("MY_SECRET");
644 await secretsForm.locator('[name=value]').fill("super-secret-value");
645 await secretsForm.locator('[name=description]').fill("A test secret");
646 await secretsForm.locator('button[type=submit]').click();
647 await page.waitForURL(/settings/);
648 // Secret name is shown, value masked
649 expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1);
650 expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0);
651
652 // Delete it
653 const deleteBtn = page
654 .locator(".label-settings-item")
655 .filter({ hasText: "MY_SECRET" })
656 .locator('button:text("Delete")');
657 await deleteBtn.click();
658 await page.waitForURL(/settings/);
659 expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0);
660 } finally {
661 await page.close();
662 }
663 });
664
665 test("secret value is masked in step logs", async () => {
666 // Add secret
667 await db
668 .insertInto("ci_secrets")
669 .values({
670 repo_id: (await db
671 .selectFrom("repositories")
672 .select("id")
673 .where("name", "=", "ci-repo")
674 .executeTakeFirstOrThrow()).id,
675 name: "MASK_ME",
676 value: "s3cr3t-p4ssw0rd",
677 })
678 .execute();
679
680 // Step echoes the secret value; mock returns it as output
681 queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 });
682 const runId = await triggerRun("ci-repo", {
683 triggerSource: "manual",
684 commitSha: ciRepoSha,
685 commitBranch: "main",
686 triggeredBy: adminUserId,
687 });
688 await waitForRun(runId);
689
690 const step = await db
691 .selectFrom("ci_steps")
692 .select("log")
693 .where("run_id", "=", runId)
694 .where("name", "=", "hello")
695 .executeTakeFirst();
696
697 expect(step?.log).not.toContain("s3cr3t-p4ssw0rd");
698 expect(step?.log).toContain("[MASKED]");
699
700 // Cleanup
701 await db
702 .deleteFrom("ci_secrets")
703 .where("name", "=", "MASK_ME")
704 .execute();
705 });
706});
707
708describe("per-repo run IDs", () => {
709 test("repo_run_id is set and increments per repo", async () => {
710 const runs = await db
711 .selectFrom("ci_runs")
712 .select(["id", "repo_run_id"])
713 .orderBy("id", "asc")
714 .execute();
715 // Every run should have a repo_run_id set
716 for (const run of runs) {
717 expect(run.repo_run_id).not.toBeNull();
718 expect(run.repo_run_id).toBeGreaterThan(0);
719 }
720 // repo_run_ids within the same repo should be sequential (no gaps, no duplicates)
721 const ids = runs.map((r) => r.repo_run_id!).sort((a, b) => a - b);
722 for (let i = 0; i < ids.length; i++) {
723 expect(ids[i]).toBe(i + 1);
724 }
725 });
726
727 test("run detail page shows repo-local run number", async () => {
728 const run = await db
729 .selectFrom("ci_runs")
730 .select(["id", "repo_run_id"])
731 .orderBy("id", "asc")
732 .executeTakeFirst();
733 if (!run?.repo_run_id) return;
734 const page = await adminCtx.newPage();
735 try {
736 await page.goto(`${BASE}/ci-repo/ci/${run.id}`);
737 const heading = await page.locator("h2").first().textContent();
738 expect(heading).toContain(`#${run.repo_run_id}`);
739 } finally {
740 await page.close();
741 }
742 });
743});
744
745describe("skip reasons", () => {
746 const SKIP_IF_TOML = `
747image = "debian:latest"
748
749[on]
750manual = true
751
752[first]
753run_sh = "echo first"
754
755[second]
756run_if = "false"
757run_sh = "echo second"
758
759[third]
760run_sh = "echo third"
761`;
762
763 test("run_if failure sets skip reason in log", async () => {
764 const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
765 // first step succeeds, second is skipped via run_if (exitCode 1), third runs
766 queueExec({ output: "first\n", exitCode: 0 }); // first step
767 queueExec({ output: "", exitCode: 1 }); // run_if check for second
768 queueExec({ output: "third\n", exitCode: 0 }); // third step
769 const runId = await triggerRun("ci-repo", {
770 triggerSource: "manual",
771 commitSha: sha,
772 commitBranch: "main",
773 triggeredBy: adminUserId,
774 });
775 await waitForRun(runId);
776
777 const skipped = await db
778 .selectFrom("ci_steps")
779 .select(["status", "log"])
780 .where("run_id", "=", runId)
781 .where("name", "=", "second")
782 .executeTakeFirst();
783 expect(skipped?.status).toBe("skipped");
784 expect(skipped?.log).toContain("condition not met");
785 });
786
787 test("failed step causes remaining steps to be skipped with reason", async () => {
788 const sha = seedCiToml("ci-repo", SKIP_IF_TOML);
789 queueExec({ output: "boom\n", exitCode: 1 }); // first step fails
790 const runId = await triggerRun("ci-repo", {
791 triggerSource: "manual",
792 commitSha: sha,
793 commitBranch: "main",
794 triggeredBy: adminUserId,
795 });
796 await waitForRun(runId);
797
798 const skipped = await db
799 .selectFrom("ci_steps")
800 .select(["status", "log"])
801 .where("run_id", "=", runId)
802 .where("name", "=", "third")
803 .executeTakeFirst();
804 expect(skipped?.status).toBe("skipped");
805 expect(skipped?.log).toContain("previous step failed");
806 });
807});
808
809describe("docker unavailable", () => {
810 test("run is marked skipped when docker socket is missing", async () => {
811 // Temporarily point at a non-existent socket
812 config.CI_DOCKER_SOCKET = "/tmp/no-such-socket.sock";
813 resetDockerSocket();
814
815 const runId = await triggerRun("ci-repo", {
816 triggerSource: "manual",
817 commitSha: ciRepoSha,
818 commitBranch: "main",
819 triggeredBy: adminUserId,
820 });
821 const status = await waitForRun(runId);
822 expect(status).toBe("skipped");
823
824 // Restore mock socket
825 config.CI_DOCKER_SOCKET = SOCKET_PATH;
826 resetDockerSocket();
827 });
828});
829
830describe("manual trigger without on.manual", () => {
831 const NO_MANUAL_TOML = `
832image = "debian:latest"
833
834[on]
835push = ["main"]
836
837[hello]
838run_sh = "echo hi"
839`;
840
841 test("manual run is allowed even without manual = true in config", async () => {
842 const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
843 queueExec({ output: "hi\n", exitCode: 0 });
844 // Trigger directly (the route check was removed)
845 const runId = await triggerRun("ci-repo", {
846 triggerSource: "manual",
847 commitSha: sha,
848 commitBranch: "main",
849 triggeredBy: adminUserId,
850 });
851 const status = await waitForRun(runId);
852 expect(status).toBe("success");
853 });
854
855 test("Run pipeline button is not disabled when toml lacks manual = true", async () => {
856 const sha = seedCiToml("ci-repo", NO_MANUAL_TOML);
857 void sha;
858 const page = await adminCtx.newPage();
859 try {
860 await page.goto(`${BASE}/ci-repo/ci`);
861 const btn = page.locator('button:text("Run pipeline")');
862 expect(await btn.isDisabled()).toBe(false);
863 } finally {
864 await page.close();
865 }
866 });
867});
868
869describe("auto-refresh toggle", () => {
870 test("Pause refresh button appears on active run and ?refresh=off shows Resume", async () => {
871 // Trigger a run that won't complete immediately by not pre-queuing output
872 // (the exec queue will block until the mock returns, which is instant, so
873 // we just check the in-progress URL before it finishes)
874 const runId = await triggerRun("ci-repo", {
875 triggerSource: "manual",
876 commitSha: ciRepoSha,
877 commitBranch: "main",
878 triggeredBy: adminUserId,
879 });
880
881 const page = await adminCtx.newPage();
882 try {
883 // Visit with default refresh (on) — run may still be pending/running
884 await page.goto(`${BASE}/ci-repo/ci/${runId}`);
885 // The "Pause refresh" link is shown when run is active and autoRefresh=true
886 // (It may not be visible if run already completed — that's acceptable)
887 const pauseLink = page.locator('a:text("Pause refresh")');
888 const resumeLink = page.locator('a:text("Resume refresh")');
889 const isPaused = await resumeLink.isVisible();
890 const isRefreshing = await pauseLink.isVisible();
891 // One of the two states must be present, or run completed
892 expect(isPaused || isRefreshing || true).toBe(true); // always passes — existence check
893
894 // Visit with ?refresh=off — meta refresh must be absent
895 await page.goto(`${BASE}/ci-repo/ci/${runId}?refresh=off`);
896 const metaRefreshCount = await page
897 .locator('meta[http-equiv="refresh"]')
898 .count();
899 expect(metaRefreshCount).toBe(0);
900 } finally {
901 await page.close();
902 }
903 await waitForRun(runId);
904 });
905});
906
907describe("purge cache", () => {
908 test("Purge caches button is visible and submits successfully", async () => {
909 const page = await adminCtx.newPage();
910 try {
911 await page.goto(`${BASE}/ci-repo/ci`);
912 const btn = page.locator('button:text("Purge caches")');
913 expect(await btn.isVisible()).toBe(true);
914 await btn.click();
915 // Should redirect back to CI history
916 await page.waitForURL(/\/ci-repo\/ci/);
917 // History page loads without error
918 expect(await page.locator("h2").textContent()).toContain("Pipelines");
919 } finally {
920 await page.close();
921 }
922 });
923});
924
925describe("repo upload", () => {
926 const CLONE_TOML = `
927image = "debian:latest"
928work_dir = "/ci/build"
929clone_project_to = "/ci/build/project"
930
931[on]
932manual = true
933
934[hello]
935run_sh = "echo hi"
936`;
937
938 let cloneSha: string;
939
940 beforeAll(() => {
941 cloneSha = seedCiToml("ci-repo", CLONE_TOML);
942 });
943
944 function trigger(): Promise<number> {
945 return triggerRun("ci-repo", {
946 triggerSource: "manual",
947 commitSha: cloneSha,
948 commitBranch: "main",
949 triggeredBy: adminUserId,
950 });
951 }
952
953 test("bare repo is uploaded instead of bind-mounted", async () => {
954 const runId = await trigger();
955 expect(await waitForRun(runId)).toBe("success");
956
957 expect(uploads.map((u) => u.path)).toContain("/hearthforge-repo.git");
958 expect(uploads[0]!.bytes).toBeGreaterThan(0);
959
960 const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []);
961 expect(binds).not.toContain("hearthforge-repo.git");
962 });
963
964 test("a failing clone fails the run before any step runs", async () => {
965 queueExec({ output: "", exitCode: 0 }); // mkdir work_dir
966 queueExec({ output: "", exitCode: 0 }); // mkdir repo path
967 queueExec({ output: "fatal: not empty\n", exitCode: 128 }); // clone
968
969 const runId = await trigger();
970 expect(await waitForRun(runId)).toBe("failure");
971
972 const step = await db
973 .selectFrom("ci_steps")
974 .select("status")
975 .where("run_id", "=", runId)
976 .where("name", "=", "hello")
977 .executeTakeFirst();
978 expect(step?.status).toBe("skipped");
979
980 const setup = await db
981 .selectFrom("ci_steps")
982 .select(["status", "log"])
983 .where("run_id", "=", runId)
984 .where("name", "=", "pipeline setup")
985 .executeTakeFirst();
986 expect(setup?.status).toBe("failure");
987 expect(setup?.log).toContain("fatal: not empty");
988 });
989
990 test("a cache path inside the clone directory is rejected", async () => {
991 const badSha = seedCiToml(
992 "ci-repo",
993 `
994image = "debian:latest"
995clone_project_to = "/ci/build/project"
996cache = ["/ci/build/project/target"]
997
998[on]
999manual = true
1000
1001[hello]
1002run_sh = "echo hi"
1003`,
1004 );
1005 const runId = await triggerRun("ci-repo", {
1006 triggerSource: "manual",
1007 commitSha: badSha,
1008 commitBranch: "main",
1009 triggeredBy: adminUserId,
1010 });
1011 expect(await waitForRun(runId)).toBe("failure");
1012 expect(uploads).toHaveLength(0);
1013
1014 const setup = await db
1015 .selectFrom("ci_steps")
1016 .select("log")
1017 .where("run_id", "=", runId)
1018 .where("name", "=", "pipeline setup")
1019 .executeTakeFirst();
1020 expect(setup?.log).toContain("is inside clone_project_to");
1021 });
1022});
1023