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[build]
14run_sh = "make all"
15`);
16 expect(cfg).not.toBeNull();
17 expect(cfg!.image).toBe("debian:latest");
18 expect(cfg!.steps).toHaveLength(1);
19 expect(cfg!.steps[0]!.name).toBe("build");
20 expect(cfg!.steps[0]!.run_sh).toBe("make all");
21 });
22
23 test("returns null when image is missing", () => {
24 expect(parseCiConfig(`[build]\nrun_sh = "make"`)).toBeNull();
25 });
26
27 test("returns null on invalid TOML", () => {
28 expect(parseCiConfig("image = [unclosed")).toBeNull();
29 });
30
31 test("excludes reserved table names from steps", () => {
32 const cfg = parseCiConfig(`
33image = "alpine"
34[on]
35push = ["main"]
36[variables]
37 [variables.FOO]
38 default = "bar"
39[step1]
40run_sh = "echo hi"
41`);
42 expect(cfg).not.toBeNull();
43 expect(cfg!.steps.map((s) => s.name)).toEqual(["step1"]);
44 });
45
46 test("preserves step order", () => {
47 const cfg = parseCiConfig(`
48image = "alpine"
49[setup]
50run_sh = "apt install"
51[compile]
52run_sh = "make"
53[test]
54run_sh = "./test.sh"
55`);
56 expect(cfg!.steps.map((s) => s.name)).toEqual([
57 "setup",
58 "compile",
59 "test",
60 ]);
61 });
62
63 test("parses optional top-level fields", () => {
64 const cfg = parseCiConfig(`
65image = "alpine"
66work_dir = "/ci"
67clone_project_to = "/ci/repo"
68shell_setup = "set -e"
69timeout = 1800
70cpu_limit = 2.0
71memory_limit = "1g"
72cache = ["/root/.npm"]
73`);
74 expect(cfg!.work_dir).toBe("/ci");
75 expect(cfg!.clone_project_to).toBe("/ci/repo");
76 expect(cfg!.shell_setup).toBe("set -e");
77 expect(cfg!.timeout).toBe(1800);
78 expect(cfg!.cpu_limit).toBe(2.0);
79 expect(cfg!.memory_limit).toBe("1g");
80 expect(cfg!.cache).toEqual(["/root/.npm"]);
81 });
82
83 test("parses on.push as array", () => {
84 const cfg = parseCiConfig(`
85image = "alpine"
86[on]
87push = ["main", "develop"]
88`);
89 expect(cfg!.on?.push).toEqual(["main", "develop"]);
90 });
91
92 test("parses on.tag and on.manual", () => {
93 const cfg = parseCiConfig(`
94image = "alpine"
95[on]
96tag = true
97manual = true
98`);
99 expect(cfg!.on?.tag).toBe(true);
100 expect(cfg!.on?.manual).toBe(true);
101 });
102
103 test("parses variables with default and description", () => {
104 const cfg = parseCiConfig(`
105image = "alpine"
106[variables]
107 [variables.DEPLOY_ENV]
108 default = "staging"
109 description = "Target environment"
110 [variables.VERSION]
111 default = "1.0.0"
112`);
113 expect(cfg!.variables?.DEPLOY_ENV).toEqual({
114 default: "staging",
115 description: "Target environment",
116 });
117 expect(cfg!.variables?.VERSION?.default).toBe("1.0.0");
118 });
119
120 test("parses step-level fields", () => {
121 const cfg = parseCiConfig(`
122image = "alpine"
123[test]
124run_sh = "./run_tests"
125run_if = 'test -n "\${CI_COMMIT_TAG}"'
126clear = true
127timeout = 300
128publish_file = ["/dist/binary"]
129publish_gzip = ["/dist/"]
130`);
131 const step = cfg!.steps[0]!;
132 expect(step.run_sh).toBe("./run_tests");
133 expect(step.run_if).toBe('test -n "${CI_COMMIT_TAG}"');
134 expect(step.clear).toBe(true);
135 expect(step.timeout).toBe(300);
136 expect(step.publish_file).toEqual(["/dist/binary"]);
137 expect(step.publish_gzip).toEqual(["/dist/"]);
138 });
139});
140
141describe("shouldTriggerPush", () => {
142 function cfg(push: string[] | boolean) {
143 return parseCiConfig(
144 `image="alpine"\n[on]\npush=${JSON.stringify(push)}`,
145 )!;
146 }
147
148 test("matches exact branch name", () => {
149 expect(shouldTriggerPush(cfg(["main"]), "main")).toBe(true);
150 expect(shouldTriggerPush(cfg(["main"]), "develop")).toBe(false);
151 });
152
153 test("wildcard * matches any branch", () => {
154 expect(shouldTriggerPush(cfg(["*"]), "anything")).toBe(true);
155 expect(shouldTriggerPush(cfg(["*"]), "main")).toBe(true);
156 });
157
158 test("matches one of multiple branches", () => {
159 const c = cfg(["main", "release"]);
160 expect(shouldTriggerPush(c, "main")).toBe(true);
161 expect(shouldTriggerPush(c, "release")).toBe(true);
162 expect(shouldTriggerPush(c, "feature/x")).toBe(false);
163 });
164
165 test("returns false when on.push is absent", () => {
166 const c = parseCiConfig('image="alpine"')!;
167 expect(shouldTriggerPush(c, "main")).toBe(false);
168 });
169
170 test("glob prefix matching", () => {
171 const c = cfg(["release/*"]);
172 expect(shouldTriggerPush(c, "release/1.0")).toBe(true);
173 expect(shouldTriggerPush(c, "main")).toBe(false);
174 });
175});
176
177describe("shouldTriggerTag", () => {
178 test("returns true when on.tag = true", () => {
179 const c = parseCiConfig('image="alpine"\n[on]\ntag=true')!;
180 expect(shouldTriggerTag(c)).toBe(true);
181 });
182
183 test("returns false when on.tag = false", () => {
184 const c = parseCiConfig('image="alpine"\n[on]\ntag=false')!;
185 expect(shouldTriggerTag(c)).toBe(false);
186 });
187
188 test("returns false when on.tag is absent", () => {
189 const c = parseCiConfig('image="alpine"')!;
190 expect(shouldTriggerTag(c)).toBe(false);
191 });
192});
193
194describe("validateCiConfig", () => {
195 const parse = (toml: string) => parseCiConfig(toml)!;
196
197 test("accepts a cache path outside the clone directory", () => {
198 const cfg = parse(`
199image = "alpine"
200clone_project_to = "/ci/build/project"
201cache = ["/ci/cache/target", "/usr/local/cargo/registry"]
202`);
203 expect(validateCiConfig(cfg)).toBeNull();
204 });
205
206 test("rejects a cache path inside the clone directory", () => {
207 const cfg = parse(`
208image = "alpine"
209clone_project_to = "/ci/build/project"
210cache = ["/ci/build/project/target"]
211`);
212 expect(validateCiConfig(cfg)).toContain("/ci/build/project/target");
213 });
214
215 test("rejects a cache path equal to the clone directory", () => {
216 const cfg = parse(`
217image = "alpine"
218clone_project_to = "/ci/build/project"
219cache = ["/ci/build/project"]
220`);
221 expect(validateCiConfig(cfg)).not.toBeNull();
222 });
223
224 test("does not treat a sibling prefix as inside", () => {
225 const cfg = parse(`
226image = "alpine"
227clone_project_to = "/ci/build/project"
228cache = ["/ci/build/project-cache"]
229`);
230 expect(validateCiConfig(cfg)).toBeNull();
231 });
232
233 test("ignores cache when nothing is cloned", () => {
234 const cfg = parse(`
235image = "alpine"
236cache = ["/anything"]
237`);
238 expect(validateCiConfig(cfg)).toBeNull();
239 });
240});
241