| 1 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 2 | import { GitHub, nextLink } from "../src/github.js"; |
| 3 | import { withGitConfig } from "../src/git.js"; |
| 4 | import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js"; |
| 5 | |
| 6 | describe("run watch", () => { |
| 7 | let srv: FakeServer; |
| 8 | beforeEach(async () => { |
| 9 | srv = await fakeServer(); |
| 10 | }); |
| 11 | afterEach(() => srv.close()); |
| 12 | |
| 13 | const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({ |
| 14 | id: "run1", |
| 15 | run_number: 4, |
| 16 | run_attempt: 1, |
| 17 | name: "CI", |
| 18 | display_title: "Fix bug", |
| 19 | path: ".github/workflows/ci.yml", |
| 20 | event: "push", |
| 21 | head_branch: "main", |
| 22 | head_sha: "abcdef1234", |
| 23 | status, |
| 24 | conclusion, |
| 25 | actor: { login: "alice" }, |
| 26 | created_at: new Date().toISOString(), |
| 27 | updated_at: new Date().toISOString(), |
| 28 | html_url: "http://x/runs/run1", |
| 29 | jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })), |
| 30 | }); |
| 31 | |
| 32 | it("prints job transitions and exits 1 when the run fails", async () => { |
| 33 | const states = [ |
| 34 | runBody("queued", null, [["a", "queued", null]]), |
| 35 | runBody("in_progress", null, [["a", "in_progress", null]]), |
| 36 | runBody("in_progress", null, [["a", "in_progress", null]]), |
| 37 | runBody("completed", "failure", [["a", "completed", "failure"]]), |
| 38 | ]; |
| 39 | let i = 0; |
| 40 | srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] })); |
| 41 | const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); |
| 42 | expect(r.code).toBe(1); |
| 43 | const lines = r.stdout.split("\n").filter((l) => l.includes("job-a")); |
| 44 | expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state) |
| 45 | expect(r.stdout).toContain("completed: failure"); |
| 46 | }); |
| 47 | |
| 48 | it("exits 0 on success", async () => { |
| 49 | srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) }); |
| 50 | const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); |
| 51 | expect(r.code).toBe(0); |
| 52 | }); |
| 53 | }); |
| 54 | |
| 55 | describe("run rerun", () => { |
| 56 | let srv: FakeServer; |
| 57 | beforeEach(async () => { |
| 58 | srv = await fakeServer(); |
| 59 | }); |
| 60 | afterEach(() => srv.close()); |
| 61 | |
| 62 | it("re-runs a run, its failed jobs, or one job", async () => { |
| 63 | for (const p of ["runs/run1/rerun", "runs/run1/rerun-failed-jobs", "jobs/job1/rerun"]) srv.on("POST", `/api/v3/repos/o/r/actions/${p}`, { status: 201, body: { run_id: "run2" } }); |
| 64 | const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); |
| 65 | for (const [args, p] of [ |
| 66 | [["run1"], "runs/run1/rerun"], |
| 67 | [["run1", "--failed"], "runs/run1/rerun-failed-jobs"], |
| 68 | [["--job", "job1"], "jobs/job1/rerun"], |
| 69 | ] as const) { |
| 70 | const r = await run(["run", "rerun", ...args, "-R", "o/r"], { env }); |
| 71 | expect(r.code).toBe(0); |
| 72 | expect(r.stdout).toContain("Re-running as run run2"); |
| 73 | expect(srv.find("POST", `/api/v3/repos/o/r/actions/${p}`)).toHaveLength(1); |
| 74 | } |
| 75 | expect((await run(["run", "rerun", "-R", "o/r"], { env })).code).toBe(2); |
| 76 | }); |
| 77 | }); |
| 78 | |
| 79 | describe("GitHub client", () => { |
| 80 | let srv: FakeServer; |
| 81 | beforeEach(async () => { |
| 82 | srv = await fakeServer(); |
| 83 | }); |
| 84 | afterEach(() => srv.close()); |
| 85 | |
| 86 | it("follows Link pagination and waits out rate limits", async () => { |
| 87 | let hits = 0; |
| 88 | srv.on("GET", "/repos/o/r/issues", (req): Reply => { |
| 89 | hits++; |
| 90 | if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } }; |
| 91 | if (req.query.page === "2") return { body: [{ number: 3 }] }; |
| 92 | return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } }; |
| 93 | }); |
| 94 | srv.on("GET", "/repos/o/r/actions/variables", (req) => |
| 95 | req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } }, |
| 96 | ); |
| 97 | srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } })); |
| 98 | const waits: number[] = []; |
| 99 | const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 }); |
| 100 | expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]); |
| 101 | expect(waits).toEqual([2000]); |
| 102 | expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght"); |
| 103 | expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" }); |
| 104 | expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([ |
| 105 | { name: "A", value: "1" }, |
| 106 | { name: "B", value: "2" }, |
| 107 | ]); |
| 108 | hits = 5; |
| 109 | waits.length = 0; |
| 110 | expect(await gh.get("/rate")).toEqual({ ok: true }); |
| 111 | expect(waits.length).toBeGreaterThan(0); |
| 112 | expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s |
| 113 | }); |
| 114 | |
| 115 | it("parses Link headers", () => { |
| 116 | expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2"); |
| 117 | expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull(); |
| 118 | expect(nextLink(null)).toBeNull(); |
| 119 | }); |
| 120 | }); |
| 121 | |
| 122 | describe("misc", () => { |
| 123 | it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => { |
| 124 | const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]); |
| 125 | expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" }); |
| 126 | }); |
| 127 | |
| 128 | it("--help works for every command and --version prints the version", async () => { |
| 129 | const { registry } = await import("../src/main.js"); |
| 130 | const env = isolatedEnv(); |
| 131 | for (const g of registry.groups) { |
| 132 | expect((await run([g.name, "--help"], { env })).code).toBe(0); |
| 133 | for (const c of g.commands) { |
| 134 | const r = await run([...c.name.split(" "), "--help"], { env }); |
| 135 | expect(r.code, c.name).toBe(0); |
| 136 | expect(r.stdout).toContain(`Usage: diffinery ${c.name}`); |
| 137 | } |
| 138 | } |
| 139 | for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0); |
| 140 | expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/); |
| 141 | expect((await run(["nope"], { env })).code).toBe(2); |
| 142 | }); |
| 143 | |
| 144 | it("api prints JSON and exits 1 on HTTP errors", async () => { |
| 145 | const srv = await fakeServer(); |
| 146 | try { |
| 147 | srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); |
| 148 | srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body })); |
| 149 | const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); |
| 150 | const r = await run(["api", "get", "/user"], { env }); |
| 151 | expect(r.code).toBe(0); |
| 152 | expect(JSON.parse(r.stdout)).toEqual({ login: "alice" }); |
| 153 | const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' }); |
| 154 | expect(JSON.parse(p.stdout)).toEqual({ name: "bug" }); |
| 155 | const missing = await run(["api", "GET", "/nope"], { env }); |
| 156 | expect(missing.code).toBe(1); |
| 157 | expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" }); |
| 158 | } finally { |
| 159 | await srv.close(); |
| 160 | } |
| 161 | }); |
| 162 | }); |
| 163 | |
| 164 | describe("secret set --org", () => { |
| 165 | let srv: FakeServer; |
| 166 | beforeEach(async () => { |
| 167 | srv = await fakeServer(); |
| 168 | }); |
| 169 | afterEach(() => srv.close()); |
| 170 | |
| 171 | it("shares an organization secret with the repositories given in --repos", async () => { |
| 172 | const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); |
| 173 | srv.on("GET", "/api/v3/repos/acme/web", { body: { id: 11, name: "web" } }); |
| 174 | srv.on("GET", "/api/v3/repos/acme/api", { body: { id: 22, name: "api" } }); |
| 175 | srv.on("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY", { status: 204 }); |
| 176 | const r = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v", "--repos", "web, acme/api"], { env }); |
| 177 | expect(r.code).toBe(0); |
| 178 | expect(srv.find("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY")[0]!.body).toEqual({ value: "v", visibility: "selected", selected_repository_ids: [11, 22] }); |
| 179 | const plain = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v2"], { env }); |
| 180 | expect(plain.code).toBe(0); |
| 181 | expect(srv.find("PUT", "/api/v3/orgs/acme/actions/secrets/DEPLOY")[1]!.body).toEqual({ value: "v2" }); |
| 182 | const bad = await run(["secret", "set", "DEPLOY", "--org", "acme", "--body", "v", "--visibility", "selected"], { env }); |
| 183 | expect(bad.code).toBe(2); |
| 184 | expect(bad.stderr).toContain("needs --repos"); |
| 185 | }); |
| 186 | }); |