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.

356 lines · 13.9 KBRaw History
1import { describe, expect, it } from "vitest";
2import {
3 containsExpression,
4 evaluate,
5 evaluateIf,
6 evaluateValue,
7 ExpressionSyntaxError,
8 interpolate,
9 splitTemplate,
10 type ExprContext,
11 type StatusFns,
12} from "../src/index.js";
13
14const ctx: ExprContext = {
15 github: {
16 event_name: "push",
17 ref: "refs/heads/main",
18 repository: "acme/widgets",
19 event: {
20 commits: [
21 { id: "a1", message: "fix: bug", author: { name: "Ann" } },
22 { id: "b2", message: "feat: thing", author: { name: "Bob" } },
23 { id: "c3", message: "chore", author: null },
24 ],
25 pull_request: { labels: [{ name: "bug" }, { name: "ci" }], number: 42 },
26 },
27 },
28 env: { NODE_ENV: "production", Mixed_Case: "yes" },
29 matrix: { node: 20, os: "ubuntu-latest" },
30 steps: { "build-step": { outputs: { artifact: "dist.tgz" }, outcome: "success" } },
31 needs: { build: { result: "success", outputs: { version: "1.2.3" } } },
32 inputs: { debug: true, count: 3, name: "" },
33 vars: { REGION: "eu" },
34};
35
36const ev = (e: string) => evaluate(e, ctx);
37
38const status = (s: "success" | "failure" | "cancelled"): StatusFns => ({
39 success: () => s === "success",
40 failure: () => s === "failure",
41 cancelled: () => s === "cancelled",
42});
43
44describe("literals", () => {
45 it("parses null, booleans, numbers, strings", () => {
46 expect(ev("null")).toBe(null);
47 expect(ev("true")).toBe(true);
48 expect(ev("false")).toBe(false);
49 expect(ev("711")).toBe(711);
50 expect(ev("-9.2")).toBe(-9.2);
51 expect(ev("0xff")).toBe(255);
52 expect(ev("-2.99e-2")).toBe(-0.0299);
53 expect(ev("1e3")).toBe(1000);
54 expect(ev("'Mona the Octocat'")).toBe("Mona the Octocat");
55 expect(ev("'It''s open source!'")).toBe("It's open source!");
56 expect(ev("''")).toBe("");
57 });
58
59 it("supports NaN and Infinity keywords", () => {
60 expect(ev("NaN")).toBeNaN();
61 expect(ev("Infinity")).toBe(Infinity);
62 });
63});
64
65describe("property access", () => {
66 it("dereferences with . and []", () => {
67 expect(ev("github.event_name")).toBe("push");
68 expect(ev("github['event_name']")).toBe("push");
69 expect(ev("steps.build-step.outputs.artifact")).toBe("dist.tgz");
70 expect(ev("steps['build-step'].outputs['artifact']")).toBe("dist.tgz");
71 expect(ev("github.event.commits[1].id")).toBe("b2");
72 expect(ev("github.event.commits['1'].id")).toBe("b2");
73 });
74
75 it("is case-insensitive for property and context names", () => {
76 expect(ev("GITHUB.Event_Name")).toBe("push");
77 expect(ev("env.mixed_case")).toBe("yes");
78 expect(ev("Needs.BUILD.outputs.VERSION")).toBe("1.2.3");
79 });
80
81 it("returns null for unknown properties and contexts", () => {
82 expect(ev("github.nope")).toBe(null);
83 expect(ev("github.nope.deeper.still")).toBe(null);
84 expect(ev("unknowncontext.x")).toBe(null);
85 expect(ev("github.event.commits[99]")).toBe(null);
86 expect(ev("github.event.commits[-1]")).toBe(null);
87 expect(ev("github.ref.length")).toBe(null);
88 });
89
90 it("supports object filters", () => {
91 expect(ev("github.event.commits.*.message")).toEqual(["fix: bug", "feat: thing", "chore"]);
92 expect(ev("github.event.commits.*.author.name")).toEqual(["Ann", "Bob"]);
93 expect(ev("github.event.pull_request.labels.*.name")).toEqual(["bug", "ci"]);
94 expect(ev("github.event.commits[*].id")).toEqual(["a1", "b2", "c3"]);
95 expect(ev("contains(github.event.pull_request.labels.*.name, 'CI')")).toBe(true);
96 expect(ev("needs.*.result")).toEqual(["success"]);
97 expect(evaluate("fruits.*.*", { fruits: { a: [1, 2], b: { x: 3 } } })).toEqual([1, 2, 3]);
98 expect(ev("github.missing.*")).toEqual([]);
99 });
100});
101
102describe("operators", () => {
103 it("logical not and grouping", () => {
104 expect(ev("!true")).toBe(false);
105 expect(ev("!''")).toBe(true);
106 expect(ev("!!github")).toBe(true);
107 expect(ev("!(1 == 2)")).toBe(true);
108 expect(ev("(true || false) && false")).toBe(false);
109 });
110
111 it("&& and || return operand values", () => {
112 expect(ev("'a' && 'b'")).toBe("b");
113 expect(ev("'' && 'b'")).toBe("");
114 expect(ev("null || 'fallback'")).toBe("fallback");
115 expect(ev("inputs.name || 'default-name'")).toBe("default-name");
116 expect(ev("0 || null")).toBe(null);
117 expect(ev("github.event_name == 'push' && 'yes' || 'no'")).toBe("yes");
118 expect(ev("github.event_name == 'pr' && 'yes' || 'no'")).toBe("no");
119 });
120
121 it("precedence: comparison binds tighter than equality, && tighter than ||", () => {
122 expect(ev("1 < 2 == true")).toBe(true);
123 expect(ev("true || false && false")).toBe(true);
124 });
125
126 it("compares strings case-insensitively", () => {
127 expect(ev("'ABC' == 'abc'")).toBe(true);
128 expect(ev("'abc' != 'ABD'")).toBe(true);
129 expect(ev("'a' < 'B'")).toBe(true);
130 expect(ev("'b' >= 'B'")).toBe(true);
131 });
132
133 it("coerces mismatched types to numbers", () => {
134 expect(ev("null == 0")).toBe(true);
135 expect(ev("true == 1")).toBe(true);
136 expect(ev("false == 0")).toBe(true);
137 expect(ev("'' == 0")).toBe(true);
138 expect(ev("'0x10' == 16")).toBe(true);
139 expect(ev("' 42 ' == 42")).toBe(true);
140 expect(ev("'1e2' == 100")).toBe(true);
141 expect(ev("'abc' == 0")).toBe(false);
142 expect(ev("'true' == true")).toBe(false); // 'true' -> NaN
143 expect(ev("null == false")).toBe(true);
144 expect(ev("inputs.count == '3'")).toBe(true);
145 expect(ev("inputs.debug == 'true'")).toBe(false);
146 expect(ev("inputs.debug == true")).toBe(true);
147 expect(ev("2 > '10'")).toBe(false);
148 expect(ev("'2' > '10'")).toBe(true); // string compare
149 });
150
151 it("follows NaN rules", () => {
152 expect(ev("NaN == NaN")).toBe(false);
153 expect(ev("NaN != NaN")).toBe(true);
154 expect(ev("'abc' < 5")).toBe(false);
155 expect(ev("'abc' >= 5")).toBe(false);
156 expect(ev("!NaN")).toBe(true);
157 });
158
159 it("compares objects and arrays by reference", () => {
160 expect(ev("github.event == github.event")).toBe(true);
161 expect(ev("fromJSON('{}') == fromJSON('{}')")).toBe(false);
162 expect(ev("github.event == 1")).toBe(false);
163 expect(ev("github.event < github.event")).toBe(false);
164 });
165
166 it("null compares equal to null", () => {
167 expect(ev("null == null")).toBe(true);
168 expect(ev("github.nope == null")).toBe(true);
169 expect(ev("github.nope == ''")).toBe(true); // null -> 0, '' -> 0
170 });
171});
172
173describe("functions", () => {
174 it("contains", () => {
175 expect(ev("contains('Hello world', 'llo')")).toBe(true);
176 expect(ev("contains('Hello world', 'LLO')")).toBe(true);
177 expect(ev("contains('Hello world', 'xyz')")).toBe(false);
178 expect(ev("contains(fromJSON('[\"push\", \"pull_request\"]'), github.event_name)")).toBe(true);
179 expect(ev("contains(fromJSON('[1, 2]'), '2')")).toBe(true);
180 expect(ev("contains(fromJSON('[1, 2]'), 3)")).toBe(false);
181 expect(ev("contains(github.ref, 'main')")).toBe(true);
182 expect(ev("CONTAINS('abc', 'b')")).toBe(true);
183 });
184
185 it("startsWith / endsWith", () => {
186 expect(ev("startsWith('Hello world', 'he')")).toBe(true);
187 expect(ev("startsWith(github.ref, 'refs/tags/')")).toBe(false);
188 expect(ev("endsWith('Hello world', 'LD')")).toBe(true);
189 expect(ev("endswith(github.ref, '/main')")).toBe(true);
190 expect(ev("startsWith(123, 12)")).toBe(true);
191 });
192
193 it("format", () => {
194 expect(ev("format('Hello {0} {1} {2}', 'Mona', 'the', 'Octocat')")).toBe("Hello Mona the Octocat");
195 expect(ev("format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')")).toBe("{Hello Mona the Octocat!}");
196 expect(ev("format('{0}{0}', 'x')")).toBe("xx");
197 expect(ev("format('{0}-{1}', 1.5, true)")).toBe("1.5-true");
198 expect(ev("format('{0}', null)")).toBe("");
199 expect(() => ev("format('{1}', 'a')")).toThrow(/no matching argument/);
200 expect(() => ev("format('{x}', 'a')")).toThrow(/invalid format/);
201 expect(() => ev("format('a } b')")).toThrow(/invalid format/);
202 });
203
204 it("join", () => {
205 expect(ev("join(github.event.pull_request.labels.*.name, ', ')")).toBe("bug, ci");
206 expect(ev("join(fromJSON('[1, true, null]'))")).toBe("1,true,");
207 expect(ev("join('abc', '-')")).toBe("abc");
208 });
209
210 it("toJSON pretty prints with 2 spaces", () => {
211 expect(ev("toJSON(matrix)")).toBe('{\n "node": 20,\n "os": "ubuntu-latest"\n}');
212 expect(ev("toJSON('x')")).toBe('"x"');
213 expect(ev("toJSON(null)")).toBe("null");
214 expect(ev("toJSON(github.nope)")).toBe("null");
215 });
216
217 it("fromJSON", () => {
218 expect(ev("fromJSON('{\"a\": [1, 2]}').a[1]")).toBe(2);
219 expect(ev("fromJSON('true')")).toBe(true);
220 expect(ev("fromJSON('42') == 42")).toBe(true);
221 expect(() => ev("fromJSON('{nope')")).toThrow(/fromJSON/);
222 });
223
224 it("hashFiles delegates to extra.hashFiles", () => {
225 expect(ev("hashFiles('**/package-lock.json')")).toBe("");
226 const seen: string[][] = [];
227 const out = evaluate("hashFiles('**/package-lock.json', '!node_modules/**')", ctx, undefined, {
228 hashFiles: (...p) => {
229 seen.push(p);
230 return "abc123";
231 },
232 });
233 expect(out).toBe("abc123");
234 expect(seen).toEqual([["**/package-lock.json", "!node_modules/**"]]);
235 });
236
237 it("status functions", () => {
238 expect(evaluate("success()", {}, status("failure"))).toBe(false);
239 expect(evaluate("failure()", {}, status("failure"))).toBe(true);
240 expect(evaluate("cancelled()", {}, status("cancelled"))).toBe(true);
241 expect(evaluate("always()", {}, status("cancelled"))).toBe(true);
242 expect(evaluate("always()", {}, { ...status("success"), always: () => false })).toBe(false);
243 expect(evaluate("success()", {})).toBe(true);
244 });
245});
246
247describe("syntax errors", () => {
248 const bad = [
249 "",
250 "github.",
251 "1 ==",
252 "(true",
253 "'unterminated",
254 "a b",
255 "nosuchfn(1)",
256 "contains('a')",
257 "success(1)",
258 "\"double\"",
259 "a = b",
260 "a & b",
261 "github[",
262 ];
263 for (const expr of bad) {
264 it(`throws for ${JSON.stringify(expr)}`, () => {
265 expect(() => evaluate(expr, ctx)).toThrow(ExpressionSyntaxError);
266 });
267 }
268 it("has a clear message", () => {
269 expect(() => evaluate("foo(1)", {})).toThrow(/Unrecognized function 'foo'/);
270 expect(() => evaluate("1 ==", {})).toThrow(/end of expression/);
271 });
272});
273
274describe("interpolate", () => {
275 it("replaces every expression", () => {
276 expect(interpolate("node-${{ matrix.node }}-${{ matrix.os }}", ctx)).toBe("node-20-ubuntu-latest");
277 expect(interpolate("no expressions", ctx)).toBe("no expressions");
278 expect(interpolate("${{matrix.node}}", ctx)).toBe("20");
279 });
280
281 it("coerces values like GitHub", () => {
282 expect(interpolate("[${{ github.nope }}]", ctx)).toBe("[]");
283 expect(interpolate("${{ true }} ${{ false }}", ctx)).toBe("true false");
284 expect(interpolate("${{ 1.50 }} ${{ 0x10 }} ${{ 1e3 }} ${{ -0 }}", ctx)).toBe("1.5 16 1000 0");
285 expect(interpolate("${{ 0.1 }}", ctx)).toBe("0.1");
286 expect(interpolate("${{ needs.build.outputs }}", ctx)).toBe('{\n "version": "1.2.3"\n}');
287 expect(interpolate("${{ github.event.commits.*.id }}", ctx)).toBe('[\n "a1",\n "b2",\n "c3"\n]');
288 });
289
290 it("does not end an expression at }} inside a string literal", () => {
291 expect(interpolate("${{ format('{{x}}={0}', 1) }}!", ctx)).toBe("{x}=1!");
292 expect(interpolate("${{ 'a}}b' }}", ctx)).toBe("a}}b");
293 });
294
295 it("throws on unclosed expressions", () => {
296 expect(() => interpolate("echo ${{ github.ref", ctx)).toThrow(/Unclosed expression/);
297 });
298
299 it("evaluateValue keeps types for sole expressions", () => {
300 expect(evaluateValue("${{ inputs.count }}", ctx)).toBe(3);
301 expect(evaluateValue("${{ fromJSON('[1,2]') }}", ctx)).toEqual([1, 2]);
302 expect(evaluateValue(" v${{ inputs.count }}", ctx)).toBe(" v3");
303 expect(evaluateValue(5, ctx)).toBe(5);
304 });
305
306 it("containsExpression / splitTemplate", () => {
307 expect(containsExpression("a ${{ b }}")).toBe(true);
308 expect(containsExpression("a ${ b }")).toBe(false);
309 expect(splitTemplate("a${{ x }}b")).toEqual([
310 { kind: "text", text: "a" },
311 { kind: "expr", expr: "x", start: 1, end: 9 },
312 { kind: "text", text: "b" },
313 ]);
314 });
315});
316
317describe("evaluateIf", () => {
318 it("undefined means success()", () => {
319 expect(evaluateIf(undefined, ctx, status("success"))).toBe(true);
320 expect(evaluateIf(undefined, ctx, status("failure"))).toBe(false);
321 expect(evaluateIf("", ctx, status("failure"))).toBe(false);
322 });
323
324 it("adds implicit success() when no status function is used", () => {
325 expect(evaluateIf("github.event_name == 'push'", ctx, status("success"))).toBe(true);
326 expect(evaluateIf("github.event_name == 'push'", ctx, status("failure"))).toBe(false);
327 expect(evaluateIf("${{ github.event_name == 'push' }}", ctx, status("success"))).toBe(true);
328 expect(evaluateIf("true", ctx, status("cancelled"))).toBe(false);
329 expect(evaluateIf(true, ctx, status("failure"))).toBe(false);
330 expect(evaluateIf(false, ctx, status("success"))).toBe(false);
331 });
332
333 it("does not add success() when a status function is present", () => {
334 expect(evaluateIf("always()", ctx, status("failure"))).toBe(true);
335 expect(evaluateIf("failure()", ctx, status("failure"))).toBe(true);
336 expect(evaluateIf("${{ failure() && github.event_name == 'push' }}", ctx, status("failure"))).toBe(true);
337 expect(evaluateIf("!cancelled()", ctx, status("failure"))).toBe(true);
338 expect(evaluateIf("cancelled()", ctx, status("success"))).toBe(false);
339 expect(evaluateIf("success() || failure()", ctx, status("failure"))).toBe(true);
340 });
341
342 it("uses truthiness of the result", () => {
343 expect(evaluateIf("inputs.name", ctx, status("success"))).toBe(false);
344 expect(evaluateIf("inputs.count", ctx, status("success"))).toBe(true);
345 expect(evaluateIf("steps.build-step.outputs.artifact", ctx, status("success"))).toBe(true);
346 expect(evaluateIf("github.nope", ctx, status("success"))).toBe(false);
347 });
348
349 it("treats mixed text and expressions as a (truthy) string", () => {
350 expect(evaluateIf("${{ false }} == true", ctx, status("success"))).toBe(true);
351 });
352
353 it("throws on syntax errors", () => {
354 expect(() => evaluateIf("github.event_name ==", ctx, status("success"))).toThrow(ExpressionSyntaxError);
355 });
356});