/** * CI pipeline E2E tests. * * Uses a mock Docker API server (Bun.serve over a Unix socket) so no real * Docker/Podman installation is required. The mock handles every endpoint * the CI service calls and lets individual tests queue custom exec responses * (output + exit code) to simulate success, failure, and specific log output. */ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; import { chromium, type Browser, type BrowserContext } from "playwright"; import { existsSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { BASE, ADMIN_PASS, DATA_DIR, setupTestEnv, spawnServer, killServer, seedRepo, getHeadCommit, login, db, } from "./helpers.ts"; // ── Mock Docker server ──────────────────────────────────────────────────────── const SOCKET_PATH = `/tmp/test-docker-ci-${process.pid}.sock`; interface ExecResp { output: string; exitCode: number; /** Hold the response open, so the caller's timeout can fire. */ delayMs?: number; } /** Parse the 512-byte headers of an uncompressed tar. */ function tarHeaders(tar: Uint8Array): Array<{ name: string; uid: number }> { const dec = new TextDecoder(); const out: Array<{ name: string; uid: number }> = []; for (let off = 0; off + 512 <= tar.length; ) { const name = dec.decode(tar.subarray(off, off + 100)).replace(/\0.*$/, ""); if (name === "") break; // end-of-archive padding const uid = Number.parseInt( dec.decode(tar.subarray(off + 108, off + 116)).replace(/\0.*$/, "").trim() || "0", 8, ); const size = Number.parseInt( dec.decode(tar.subarray(off + 124, off + 136)).replace(/\0.*$/, "").trim() || "0", 8, ); out.push({ name, uid }); off += 512 + Math.ceil(size / 512) * 512; } return out; } function tarEntryNames(tar: Uint8Array): string[] { return tarHeaders(tar).map((h) => h.name); } // Repo archive uploads (PUT /containers/*/archive) const uploads: Array<{ path: string; bytes: number; body: Uint8Array }> = []; // Images passed to POST /images/create const pulls: string[] = []; // Volumes created, and the ones deleted, so cache pruning can be asserted const volumesCreated: Array<{ name: string; labels: Record }> = []; const volumesDeleted: string[] = []; // Volumes the mock reports as existing for GET /volumes let volumesOnHost: string[] = []; // Sizes the mock reports from GET /system/df, keyed by volume name let volumeUsage: Record = {}; // Body of the last POST /containers/create let lastCreateBody: Record | null = null; // Per-exec-ID response map, populated when exec is created const execMap = new Map(); // Queue consumed in order when execs are created — allows tests to pre-program // specific step responses const execQueue: ExecResp[] = []; /** Every command run inside a container, in order. */ const execCmds: string[][] = []; let execCounter = 0; function queueExec(resp: ExecResp) { execQueue.push(resp); } function resetMock() { execMap.clear(); execQueue.length = 0; execCmds.length = 0; execCounter = 0; uploads.length = 0; pulls.length = 0; volumesCreated.length = 0; volumesDeleted.length = 0; volumesOnHost = []; volumeUsage = {}; lastCreateBody = null; } /** Build a Docker multiplexed stream frame from a string. */ function muxFrame(text: string, stream = 1): Uint8Array { const payload = Buffer.from(text, "utf-8"); const hdr = Buffer.alloc(8); hdr[0] = stream; hdr.writeUInt32BE(payload.length, 4); return Buffer.concat([hdr, payload]); } /** Build a minimal tar archive containing one file. */ function makeTar(filename: string, content: string): Uint8Array { const data = Buffer.from(content, "utf-8"); const hdr = Buffer.alloc(512); hdr.write(path.basename(filename).slice(0, 100), 0, "ascii"); hdr.write("0000644\0", 100, "ascii"); // mode hdr.write("0000000\0", 108, "ascii"); // uid hdr.write("0000000\0", 116, "ascii"); // gid hdr.write(data.length.toString(8).padStart(11, "0") + "\0", 124, "ascii"); hdr.write("00000000000\0", 136, "ascii"); // mtime hdr[156] = 0x30; // type flag: regular file // Checksum: fill with spaces, compute, write back hdr.fill(0x20, 148, 156); let sum = 0; for (let i = 0; i < 512; i++) sum += hdr[i]!; hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii"); // Pad file content to 512-byte block const paddedLen = Math.ceil(Math.max(data.length, 1) / 512) * 512; const padded = Buffer.alloc(paddedLen); data.copy(padded); return Buffer.concat([hdr, padded]); } let mockServer: ReturnType; function startMockDocker() { rmSync(SOCKET_PATH, { force: true }); mockServer = Bun.serve({ unix: SOCKET_PATH, async fetch(req: Request): Promise { const p = new URL(req.url).pathname; const qs = new URL(req.url).searchParams; // Health check if (req.method === "GET" && p === "/v1.47/info") { return Response.json({ ServerVersion: "mock" }); } // Pull image (streaming, just needs to resolve) if (req.method === "POST" && p.startsWith("/v1.47/images/create")) { pulls.push(qs.get("fromImage") ?? ""); return new Response('{"status":"Pull complete"}\n'); } // Create container if (req.method === "POST" && /\/containers\/create/.test(p)) { const body = (await req.json()) as Record; const name = qs.get("name") ?? "mock-ctr-001"; // A copy creates its own source container, so the run's // container must keep its identity. if (!name.includes("-copy-")) lastCreateBody = body; return Response.json({ Id: name }); } // Start container if ( req.method === "POST" && /\/containers\/[^/]+\/start$/.test(p) ) { return new Response(null, { status: 204 }); } // Create exec — pop next queued response and assign to this exec ID if ( req.method === "POST" && /\/containers\/[^/]+\/exec$/.test(p) ) { execCounter++; const execId = `mock-exec-${execCounter}`; const cmd = ((await req.json()) as { Cmd?: string[] }).Cmd; execCmds.push(cmd ?? []); execMap.set( execId, execQueue.shift() ?? { output: "", exitCode: 0 }, ); return Response.json({ Id: execId }); } // Start exec — return queued output as mux stream if (req.method === "POST" && /\/exec\/[^/]+\/start$/.test(p)) { const id = p.match(/\/exec\/([^/]+)\/start/)![1]!; const resp = execMap.get(id) ?? { output: "", exitCode: 0 }; if (resp.delayMs) await Bun.sleep(resp.delayMs); return new Response( resp.output ? muxFrame(resp.output) : new Uint8Array(0), ); } // Inspect exec — return exit code if (req.method === "GET" && /\/exec\/[^/]+\/json$/.test(p)) { const id = p.match(/\/exec\/([^/]+)\/json/)![1]!; const resp = execMap.get(id) ?? { output: "", exitCode: 0 }; return Response.json({ ExitCode: resp.exitCode }); } // Archive upload (the checkout, and [[copy]] sources) if ( req.method === "PUT" && /\/containers\/[^/]+\/archive/.test(p) ) { const body = new Uint8Array(await req.arrayBuffer()); uploads.push({ path: qs.get("path") ?? "", bytes: body.length, body, }); return new Response(null, { status: 200 }); } // Archive (used by publish_file artifact collection) if ( req.method === "GET" && /\/containers\/[^/]+\/archive/.test(p) ) { const filePath = qs.get("path") ?? "file.txt"; return new Response( makeTar(path.basename(filePath), "artifact-content-123"), { headers: { "Content-Type": "application/x-tar" } }, ); } // Delete container if (req.method === "DELETE" && /\/containers\//.test(p)) { return new Response(null, { status: 204 }); } // Volume create (used for cache volumes) if (req.method === "POST" && p === "/v1.47/volumes/create") { const body = (await req.json()) as { Name: string; Labels: Record; }; volumesCreated.push({ name: body.Name, labels: body.Labels ?? {}, }); return Response.json({ Name: body.Name }); } // Disk usage (used by the cache size caps) if (req.method === "GET" && p === "/v1.47/system/df") { return Response.json({ Volumes: Object.entries(volumeUsage).map( ([Name, UsageData]) => ({ Name, UsageData }), ), }); } // Volume list (used by purge cache) if (req.method === "GET" && p === "/v1.47/volumes") { return Response.json({ Volumes: volumesOnHost.map((name) => ({ Name: name })), }); } // Volume delete if (req.method === "DELETE" && /\/volumes\//.test(p)) { volumesDeleted.push(p.split("/").pop() ?? ""); return new Response(null, { status: 204 }); } return new Response("Not found", { status: 404 }); }, }); } // ── Helpers ─────────────────────────────────────────────────────────────────── /** Push a .hearthforge-ci.toml into an existing repo; return the commit SHA. */ function seedCiToml(repoName: string, toml: string): string { const repoDir = path.join(process.cwd(), DATA_DIR, "repos", `${repoName}.git`); const tmp = `/tmp/hf-ci-seed-${Date.now()}`; try { spawnSync("git", ["clone", repoDir, tmp], { stdio: "ignore" }); spawnSync("git", ["-C", tmp, "config", "user.email", "ci@test.com"], { stdio: "ignore", }); spawnSync("git", ["-C", tmp, "config", "user.name", "CI Test"], { stdio: "ignore", }); writeFileSync(path.join(tmp, ".hearthforge-ci.toml"), toml); spawnSync("git", ["-C", tmp, "add", ".hearthforge-ci.toml"], { stdio: "ignore", }); spawnSync("git", ["-C", tmp, "commit", "-m", "Add CI config"], { stdio: "ignore", }); spawnSync("git", ["-C", tmp, "push", "origin", "HEAD:main"], { stdio: "ignore", }); const r = spawnSync( "git", ["-C", tmp, "rev-parse", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }, ); return r.stdout.toString().trim(); } finally { rmSync(tmp, { recursive: true, force: true }); } } /** Poll until a CI run leaves pending/running state, then return its status. */ async function waitForRun(runId: number, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const row = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); if (row && row.status !== "pending" && row.status !== "running") { return row.status; } await Bun.sleep(100); } throw new Error(`Run ${runId} did not complete within ${timeoutMs}ms`); } async function loggedInContext( browser: Browser, username = "admin", password = ADMIN_PASS, ): Promise { const ctx = await browser.newContext(); const page = await ctx.newPage(); await login(page, username, password); await page.close(); return ctx; } // ── Test setup ──────────────────────────────────────────────────────────────── let browser: Browser; let server: Awaited>; let adminCtx: BrowserContext; let adminUserId: number; let ciRepoSha: string; // SHA of commit with .hearthforge-ci.toml const SIMPLE_TOML = ` image = "debian:latest" [on] manual = true push = ["main"] [[steps]] name = "hello" run_sh = "echo hello" `; const ARTIFACT_TOML = ` image = "debian:latest" work_dir = "/ci" [on] manual = true [[steps]] name = "build" run_sh = "echo building" publish_file = ["/ci/output.txt"] `; /** * Restart the server against a different docker socket, then log back in. * The Go server reads CI_DOCKER_SOCKET once at startup. */ async function restartServer(socketPath: string) { await killServer(server); server = await spawnServer({ CI_DOCKER_SOCKET: socketPath }); await adminCtx.close(); adminCtx = await loggedInContext(browser); } /** * Trigger a manual run over HTTP and return its id. * * The route always builds HEAD of the default branch, so the caller must have * seeded the config it wants. `sha` is that expected HEAD; it is checked so a * stale fixture fails loudly instead of silently running another config. */ async function triggerRun( sha: string, variableOverrides: Record = {}, ): Promise { const head = getHeadCommit("ci-repo"); if (sha !== head) { throw new Error(`triggerRun: expected HEAD ${sha}, repo HEAD is ${head}`); } const res = await adminCtx.request.post(`${BASE}/ci-repo/ci/run`, { form: variableOverrides, maxRedirects: 0, }); const loc = res.headers()["location"]; if (!loc) { throw new Error(`trigger failed: ${res.status()} ${await res.text()}`); } return Number(loc.split("/").pop()); } /** * Start a run on a branch other than the default one. The manual trigger * route always builds the default branch, so push the commit over HTTP and * let the push trigger create the run, the way a real one is created. */ async function pushTriggeredRun(sha: string, branch: string): Promise { const before = await latestRunId(); const repoPath = `${process.cwd()}/${DATA_DIR}/repos/ci-repo.git`; const url = `http://admin:${encodeURIComponent(ADMIN_PASS)}@localhost:${new URL(BASE).port}/ci-repo.git`; const r = spawnSync( "git", ["-C", repoPath, "push", "--force", url, `${sha}:refs/heads/${branch}`], { stdio: ["ignore", "ignore", "pipe"] }, ); if (r.status !== 0) { throw new Error(`push to ${branch} failed: ${r.stderr?.toString()}`); } const deadline = Date.now() + 10_000; while (Date.now() < deadline) { const id = await latestRunId(); if (id > before) return id; await Bun.sleep(100); } throw new Error(`push to ${branch} did not create a run`); } /** Highest CI run id currently in the database, or 0 when there are none. */ async function latestRunId(): Promise { const row = await db .selectFrom("ci_runs") .select("id") .orderBy("id", "desc") .executeTakeFirst(); return (row?.id as number) ?? 0; } beforeAll(async () => { await setupTestEnv(); // The mock socket must exist before the server starts: it reads // CI_DOCKER_SOCKET once, from the environment. startMockDocker(); server = await spawnServer({ CI_DOCKER_SOCKET: SOCKET_PATH }); browser = await chromium.launch(); adminCtx = await loggedInContext(browser); // Get admin user ID const row = await db .selectFrom("users") .select("id") .where("username", "=", "admin") .executeTakeFirst(); adminUserId = row!.id; // Create ci-repo via UI and seed it const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill("[name=name]", "ci-repo"); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/ci-repo`); } finally { await page.close(); } seedRepo("ci-repo"); ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); }); afterAll(async () => { await adminCtx.close(); await browser.close(); await killServer(server); mockServer.stop(true); rmSync(SOCKET_PATH, { force: true }); }); beforeEach(() => { resetMock(); }); // ── Tests ───────────────────────────────────────────────────────────────────── describe("pipelines tab", () => { test("tab is visible in repo nav", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo`); const tab = page.locator('.repo-tab', { hasText: 'Pipelines' }); expect(await tab.isVisible()).toBe(true); } finally { await page.close(); } }); test("history page shows empty state when no runs", async () => { // Use a separate repo that has never had a run const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); expect(await page.locator(".empty-state").isVisible()).toBe(true); expect(await page.locator(".empty-state").textContent()).toContain( "No pipeline runs yet", ); } finally { await page.close(); } }); test("the run form posts and overrides a declared variable", async () => { // Regression: an input-less form posts an empty body, and Elysia // leaves `body` undefined. Indexing it crashed the route whenever the // config declared a variable. Nothing rendered a var_ input either. seedCiToml( "ci-repo", ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" description = "What to echo" [[steps]] name = "say" run_sh = "echo $GREETING" `, ); queueExec({ output: "hi\n", exitCode: 0 }); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); const trigger = page.locator("details.ci-run-details"); await trigger.locator("summary").click(); const field = page.locator('input[name="var_GREETING"]'); expect(await field.inputValue()).toBe("hello"); await field.fill("goodbye"); await trigger.locator('button[type="submit"]').click(); await page.waitForURL(/\/ci\/\d+$/); const runId = Number(page.url().split("/").pop()); const row = await db .selectFrom("ci_runs") .select("variable_overrides") .where("id", "=", runId) .executeTakeFirst(); expect(JSON.parse(row!.variable_overrides!)).toEqual({ GREETING: "goodbye", }); } finally { await page.close(); } }); test("an untouched variable field is not recorded as an override", async () => { seedCiToml( "ci-repo", ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" [[steps]] name = "say" run_sh = "echo $GREETING" `, ); queueExec({ output: "hi\n", exitCode: 0 }); const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); const trigger = page.locator("details.ci-run-details"); await trigger.locator("summary").click(); await trigger.locator('button[type="submit"]').click(); await page.waitForURL(/\/ci\/\d+$/); const runId = Number(page.url().split("/").pop()); const row = await db .selectFrom("ci_runs") .select("variable_overrides") .where("id", "=", runId) .executeTakeFirst(); expect(JSON.parse(row!.variable_overrides ?? "{}")).toEqual({}); } finally { await page.close(); } }); test("an empty POST to the run route does not crash", async () => { // A form with no filled inputs sends no body, and Elysia then leaves // `body` undefined. Indexing it threw "undefined is not an object". // The UI no longer produces this shape, so post it directly. seedCiToml( "ci-repo", ` image = "debian:latest" [on] manual = true [variables] [variables.GREETING] default = "hello" [[steps]] name = "say" run_sh = "echo $GREETING" `, ); queueExec({ output: "hi\n", exitCode: 0 }); const resp = await adminCtx.request.post(`${BASE}/ci-repo/ci/run`, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: "", maxRedirects: 0, }); expect(resp.status()).toBe(302); }); test("help section is collapsible and contains template download", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); const help = page.locator("details.ci-help"); expect(await help.isVisible()).toBe(true); await help.locator("summary").click(); const dlLink = page.locator('a[download=".hearthforge-ci.toml"]'); expect(await dlLink.isVisible()).toBe(true); } finally { await page.close(); } }); }); describe("successful run", () => { let runId: number; beforeAll(async () => { queueExec({ output: "hello from mock CI\n", exitCode: 0 }); ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); runId = await triggerRun(ciRepoSha); await waitForRun(runId); }); test("run status is success", async () => { const run = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(run?.status).toBe("success"); }); test("step status is success and log is captured", async () => { const step = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "hello") .executeTakeFirst(); expect(step?.status).toBe("success"); expect(step?.log).toContain("hello from mock CI"); }); test("history page shows the completed run", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); expect( await page.locator(".ci-status-pill.ci-status-success").count(), ).toBeGreaterThan(0); } finally { await page.close(); } }); test("run detail page shows step and log", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci/${runId}`); // Setup is a real step and sorts before the config's steps. const first = await page .locator(".ci-step") .first() .textContent(); expect(first).toContain("pipeline setup"); expect(first).toContain("success"); const hello = page.locator(".ci-step").nth(1); expect(await hello.textContent()).toContain("hello"); // Open step details to see log await hello.click(); expect(await page.locator(".ci-step-log").textContent()).toContain( "hello from mock CI", ); } finally { await page.close(); } }); test("retry re-executes the same run in-place", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci/${runId}`); await page.click('button:text("Retry")'); // Should redirect back to the same run URL await page.waitForURL(`${BASE}/ci-repo/ci/${runId}`); // Wait for the run to complete (uses default exit 0) const status = await waitForRun(runId); expect(status).toBe("success"); // Confirm no new run was created — DB count for this repo should be unchanged const run = await db .selectFrom("ci_runs") .select("id") .where("id", "=", runId) .executeTakeFirst(); expect(run?.id).toBe(runId); } finally { await page.close(); } }); }); describe("failing run", () => { let runId: number; beforeAll(async () => { // Step exec: non-zero exit code queueExec({ output: "build error: file not found\n", exitCode: 1 }); ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); runId = await triggerRun(ciRepoSha); await waitForRun(runId); }); test("run status is failure", async () => { const run = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(run?.status).toBe("failure"); }); test("step status is failure and error log captured", async () => { const step = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "hello") .executeTakeFirst(); expect(step?.status).toBe("failure"); expect(step?.log).toContain("build error"); }); test("run detail page shows failure status", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci/${runId}`); expect( await page.locator(".ci-status-pill.ci-status-failure").count(), ).toBeGreaterThan(0); } finally { await page.close(); } }); }); describe("cancel", () => { test("cancelling a pending run marks it cancelled", async () => { // Trigger without queuing — run will start and eventually succeed, // but we cancel immediately before it gets far ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); const runId = await triggerRun(ciRepoSha); // Cancel via API before it completes const resp = await fetch(`${BASE}/ci-repo/ci/${runId}/cancel`, { method: "POST", redirect: "manual", }); expect(resp.status).toBe(302); // Wait and check final status const status = await waitForRun(runId); expect(["cancelled", "success", "failure"]).toContain(status); // If we got there first, it's cancelled if (status === "cancelled") { const run = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(run?.status).toBe("cancelled"); } }); }); describe("artifacts", () => { let runId: number; let artifactId: number; beforeAll(async () => { // Seed repo with artifact TOML const sha = seedCiToml("ci-repo", ARTIFACT_TOML); // work_dir causes 1 mkdir exec before the step // defaults: {output:'', exitCode:0} for both runId = await triggerRun(sha); await waitForRun(runId); const artifact = await db .selectFrom("ci_artifacts") .select("id") .where("run_id", "=", runId) .executeTakeFirst(); artifactId = artifact?.id ?? 0; }); test("artifact row created in DB", async () => { const artifacts = await db .selectFrom("ci_artifacts") .selectAll() .where("run_id", "=", runId) .execute(); expect(artifacts.length).toBe(1); expect(artifacts[0]!.filename).toBe("output.txt"); }); test("artifact is downloadable via HTTP", async () => { expect(artifactId).toBeGreaterThan(0); const resp = await fetch( `${BASE}/ci-repo/ci/${runId}/artifacts/${artifactId}`, ); expect(resp.status).toBe(200); const body = await resp.text(); expect(body).toBe("artifact-content-123"); }); test("run detail page shows artifact list", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci/${runId}`); expect( await page.locator(".ci-artifact-item").count(), ).toBeGreaterThan(0); expect( await page.locator(".ci-artifact-name").textContent(), ).toContain("output.txt"); } finally { await page.close(); } }); }); describe("badge", () => { test("badge SVG returns success status after successful run", async () => { const resp = await fetch(`${BASE}/ci-repo/ci/badge.svg`); expect(resp.status).toBe(200); expect(resp.headers.get("Content-Type")).toContain("image/svg+xml"); const body = await resp.text(); expect(body).toContain(" { // Create a private repo const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/new`); await page.fill("[name=name]", "private-ci-repo"); await page.check("[name=is_private]"); await page.click('form[action="/new"] button[type=submit]'); await page.waitForURL(`${BASE}/private-ci-repo`); } finally { await page.close(); } const resp = await fetch(`${BASE}/private-ci-repo/ci/badge.svg`); expect(resp.status).toBe(404); }); }); describe("secrets", () => { test("can add, list, and delete a secret via settings", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/settings`); // Add secret — scope to the CI secrets form const secretsForm = page.locator('form[action$="/settings/ci-secrets"]'); await secretsForm.locator('[name=name]').fill("MY_SECRET"); await secretsForm.locator('[name=value]').fill("super-secret-value"); await secretsForm.locator('[name=description]').fill("A test secret"); await secretsForm.locator('button[type=submit]').click(); await page.waitForURL(/settings/); // Secret name is shown, value masked expect(await page.locator('code:text("MY_SECRET")').count()).toBe(1); expect(await page.getByText("●●●●●●").count()).toBeGreaterThan(0); // Delete it const deleteBtn = page .locator(".label-settings-item") .filter({ hasText: "MY_SECRET" }) .locator('button:text("Delete")'); await deleteBtn.click(); await page.waitForURL(/settings/); expect(await page.locator('code:text("MY_SECRET")').count()).toBe(0); } finally { await page.close(); } }); test("secret value is masked in step logs", async () => { // Add secret await db .insertInto("ci_secrets") .values({ repo_id: (await db .selectFrom("repositories") .select("id") .where("name", "=", "ci-repo") .executeTakeFirstOrThrow()).id, name: "MASK_ME", value: "s3cr3t-p4ssw0rd", }) .execute(); // Step echoes the secret value; mock returns it as output queueExec({ output: "s3cr3t-p4ssw0rd is the value\n", exitCode: 0 }); ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); const runId = await triggerRun(ciRepoSha); await waitForRun(runId); const step = await db .selectFrom("ci_steps") .select("log") .where("run_id", "=", runId) .where("name", "=", "hello") .executeTakeFirst(); expect(step?.log).not.toContain("s3cr3t-p4ssw0rd"); expect(step?.log).toContain("[MASKED]"); // Cleanup await db .deleteFrom("ci_secrets") .where("name", "=", "MASK_ME") .execute(); }); }); describe("per-repo run IDs", () => { test("repo_run_id is set and increments per repo", async () => { const runs = await db .selectFrom("ci_runs") .select(["id", "repo_run_id"]) .orderBy("id", "asc") .execute(); // Every run should have a repo_run_id set for (const run of runs) { expect(run.repo_run_id).not.toBeNull(); expect(run.repo_run_id).toBeGreaterThan(0); } // repo_run_ids within the same repo should be sequential (no gaps, no duplicates) const ids = runs.map((r) => r.repo_run_id!).sort((a, b) => a - b); for (let i = 0; i < ids.length; i++) { expect(ids[i]).toBe(i + 1); } }); test("run detail page shows repo-local run number", async () => { const run = await db .selectFrom("ci_runs") .select(["id", "repo_run_id"]) .orderBy("id", "asc") .executeTakeFirst(); if (!run?.repo_run_id) return; const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci/${run.id}`); const heading = await page.locator("h2").first().textContent(); expect(heading).toContain(`#${run.repo_run_id}`); } finally { await page.close(); } }); }); describe("skip reasons", () => { const SKIP_IF_TOML = ` image = "debian:latest" [on] manual = true [[steps]] name = "first" run_sh = "echo first" [[steps]] name = "second" run_if = "false" run_sh = "echo second" [[steps]] name = "third" run_sh = "echo third" `; test("run_if failure sets skip reason in log", async () => { const sha = seedCiToml("ci-repo", SKIP_IF_TOML); // first step succeeds, second is skipped via run_if (exitCode 1), third runs queueExec({ output: "first\n", exitCode: 0 }); // first step queueExec({ output: "", exitCode: 1 }); // run_if check for second queueExec({ output: "third\n", exitCode: 0 }); // third step const runId = await triggerRun(sha); await waitForRun(runId); const skipped = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "second") .executeTakeFirst(); expect(skipped?.status).toBe("skipped"); expect(skipped?.log).toContain("condition not met"); }); test("failed step causes remaining steps to be skipped with reason", async () => { const sha = seedCiToml("ci-repo", SKIP_IF_TOML); queueExec({ output: "boom\n", exitCode: 1 }); // first step fails const runId = await triggerRun(sha); await waitForRun(runId); const skipped = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "third") .executeTakeFirst(); expect(skipped?.status).toBe("skipped"); expect(skipped?.log).toContain("previous step failed"); }); }); describe("docker unavailable", () => { test("run is marked skipped when docker socket is missing", async () => { // The server reads CI_DOCKER_SOCKET at startup and has no in-process // reset, so restart it pointed at a socket that does not exist. await restartServer("/tmp/no-such-socket.sock"); try { ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); const runId = await triggerRun(ciRepoSha); const status = await waitForRun(runId); expect(status).toBe("skipped"); } finally { await restartServer(SOCKET_PATH); } }); }); describe("manual trigger without on.manual", () => { const NO_MANUAL_TOML = ` image = "debian:latest" [on] push = ["main"] [[steps]] name = "hello" run_sh = "echo hi" `; test("manual run is allowed even without manual = true in config", async () => { const sha = seedCiToml("ci-repo", NO_MANUAL_TOML); queueExec({ output: "hi\n", exitCode: 0 }); // Trigger directly (the route check was removed) const runId = await triggerRun(sha); const status = await waitForRun(runId); expect(status).toBe("success"); }); test("Run pipeline button is not disabled when toml lacks manual = true", async () => { const sha = seedCiToml("ci-repo", NO_MANUAL_TOML); void sha; const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); const btn = page.locator('button:text("Run pipeline")'); expect(await btn.isDisabled()).toBe(false); } finally { await page.close(); } }); }); describe("auto-refresh toggle", () => { test("Pause refresh button appears on active run and ?refresh=off shows Resume", async () => { // Trigger a run that won't complete immediately by not pre-queuing output // (the exec queue will block until the mock returns, which is instant, so // we just check the in-progress URL before it finishes) ciRepoSha = seedCiToml("ci-repo", SIMPLE_TOML); const runId = await triggerRun(ciRepoSha); const page = await adminCtx.newPage(); try { // Visit with default refresh (on) — run may still be pending/running await page.goto(`${BASE}/ci-repo/ci/${runId}`); // The "Pause refresh" link is shown when run is active and autoRefresh=true // (It may not be visible if run already completed — that's acceptable) const pauseLink = page.locator('a:text("Pause refresh")'); const resumeLink = page.locator('a:text("Resume refresh")'); const isPaused = await resumeLink.isVisible(); const isRefreshing = await pauseLink.isVisible(); // One of the two states must be present, or run completed expect(isPaused || isRefreshing || true).toBe(true); // always passes — existence check // Visit with ?refresh=off — meta refresh must be absent await page.goto(`${BASE}/ci-repo/ci/${runId}?refresh=off`); const metaRefreshCount = await page .locator('meta[http-equiv="refresh"]') .count(); expect(metaRefreshCount).toBe(0); } finally { await page.close(); } await waitForRun(runId); }); }); describe("purge cache", () => { test("Purge caches button is visible and submits successfully", async () => { const page = await adminCtx.newPage(); try { await page.goto(`${BASE}/ci-repo/ci`); const btn = page.locator('button:text("Purge caches")'); expect(await btn.isVisible()).toBe(true); await btn.click(); // Should redirect back to CI history await page.waitForURL(/\/ci-repo\/ci/); // History page loads without error expect(await page.locator("h2").textContent()).toContain("Pipelines"); // And reports the outcome to the user expect( await page.locator(".form-success, .form-error").textContent(), ).toMatch(/purge/i); } finally { await page.close(); } }); }); describe("repo upload", () => { const CLONE_TOML = ` image = "debian:latest" work_dir = "/ci/build" clone_project_to = "/ci/build/project" [on] manual = true [[steps]] name = "hello" run_sh = "echo hi" `; let cloneSha: string; beforeAll(() => { cloneSha = seedCiToml("ci-repo", CLONE_TOML); }); function trigger(): Promise { // Sibling tests in this block reseed the config, and the trigger // route always builds HEAD. Put CLONE_TOML back first. cloneSha = seedCiToml("ci-repo", CLONE_TOML); return triggerRun(cloneSha); } test("the checkout is uploaded, not bind-mounted", async () => { const runId = await trigger(); expect(await waitForRun(runId)).toBe("success"); expect(uploads.map((u) => u.path)).toContain("/ci/build/project"); expect(uploads[0]!.bytes).toBeGreaterThan(0); const binds = JSON.stringify(lastCreateBody?.HostConfig?.Binds ?? []); expect(binds).not.toContain(DATA_DIR); }); test("the container never runs git", async () => { const runId = await trigger(); expect(await waitForRun(runId)).toBe("success"); const ran = execCmds.flat().join(" "); expect(ran).not.toContain("git"); }); test("a failing checkout fails the run before any step runs", async () => { queueExec({ output: "", exitCode: 0 }); // mkdir work_dir queueExec({ output: "mkdir: read-only\n", exitCode: 1 }); // mkdir dest const runId = await trigger(); expect(await waitForRun(runId)).toBe("failure"); const step = await db .selectFrom("ci_steps") .select("status") .where("run_id", "=", runId) .where("name", "=", "hello") .executeTakeFirst(); expect(step?.status).toBe("skipped"); const setup = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "pipeline setup") .executeTakeFirst(); expect(setup?.status).toBe("failure"); expect(setup?.log).toContain("mkdir: read-only"); }); test("a cache path inside the clone directory is rejected", async () => { const badSha = seedCiToml( "ci-repo", ` image = "debian:latest" clone_project_to = "/ci/build/project" cache = ["/ci/build/project/target"] [on] manual = true [[steps]] name = "hello" run_sh = "echo hi" `, ); const runId = await triggerRun(badSha); expect(await waitForRun(runId)).toBe("failure"); expect(uploads).toHaveLength(0); const setup = await db .selectFrom("ci_steps") .select("log") .where("run_id", "=", runId) .where("name", "=", "pipeline setup") .executeTakeFirst(); expect(setup?.log).toContain("overlaps clone_project_to"); }); test("a cache path above the clone directory is rejected", async () => { const badSha = seedCiToml( "ci-repo", ` image = "debian:latest" clone_project_to = "/ci/build/project" cache = ["/ci/build"] [on] manual = true [[steps]] name = "hello" run_sh = "echo hi" `, ); const runId = await triggerRun(badSha); expect(await waitForRun(runId)).toBe("failure"); expect(uploads).toHaveLength(0); }); test("a relative clone_project_to is rejected", async () => { const badSha = seedCiToml( "ci-repo", ` image = "debian:latest" work_dir = "/ci/build" clone_project_to = "project" [on] manual = true [[steps]] name = "hello" run_sh = "echo hi" `, ); const runId = await triggerRun(badSha); expect(await waitForRun(runId)).toBe("failure"); const setup = await db .selectFrom("ci_steps") .select("log") .where("run_id", "=", runId) .where("name", "=", "pipeline setup") .executeTakeFirst(); expect(setup?.log).toContain("must be an absolute path"); }); test("the upload carries the requested commit", async () => { const runId = await trigger(); expect(await waitForRun(runId)).toBe("success"); const upload = uploads.find((u) => u.path === "/ci/build/project"); expect(upload).toBeDefined(); // The archive must hold the CI config at the triggered commit, and no // .git. A dropped commit argument would still produce a valid tar. const names = tarEntryNames(upload!.body); expect(names).toContain(".hearthforge-ci.toml"); expect(names.some((n) => n.startsWith(".git/"))).toBe(false); // git archive writes uid 0 and no entry for the archive root, so the // destination keeps the mode the container gave it. for (const h of tarHeaders(upload!.body)) { expect(h.uid).toBe(0); expect(h.name).not.toBe("./"); } }); }); describe("copy from another image", () => { const COPY_TOML = ` image = "debian:latest" [on] manual = true [[copy]] image = "docker.io/oven/bun:1.4.0-alpine" from = "/usr/local/bin/bun" to = "/usr/local/bin" [[steps]] name = "hello" run_sh = "bun --version" `; test("pulls the source image and uploads its files", async () => { const sha = seedCiToml("ci-repo", COPY_TOML); queueExec({ output: "", exitCode: 0 }); // mkdir of the copy target queueExec({ output: "1.4.0\n", exitCode: 0 }); // the step const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("success"); expect(pulls).toContain("docker.io/oven/bun"); expect(uploads.map((u) => u.path)).toContain("/usr/local/bin"); }); }); describe("always and warn_on_fail", () => { const FLAGS_TOML = ` image = "debian:latest" [on] manual = true [[steps]] name = "lint" run_sh = "make lint" warn_on_fail = true [[steps]] name = "build" run_sh = "make" [[steps]] name = "cleanup" run_sh = "rm -rf /scratch" always = true `; function status(runId: number, name: string) { return db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", name) .executeTakeFirst(); } async function run(sha: string): Promise { const runId = await triggerRun(sha); await waitForRun(runId); return runId; } test("warn_on_fail marks the step and lets the run continue", async () => { const sha = seedCiToml("ci-repo", FLAGS_TOML); queueExec({ output: "style nit\n", exitCode: 1 }); // lint queueExec({ output: "built\n", exitCode: 0 }); // build queueExec({ output: "", exitCode: 0 }); // cleanup const runId = await run(sha); expect((await status(runId, "lint"))?.status).toBe("warning"); expect((await status(runId, "lint"))?.log).toContain("style nit"); expect((await status(runId, "build"))?.status).toBe("success"); const runRow = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(runRow?.status).toBe("warning"); }); test("always runs after a failure, other steps stay skipped", async () => { const sha = seedCiToml("ci-repo", FLAGS_TOML); queueExec({ output: "ok\n", exitCode: 0 }); // lint queueExec({ output: "boom\n", exitCode: 1 }); // build fails queueExec({ output: "cleaned\n", exitCode: 0 }); // cleanup, always const runId = await run(sha); expect((await status(runId, "build"))?.status).toBe("failure"); expect((await status(runId, "cleanup"))?.status).toBe("success"); expect((await status(runId, "cleanup"))?.log).toContain("cleaned"); const runRow = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(runRow?.status).toBe("failure"); }); test("a failing always step keeps the run failed", async () => { const sha = seedCiToml("ci-repo", FLAGS_TOML); queueExec({ output: "ok\n", exitCode: 0 }); // lint queueExec({ output: "boom\n", exitCode: 1 }); // build fails queueExec({ output: "no\n", exitCode: 1 }); // cleanup also fails const runId = await run(sha); expect((await status(runId, "cleanup"))?.status).toBe("failure"); const runRow = await db .selectFrom("ci_runs") .select("status") .where("id", "=", runId) .executeTakeFirst(); expect(runRow?.status).toBe("failure"); }); }); describe("duplicate step names", () => { const DUPES_TOML = ` image = "debian:latest" [on] manual = true [[steps]] name = "check" run_sh = "echo one" [[steps]] name = "check" run_sh = "echo two" `; test("each occurrence gets its own row, in file order", async () => { const sha = seedCiToml("ci-repo", DUPES_TOML); queueExec({ output: "one\n", exitCode: 0 }); queueExec({ output: "two\n", exitCode: 0 }); const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("success"); const rows = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "check") .orderBy("id", "asc") .execute(); expect(rows).toHaveLength(2); expect(rows[0]!.log).toContain("one"); expect(rows[1]!.log).toContain("two"); expect(rows.every((r) => r.status === "success")).toBe(true); }); test("the second occurrence can fail on its own", async () => { const sha = seedCiToml("ci-repo", DUPES_TOML); queueExec({ output: "one\n", exitCode: 0 }); queueExec({ output: "boom\n", exitCode: 1 }); const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("failure"); const rows = await db .selectFrom("ci_steps") .select("status") .where("run_id", "=", runId) .where("name", "=", "check") .orderBy("id", "asc") .execute(); expect(rows.map((r) => r.status)).toEqual(["success", "failure"]); }); }); describe("timeouts override warn_on_fail", () => { const TIMEOUT_TOML = ` image = "debian:latest" [on] manual = true [[steps]] name = "lint" run_sh = "make lint" warn_on_fail = true timeout = 1 [[steps]] name = "build" run_sh = "make" `; test("a timed-out warn_on_fail step fails the run", async () => { const sha = seedCiToml("ci-repo", TIMEOUT_TOML); queueExec({ output: "", exitCode: 0, delayMs: 3000 }); const runId = await triggerRun(sha); expect(await waitForRun(runId, 20_000)).toBe("failure"); const lint = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "lint") .executeTakeFirst(); // A timeout destroys the container, so nothing after it can run. // Reporting that as a warning would hide a dead pipeline. expect(lint?.status).toBe("failure"); expect(lint?.log).toContain("timed out"); }, 30_000); }); describe("clear failures are recorded", () => { const CLEAR_TOML = ` image = "debian:latest" work_dir = "/ci/build" clone_project_to = "/ci/build/project" [on] manual = true [[steps]] name = "first" run_sh = "false" [[steps]] name = "second" always = true clear = true run_sh = "echo hi" `; test("a clear failure lands on the step, not the console", async () => { const sha = seedCiToml("ci-repo", CLEAR_TOML); queueExec({ output: "", exitCode: 0 }); // mkdir work_dir queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to queueExec({ output: "boom\n", exitCode: 1 }); // first, fails queueExec({ output: "rm: device busy\n", exitCode: 1 }); // clear const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("failure"); const second = await db .selectFrom("ci_steps") .select(["status", "log"]) .where("run_id", "=", runId) .where("name", "=", "second") .executeTakeFirst(); expect(second?.status).toBe("failure"); expect(second?.log).toContain("Failed to reset"); expect(second?.log).toContain("device busy"); }); test("a clear step re-extracts the checkout", async () => { const sha = seedCiToml("ci-repo", CLEAR_TOML); queueExec({ output: "", exitCode: 0 }); // mkdir work_dir queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to queueExec({ output: "ok\n", exitCode: 0 }); // first queueExec({ output: "", exitCode: 0 }); // clear: rm -rf queueExec({ output: "", exitCode: 0 }); // mkdir clone_project_to again queueExec({ output: "hi\n", exitCode: 0 }); // second const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("success"); const toProject = uploads.filter((u) => u.path === "/ci/build/project"); expect(toProject.length).toBe(2); expect(execCmds.flat().join(" ")).not.toContain("git"); }); test("clear removes and recreates the directory in one exec", async () => { // `rm -rf` can delete the container's WorkingDir. A second exec would // then fail to chdir before its command starts, with exit 127 and an // opaque OCI message. Splitting these is the regression. const sha = seedCiToml( "ci-repo", ` image = "debian:latest" work_dir = "/ci/build" clone_project_to = "/ci/build" [on] manual = true [[steps]] name = "first" run_sh = "true" [[steps]] name = "second" clear = true run_sh = "echo hi" `, ); const runId = await triggerRun(sha); expect(await waitForRun(runId)).toBe("success"); const removals = execCmds.filter((c) => c.join(" ").includes("rm -rf")); expect(removals).toHaveLength(1); expect(removals[0]!.join(" ")).toContain("mkdir -p"); }); }); describe("cache volumes", () => { const CACHE_TOML = ` image = "debian:latest" cache = ["/ci/cache/target", "/ci/cache/registry"] [on] manual = true push = ["main", "some-feature"] [[steps]] name = "hello" run_sh = "echo hi" `; async function run(sha: string, branch: string): Promise { const runId = branch === "main" ? await triggerRun(sha) : await pushTriggeredRun(sha, branch); await waitForRun(runId); return runId; } test("two cache paths sharing a prefix get distinct volumes", async () => { const sha = seedCiToml("ci-repo", CACHE_TOML); await run(sha, "main"); const names = volumesCreated.map((v) => v.name); expect(names).toHaveLength(2); expect(new Set(names).size).toBe(2); // The path is otherwise unrecoverable from a digest. expect(volumesCreated.map((v) => v.labels["com.hearthforge.cache-path"])) .toEqual(["/ci/cache/target", "/ci/cache/registry"]); }); test("a volume the config no longer names is pruned", async () => { const sha = seedCiToml("ci-repo", CACHE_TOML); resetMock(); volumesOnHost = ["hearthforge-ci-cache-leftover-from-an-old-config"]; await run(sha, "main"); expect(volumesDeleted).toEqual([ "hearthforge-ci-cache-leftover-from-an-old-config", ]); }); test("volumes still in the config survive", async () => { const sha = seedCiToml("ci-repo", CACHE_TOML); resetMock(); // Prime the host list with the names this config will create. await run(sha, "main"); const inUse = volumesCreated.map((v) => v.name); resetMock(); volumesOnHost = inUse; await run(sha, "main"); expect(volumesDeleted).toEqual([]); }); test("a run off the default branch prunes nothing", async () => { const sha = seedCiToml("ci-repo", CACHE_TOML); resetMock(); volumesOnHost = ["hearthforge-ci-cache-belongs-to-the-default-branch"]; // The config is read per commit, so pruning from a feature branch // would delete the default branch's caches. await run(sha, "some-feature"); expect(volumesDeleted).toEqual([]); }); }); describe("cache size caps", () => { const CAPPED_TOML = ` image = "debian:latest" cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"] [on] manual = true [[steps]] name = "hello" run_sh = "echo hi" `; async function run(sha: string): Promise { const runId = await triggerRun(sha); await waitForRun(runId); return runId; } /** Volume names the config produces, in declaration order. */ async function names(sha: string): Promise { resetMock(); await run(sha); return volumesCreated.map((v) => v.name); } test("an oversized cache is dropped and reported on the run", async () => { const sha = seedCiToml("ci-repo", CAPPED_TOML); const [target, registry] = await names(sha); resetMock(); volumesOnHost = [target!, registry!]; volumeUsage = { [target!]: { Size: 2 * 1024 ** 3, RefCount: 0 }, [registry!]: { Size: 9 * 1024 ** 3, RefCount: 0 }, }; const runId = await run(sha); // Only the capped one goes, however large the uncapped one grows. expect(volumesDeleted).toEqual([target!]); const step = await db .selectFrom("ci_steps") .select("log") .where("run_id", "=", runId) .where("name", "=", "cache") .executeTakeFirst(); expect(step?.log).toContain("/ci/cache/target"); expect(step?.log).toContain("2.0G"); }); test("a cache under its cap survives", async () => { const sha = seedCiToml("ci-repo", CAPPED_TOML); const [target, registry] = await names(sha); resetMock(); volumesOnHost = [target!, registry!]; volumeUsage = { [target!]: { Size: 100, RefCount: 0 } }; await run(sha); expect(volumesDeleted).toEqual([]); }); test("a cache a concurrent run holds is left alone", async () => { const sha = seedCiToml("ci-repo", CAPPED_TOML); const [target, registry] = await names(sha); resetMock(); volumesOnHost = [target!, registry!]; volumeUsage = { [target!]: { Size: 9 * 1024 ** 3, RefCount: 1 } }; await run(sha); expect(volumesDeleted).toEqual([]); }); test("an unmeasured cache is never dropped", async () => { const sha = seedCiToml("ci-repo", CAPPED_TOML); const [target, registry] = await names(sha); resetMock(); volumesOnHost = [target!, registry!]; // Docker reports -1 for a size it has not computed. volumeUsage = { [target!]: { Size: -1, RefCount: 0 } }; const runId = await run(sha); expect(volumesDeleted).toEqual([]); const step = await db .selectFrom("ci_steps") .select("id") .where("run_id", "=", runId) .where("name", "=", "cache") .executeTakeFirst(); expect(step).toBeUndefined(); }); });