Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

138 lines · 6.6 KBRaw History
1import { afterEach, beforeEach, describe, expect, it } from "vitest";
2import { GitHub, nextLink } from "../src/github.js";
3import { withGitConfig } from "../src/git.js";
4import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js";
5
6describe("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
55describe("GitHub client", () => {
56 let srv: FakeServer;
57 beforeEach(async () => {
58 srv = await fakeServer();
59 });
60 afterEach(() => srv.close());
61
62 it("follows Link pagination and waits out rate limits", async () => {
63 let hits = 0;
64 srv.on("GET", "/repos/o/r/issues", (req): Reply => {
65 hits++;
66 if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } };
67 if (req.query.page === "2") return { body: [{ number: 3 }] };
68 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"` } };
69 });
70 srv.on("GET", "/repos/o/r/actions/variables", (req) =>
71 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"` } },
72 );
73 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 } }));
74 const waits: number[] = [];
75 const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 });
76 expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]);
77 expect(waits).toEqual([2000]);
78 expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght");
79 expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" });
80 expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([
81 { name: "A", value: "1" },
82 { name: "B", value: "2" },
83 ]);
84 hits = 5;
85 waits.length = 0;
86 expect(await gh.get("/rate")).toEqual({ ok: true });
87 expect(waits.length).toBeGreaterThan(0);
88 expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s
89 });
90
91 it("parses Link headers", () => {
92 expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2");
93 expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull();
94 expect(nextLink(null)).toBeNull();
95 });
96});
97
98describe("misc", () => {
99 it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => {
100 const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]);
101 expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" });
102 });
103
104 it("--help works for every command and --version prints the version", async () => {
105 const { registry } = await import("../src/main.js");
106 const env = isolatedEnv();
107 for (const g of registry.groups) {
108 expect((await run([g.name, "--help"], { env })).code).toBe(0);
109 for (const c of g.commands) {
110 const r = await run([...c.name.split(" "), "--help"], { env });
111 expect(r.code, c.name).toBe(0);
112 expect(r.stdout).toContain(`Usage: diffinery ${c.name}`);
113 }
114 }
115 for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0);
116 expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/);
117 expect((await run(["nope"], { env })).code).toBe(2);
118 });
119
120 it("api prints JSON and exits 1 on HTTP errors", async () => {
121 const srv = await fakeServer();
122 try {
123 srv.on("GET", "/api/v3/user", { body: { login: "alice" } });
124 srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body }));
125 const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" });
126 const r = await run(["api", "get", "/user"], { env });
127 expect(r.code).toBe(0);
128 expect(JSON.parse(r.stdout)).toEqual({ login: "alice" });
129 const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' });
130 expect(JSON.parse(p.stdout)).toEqual({ name: "bug" });
131 const missing = await run(["api", "GET", "/nope"], { env });
132 expect(missing.code).toBe(1);
133 expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" });
134 } finally {
135 await srv.close();
136 }
137 });
138});