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.

157 lines · 5.7 KBRaw History
1import { mkdtempSync } from "node:fs";
2import { createServer, type Server } from "node:http";
3import type { AddressInfo } from "node:net";
4import { tmpdir } from "node:os";
5import path from "node:path";
6import type { JobCompletion, JobSpec, LogLine, StepReport, StepSpec } from "@diffinery/shared";
7
8export interface FakeApi {
9 url: string;
10 token: string;
11 logs: LogLine[];
12 steps: Array<{ index: number; report: StepReport }>;
13 completion: JobCompletion | null;
14 cache: Map<string, Buffer>;
15 artifacts: Map<string, Buffer>;
16 requests: Array<{ method: string; path: string; auth: string | undefined }>;
17 /** Fail the next N log POSTs with 503 (to test retries). */
18 failLogs: number;
19 specs: Map<string, JobSpec>;
20 close(): Promise<void>;
21 logText(step?: number): string;
22 lastReport(index: number): StepReport | undefined;
23}
24
25export async function startFakeApi(token = "job-token-123"): Promise<FakeApi> {
26 const api: FakeApi = {
27 url: "",
28 token,
29 logs: [],
30 steps: [],
31 completion: null,
32 cache: new Map(),
33 artifacts: new Map(),
34 requests: [],
35 failLogs: 0,
36 specs: new Map(),
37 close: async () => {},
38 logText(step?: number) {
39 return this.logs
40 .filter((l) => step === undefined || l.step === step)
41 .map((l) => l.text)
42 .join("\n");
43 },
44 lastReport(index: number) {
45 return [...this.steps].reverse().find((s) => s.index === index)?.report;
46 },
47 };
48 const server: Server = createServer(async (req, res) => {
49 const chunks: Buffer[] = [];
50 for await (const c of req) chunks.push(c as Buffer);
51 const body = Buffer.concat(chunks);
52 const u = new URL(req.url ?? "/", "http://x");
53 api.requests.push({ method: req.method!, path: u.pathname + u.search, auth: req.headers.authorization });
54 const json = (status: number, v: unknown) => {
55 res.writeHead(status, { "content-type": "application/json" });
56 res.end(JSON.stringify(v));
57 };
58 if (req.headers.authorization !== `Bearer ${token}`) return json(401, { error: "unauthorized" });
59 const m = /^\/runner\/jobs\/([^/]+)(\/.*)?$/.exec(u.pathname);
60 if (!m) return json(404, {});
61 const sub = m[2] ?? "";
62 if (sub === "" && req.method === "GET") {
63 const s = api.specs.get(decodeURIComponent(m[1]!));
64 return s ? json(200, s) : json(404, {});
65 }
66 if (sub === "/logs" && req.method === "POST") {
67 if (api.failLogs > 0) {
68 api.failLogs--;
69 return json(503, { error: "try later" });
70 }
71 api.logs.push(...(JSON.parse(body.toString()) as { lines: LogLine[] }).lines);
72 return json(200, {});
73 }
74 const sm = /^\/steps\/(\d+)$/.exec(sub);
75 if (sm && req.method === "POST") {
76 api.steps.push({ index: Number(sm[1]), report: JSON.parse(body.toString()) as StepReport });
77 return json(200, {});
78 }
79 if (sub === "/complete" && req.method === "POST") {
80 api.completion = JSON.parse(body.toString()) as JobCompletion;
81 return json(200, {});
82 }
83 if (sub === "/cache" && req.method === "GET") {
84 const key = u.searchParams.get("key")!;
85 if (api.cache.has(key)) return json(200, { key, url: `${m[0].replace(/\/cache$/, "")}/cache/${encodeURIComponent(key)}` });
86 for (const prefix of (u.searchParams.get("restore") ?? "").split(",").filter(Boolean)) {
87 const hit = [...api.cache.keys()].find((k) => k.startsWith(prefix));
88 if (hit) return json(200, { key: hit, url: `/runner/jobs/${m[1]}/cache/${encodeURIComponent(hit)}` });
89 }
90 return json(404, {});
91 }
92 const cm = /^\/cache\/(.+)$/.exec(sub);
93 if (cm) {
94 const key = decodeURIComponent(cm[1]!);
95 if (req.method === "PUT") {
96 if (api.cache.has(key)) return json(409, {});
97 api.cache.set(key, body);
98 return json(201, {});
99 }
100 const b = api.cache.get(key);
101 if (!b) return json(404, {});
102 res.writeHead(200, { "content-type": "application/gzip" });
103 return res.end(b);
104 }
105 const am = /^\/artifacts\/(.+)$/.exec(sub);
106 if (am) {
107 const name = decodeURIComponent(am[1]!);
108 if (req.method === "PUT") {
109 api.artifacts.set(name, body);
110 return json(201, { id: name.length, url: `https://example.test/artifacts/${name}` });
111 }
112 const b = api.artifacts.get(name);
113 if (!b) return json(404, {});
114 res.writeHead(200, { "content-type": "application/gzip" });
115 return res.end(b);
116 }
117 if (sub === "/oidc") return json(200, { value: `oidc-for-${u.searchParams.get("audience")}` });
118 return json(404, {});
119 });
120 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
121 api.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
122 api.close = () => new Promise<void>((r) => server.close(() => r()));
123 return api;
124}
125
126export function tmp(prefix = "runner-test-"): string {
127 return mkdtempSync(path.join(tmpdir(), prefix));
128}
129
130export function makeSpec(api: FakeApi, steps: StepSpec[], over: Partial<JobSpec> = {}): JobSpec {
131 return {
132 runId: "run-1",
133 jobId: "job-1",
134 jobKey: "build",
135 name: "build",
136 repo: { owner: "acme", name: "widgets", cloneUrl: "http://invalid.test/acme/widgets.git", defaultBranch: "main" },
137 sha: "0123456789abcdef0123456789abcdef01234567",
138 ref: "refs/heads/main",
139 workflowPath: ".github/workflows/ci.yml",
140 github: { event_name: "push", actor: "octo", event: { ref: "refs/heads/main", pusher: { name: "octo" } } },
141 env: {},
142 matrix: {},
143 strategy: { "fail-fast": true, "job-index": 0, "job-total": 1 },
144 needs: {},
145 inputs: {},
146 vars: {},
147 secrets: {},
148 outputs: {},
149 steps,
150 timeoutMinutes: 10,
151 continueOnError: false,
152 idToken: false,
153 apiUrl: api.url,
154 token: api.token,
155 ...over,
156 };
157}