Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

deploy-watcher.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.

deploy-watcher.test.tsBlame327 lines · 1 contributor
055ebc4Claude1/**
2 * Block K7 — deploy-watcher tests.
3 *
4 * Same shape as heal-bot.test.ts + fix-agent.test.ts: pure helpers exhaustively,
5 * entry-point arg validation, graceful-degradation when DB/Crontech offline.
6 */
7
8import {
9 afterEach,
10 beforeEach,
11 describe,
12 expect,
13 it,
14} from "bun:test";
15import {
16 DEPLOY_WATCHER_BOT_USERNAME,
17 DEPLOY_WATCHER_COST_CENTS,
18 DEPLOY_WATCHER_ERROR_THRESHOLD,
19 DEPLOY_WATCHER_SLUG,
20 DEPLOY_WATCHER_WINDOW_MS,
21 buildDeployWatcherSummary,
22 renderIncidentIssueBody,
23 runDeployWatcher,
24 shouldRollback,
25} from "../../lib/agents/deploy-watcher";
26import type { DeployWatchResult } from "../../lib/crontech-client";
27
28const ENV_KEYS = ["CRONTECH_API_KEY", "CRONTECH_BASE_URL"] as const;
29
30let savedEnv: Record<string, string | undefined> = {};
31let savedFetch: typeof fetch;
32
33beforeEach(() => {
34 savedEnv = {};
35 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
36 for (const k of ENV_KEYS) delete process.env[k];
37 savedFetch = globalThis.fetch;
38});
39
40afterEach(() => {
41 for (const k of ENV_KEYS) {
42 const v = savedEnv[k];
43 if (v === undefined) delete process.env[k];
44 else process.env[k] = v;
45 }
46 globalThis.fetch = savedFetch;
47});
48
49// ---------------------------------------------------------------------------
50// Identity + constants
51// ---------------------------------------------------------------------------
52
53describe("deploy-watcher — identity constants", () => {
54 it("uses the agent- prefixed slug", () => {
55 expect(DEPLOY_WATCHER_SLUG).toBe("agent-deploy-watcher");
56 });
57 it("uses the [bot] suffixed username", () => {
58 expect(DEPLOY_WATCHER_BOT_USERNAME).toBe("agent-deploy-watcher[bot]");
59 expect(DEPLOY_WATCHER_BOT_USERNAME.endsWith("[bot]")).toBe(true);
60 });
61 it("cost is flat 2¢", () => {
62 expect(DEPLOY_WATCHER_COST_CENTS).toBe(2);
63 });
64 it("error threshold defaults to 5", () => {
65 expect(DEPLOY_WATCHER_ERROR_THRESHOLD).toBe(5);
66 });
67 it("watch window is 15 minutes", () => {
68 expect(DEPLOY_WATCHER_WINDOW_MS).toBe(15 * 60 * 1000);
69 });
70});
71
72// ---------------------------------------------------------------------------
73// shouldRollback
74// ---------------------------------------------------------------------------
75
76function mkWatch(status: DeployWatchResult["finalStatus"]): DeployWatchResult {
77 return {
78 deployId: "d1",
79 finalStatus: status,
80 errors: [],
81 watchedForMs: 60_000,
82 offline: false,
83 };
84}
85
86describe("deploy-watcher — shouldRollback", () => {
87 it("rolls back when deploy finalStatus=failed", () => {
88 const r = shouldRollback({
89 watchResult: mkWatch("failed"),
90 errorSignalCount: 0,
91 threshold: 5,
92 });
93 expect(r.rollback).toBe(true);
94 expect(r.reason).toContain("status=failed");
95 });
96
97 it("does NOT roll back when already rolled_back", () => {
98 const r = shouldRollback({
99 watchResult: mkWatch("rolled_back"),
100 errorSignalCount: 99,
101 threshold: 5,
102 });
103 expect(r.rollback).toBe(false);
104 expect(r.reason).toContain("already rolled back");
105 });
106
107 it("rolls back when error signals ≥ threshold on a live deploy", () => {
108 const r = shouldRollback({
109 watchResult: mkWatch("live"),
110 errorSignalCount: 7,
111 threshold: 5,
112 });
113 expect(r.rollback).toBe(true);
114 expect(r.reason).toContain("7 error signals");
115 expect(r.reason).toContain("threshold 5");
116 });
117
118 it("rolls back when signals exactly equal threshold", () => {
119 const r = shouldRollback({
120 watchResult: mkWatch("live"),
121 errorSignalCount: 5,
122 threshold: 5,
123 });
124 expect(r.rollback).toBe(true);
125 });
126
127 it("declares healthy when deploy live + signals below threshold", () => {
128 const r = shouldRollback({
129 watchResult: mkWatch("live"),
130 errorSignalCount: 2,
131 threshold: 5,
132 });
133 expect(r.rollback).toBe(false);
134 expect(r.reason).toBe("deploy healthy");
135 });
136
137 it("healthy when deploy pending + no signals", () => {
138 const r = shouldRollback({
139 watchResult: mkWatch("pending"),
140 errorSignalCount: 0,
141 threshold: 5,
142 });
143 expect(r.rollback).toBe(false);
144 });
145});
146
147// ---------------------------------------------------------------------------
148// renderIncidentIssueBody
149// ---------------------------------------------------------------------------
150
151describe("deploy-watcher — renderIncidentIssueBody", () => {
152 it("includes commit link, deploy id, reason, and top errors table", () => {
153 const body = renderIncidentIssueBody({
154 commitSha: "abcdef1234567890",
155 ownerUsername: "alice",
156 repoName: "web",
157 deployId: "dpl_xyz",
158 reason: "7 error signals ≥ threshold 5",
159 topErrors: [
160 { hash: "aaaaaaaaaaaaaaaa", message: "Cannot read null", count: 12 },
161 { hash: "bbbbbbbbbbbbbbbb", message: "Timeout", count: 3 },
162 ],
163 });
164 expect(body).toContain("/alice/web/commit/abcdef1234567890");
165 expect(body).toContain("`abcdef1`");
166 expect(body).toContain("dpl_xyz");
167 expect(body).toContain("7 error signals");
168 expect(body).toContain("aaaaaaaaaaaaaaaa");
169 expect(body).toContain("| 12 |");
170 expect(body).toContain("Cannot read null");
171 expect(body).toContain(DEPLOY_WATCHER_BOT_USERNAME);
172 });
173
174 it("escapes pipe chars in error messages", () => {
175 const body = renderIncidentIssueBody({
176 commitSha: "deadbeefcafebabe",
177 ownerUsername: "o",
178 repoName: "r",
179 deployId: "d",
180 reason: "x",
181 topErrors: [
182 { hash: "h", message: "pipe | inside | message", count: 1 },
183 ],
184 });
185 expect(body).toContain("pipe \\| inside \\| message");
186 });
187
188 it("truncates long messages to ~120 chars in the table", () => {
189 const longMsg = "x".repeat(500);
190 const body = renderIncidentIssueBody({
191 commitSha: "1234567",
192 ownerUsername: "o",
193 repoName: "r",
194 deployId: "d",
195 reason: "x",
196 topErrors: [{ hash: "h", message: longMsg, count: 1 }],
197 });
198 // The row line contains at most 120 x's between the pipes.
199 const match = body.match(/\| `h` \| 1 \| (x+) \|/);
200 expect(match).not.toBeNull();
201 expect(match![1]!.length).toBeLessThanOrEqual(120);
202 });
203
204 it("omits the errors table when topErrors is empty", () => {
205 const body = renderIncidentIssueBody({
206 commitSha: "1234567",
207 ownerUsername: "o",
208 repoName: "r",
209 deployId: "d",
210 reason: "deploy status=failed",
211 topErrors: [],
212 });
213 expect(body).not.toContain("Top errors");
214 });
215});
216
217// ---------------------------------------------------------------------------
218// buildDeployWatcherSummary
219// ---------------------------------------------------------------------------
220
221describe("deploy-watcher — buildDeployWatcherSummary", () => {
222 it("offline message when crontech offline", () => {
223 expect(
224 buildDeployWatcherSummary({
225 offline: true,
226 rolledBack: false,
227 reason: "",
228 incidentIssueNumber: null,
229 watchedForMs: 0,
230 errorSignalCount: 0,
231 })
232 ).toBe("crontech offline; watch skipped");
233 });
234
235 it("healthy deploy summary with seconds watched + signal count", () => {
236 const s = buildDeployWatcherSummary({
237 offline: false,
238 rolledBack: false,
239 reason: "deploy healthy",
240 incidentIssueNumber: null,
241 watchedForMs: 125_000,
242 errorSignalCount: 3,
243 });
244 expect(s).toContain("healthy");
245 expect(s).toContain("125s");
246 expect(s).toContain("3 signal");
247 });
248
249 it("rolled-back summary embeds reason + incident issue number", () => {
250 const s = buildDeployWatcherSummary({
251 offline: false,
252 rolledBack: true,
253 reason: "7 error signals ≥ threshold 5",
254 incidentIssueNumber: 42,
255 watchedForMs: 300_000,
256 errorSignalCount: 7,
257 });
258 expect(s).toContain("ROLLED BACK");
259 expect(s).toContain("threshold");
260 expect(s).toContain("#42");
261 });
262
263 it("handles missing incident issue number on rollback", () => {
264 const s = buildDeployWatcherSummary({
265 offline: false,
266 rolledBack: true,
267 reason: "deploy reported status=failed",
268 incidentIssueNumber: null,
269 watchedForMs: 10_000,
270 errorSignalCount: 0,
271 });
272 expect(s).toContain("ROLLED BACK");
273 expect(s).toContain("(unknown issue)");
274 });
275});
276
277// ---------------------------------------------------------------------------
278// runDeployWatcher — arg validation + graceful degradation
279// ---------------------------------------------------------------------------
280
281describe("deploy-watcher — runDeployWatcher", () => {
282 it("rejects empty repositoryId", async () => {
283 const r = await runDeployWatcher({
284 repositoryId: "",
285 deployId: "d1",
286 commitSha: "abcdef1234567",
287 });
288 expect(r.ok).toBe(false);
289 expect(r.runId).toBeNull();
290 expect(r.summary.toLowerCase()).toContain("invalid args");
291 });
292
293 it("rejects empty deployId", async () => {
294 const r = await runDeployWatcher({
295 repositoryId: "00000000-0000-0000-0000-000000000000",
296 deployId: "",
297 commitSha: "abcdef1234567",
298 });
299 expect(r.ok).toBe(false);
300 expect(r.summary.toLowerCase()).toContain("invalid args");
301 });
302
303 it("rejects empty commitSha", async () => {
304 const r = await runDeployWatcher({
305 repositoryId: "00000000-0000-0000-0000-000000000000",
306 deployId: "d1",
307 commitSha: "",
308 });
309 expect(r.ok).toBe(false);
310 expect(r.summary.toLowerCase()).toContain("invalid args");
311 });
312
313 it("returns documented failure when DB cannot open a run", async () => {
314 globalThis.fetch = (() => {
315 throw new Error("fetch must not be called when run cannot be opened");
316 }) as unknown as typeof fetch;
317 const r = await runDeployWatcher({
318 repositoryId: "00000000-0000-0000-0000-000000000000",
319 deployId: "d1",
320 commitSha: "abcdef1234567",
321 });
322 expect(r.ok).toBe(false);
323 expect(r.runId).toBeNull();
324 expect(r.rolledBack).toBe(false);
325 expect(r.incidentIssueNumber).toBeNull();
326 });
327});