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.

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