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.

255 lines · 10.9 KBRaw History
1import { mkdirSync, writeFileSync } from "node:fs";
2import path from "node:path";
3import { describe, expect, it } from "vitest";
4import { LogBatcher, cpuTimes, hostCpu } from "../src/api.js";
5import { parseCommand, parseKeyValueFile, parsePathFile } from "../src/commands.js";
6import { Masker } from "../src/mask.js";
7import { parseUses, inputEnvName } from "../src/steps/actions.js";
8import { resolveShell } from "../src/steps/run.js";
9import { globPaths, hashFiles, maxSatisfying, satisfies, shellWords } from "../src/util.js";
10import { parseYaml } from "../src/yaml.js";
11import { resolveNodeVersion } from "../src/builtins/setup-node.js";
12import { cloneUrlFor, basicAuthHeader } from "../src/builtins/checkout.js";
13import { tmp } from "./helpers.js";
14import { jobUsesDocker } from "../src/job.js";
15import type { JobSpec } from "@diffinery/shared";
16
17describe("yaml", () => {
18 it("parses a typical action.yml", () => {
19 const doc = parseYaml(`
20# comment
21name: 'My Action'
22description: "Does \\"things\\""
23author: someone # trailing comment
24inputs:
25 token:
26 description: >
27 The token to use
28 for things.
29 required: true
30 default: \${{ github.token }}
31 list:
32 default: |
33 a
34 b
35 empty:
36outputs:
37 result:
38 value: \${{ steps.x.outputs.y }}
39branding: { icon: 'box', color: blue }
40tags: [a, "b c", 3]
41runs:
42 using: composite
43 steps:
44 - id: x
45 run: |
46 echo "hi: there"
47 echo done
48 shell: bash
49 - uses: actions/checkout@v4
50 with:
51 fetch-depth: 0
52 -
53 name: nested
54 run: echo nested
55 multi: this is
56 a folded plain
57`) as Record<string, any>;
58 expect(doc.name).toBe("My Action");
59 expect(doc.description).toBe('Does "things"');
60 expect(doc.author).toBe("someone");
61 expect(doc.inputs.token.description).toBe("The token to use for things.\n");
62 expect(doc.inputs.token.required).toBe(true);
63 expect(doc.inputs.token.default).toBe("${{ github.token }}");
64 expect(doc.inputs.list.default).toBe("a\nb\n");
65 expect(doc.inputs.empty).toBeNull();
66 expect(doc.outputs.result.value).toBe("${{ steps.x.outputs.y }}");
67 expect(doc.branding).toEqual({ icon: "box", color: "blue" });
68 expect(doc.tags).toEqual(["a", "b c", 3]);
69 expect(doc.runs.using).toBe("composite");
70 expect(doc.runs.steps).toHaveLength(3);
71 expect(doc.runs.steps[0]).toEqual({ id: "x", run: 'echo "hi: there"\necho done\n', shell: "bash" });
72 expect(doc.runs.steps[1]).toEqual({ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } });
73 expect(doc.runs.steps[2]).toEqual({ name: "nested", run: "echo nested" });
74 expect(doc.runs.multi).toBe("this is a folded plain");
75 });
76
77 it("handles chomping, sequences at mapping indent and quotes", () => {
78 const doc = parseYaml(`a: |-
79 keep
80 this
81b: >-
82 folded
83 text
84
85 para
86c:
87- x
88- 'it''s'
89- "tab\\tnl\\n"
90d: "multi
91 line"
92`) as Record<string, any>;
93 expect(doc.a).toBe("keep\nthis");
94 expect(doc.b).toBe("folded text\npara");
95 expect(doc.c).toEqual(["x", "it's", "tab\tnl\n"]);
96 expect(doc.d).toBe("multi line");
97 });
98});
99
100describe("workflow commands", () => {
101 it("parses commands with properties and escapes", () => {
102 expect(parseCommand("::set-output name=foo::bar")).toEqual({ command: "set-output", properties: { name: "foo" }, message: "bar" });
103 expect(parseCommand("::error file=a.ts,line=3,title=Bad%3A thing::multi%0Aline%25")).toEqual({
104 command: "error",
105 properties: { file: "a.ts", line: "3", title: "Bad: thing" },
106 message: "multi\nline%",
107 });
108 expect(parseCommand("::endgroup::")).toEqual({ command: "endgroup", properties: {}, message: "" });
109 expect(parseCommand("not a command")).toBeNull();
110 expect(parseCommand(":: nope")).toBeNull();
111 });
112
113 it("parses env files incl. heredoc syntax", () => {
114 const c = "A=1\nB=x=y\nMULTI<<EOF\nl1\nl2\nEOF\nEMPTY=\nC<<ghadelimiter_123\n\nghadelimiter_123\n";
115 expect(parseKeyValueFile(c)).toEqual([
116 ["A", "1"],
117 ["B", "x=y"],
118 ["MULTI", "l1\nl2"],
119 ["EMPTY", ""],
120 ["C", ""],
121 ]);
122 expect(() => parseKeyValueFile("X<<EOF\nnever closed")).toThrow(/delimiter/);
123 expect(() => parseKeyValueFile("garbage")).toThrow();
124 expect(parsePathFile("/a\n\n /b \n")).toEqual(["/a", "/b"]);
125 });
126});
127
128describe("masker", () => {
129 it("masks values, longest first, and each line of multi-line secrets", () => {
130 const m = new Masker();
131 m.add("abc");
132 m.add("abcdef");
133 m.add("-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----");
134 m.add("");
135 m.add(" ");
136 m.add("t"); // too short to mask
137 expect(m.mask("test")).toBe("test");
138 expect(m.mask("x abcdef y abc z")).toBe("x *** y *** z");
139 expect(m.mask("AAAABBBB")).toBe("***");
140 expect(m.mask("nothing here")).toBe("nothing here");
141 expect(m.containsSecret("..abc..")).toBe(true);
142 });
143});
144
145describe("versions", () => {
146 it("matches common version specs", () => {
147 expect(satisfies("22.11.0", "22")).toBe(true);
148 expect(satisfies("22.11.0", "22.x")).toBe(true);
149 expect(satisfies("22.11.0", "20")).toBe(false);
150 expect(satisfies("22.11.0", ">=20")).toBe(true);
151 expect(satisfies("22.11.0", ">=18 <21")).toBe(false);
152 expect(satisfies("20.5.0", "18 || 20")).toBe(true);
153 expect(satisfies("20.5.1", "^20.5")).toBe(true);
154 expect(satisfies("20.6.0", "~20.5")).toBe(false);
155 expect(satisfies("3.12.4", "3.12")).toBe(true);
156 expect(satisfies("3.13.0", "3.12")).toBe(false);
157 expect(satisfies("1.22.3", "1.22.3")).toBe(true);
158 expect(maxSatisfying(["v20.1.0", "v22.3.0", "v22.10.1", "v23.0.0"], "22")).toBe("v22.10.1");
159 });
160
161 it("resolves node lts aliases", () => {
162 const idx = [
163 { version: "v23.1.0", lts: false as const, files: ["linux-x64", "linux-arm64"] },
164 { version: "v22.11.0", lts: "Jod", files: ["linux-x64", "linux-arm64"] },
165 { version: "v20.18.0", lts: "Iron", files: ["linux-x64", "linux-arm64"] },
166 ];
167 expect(resolveNodeVersion(idx, "lts/*")).toBe("22.11.0");
168 expect(resolveNodeVersion(idx, "lts/iron")).toBe("20.18.0");
169 expect(resolveNodeVersion(idx, "lts/-1")).toBe("20.18.0");
170 expect(resolveNodeVersion(idx, "latest")).toBe("23.1.0");
171 expect(resolveNodeVersion(idx, "20")).toBe("20.18.0");
172 });
173});
174
175describe("glob + hashFiles", () => {
176 it("expands globs with negation and hashes deterministically", () => {
177 const root = tmp();
178 mkdirSync(path.join(root, "a/b"), { recursive: true });
179 writeFileSync(path.join(root, "a/x.txt"), "x");
180 writeFileSync(path.join(root, "a/b/y.txt"), "y");
181 writeFileSync(path.join(root, "a/b/z.log"), "z");
182 expect(globPaths(root, ["**/*.txt"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]);
183 expect(globPaths(root, ["a/**", "!a/b/z.log"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]);
184 expect(globPaths(root, ["a"]).map((p) => path.relative(root, p))).toEqual(["a"]);
185 const h1 = hashFiles(root, ["**/*.txt"]);
186 expect(h1).toMatch(/^[0-9a-f]{64}$/);
187 expect(hashFiles(root, ["**/*.txt"])).toBe(h1);
188 expect(hashFiles(root, ["**/*.nope"])).toBe("");
189 });
190});
191
192describe("misc", () => {
193 it("splits shell words and resolves shells", () => {
194 expect(shellWords(`pwsh -command ". '/tmp/x.ps1'"`)).toEqual(["pwsh", "-command", ". '/tmp/x.ps1'"]);
195 expect(resolveShell(undefined, "/t/s.sh", process.env.PATH).argv).toEqual(["bash", "--noprofile", "--norc", "-eo", "pipefail", "/t/s.sh"]);
196 expect(resolveShell("sh", "/t/s.sh", process.env.PATH).argv).toEqual(["sh", "-e", "/t/s.sh"]);
197 expect(resolveShell("perl {0}", "/t/s.sh", process.env.PATH).argv).toEqual(["perl", "/t/s.sh"]);
198 expect(resolveShell("python", "/t/s.py", process.env.PATH).ext).toBe(".py");
199 expect(() => resolveShell("zsh", "/t/s", process.env.PATH)).toThrow(/Invalid shell/);
200 });
201
202 it("parses uses references", () => {
203 expect(parseUses("actions/checkout@v4")).toMatchObject({ kind: "remote", owner: "actions", repo: "checkout", subpath: "", ref: "v4", name: "actions/checkout" });
204 expect(parseUses("actions/cache/restore@v4")).toMatchObject({ kind: "remote", subpath: "restore", name: "actions/cache/restore" });
205 expect(parseUses("./.github/actions/x")).toEqual({ kind: "local", path: "./.github/actions/x" });
206 expect(parseUses("docker://alpine:3")).toEqual({ kind: "docker", image: "alpine:3" });
207 expect(() => parseUses("nope")).toThrow();
208 expect(inputEnvName("my input-name")).toBe("INPUT_MY_INPUT-NAME");
209 });
210
211 it("derives clone urls and auth headers", () => {
212 const spec = { repo: { owner: "acme", name: "widgets", cloneUrl: "https://diffinery.com/acme/widgets.git" } };
213 expect(cloneUrlFor(spec, "acme/widgets")).toBe("https://diffinery.com/acme/widgets.git");
214 expect(cloneUrlFor(spec, "other/lib")).toBe("https://diffinery.com/other/lib.git");
215 expect(basicAuthHeader("tok")).toBe(`AUTHORIZATION: basic ${Buffer.from("x-access-token:tok").toString("base64")}`);
216 });
217
218 it("log batcher retries and never drops lines", async () => {
219 const received: string[] = [];
220 let fails = 2;
221 const b = new LogBatcher(
222 async (lines) => {
223 if (fails-- > 0) throw new Error("boom");
224 received.push(...lines.map((l) => l.text));
225 },
226 { intervalMs: 20, maxBatch: 3 },
227 );
228 for (let i = 0; i < 10; i++) b.push({ t: "", step: 0, text: `l${i}` });
229 expect(await b.close(10_000)).toBe(true);
230 expect(received).toEqual(Array.from({ length: 10 }, (_, i) => `l${i}`));
231 });
232});
233
234describe("host", () => {
235 it("names the host's processor, and reads the vCPUs' stolen time", () => {
236 const core = (n: number) => `processor\t: ${n}\nvendor_id\t: AuthenticAMD\ncpu family\t: 25\nmodel\t\t: 17\nmodel name\t: AMD EPYC\ncpu MHz\t\t: 2549.998\n\n`;
237 expect(hostCpu([0, 1, 2, 3].map(core).join(""))).toBe("AMD EPYC (family 25, model 17), 4 vCPUs, 2550 MHz");
238 expect(hostCpu("")).toBeUndefined();
239 expect(cpuTimes("cpu 1498 0 227 267312 359 0 61 514 0 0\ncpu0 1 2 3 4 5 6 7 8 0 0\n")).toEqual({ total: 269971, steal: 514 });
240 expect(cpuTimes("intr 1 2 3\n")).toBeNull();
241 });
242});
243
244describe("docker head start", () => {
245 const spec = (steps: Array<{ run?: string; uses?: string }>, more: Partial<JobSpec> = {}) => ({ steps, ...more }) as JobSpec;
246 it("starts the daemon early only for jobs that will use Docker", () => {
247 expect(jobUsesDocker(spec([{ uses: "actions/checkout@v4" }, { run: "docker build -t app ." }]))).toBe(true);
248 expect(jobUsesDocker(spec([{ uses: "docker/build-push-action@v6" }]))).toBe(true);
249 expect(jobUsesDocker(spec([{ uses: "docker://alpine:3.20" }]))).toBe(true);
250 expect(jobUsesDocker(spec([{ run: "npm test" }], { services: { db: { image: "postgres:16" } } as JobSpec["services"] }))).toBe(true);
251 expect(jobUsesDocker(spec([{ run: "npm test" }], { container: { image: "node:22" } as JobSpec["container"] }))).toBe(true);
252 expect(jobUsesDocker(spec([{ uses: "actions/checkout@v4" }, { run: "pnpm install && pnpm test" }]))).toBe(false);
253 expect(jobUsesDocker(spec([{ run: "echo dockerfile-lint" }]))).toBe(false);
254 });
255});