Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

heal-bot.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

heal-bot.test.tsBlame336 lines · 1 contributor
ddb25a6Claude1/**
2 * Block K12 — heal-bot tests.
3 *
4 * The agent runtime writes to Neon on every step, so in the test environment
5 * (which has no DATABASE_URL) the DB-touching entry points degrade to the
6 * documented "never throws" shapes. The tests below exercise:
7 *
8 * 1. Pure helpers (`renderHealBotPrBody`, `renderHealBotPrTitle`,
9 * `buildHealBotSummary`) — fully exercised without any I/O.
10 * 2. `runHealBot` argument validation.
11 * 3. `runHealBot`'s graceful-degradation path when the DB / Gatetest are
12 * unreachable (no throws, well-formed result shape, honours the
13 * "gatetest offline" summary when the Gatetest key isn't set).
14 * 4. `runHealBotForAll` — empty / failing repo listing must not throw.
15 *
16 * For Gatetest we flip `globalThis.fetch` and `GATETEST_API_KEY` to steer the
17 * client between its offline short-circuit and its "fetch returned 500"
18 * short-circuit, without reaching the network.
19 */
20
21import {
22 afterEach,
23 beforeEach,
24 describe,
25 expect,
26 it,
27} from "bun:test";
28import {
29 buildHealBotSummary,
30 HEAL_BOT_BOT_USERNAME,
31 HEAL_BOT_SLUG,
32 renderHealBotPrBody,
33 renderHealBotPrTitle,
34 runHealBot,
35 runHealBotForAll,
36} from "../../lib/agents/heal-bot";
37import { healSuite } from "../../lib/gatetest-client";
38
39const ENV_KEYS = ["GATETEST_API_KEY", "GATETEST_BASE_URL"] as const;
40
41let savedEnv: Record<string, string | undefined> = {};
42let savedFetch: typeof fetch;
43
44beforeEach(() => {
45 savedEnv = {};
46 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
47 for (const k of ENV_KEYS) delete process.env[k];
48 savedFetch = globalThis.fetch;
49});
50
51afterEach(() => {
52 for (const k of ENV_KEYS) {
53 const v = savedEnv[k];
54 if (v === undefined) delete process.env[k];
55 else process.env[k] = v;
56 }
57 globalThis.fetch = savedFetch;
58});
59
60// ---------------------------------------------------------------------------
61// Constants / identity
62// ---------------------------------------------------------------------------
63
64describe("heal-bot — identity constants", () => {
65 it("uses the agent- prefixed slug", () => {
66 expect(HEAL_BOT_SLUG).toBe("agent-heal-bot");
67 });
68
69 it("uses the [bot] suffixed username", () => {
70 expect(HEAL_BOT_BOT_USERNAME).toBe("agent-heal-bot[bot]");
71 expect(HEAL_BOT_BOT_USERNAME.endsWith("[bot]")).toBe(true);
72 });
73});
74
75// ---------------------------------------------------------------------------
76// Pure helpers — renderHealBotPrTitle
77// ---------------------------------------------------------------------------
78
79describe("heal-bot — renderHealBotPrTitle", () => {
80 it("pluralises for multiple repairs", () => {
81 expect(renderHealBotPrTitle(5)).toBe("chore(tests): heal-bot — 5 repairs");
82 });
83
84 it("uses the singular form for exactly one repair", () => {
85 expect(renderHealBotPrTitle(1)).toBe("chore(tests): heal-bot — 1 repair");
86 });
87
88 it("still uses plural for zero (degenerate)", () => {
89 expect(renderHealBotPrTitle(0)).toBe("chore(tests): heal-bot — 0 repairs");
90 });
91});
92
93// ---------------------------------------------------------------------------
94// Pure helpers — renderHealBotPrBody
95// ---------------------------------------------------------------------------
96
97describe("heal-bot — renderHealBotPrBody", () => {
98 it("includes finding counts + branches + generator footer", () => {
99 const body = renderHealBotPrBody({
100 flakyFound: 3,
101 deadFound: 1,
102 coverageGapsFound: 2,
103 headBranch: "gatetest/heal-2026-04-17",
104 baseBranch: "main",
105 });
106 expect(body).toContain("6 repairs");
107 expect(body).toContain("gatetest/heal-2026-04-17");
108 expect(body).toContain("main");
109 expect(body).toContain("Flaky tests stabilised | 3");
110 expect(body).toContain("Dead / obsolete tests pruned | 1");
111 expect(body).toContain("Coverage gaps newly covered | 2");
112 expect(body).toContain(HEAL_BOT_BOT_USERNAME);
113 // Must never suggest auto-merge.
114 expect(body.toLowerCase()).toContain("never auto-merges");
115 });
116
117 it("uses singular 'repair' for exactly one finding", () => {
118 const body = renderHealBotPrBody({
119 flakyFound: 1,
120 deadFound: 0,
121 coverageGapsFound: 0,
122 headBranch: "feature/x",
123 baseBranch: "main",
124 });
125 expect(body).toContain("1 repair");
126 expect(body).not.toContain("1 repairs");
127 });
128});
129
130// ---------------------------------------------------------------------------
131// Pure helpers — buildHealBotSummary
132// ---------------------------------------------------------------------------
133
134describe("heal-bot — buildHealBotSummary", () => {
135 it("returns 'suite healthy' when there are no findings", () => {
136 expect(
137 buildHealBotSummary({
138 flakyFound: 0,
139 deadFound: 0,
140 coverageGapsFound: 0,
141 prNumber: null,
142 branchProduced: false,
143 })
144 ).toBe("suite healthy");
145 });
146
147 it("reports degraded reconfiguration hint when findings > 0 but no branch", () => {
148 const s = buildHealBotSummary({
149 flakyFound: 2,
150 deadFound: 1,
151 coverageGapsFound: 0,
152 prNumber: null,
153 branchProduced: false,
154 });
155 expect(s).toContain("3 findings");
156 expect(s).toContain("no branch produced");
157 expect(s).toContain("Gatetest");
158 });
159
160 it("embeds the PR number + counts when a branch was produced", () => {
161 const s = buildHealBotSummary({
162 flakyFound: 4,
163 deadFound: 2,
164 coverageGapsFound: 1,
165 prNumber: 42,
166 branchProduced: true,
167 });
168 expect(s).toBe("opened #42 (4 flaky, 2 dead, 1 coverage)");
169 });
170
171 it("handles a missing prNumber gracefully when branch is produced", () => {
172 const s = buildHealBotSummary({
173 flakyFound: 1,
174 deadFound: 0,
175 coverageGapsFound: 0,
176 prNumber: null,
177 branchProduced: true,
178 });
179 expect(s).toContain("(unknown PR)");
180 });
181});
182
183// ---------------------------------------------------------------------------
184// runHealBot — arg validation + graceful degradation.
185//
186// Because the test env has no DATABASE_URL, `startAgentRun` fails and
187// `runHealBot` returns its documented "could not open agent_runs row" shape.
188// That still proves the never-throws contract and shows that the DB is only
189// ever touched via defensive helpers.
190// ---------------------------------------------------------------------------
191
192describe("heal-bot — runHealBot", () => {
193 it("rejects missing args without throwing", async () => {
194 const r = await runHealBot({ repositoryId: "" });
195 expect(r.ok).toBe(false);
196 expect(r.runId).toBeNull();
197 expect(r.summary.toLowerCase()).toContain("invalid args");
198 });
199
200 it("rejects non-string repositoryId without throwing", async () => {
201 const r = await runHealBot({
202 // Intentional cast — simulates a caller passing bad data.
203 repositoryId: 123 as unknown as string,
204 });
205 expect(r.ok).toBe(false);
206 expect(r.runId).toBeNull();
207 });
208
209 it("returns a well-formed result when the DB is unavailable (scheduled)", async () => {
210 // Mock fetch so a network call would fail loudly — proving the path
211 // short-circuits before Gatetest is reached (we never even open a run).
212 globalThis.fetch = (() => {
213 throw new Error("fetch must not be called when run cannot be opened");
214 }) as unknown as typeof fetch;
215 const r = await runHealBot({
216 repositoryId: "00000000-0000-0000-0000-000000000000",
217 });
218 // With no DATABASE_URL the run-opening step returns null and the agent
219 // reports the documented failure without throwing.
220 expect(r.ok).toBe(false);
221 expect(r.runId).toBeNull();
222 expect(r.summary.toLowerCase()).toContain("agent_runs");
223 });
224
225 it("treats triggerBy truthiness as the 'manual' switch", async () => {
226 // We can't observe the trigger directly without a DB, but we can at least
227 // prove the call completes cleanly when triggerBy is provided.
228 const r = await runHealBot({
229 repositoryId: "00000000-0000-0000-0000-000000000000",
230 triggerBy: "11111111-1111-1111-1111-111111111111",
231 });
232 expect(r.ok).toBe(false); // no DB in test env
233 expect(r.runId).toBeNull();
234 // Must not have thrown — reaching this line is the assertion.
235 expect(typeof r.summary).toBe("string");
236 });
237});
238
239// ---------------------------------------------------------------------------
240// Underlying Gatetest contract — we want the client's `offline` signal to
241// propagate correctly, so we assert it here. If this contract breaks the
242// heal-bot's "offline" branch never fires.
243// ---------------------------------------------------------------------------
244
245describe("heal-bot — gatetest client contract", () => {
246 it("propagates offline:true when the API key is missing", async () => {
247 // No GATETEST_API_KEY → client returns the offline shape, no fetch call.
248 globalThis.fetch = (() => {
249 throw new Error("fetch must not be called when offline");
250 }) as unknown as typeof fetch;
251 const result = await healSuite({ repo: "o/r" });
252 expect(result.offline).toBe(true);
253 expect(result.flakyFound).toBe(0);
254 expect(result.deadFound).toBe(0);
255 expect(result.coverageGapsFound).toBe(0);
256 expect(result.prDraftBranch).toBeNull();
257 });
258
259 it("returns an offline-shaped result when fetch returns a non-200", async () => {
260 process.env.GATETEST_API_KEY = "sk-test";
261 globalThis.fetch = (async () =>
262 new Response("fail", { status: 500 })) as unknown as typeof fetch;
263 const result = await healSuite({ repo: "o/r" });
264 // The client's contract is: any non-2xx → offline shape.
265 expect(result.offline).toBe(true);
266 expect(result.prDraftBranch).toBeNull();
267 });
268
269 it("surfaces the draft branch when Gatetest returns one", async () => {
270 process.env.GATETEST_API_KEY = "sk-test";
271 globalThis.fetch = (async () =>
272 new Response(
273 JSON.stringify({
274 flakyFound: 2,
275 deadFound: 1,
276 coverageGapsFound: 3,
277 prDraftBranch: "gatetest/heal-abc",
278 }),
279 { status: 200 }
280 )) as unknown as typeof fetch;
281 const result = await healSuite({ repo: "o/r" });
282 expect(result.offline).toBe(false);
283 expect(result.flakyFound).toBe(2);
284 expect(result.deadFound).toBe(1);
285 expect(result.coverageGapsFound).toBe(3);
286 expect(result.prDraftBranch).toBe("gatetest/heal-abc");
287 });
288
289 it("normalises to zero when Gatetest returns zero findings + null branch", async () => {
290 process.env.GATETEST_API_KEY = "sk-test";
291 globalThis.fetch = (async () =>
292 new Response(
293 JSON.stringify({
294 flakyFound: 0,
295 deadFound: 0,
296 coverageGapsFound: 0,
297 prDraftBranch: null,
298 }),
299 { status: 200 }
300 )) as unknown as typeof fetch;
301 const result = await healSuite({ repo: "o/r" });
302 expect(result.offline).toBe(false);
303 expect(result.flakyFound).toBe(0);
304 expect(result.deadFound).toBe(0);
305 expect(result.coverageGapsFound).toBe(0);
306 expect(result.prDraftBranch).toBeNull();
307 });
308});
309
310// ---------------------------------------------------------------------------
311// runHealBotForAll — empty repo set / DB failure handling
312// ---------------------------------------------------------------------------
313
314describe("heal-bot — runHealBotForAll", () => {
315 it("returns zeroed aggregates when no repos are listable (no DB)", async () => {
316 // No DATABASE_URL → listEligibleRepositoryIds returns []. We assert the
317 // function terminates cleanly with a zero result and no throw.
318 const agg = await runHealBotForAll();
319 expect(agg).toEqual({
320 started: 0,
321 succeeded: 0,
322 failed: 0,
323 skipped: 0,
324 });
325 });
326
327 it("does not invoke fetch when there are no repos to process", async () => {
328 let fetchCalls = 0;
329 globalThis.fetch = (async (...args: unknown[]) => {
330 fetchCalls++;
331 return new Response("", { status: 500 });
332 }) as unknown as typeof fetch;
333 await runHealBotForAll();
334 expect(fetchCalls).toBe(0);
335 });
336});