| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 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 | |
| 14 | const 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 | |
| 36 | const ev = (e: string) => evaluate(e, ctx); |
| 37 | |
| 38 | const status = (s: "success" | "failure" | "cancelled"): StatusFns => ({ |
| 39 | success: () => s === "success", |
| 40 | failure: () => s === "failure", |
| 41 | cancelled: () => s === "cancelled", |
| 42 | }); |
| 43 | |
| 44 | describe("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 | |
| 65 | describe("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 | |
| 102 | describe("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 | |
| 173 | describe("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 | |
| 247 | describe("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 | |
| 274 | describe("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 | |
| 317 | describe("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 | }); |