CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sleep-mode.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.
| 46d6165 | 1 | /** |
| 2 | * Block L1 — Sleep Mode tests. | |
| 3 | * | |
| 4 | * Covers: | |
| 5 | * - `renderSleepModeDigest` HTML + plaintext output, including XSS resistance | |
| 6 | * - `computeHoursSaved` heuristic | |
| 7 | * - autopilot `sleep-mode-digest` task: cooldown, hour-match, enabled filter, | |
| 8 | * per-tick cap, per-user failure isolation | |
| 9 | * - `/sleep-mode` public marketing page returns 200 | |
| 10 | * - `composeSleepModeReport` zero report for a user with no repos | |
| 11 | * | |
| 12 | * DI test pattern follows K3's `autopilot-ai-tasks.test.ts` — every DB call is | |
| 13 | * dependency-injected so tests run without a real DB. | |
| 14 | */ | |
| 15 | ||
| 16 | import { describe, it, expect } from "bun:test"; | |
| 17 | import app from "../app"; | |
| 18 | import { | |
| 19 | renderSleepModeDigest, | |
| 20 | composeSleepModeReport, | |
| 21 | type SleepModeReport, | |
| 22 | } from "../lib/sleep-mode"; | |
| 516b91e | 23 | import { computeHoursSaved } from "../lib/ai-hours-saved"; |
| 46d6165 | 24 | import { |
| 25 | runSleepModeDigestTaskOnce, | |
| 26 | type SleepModeDigestCandidate, | |
| 27 | } from "../lib/autopilot"; | |
| 28 | ||
| 29 | // --------------------------------------------------------------------------- | |
| 30 | // Fixtures | |
| 31 | // --------------------------------------------------------------------------- | |
| 32 | ||
| 33 | function emptyReport(): SleepModeReport { | |
| 34 | return { | |
| 35 | windowHours: 24, | |
| 36 | prsAutoMerged: [], | |
| 37 | issuesBuiltByAi: [], | |
| 38 | aiReviewsPosted: 0, | |
| 39 | securityIssuesAutoFixed: 0, | |
| 40 | gateFailuresAutoRepaired: 0, | |
| 41 | hoursSaved: 0, | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | function busyReport(): SleepModeReport { | |
| 46 | return { | |
| 47 | windowHours: 24, | |
| 48 | prsAutoMerged: [ | |
| 49 | { number: 1, title: "Bump axios", repo: "api" }, | |
| 50 | { number: 2, title: "Fix retry", repo: "billing" }, | |
| 51 | ], | |
| 52 | issuesBuiltByAi: [ | |
| 53 | { number: 7, title: "Add /metrics", repo: "api", prNumber: 8 }, | |
| 54 | ], | |
| 55 | aiReviewsPosted: 3, | |
| 56 | securityIssuesAutoFixed: 1, | |
| 57 | gateFailuresAutoRepaired: 2, | |
| 58 | hoursSaved: 0, | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | // --------------------------------------------------------------------------- | |
| 63 | // computeHoursSaved | |
| 64 | // --------------------------------------------------------------------------- | |
| 65 | ||
| 66 | describe("sleep-mode — computeHoursSaved", () => { | |
| 67 | it("returns 0 for an empty report", () => { | |
| 68 | expect( | |
| 69 | computeHoursSaved({ | |
| 70 | prsAutoMerged: 0, | |
| 71 | issuesBuiltByAi: 0, | |
| 72 | aiReviewsPosted: 0, | |
| 516b91e | 73 | aiTriagesPosted: 0, |
| 74 | aiCommitMsgs: 0, | |
| 75 | secretsAutoRepaired: 0, | |
| 76 | gateAutoRepairs: 0, | |
| 46d6165 | 77 | }) |
| 78 | ).toBe(0); | |
| 79 | }); | |
| 80 | ||
| 81 | it("applies the documented heuristic (rounded to 1 decimal)", () => { | |
| 516b91e | 82 | // 2*0.3 + 1*1.5 + 3*0.25 + 1*0.5 + 2*0.4 = 0.6 + 1.5 + 0.75 + 0.5 + 0.8 = 4.15 -> 4.2 |
| 83 | // (secretsAutoRepaired=1 * 0.5, gateAutoRepairs=2 * 0.4) | |
| 46d6165 | 84 | const v = computeHoursSaved({ |
| 85 | prsAutoMerged: 2, | |
| 86 | issuesBuiltByAi: 1, | |
| 87 | aiReviewsPosted: 3, | |
| 516b91e | 88 | aiTriagesPosted: 0, |
| 89 | aiCommitMsgs: 0, | |
| 90 | secretsAutoRepaired: 1, | |
| 91 | gateAutoRepairs: 2, | |
| 46d6165 | 92 | }); |
| 516b91e | 93 | // 0.6 + 1.5 + 0.75 + 0.5 + 0.8 = 4.15 -> Math.round(41.5)/10 = 4.2 |
| 94 | expect(v).toBe(4.2); | |
| 46d6165 | 95 | }); |
| 96 | ||
| 97 | it("rounds .25 down per HALF_EVEN-ish .5-bias of Math.round", () => { | |
| 98 | // 1*0.25 = 0.25 -> rounded *10 = 2.5 -> Math.round(2.5)=3 -> 0.3 | |
| 99 | expect( | |
| 100 | computeHoursSaved({ | |
| 101 | prsAutoMerged: 0, | |
| 102 | issuesBuiltByAi: 0, | |
| 103 | aiReviewsPosted: 1, | |
| 516b91e | 104 | aiTriagesPosted: 0, |
| 105 | aiCommitMsgs: 0, | |
| 106 | secretsAutoRepaired: 0, | |
| 107 | gateAutoRepairs: 0, | |
| 46d6165 | 108 | }) |
| 109 | ).toBe(0.3); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | // --------------------------------------------------------------------------- | |
| 114 | // renderSleepModeDigest | |
| 115 | // --------------------------------------------------------------------------- | |
| 116 | ||
| 117 | describe("sleep-mode — renderSleepModeDigest", () => { | |
| 118 | it("produces valid plaintext + html for an empty report", () => { | |
| 119 | const out = renderSleepModeDigest(emptyReport(), { username: "alice" }); | |
| 120 | expect(out.subject).toContain("quiet night"); | |
| 121 | expect(out.text).toContain("Hi alice"); | |
| 122 | expect(out.text).toContain("Quiet night"); | |
| 123 | expect(out.html).toContain("<html>"); | |
| 124 | expect(out.html).toContain("Good morning, alice"); | |
| 125 | // No section headers on the empty report — nothing to list. | |
| 126 | expect(out.html).not.toContain("PRs auto-merged</h3>"); | |
| 127 | }); | |
| 128 | ||
| 129 | it("produces a busy-night subject and lists every section", () => { | |
| 130 | const out = renderSleepModeDigest(busyReport(), { username: "alice" }); | |
| 131 | expect(out.subject).toContain("Claude shipped"); | |
| 132 | // 2 PRs + 1 issue + 3 reviews + 1 sec + 2 gates = 9 items | |
| 133 | expect(out.subject).toContain("9"); | |
| 134 | expect(out.html).toContain("PRs auto-merged"); | |
| 135 | expect(out.html).toContain("Issues built by AI"); | |
| 136 | expect(out.html).toContain("Automated guardrails"); | |
| 137 | expect(out.text).toContain("## PRs auto-merged"); | |
| 138 | expect(out.text).toContain("## Issues built by AI"); | |
| 139 | expect(out.text).toContain("## Automated guardrails"); | |
| 140 | }); | |
| 141 | ||
| 142 | it("escapes user-controlled titles, repo names, and usernames (no XSS)", () => { | |
| 143 | const malicious: SleepModeReport = { | |
| 144 | ...emptyReport(), | |
| 145 | prsAutoMerged: [ | |
| 146 | { | |
| 147 | number: 1, | |
| 148 | title: `<script>alert('pr')</script>`, | |
| 149 | repo: `<img src=x onerror=1>`, | |
| 150 | }, | |
| 151 | ], | |
| 152 | issuesBuiltByAi: [ | |
| 153 | { | |
| 154 | number: 2, | |
| 155 | title: `<svg/onload=alert(1)>`, | |
| 156 | repo: `"><script>x</script>`, | |
| 157 | }, | |
| 158 | ], | |
| 159 | }; | |
| 160 | const out = renderSleepModeDigest(malicious, { | |
| 161 | username: `<b>boss</b>`, | |
| 162 | }); | |
| 163 | const lower = out.html.toLowerCase(); | |
| 164 | // No raw <script> tags — they must be escaped to <script>. | |
| 165 | expect(lower).not.toContain("<script>"); | |
| 166 | expect(lower).not.toContain("</script>"); | |
| 167 | // No live attribute injection — the `<img` open-tag and `<svg` open-tag | |
| 168 | // must be escaped. (Substring search for `onerror=` would yield a false | |
| 169 | // positive because the escaped <img> still contains the literal | |
| 170 | // characters, but inside escaped angle-brackets they can't execute.) | |
| 171 | expect(lower).not.toContain("<img"); | |
| 172 | expect(lower).not.toContain("<svg"); | |
| 173 | // The escaped form must be present. | |
| 174 | expect(out.html).toContain("<script>"); | |
| 175 | expect(out.html).toContain("<b>boss</b>"); | |
| 176 | expect(out.html).toContain("<img src=x onerror=1>"); | |
| 177 | expect(out.html).toContain("<svg/onload=alert(1)>"); | |
| 178 | // Plaintext should still contain the un-escaped strings (it IS plain text). | |
| 179 | expect(out.text).toContain("<script>alert('pr')</script>"); | |
| 180 | }); | |
| 181 | ||
| 182 | it("subject is singular vs plural for total=1 case", () => { | |
| 183 | const r: SleepModeReport = { | |
| 184 | ...emptyReport(), | |
| 185 | prsAutoMerged: [{ number: 1, title: "x", repo: "r" }], | |
| 186 | }; | |
| 187 | const out = renderSleepModeDigest(r, { username: "alice" }); | |
| 188 | expect(out.subject).toContain("shipped 1 thing"); | |
| 189 | expect(out.subject).not.toContain("shipped 1 things"); | |
| 190 | }); | |
| 191 | }); | |
| 192 | ||
| 193 | // --------------------------------------------------------------------------- | |
| 194 | // composeSleepModeReport (DB-touching; graceful when DB unavailable) | |
| 195 | // --------------------------------------------------------------------------- | |
| 196 | ||
| 197 | describe("sleep-mode — composeSleepModeReport", () => { | |
| 198 | it("returns a zero-valued report for a user with no repos (graceful)", async () => { | |
| 199 | // Use a random UUID — guaranteed no owned repos. Either the DB query | |
| 200 | // returns empty (and we get an empty report) or the DB is unavailable | |
| 201 | // (and we fall through the catch block to the same empty report). | |
| 202 | // Either way the function must NEVER throw and must return all-zeros. | |
| 203 | const r = await composeSleepModeReport( | |
| 204 | "00000000-0000-0000-0000-000000000000" | |
| 205 | ); | |
| 206 | expect(r.prsAutoMerged).toEqual([]); | |
| 207 | expect(r.issuesBuiltByAi).toEqual([]); | |
| 208 | expect(r.aiReviewsPosted).toBe(0); | |
| 209 | expect(r.securityIssuesAutoFixed).toBe(0); | |
| 210 | expect(r.gateFailuresAutoRepaired).toBe(0); | |
| 211 | expect(r.hoursSaved).toBe(0); | |
| 212 | expect(r.windowHours).toBe(24); | |
| 213 | }); | |
| 214 | ||
| 215 | it("respects custom sinceHoursAgo", async () => { | |
| 216 | const r = await composeSleepModeReport( | |
| 217 | "00000000-0000-0000-0000-000000000000", | |
| 218 | { sinceHoursAgo: 48 } | |
| 219 | ); | |
| 220 | expect(r.windowHours).toBe(48); | |
| 221 | }); | |
| 222 | }); | |
| 223 | ||
| 224 | // --------------------------------------------------------------------------- | |
| 225 | // runSleepModeDigestTaskOnce | |
| 226 | // --------------------------------------------------------------------------- | |
| 227 | ||
| 228 | describe("sleep-mode — autopilot task (runSleepModeDigestTaskOnce)", () => { | |
| 229 | const sentinelNow = new Date("2026-05-13T09:00:00Z"); // UTC hour = 9 | |
| 230 | ||
| 231 | function cand( | |
| 232 | overrides: Partial<SleepModeDigestCandidate> = {} | |
| 233 | ): SleepModeDigestCandidate { | |
| 234 | return { | |
| 235 | userId: "u-1", | |
| 236 | digestHourUtc: 9, | |
| e1fc7db | 237 | lastSleepDigestSentAt: null, |
| 46d6165 | 238 | ...overrides, |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | it("sends for users whose current UTC hour matches their digestHourUtc and cooldown is clear", async () => { | |
| 243 | const sent: string[] = []; | |
| 244 | const summary = await runSleepModeDigestTaskOnce({ | |
| 245 | findCandidates: async () => [cand({ userId: "alice" })], | |
| 246 | sendOne: async (id) => { | |
| 247 | sent.push(id); | |
| 248 | return { ok: true }; | |
| 249 | }, | |
| 250 | now: () => sentinelNow, | |
| 251 | }); | |
| 252 | expect(sent).toEqual(["alice"]); | |
| 253 | expect(summary).toEqual({ sent: 1, skipped: 0 }); | |
| 254 | }); | |
| 255 | ||
| 256 | it("skips users whose digestHourUtc does NOT match the current UTC hour", async () => { | |
| 257 | const sent: string[] = []; | |
| 258 | const summary = await runSleepModeDigestTaskOnce({ | |
| 259 | findCandidates: async () => [ | |
| 260 | cand({ userId: "alice", digestHourUtc: 9 }), | |
| 261 | cand({ userId: "bob", digestHourUtc: 10 }), | |
| 262 | cand({ userId: "carol", digestHourUtc: 8 }), | |
| 263 | ], | |
| 264 | sendOne: async (id) => { | |
| 265 | sent.push(id); | |
| 266 | return { ok: true }; | |
| 267 | }, | |
| 268 | now: () => sentinelNow, | |
| 269 | }); | |
| 270 | expect(sent).toEqual(["alice"]); | |
| 271 | expect(summary).toEqual({ sent: 1, skipped: 2 }); | |
| 272 | }); | |
| 273 | ||
| 274 | it("skips users whose last digest was within the 23h cooldown", async () => { | |
| 275 | const sent: string[] = []; | |
| 276 | // Sent 1h ago — within cooldown. | |
| 277 | const recent = new Date(sentinelNow.getTime() - 60 * 60 * 1000); | |
| 278 | // Sent 24h ago — past cooldown. | |
| 279 | const old = new Date(sentinelNow.getTime() - 24 * 60 * 60 * 1000); | |
| 280 | const summary = await runSleepModeDigestTaskOnce({ | |
| 281 | findCandidates: async () => [ | |
| e1fc7db | 282 | cand({ userId: "recent-user", lastSleepDigestSentAt: recent }), |
| 283 | cand({ userId: "old-user", lastSleepDigestSentAt: old }), | |
| 284 | cand({ userId: "never-user", lastSleepDigestSentAt: null }), | |
| 46d6165 | 285 | ], |
| 286 | sendOne: async (id) => { | |
| 287 | sent.push(id); | |
| 288 | return { ok: true }; | |
| 289 | }, | |
| 290 | now: () => sentinelNow, | |
| 291 | }); | |
| 292 | expect(sent.sort()).toEqual(["never-user", "old-user"]); | |
| 293 | expect(summary).toEqual({ sent: 2, skipped: 1 }); | |
| 294 | }); | |
| 295 | ||
| 296 | it("counts sendOne ok:false as skipped (not sent)", async () => { | |
| 297 | const summary = await runSleepModeDigestTaskOnce({ | |
| 298 | findCandidates: async () => [cand({ userId: "alice" })], | |
| 299 | sendOne: async () => ({ ok: false, reason: "no email provider" }), | |
| 300 | now: () => sentinelNow, | |
| 301 | }); | |
| 302 | expect(summary).toEqual({ sent: 0, skipped: 1 }); | |
| 303 | }); | |
| 304 | ||
| 305 | it("isolates per-user failures — a thrown sendOne doesn't stop later users", async () => { | |
| 306 | const sent: string[] = []; | |
| 307 | const summary = await runSleepModeDigestTaskOnce({ | |
| 308 | findCandidates: async () => [ | |
| 309 | cand({ userId: "first" }), | |
| 310 | cand({ userId: "second" }), | |
| 311 | ], | |
| 312 | sendOne: async (id) => { | |
| 313 | if (id === "first") throw new Error("kaboom"); | |
| 314 | sent.push(id); | |
| 315 | return { ok: true }; | |
| 316 | }, | |
| 317 | now: () => sentinelNow, | |
| 318 | }); | |
| 319 | expect(sent).toEqual(["second"]); | |
| 320 | expect(summary).toEqual({ sent: 1, skipped: 1 }); | |
| 321 | }); | |
| 322 | ||
| 323 | it("returns zero summary if findCandidates throws", async () => { | |
| 324 | const summary = await runSleepModeDigestTaskOnce({ | |
| 325 | findCandidates: async () => { | |
| 326 | throw new Error("db down"); | |
| 327 | }, | |
| 328 | now: () => sentinelNow, | |
| 329 | }); | |
| 330 | expect(summary).toEqual({ sent: 0, skipped: 0 }); | |
| 331 | }); | |
| 332 | ||
| 333 | it("honours a custom cap parameter", async () => { | |
| 334 | let capRequested = -1; | |
| 335 | await runSleepModeDigestTaskOnce({ | |
| 336 | findCandidates: async (cap) => { | |
| 337 | capRequested = cap; | |
| 338 | return []; | |
| 339 | }, | |
| 340 | now: () => sentinelNow, | |
| 341 | cap: 7, | |
| 342 | }); | |
| 343 | expect(capRequested).toBe(7); | |
| 344 | }); | |
| 345 | }); | |
| 346 | ||
| 347 | // --------------------------------------------------------------------------- | |
| 348 | // /sleep-mode public route | |
| 349 | // --------------------------------------------------------------------------- | |
| 350 | ||
| 351 | describe("sleep-mode — public marketing page", () => { | |
| 352 | it("GET /sleep-mode returns 200 with the pitch", async () => { | |
| 353 | const res = await app.request("/sleep-mode"); | |
| 354 | expect(res.status).toBe(200); | |
| 355 | const body = await res.text(); | |
| 356 | expect(body).toContain("Sleep Mode"); | |
| 357 | expect(body).toContain("Wake up to a digest"); | |
| 358 | // Sample digest is rendered inline as part of the page. | |
| 359 | expect(body).toContain("Good morning"); | |
| 360 | // CTA link target. | |
| 361 | expect(body).toContain('href="/settings"'); | |
| 362 | }); | |
| 363 | }); |