| 1 | import { mkdirSync, writeFileSync } from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import { describe, expect, it } from "vitest"; |
| 4 | import { LogBatcher } from "../src/api.js"; |
| 5 | import { parseCommand, parseKeyValueFile, parsePathFile } from "../src/commands.js"; |
| 6 | import { Masker } from "../src/mask.js"; |
| 7 | import { parseUses, inputEnvName } from "../src/steps/actions.js"; |
| 8 | import { resolveShell } from "../src/steps/run.js"; |
| 9 | import { globPaths, hashFiles, maxSatisfying, satisfies, shellWords } from "../src/util.js"; |
| 10 | import { parseYaml } from "../src/yaml.js"; |
| 11 | import { resolveNodeVersion } from "../src/builtins/setup-node.js"; |
| 12 | import { cloneUrlFor, basicAuthHeader } from "../src/builtins/checkout.js"; |
| 13 | import { tmp } from "./helpers.js"; |
| 14 | |
| 15 | describe("yaml", () => { |
| 16 | it("parses a typical action.yml", () => { |
| 17 | const doc = parseYaml(` |
| 18 | # comment |
| 19 | name: 'My Action' |
| 20 | description: "Does \\"things\\"" |
| 21 | author: someone # trailing comment |
| 22 | inputs: |
| 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: |
| 34 | outputs: |
| 35 | result: |
| 36 | value: \${{ steps.x.outputs.y }} |
| 37 | branding: { icon: 'box', color: blue } |
| 38 | tags: [a, "b c", 3] |
| 39 | runs: |
| 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 |
| 79 | b: >- |
| 80 | folded |
| 81 | text |
| 82 | |
| 83 | para |
| 84 | c: |
| 85 | - x |
| 86 | - 'it''s' |
| 87 | - "tab\\tnl\\n" |
| 88 | d: "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 | |
| 98 | describe("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 | |
| 126 | describe("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 | m.add("t"); // too short to mask |
| 135 | expect(m.mask("test")).toBe("test"); |
| 136 | expect(m.mask("x abcdef y abc z")).toBe("x *** y *** z"); |
| 137 | expect(m.mask("AAAABBBB")).toBe("***"); |
| 138 | expect(m.mask("nothing here")).toBe("nothing here"); |
| 139 | expect(m.containsSecret("..abc..")).toBe(true); |
| 140 | }); |
| 141 | }); |
| 142 | |
| 143 | describe("versions", () => { |
| 144 | it("matches common version specs", () => { |
| 145 | expect(satisfies("22.11.0", "22")).toBe(true); |
| 146 | expect(satisfies("22.11.0", "22.x")).toBe(true); |
| 147 | expect(satisfies("22.11.0", "20")).toBe(false); |
| 148 | expect(satisfies("22.11.0", ">=20")).toBe(true); |
| 149 | expect(satisfies("22.11.0", ">=18 <21")).toBe(false); |
| 150 | expect(satisfies("20.5.0", "18 || 20")).toBe(true); |
| 151 | expect(satisfies("20.5.1", "^20.5")).toBe(true); |
| 152 | expect(satisfies("20.6.0", "~20.5")).toBe(false); |
| 153 | expect(satisfies("3.12.4", "3.12")).toBe(true); |
| 154 | expect(satisfies("3.13.0", "3.12")).toBe(false); |
| 155 | expect(satisfies("1.22.3", "1.22.3")).toBe(true); |
| 156 | expect(maxSatisfying(["v20.1.0", "v22.3.0", "v22.10.1", "v23.0.0"], "22")).toBe("v22.10.1"); |
| 157 | }); |
| 158 | |
| 159 | it("resolves node lts aliases", () => { |
| 160 | const idx = [ |
| 161 | { version: "v23.1.0", lts: false as const, files: ["linux-x64", "linux-arm64"] }, |
| 162 | { version: "v22.11.0", lts: "Jod", files: ["linux-x64", "linux-arm64"] }, |
| 163 | { version: "v20.18.0", lts: "Iron", files: ["linux-x64", "linux-arm64"] }, |
| 164 | ]; |
| 165 | expect(resolveNodeVersion(idx, "lts/*")).toBe("22.11.0"); |
| 166 | expect(resolveNodeVersion(idx, "lts/iron")).toBe("20.18.0"); |
| 167 | expect(resolveNodeVersion(idx, "lts/-1")).toBe("20.18.0"); |
| 168 | expect(resolveNodeVersion(idx, "latest")).toBe("23.1.0"); |
| 169 | expect(resolveNodeVersion(idx, "20")).toBe("20.18.0"); |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | describe("glob + hashFiles", () => { |
| 174 | it("expands globs with negation and hashes deterministically", () => { |
| 175 | const root = tmp(); |
| 176 | mkdirSync(path.join(root, "a/b"), { recursive: true }); |
| 177 | writeFileSync(path.join(root, "a/x.txt"), "x"); |
| 178 | writeFileSync(path.join(root, "a/b/y.txt"), "y"); |
| 179 | writeFileSync(path.join(root, "a/b/z.log"), "z"); |
| 180 | expect(globPaths(root, ["**/*.txt"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]); |
| 181 | expect(globPaths(root, ["a/**", "!a/b/z.log"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]); |
| 182 | expect(globPaths(root, ["a"]).map((p) => path.relative(root, p))).toEqual(["a"]); |
| 183 | const h1 = hashFiles(root, ["**/*.txt"]); |
| 184 | expect(h1).toMatch(/^[0-9a-f]{64}$/); |
| 185 | expect(hashFiles(root, ["**/*.txt"])).toBe(h1); |
| 186 | expect(hashFiles(root, ["**/*.nope"])).toBe(""); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | describe("misc", () => { |
| 191 | it("splits shell words and resolves shells", () => { |
| 192 | expect(shellWords(`pwsh -command ". '/tmp/x.ps1'"`)).toEqual(["pwsh", "-command", ". '/tmp/x.ps1'"]); |
| 193 | expect(resolveShell(undefined, "/t/s.sh", process.env.PATH).argv).toEqual(["bash", "--noprofile", "--norc", "-eo", "pipefail", "/t/s.sh"]); |
| 194 | expect(resolveShell("sh", "/t/s.sh", process.env.PATH).argv).toEqual(["sh", "-e", "/t/s.sh"]); |
| 195 | expect(resolveShell("perl {0}", "/t/s.sh", process.env.PATH).argv).toEqual(["perl", "/t/s.sh"]); |
| 196 | expect(resolveShell("python", "/t/s.py", process.env.PATH).ext).toBe(".py"); |
| 197 | expect(() => resolveShell("zsh", "/t/s", process.env.PATH)).toThrow(/Invalid shell/); |
| 198 | }); |
| 199 | |
| 200 | it("parses uses references", () => { |
| 201 | expect(parseUses("actions/checkout@v4")).toMatchObject({ kind: "remote", owner: "actions", repo: "checkout", subpath: "", ref: "v4", name: "actions/checkout" }); |
| 202 | expect(parseUses("actions/cache/restore@v4")).toMatchObject({ kind: "remote", subpath: "restore", name: "actions/cache/restore" }); |
| 203 | expect(parseUses("./.github/actions/x")).toEqual({ kind: "local", path: "./.github/actions/x" }); |
| 204 | expect(parseUses("docker://alpine:3")).toEqual({ kind: "docker", image: "alpine:3" }); |
| 205 | expect(() => parseUses("nope")).toThrow(); |
| 206 | expect(inputEnvName("my input-name")).toBe("INPUT_MY_INPUT-NAME"); |
| 207 | }); |
| 208 | |
| 209 | it("derives clone urls and auth headers", () => { |
| 210 | const spec = { repo: { owner: "acme", name: "widgets", cloneUrl: "https://diffinery.com/acme/widgets.git" } }; |
| 211 | expect(cloneUrlFor(spec, "acme/widgets")).toBe("https://diffinery.com/acme/widgets.git"); |
| 212 | expect(cloneUrlFor(spec, "other/lib")).toBe("https://diffinery.com/other/lib.git"); |
| 213 | expect(basicAuthHeader("tok")).toBe(`AUTHORIZATION: basic ${Buffer.from("x-access-token:tok").toString("base64")}`); |
| 214 | }); |
| 215 | |
| 216 | it("log batcher retries and never drops lines", async () => { |
| 217 | const received: string[] = []; |
| 218 | let fails = 2; |
| 219 | const b = new LogBatcher( |
| 220 | async (lines) => { |
| 221 | if (fails-- > 0) throw new Error("boom"); |
| 222 | received.push(...lines.map((l) => l.text)); |
| 223 | }, |
| 224 | { intervalMs: 20, maxBatch: 3 }, |
| 225 | ); |
| 226 | for (let i = 0; i < 10; i++) b.push({ t: "", step: 0, text: `l${i}` }); |
| 227 | expect(await b.close(10_000)).toBe(true); |
| 228 | expect(received).toEqual(Array.from({ length: 10 }, (_, i) => `l${i}`)); |
| 229 | }); |
| 230 | }); |