ci.unit.test.ts
⎇
Raw
1import { describe, test, expect } from "bun:test";
2import {
3 parseCiConfig,
4 shouldTriggerPush,
5 shouldTriggerTag,
6 validateCiConfig,
7} from "../src/services/ci.ts";
8
9describe("parseCiConfig", () => {
10 test("parses a minimal valid config", () => {
11 const cfg = parseCiConfig(`
12image = "debian:latest"
13[[steps]]
14name = "build"
15run_sh = "make all"
16`);
17 expect(cfg).not.toBeNull();
18 expect(cfg!.image).toBe("debian:latest");
19 expect(cfg!.steps).toHaveLength(1);
20 expect(cfg!.steps[0]!.name).toBe("build");
21 expect(cfg!.steps[0]!.run_sh).toBe("make all");
22 });
23
24 test("returns null when image is missing", () => {
25 expect(
26 parseCiConfig(`[[steps]]\nname = "build"\nrun_sh = "make"`),
27 ).toBeNull();
28 });
29
30 test("returns null on invalid TOML", () => {
31 expect(parseCiConfig("image = [unclosed")).toBeNull();
32 });
33
34 test("returns null when steps are missing", () => {
35 expect(parseCiConfig(`image = "alpine"`)).toBeNull();
36 });
37
38 test("returns null when a step has no name", () => {
39 expect(
40 parseCiConfig(`image = "alpine"\n[[steps]]\nrun_sh = "make"`),
41 ).toBeNull();
42 });
43
44 test("preserves step order, including numeric names", () => {
45 // An object keyed by step name would reorder this: JavaScript
46 // enumerates integer-like keys first.
47 const cfg = parseCiConfig(`
48image = "alpine"
49[[steps]]
50name = "setup"
51run_sh = "apt install"
52[[steps]]
53name = "2024"
54run_sh = "echo year"
55[[steps]]
56name = "test"
57run_sh = "./test.sh"
58`);
59 expect(cfg!.steps.map((s) => s.name)).toEqual([
60 "setup",
61 "2024",
62 "test",
63 ]);
64 });
65
66 test("parses copy entries", () => {
67 const cfg = parseCiConfig(`
68image = "alpine"
69[[copy]]
70image = "docker.io/oven/bun:1.4.0-alpine"
71from = "/usr/local/bin/bun"
72to = "/usr/local/bin"
73[[steps]]
74name = "build"
75run_sh = "make"
76`);
77 expect(cfg!.copy).toEqual([
78 {
79 image: "docker.io/oven/bun:1.4.0-alpine",
80 from: "/usr/local/bin/bun",
81 to: "/usr/local/bin",
82 },
83 ]);
84 });
85
86 test("returns null when a copy entry is incomplete", () => {
87 expect(
88 parseCiConfig(`
89image = "alpine"
90[[copy]]
91image = "busybox"
92from = "/bin/busybox"
93[[steps]]
94name = "build"
95run_sh = "make"
96`),
97 ).toBeNull();
98 });
99
100 test("parses optional top-level fields", () => {
101 const cfg = parseCiConfig(`
102image = "alpine"
103work_dir = "/ci"
104clone_project_to = "/ci/repo"
105shell_setup = "set -e"
106timeout = 1800
107cpu_limit = 2.0
108memory_limit = "1g"
109cache = ["/root/.npm"]
110[[steps]]
111name = "build"
112run_sh = "make"
113`);
114 expect(cfg!.work_dir).toBe("/ci");
115 expect(cfg!.clone_project_to).toBe("/ci/repo");
116 expect(cfg!.shell_setup).toBe("set -e");
117 expect(cfg!.timeout).toBe(1800);
118 expect(cfg!.cpu_limit).toBe(2.0);
119 expect(cfg!.memory_limit).toBe("1g");
120 expect(cfg!.cache).toEqual([{ path: "/root/.npm" }]);
121 });
122
123 test("parses on.push as array", () => {
124 const cfg = parseCiConfig(`
125image = "alpine"
126[on]
127push = ["main", "develop"]
128[[steps]]
129name = "build"
130run_sh = "make"
131`);
132 expect(cfg!.on?.push).toEqual(["main", "develop"]);
133 });
134
135 test("parses on.tag and on.manual", () => {
136 const cfg = parseCiConfig(`
137image = "alpine"
138[on]
139tag = true
140manual = true
141[[steps]]
142name = "build"
143run_sh = "make"
144`);
145 expect(cfg!.on?.tag).toBe(true);
146 expect(cfg!.on?.manual).toBe(true);
147 });
148
149 test("parses variables with default and description", () => {
150 const cfg = parseCiConfig(`
151image = "alpine"
152[variables]
153 [variables.DEPLOY_ENV]
154 default = "staging"
155 description = "Target environment"
156 [variables.VERSION]
157 default = "1.0.0"
158[[steps]]
159name = "build"
160run_sh = "make"
161`);
162 expect(cfg!.variables?.DEPLOY_ENV).toEqual({
163 default: "staging",
164 description: "Target environment",
165 });
166 expect(cfg!.variables?.VERSION?.default).toBe("1.0.0");
167 });
168
169 test("parses step-level fields", () => {
170 const cfg = parseCiConfig(`
171image = "alpine"
172[[steps]]
173name = "test"
174run_sh = "./run_tests"
175run_if = 'test -n "\${CI_COMMIT_TAG}"'
176clear = true
177timeout = 300
178publish_file = ["/dist/binary"]
179publish_gzip = ["/dist/"]
180`);
181 const step = cfg!.steps[0]!;
182 expect(step.run_sh).toBe("./run_tests");
183 expect(step.run_if).toBe('test -n "${CI_COMMIT_TAG}"');
184 expect(step.clear).toBe(true);
185 expect(step.timeout).toBe(300);
186 expect(step.publish_file).toEqual(["/dist/binary"]);
187 expect(step.publish_gzip).toEqual(["/dist/"]);
188 });
189
190 test("parses always and warn_on_fail", () => {
191 const cfg = parseCiConfig(`
192image = "alpine"
193[[steps]]
194name = "lint"
195run_sh = "make lint"
196warn_on_fail = true
197[[steps]]
198name = "cleanup"
199run_sh = "rm -rf /scratch"
200always = true
201`);
202 expect(cfg!.steps[0]!.warn_on_fail).toBe(true);
203 expect(cfg!.steps[0]!.always).toBeUndefined();
204 expect(cfg!.steps[1]!.always).toBe(true);
205 });
206});
207
208describe("shouldTriggerPush", () => {
209 function cfg(push: string[] | boolean) {
210 return parseCiConfig(
211 `image="alpine"\n[[steps]]\nname="b"\n[on]\npush=${JSON.stringify(push)}`,
212 )!;
213 }
214
215 test("matches exact branch name", () => {
216 expect(shouldTriggerPush(cfg(["main"]), "main")).toBe(true);
217 expect(shouldTriggerPush(cfg(["main"]), "develop")).toBe(false);
218 });
219
220 test("wildcard * matches any branch", () => {
221 expect(shouldTriggerPush(cfg(["*"]), "anything")).toBe(true);
222 expect(shouldTriggerPush(cfg(["*"]), "main")).toBe(true);
223 });
224
225 test("matches one of multiple branches", () => {
226 const c = cfg(["main", "release"]);
227 expect(shouldTriggerPush(c, "main")).toBe(true);
228 expect(shouldTriggerPush(c, "release")).toBe(true);
229 expect(shouldTriggerPush(c, "feature/x")).toBe(false);
230 });
231
232 test("returns false when on.push is absent", () => {
233 const c = parseCiConfig('image="alpine"\n[[steps]]\nname="b"')!;
234 expect(shouldTriggerPush(c, "main")).toBe(false);
235 });
236
237 test("glob prefix matching", () => {
238 const c = cfg(["release/*"]);
239 expect(shouldTriggerPush(c, "release/1.0")).toBe(true);
240 expect(shouldTriggerPush(c, "main")).toBe(false);
241 });
242});
243
244describe("shouldTriggerTag", () => {
245 test("returns true when on.tag = true", () => {
246 const c = parseCiConfig('image="alpine"\n[[steps]]\nname="b"\n[on]\ntag=true')!;
247 expect(shouldTriggerTag(c)).toBe(true);
248 });
249
250 test("returns false when on.tag = false", () => {
251 const c = parseCiConfig('image="alpine"\n[[steps]]\nname="b"\n[on]\ntag=false')!;
252 expect(shouldTriggerTag(c)).toBe(false);
253 });
254
255 test("returns false when on.tag is absent", () => {
256 const c = parseCiConfig('image="alpine"\n[[steps]]\nname="b"')!;
257 expect(shouldTriggerTag(c)).toBe(false);
258 });
259});
260
261describe("validateCiConfig", () => {
262 const parse = (toml: string) => parseCiConfig(toml)!;
263
264 test("accepts a cache path outside the clone directory", () => {
265 const cfg = parse(`
266image = "alpine"
267clone_project_to = "/ci/build/project"
268cache = ["/ci/cache/target", "/usr/local/cargo/registry"]
269[[steps]]
270name = "build"
271run_sh = "make"
272`);
273 expect(validateCiConfig(cfg)).toBeNull();
274 });
275
276 test("rejects a cache path inside the clone directory", () => {
277 const cfg = parse(`
278image = "alpine"
279clone_project_to = "/ci/build/project"
280cache = ["/ci/build/project/target"]
281[[steps]]
282name = "build"
283run_sh = "make"
284`);
285 expect(validateCiConfig(cfg)).toContain("/ci/build/project/target");
286 });
287
288 test("rejects a cache path equal to the clone directory", () => {
289 const cfg = parse(`
290image = "alpine"
291clone_project_to = "/ci/build/project"
292cache = ["/ci/build/project"]
293[[steps]]
294name = "build"
295run_sh = "make"
296`);
297 expect(validateCiConfig(cfg)).not.toBeNull();
298 });
299
300 test("does not treat a sibling prefix as inside", () => {
301 const cfg = parse(`
302image = "alpine"
303clone_project_to = "/ci/build/project"
304cache = ["/ci/build/project-cache"]
305[[steps]]
306name = "build"
307run_sh = "make"
308`);
309 expect(validateCiConfig(cfg)).toBeNull();
310 });
311
312 test("ignores cache when nothing is cloned", () => {
313 const cfg = parse(`
314image = "alpine"
315cache = ["/anything"]
316[[steps]]
317name = "build"
318run_sh = "make"
319`);
320 expect(validateCiConfig(cfg)).toBeNull();
321 });
322});
323
324describe("validateCiConfig, copy destinations", () => {
325 const parse = (copyTo: string) =>
326 parseCiConfig(`
327image = "alpine"
328[[copy]]
329image = "busybox"
330from = "/bin/busybox"
331to = "${copyTo}"
332[[steps]]
333name = "build"
334run_sh = "make"
335`)!;
336
337 test("accepts a directory destination", () => {
338 expect(validateCiConfig(parse("/usr/local/bin"))).toBeNull();
339 });
340
341 test("rejects a destination that repeats the source basename", () => {
342 // Looks like a rename, silently produces /usr/local/bin/busybox/busybox.
343 const err = validateCiConfig(parse("/usr/local/bin/busybox"));
344 expect(err).toContain("/usr/local/bin/busybox/busybox");
345 });
346});
347
348describe("cache entries", () => {
349 test("accepts bare paths and tables in one array", () => {
350 const cfg = parseCiConfig(`
351image = "alpine"
352cache = [
353 { path = "/ci/cache/target", max_size = "12g" },
354 "/usr/local/cargo/registry",
355 { path = "/root/.bun/install/cache" },
356]
357[[steps]]
358name = "build"
359run_sh = "make"
360`);
361 expect(cfg!.cache).toEqual([
362 { path: "/ci/cache/target", maxSize: 12 * 1024 ** 3 },
363 { path: "/usr/local/cargo/registry" },
364 { path: "/root/.bun/install/cache" },
365 ]);
366 });
367
368 test("rejects a size it cannot parse", () => {
369 // parseMemoryBytes answers 0 here, which would be a limit every cache
370 // exceeds, wiping it after every run.
371 expect(
372 parseCiConfig(`
373image = "alpine"
374cache = [{ path = "/c", max_size = "12 GiB" }]
375[[steps]]
376name = "build"
377run_sh = "make"
378`),
379 ).toBeNull();
380 });
381
382 test("rejects a table with no path", () => {
383 expect(
384 parseCiConfig(`
385image = "alpine"
386cache = [{ max_size = "1g" }]
387[[steps]]
388name = "build"
389run_sh = "make"
390`),
391 ).toBeNull();
392 });
393
394 test("still rejects a capped cache inside the clone directory", () => {
395 const cfg = parseCiConfig(`
396image = "alpine"
397clone_project_to = "/ci/build/project"
398cache = [{ path = "/ci/build/project/target", max_size = "1g" }]
399[[steps]]
400name = "build"
401run_sh = "make"
402`)!;
403 expect(validateCiConfig(cfg)).toContain("/ci/build/project/target");
404 });
405});
406