| 1 | import { existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 4 | import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 5 | |
| 6 | /** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */ |
| 7 | function fakeAgent(exitCode = 0): { agent: string; record: string } { |
| 8 | const dir = tempDir("diffinery-agent-"); |
| 9 | const record = path.join(dir, "record.json"); |
| 10 | const agent = path.join(dir, "agent.mjs"); |
| 11 | writeFileSync( |
| 12 | agent, |
| 13 | `import { writeFileSync } from "node:fs"; |
| 14 | writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env })); |
| 15 | console.log("hello from the agent"); |
| 16 | console.error("agent stderr line"); |
| 17 | process.exit(${exitCode}); |
| 18 | `, |
| 19 | ); |
| 20 | return { agent, record }; |
| 21 | } |
| 22 | |
| 23 | describe("runner", () => { |
| 24 | let srv: FakeServer; |
| 25 | let env: NodeJS.ProcessEnv; |
| 26 | beforeEach(async () => { |
| 27 | srv = await fakeServer(); |
| 28 | env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" }); |
| 29 | }); |
| 30 | afterEach(() => srv.close()); |
| 31 | |
| 32 | it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => { |
| 33 | let claims = 0; |
| 34 | srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => |
| 35 | ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 }, |
| 36 | ); |
| 37 | const { agent, record } = fakeAgent(3); |
| 38 | const work = tempDir("diffinery-work-"); |
| 39 | const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env }); |
| 40 | |
| 41 | expect(r.code).toBe(3); |
| 42 | const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); |
| 43 | expect(claimReq).toHaveLength(1); |
| 44 | expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] }); |
| 45 | expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token"); |
| 46 | |
| 47 | const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> }; |
| 48 | expect(rec.argv).toEqual([]); |
| 49 | expect(rec.env.DIFFINERY_API_URL).toBe(srv.url); |
| 50 | expect(rec.env.DIFFINERY_JOB_ID).toBe("job42"); |
| 51 | expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret"); |
| 52 | expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work); |
| 53 | expect(rec.env.DIFFINERY_TOKEN).toBeUndefined(); |
| 54 | expect(rec.env.DIFFINERY_URL).toBeUndefined(); |
| 55 | |
| 56 | expect(r.stdout).toContain("[build (linux)] hello from the agent"); |
| 57 | expect(r.stderr).toContain("[build (linux)] agent stderr line"); |
| 58 | expect(r.stdout).not.toContain("djt_repo_job42_secret"); |
| 59 | }); |
| 60 | |
| 61 | it("--once with no waiting job exits 0 without running the agent", async () => { |
| 62 | srv.on("POST", /\/runners\/claim$/, { status: 204 }); |
| 63 | const { agent, record } = fakeAgent(0); |
| 64 | const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env }); |
| 65 | expect(r.code).toBe(0); |
| 66 | expect(r.stdout).toContain("No jobs waiting"); |
| 67 | expect(existsSync(record)).toBe(false); |
| 68 | expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]); |
| 69 | }); |
| 70 | |
| 71 | it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => { |
| 72 | srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); |
| 73 | srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } }); |
| 74 | const { agent, record } = fakeAgent(0); |
| 75 | const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } }); |
| 76 | expect(r.code).toBe(0); |
| 77 | expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1"); |
| 78 | }); |
| 79 | |
| 80 | it("explains how to build the agent when none is configured", async () => { |
| 81 | const r = await run(["runner", "-R", "acme/app", "--once"], { env }); |
| 82 | expect(r.code).toBe(1); |
| 83 | expect(r.stderr).toContain("pnpm --filter @diffinery/runner build"); |
| 84 | expect(r.stderr).toContain("containers/runner/dist/agent.mjs"); |
| 85 | }); |
| 86 | |
| 87 | it("fails fast on permission errors", async () => { |
| 88 | srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } }); |
| 89 | const { agent } = fakeAgent(0); |
| 90 | const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env }); |
| 91 | expect(r.code).toBe(1); |
| 92 | expect(r.stderr).toContain("admin access required"); |
| 93 | }); |
| 94 | }); |