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