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.
| 055ebc4 | 1 | /** |
| 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 | ||
| 8 | import { | |
| 9 | afterEach, | |
| 10 | beforeEach, | |
| 11 | describe, | |
| 12 | expect, | |
| 13 | it, | |
| 14 | } from "bun:test"; | |
| 15 | import { | |
| 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"; | |
| 26 | import type { DeployWatchResult } from "../../lib/crontech-client"; | |
| 27 | ||
| 28 | const ENV_KEYS = ["CRONTECH_API_KEY", "CRONTECH_BASE_URL"] as const; | |
| 29 | ||
| 30 | let savedEnv: Record<string, string | undefined> = {}; | |
| 31 | let savedFetch: typeof fetch; | |
| 32 | ||
| 33 | beforeEach(() => { | |
| 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 | ||
| 40 | afterEach(() => { | |
| 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 | ||
| 53 | describe("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 | ||
| 76 | function 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 | ||
| 86 | describe("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 | ||
| 151 | describe("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 | ||
| 221 | describe("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 | ||
| 281 | describe("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 | }); |