import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { Database } from "bun:sqlite"; import { fakeD1 } from "../metering/fake-d1.test-helper"; import { memoryScopes } from "../scoped-db/testing"; import { seedJob, seedRow, seedRun } from "./ci-fixtures.test-helper"; let d1 = fakeD1(); let scopes = memoryScopes(); let state: Database = scopes.raw("repo", "r1"); const runnerCancels: string[] = []; let runnerCancelError: Error | null = null; const metered: string[] = []; const released: string[] = []; const statuses: string[] = []; const deployments: string[] = []; const repoTokens: string[] = []; const signals: string[] = []; let statusFailures = 0; const fakeEnv: Record = { get DB() { return d1.binding; }, PUBLIC_URL: "https://forge.test", CI_WORKFLOW: { get: async (runId: string) => ({ sendEvent: async () => void signals.push(runId) }), }, RUNNER: { idFromName: (name: string) => name, get: (id: string) => ({ announce: async () => undefined, cancel: async () => { runnerCancels.push(id); if (runnerCancelError) throw runnerCancelError; }, }), }, }; mock.module("cloudflare:workers", () => ({ DurableObject: class {}, env: fakeEnv, waitUntil: () => undefined })); mock.module("../scoped-db", () => ({ repoDb: (id: string) => scopes.repoDb(id), accountDb: (id: string) => scopes.accountDb(id), stmt: scopes.module.stmt, })); mock.module("../repos", () => ({ loadRepoById: async (id: string) => ({ id, owner_id: "o1", owner_name: "acme", name: "app" }), })); mock.module("../statuses", () => ({ setCommitStatus: async (input: { state: string }) => { if (statusFailures > 0) { statusFailures--; throw new Error("status write failed"); } statuses.push(input.state); }, })); mock.module("./deployments", () => ({ markDeploymentStarted: async () => undefined, settleDeployment: async (jobId: string, conclusion: string) => void deployments.push(`${jobId}:${conclusion}`), })); mock.module("../metering/ci", () => ({ meterCompletedJob: async (input: { jobId: string }) => void metered.push(input.jobId), runnerMeter: () => "compute.runner", jobSeconds: () => 1, })); mock.module("../analytics", () => ({ captureServerEvent: () => undefined })); mock.module("./job-hold", () => ({ releaseJobHold: async (jobId: string) => void released.push(jobId) })); mock.module("../git/artifacts", () => ({ mintHeldRepoToken: async () => ({ secret: "s" }), releaseRepoToken: async (owner: string) => void repoTokens.push(owner), })); mock.module("./scheduler", () => ({ runUrl: () => "https://forge.test/run", statusContext: () => "ci / test" })); const { cancelActiveJob, completeJob, loadJobExecution, markJobStarted } = await import("./jobs"); function job(): { status: string; conclusion: string | null; started_at: number | null } { return state.query("SELECT status, conclusion, started_at FROM workflow_jobs WHERE id = 'j1'").get() as never; } beforeEach(() => { d1 = fakeD1(); runnerCancels.length = metered.length = released.length = statuses.length = 0; deployments.length = repoTokens.length = signals.length = statusFailures = 0; runnerCancelError = null; scopes = memoryScopes(); state = scopes.raw("repo", "r1"); d1.db.exec("INSERT INTO scope_locators VALUES ('workflow_job', 'j1', 'r1'), ('workflow_job', 'j2', 'r1')"); seedRun(state, { id: "w1", repo_id: "r1", run_number: 1, sha: "abc", workflow_name: "ci" }); seedJob(state, { id: "j1", run_id: "w1", repo_id: "r1", name: "test", runs_on: '["self-hosted"]', spec: '{"runsOn":["self-hosted"],"steps":[]}', runner_id: "rn1", runner_name: "box", started_at: 1, }); seedRow(state, "job_steps", { job_id: "j1", idx: 0, name: "a", status: "in_progress", started_at: 1 }); seedRow(state, "job_steps", { job_id: "j1", idx: 1, name: "b", status: "pending" }); addToken(); }); function addToken(): void { state .query( "INSERT INTO job_tokens (token_hash, job_id, repo_id, permissions, expires_at) VALUES (?, 'j1', 'r1', '{}', 0)", ) .run(crypto.randomUUID()); } function laterRun(status: string, startedAt: number | null): void { seedRun(state, { id: "w2", repo_id: "r1", run_number: 2, sha: "abc", workflow_name: "ci" }); seedJob(state, { id: "j2", run_id: "w2", repo_id: "r1", name: "test", runs_on: '["self-hosted"]', spec: '{"runsOn":["self-hosted"],"steps":[]}', status, started_at: startedAt, }); } describe("cancelling a running job", () => { test("settles the row, meter and hold at cancel time even when the runner had nothing to stop", async () => { await cancelActiveJob({ id: "j1", runs_on: '["self-hosted"]' }, "cancelled by user"); expect(runnerCancels).toEqual(["job:j1"]); expect(job()).toMatchObject({ status: "completed", conclusion: "cancelled" }); expect(metered).toEqual(["j1"]); expect(released).toEqual(["j1"]); expect(statuses).toEqual(["error"]); const steps = state.query("SELECT conclusion FROM job_steps ORDER BY idx").all() as { conclusion: string }[]; expect(steps.map((step) => step.conclusion)).toEqual(["cancelled", "skipped"]); }); test("still settles when the runner Durable Object call fails", async () => { runnerCancelError = new Error("unreachable"); await cancelActiveJob({ id: "j1", runs_on: '["self-hosted"]' }, "cancelled by user"); expect(job()).toMatchObject({ status: "completed", conclusion: "cancelled" }); }); test("a late success from the runner does not overwrite the cancel", async () => { await cancelActiveJob({ id: "j1", runs_on: '["self-hosted"]' }, "cancelled by user"); await completeJob("j1", "success"); expect(job()).toMatchObject({ status: "completed", conclusion: "cancelled" }); expect(statuses).toEqual(["error", "error"]); expect(deployments).toEqual(["j1:cancelled", "j1:cancelled"]); expect(metered).toEqual(["j1"]); }); test("a retry after a partial first settle still runs the cleanup, without metering twice", async () => { statusFailures = 1; await expect(completeJob("j1", "success")).rejects.toThrow("status write failed"); expect(job()).toMatchObject({ status: "completed", conclusion: "success" }); expect(signals).toEqual([]); addToken(); released.length = repoTokens.length = deployments.length = 0; await cancelActiveJob({ id: "j1", runs_on: '["self-hosted"]' }, "workflow ended"); expect(job()).toMatchObject({ status: "completed", conclusion: "success" }); expect(state.query("SELECT COUNT(*) AS n FROM job_tokens").get()).toEqual({ n: 0 }); expect(released).toEqual(["j1"]); expect(repoTokens).toEqual(["ci-job:j1"]); expect(deployments).toEqual(["j1:success"]); expect(statuses).toEqual(["success"]); expect(signals).toEqual(["w1"]); expect(metered).toEqual(["j1"]); }); test("a late retry for a re-run attempt leaves the newer attempt's commit status alone", async () => { await cancelActiveJob({ id: "j1", runs_on: '["self-hosted"]' }, "cancelled by user"); expect(statuses).toEqual(["error"]); laterRun("in_progress", 2); addToken(); released.length = repoTokens.length = signals.length = 0; await completeJob("j1", "success"); expect(statuses).toEqual(["error"]); expect(job()).toMatchObject({ status: "completed", conclusion: "cancelled" }); expect(state.query("SELECT COUNT(*) AS n FROM job_tokens").get()).toEqual({ n: 0 }); expect(released).toEqual(["j1"]); expect(repoTokens).toEqual(["ci-job:j1"]); expect(signals).toEqual(["w1"]); }); test("an earlier concurrent run still reports while the later run on the commit hasn't started", async () => { laterRun("pending", null); await completeJob("j1", "success"); expect(statuses).toEqual(["success"]); await completeJob("j2", "cancelled"); statuses.length = 0; await completeJob("j1", "success"); expect(statuses).toEqual(["success"]); }); test("a job cancelled before its runner started it is not revived as in_progress", async () => { state.exec("UPDATE workflow_jobs SET status = 'queued', started_at = NULL"); const execution = await loadJobExecution("j1"); if (!execution) throw new Error("missing job"); await completeJob("j1", "cancelled"); expect(await markJobStarted(execution, null, "cloudflare-container")).toBe(false); expect(job()).toMatchObject({ status: "completed", conclusion: "cancelled" }); }); test("a queued job still starts normally", async () => { state.exec("UPDATE workflow_jobs SET status = 'queued', started_at = NULL"); const execution = await loadJobExecution("j1"); if (!execution) throw new Error("missing job"); expect(await markJobStarted(execution, null, "cloudflare-container")).toBe(true); expect(job().status).toBe("in_progress"); expect(job().started_at).not.toBeNull(); }); });