g1t/apps/status/src/detection-store.test.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1 | /** |
| 2 | * Detection through the database, as the cron runs it: streaks saved and | |
| 3 | * read back each round, deploy windows, and recovered drafts dismissed. | |
| 4 | * D1 here is SQLite (node:sqlite) behind the few D1 calls the store makes, | |
| 5 | * with the real migrations applied. | |
| 6 | */ | |
| 7 | import assert from "node:assert/strict"; | |
| 8 | import { readFileSync, readdirSync } from "node:fs"; | |
| 9 | import { DatabaseSync } from "node:sqlite"; | |
| 10 | import { test } from "node:test"; | |
| 11 | ||
| 12 | import { type Streak, autoDismissText, deployChange, detect, settleDrafts } from "./detect.ts"; | |
| 13 | import { stamp } from "./postmortem.ts"; | |
| 14 | import { | |
| 15 | autoDismiss, | |
| 16 | createIncident, | |
| 17 | incidentDetail, | |
| 18 | loadDeploy, | |
| 19 | loadStreaks, | |
| 20 | openRefs, | |
| 21 | saveDeploy, | |
| 22 | saveHealthy, | |
| 23 | saveStreaks, | |
| 24 | watchedDrafts, | |
| 25 | } from "./store.ts"; | |
| 26 | ||
| 27 | type Params = (string | number | null)[]; | |
| 28 | ||
| 29 | /** Just enough of D1 over SQLite for the store. */ | |
| 30 | function d1(): D1Database { | |
| 31 | const db = new DatabaseSync(":memory:"); | |
| 32 | const dir = new URL("../migrations/", import.meta.url); | |
| 33 | for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 34 | class Statement { | |
| 35 | constructor( | |
| 36 | readonly sql: string, | |
| 37 | readonly params: Params = [], | |
| 38 | ) {} | |
| 39 | bind(...params: Params) { | |
| 40 | return new Statement(this.sql, params); | |
| 41 | } | |
| 42 | async all() { | |
| 43 | return { results: db.prepare(this.sql).all(...this.params), success: true, meta: {} }; | |
| 44 | } | |
| 45 | async first() { | |
| 46 | return db.prepare(this.sql).get(...this.params) ?? null; | |
| 47 | } | |
| 48 | async run() { | |
| 49 | const r = db.prepare(this.sql).run(...this.params); | |
| 50 | return { results: [], success: true, meta: { changes: Number(r.changes) } }; | |
| 51 | } | |
| 52 | } | |
| 53 | return { | |
| 54 | prepare: (sql: string) => new Statement(sql), | |
| 55 | batch: async (list: Statement[]) => { | |
| 56 | db.exec("BEGIN"); | |
| 57 | try { | |
| 58 | const out = []; | |
| 59 | for (const s of list) out.push(/^\s*SELECT/i.test(s.sql) ? await s.all() : await s.run()); | |
| 60 | db.exec("COMMIT"); | |
| 61 | return out; | |
| 62 | } catch (error) { | |
| 63 | db.exec("ROLLBACK"); | |
| 64 | throw error; | |
| 65 | } | |
| 66 | }, | |
| 67 | } as unknown as D1Database; | |
| 68 | } | |
| 69 | ||
| 70 | const at = (h: number, m: number) => new Date(Date.UTC(2026, 9, 6, h, m, 32)); | |
| 71 | ||
| 72 | /** One cron round's detection against the database; returns what it found. */ | |
| 73 | async function round(db: D1Database, now: Date, git: "up" | "degraded" | "down", quiet = false) { | |
| 74 | const found = detect(await loadStreaks(db), [{ component: "git", state: git }, { component: "api", state: "up" }], await openRefs(db), new Set(), now, { quiet }); | |
| 75 | await saveStreaks(db, found.streaks); | |
| 76 | return found; | |
| 77 | } | |
| 78 | ||
| 79 | test("the 6 Oct draft: a slow run at 03:13 that recovered does not leak into the one at 07:25", async () => { | |
| 80 | const db = d1(); | |
| 81 | // 03:13 and 03:14 slow, then fine: never three in a row. | |
| 82 | assert.equal((await round(db, at(3, 13), "degraded")).draft.length, 0); | |
| 83 | assert.equal((await round(db, at(3, 14), "degraded")).draft.length, 0); | |
| 84 | await round(db, at(3, 15), "up"); | |
| 85 | assert.equal((await loadStreaks(db)).size, 0, "a recovery with nothing else failing leaves no run behind"); | |
| 86 | // 07:25 slow: the first check of a new run, not the third of the old one. | |
| 87 | const first = await round(db, at(7, 25), "degraded"); | |
| 88 | assert.equal(first.draft.length, 0); | |
| 89 | assert.deepEqual([...(await loadStreaks(db)).values()], [{ component: "git", state: "degraded", count: 1, since: at(7, 25).toISOString(), alerted: false }]); | |
| 90 | await round(db, at(7, 26), "degraded"); | |
| 91 | const third = await round(db, at(7, 27), "degraded"); | |
| 92 | assert.deepEqual(third.draft, [{ key: "git", state: "degraded", since: at(7, 25).toISOString(), checks: 3 }]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a run is kept while it lasts, and only the parts still failing keep one", async () => { | |
| 96 | const db = d1(); | |
| 97 | const streak = (component: string): Streak => ({ component, state: "down", count: 2, since: at(1, 0).toISOString(), alerted: false }); | |
| 98 | await saveStreaks(db, [streak("git"), streak("api")]); | |
| 99 | await saveStreaks(db, [streak("api")]); | |
| 100 | assert.deepEqual([...(await loadStreaks(db)).keys()], ["api"]); | |
| 101 | await saveStreaks(db, []); | |
| 102 | assert.equal((await loadStreaks(db)).size, 0); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the deploy window is kept between rounds", async () => { | |
| 106 | const db = d1(); | |
| 107 | assert.equal(await loadDeploy(db), null); | |
| 108 | await saveDeploy(db, deployChange(null, "started", "run-1", at(7, 20))); | |
| 109 | await saveDeploy(db, deployChange(await loadDeploy(db), "finished", null, at(7, 24))); | |
| 110 | assert.deepEqual(await loadDeploy(db), { id: "run-1", started_at: at(7, 20).toISOString(), finished_at: at(7, 24).toISOString() }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a detected draft that recovered is dismissed after ten healthy minutes; one staff picked up is left alone", async () => { | |
| 114 | const db = d1(); | |
| 115 | const draft = (title: string, acknowledged: string | null) => | |
| 116 | createIncident( | |
| 117 | db, | |
| 118 | { | |
| 119 | title, | |
| 120 | severity: "sev3", | |
| 121 | status: "investigating", | |
| 122 | visibility: "draft", | |
| 123 | source: "detected", | |
| 124 | components: [{ key: "git", impact: "degraded" }], | |
| 125 | started_at: at(7, 25).toISOString(), | |
| 126 | acknowledged_at: acknowledged, | |
| 127 | commander: null, | |
| 128 | communications: null, | |
| 129 | by: "status", | |
| 130 | }, | |
| 131 | [{ kind: "detected", public: false, status: null, text: "Detected." }], | |
| 132 | at(7, 27), | |
| 133 | null, | |
| 134 | ); | |
| 135 | const id = await draft("Detected: Git and repositories slow", null); | |
| 136 | const picked = await draft("Detected: Git and repositories slow (picked up)", at(7, 28).toISOString()); | |
| 137 | ||
| 138 | const tick = async (now: Date, troubled: string[]) => { | |
| 139 | const settled = settleDrafts(await watchedDrafts(db), new Set(troubled), now); | |
| 140 | await saveHealthy(db, settled.healthy); | |
| 141 | const dismissed: string[] = []; | |
| 142 | for (const d of settled.dismiss) if (await autoDismiss(db, d.id, d.recovered_at, autoDismissText(d.lasted_ms, stamp(d.recovered_at)), now)) dismissed.push(d.id); | |
| 143 | return dismissed; | |
| 144 | }; | |
| 145 | assert.deepEqual((await watchedDrafts(db)).map((d) => d.id), [id], "only untouched drafts are watched"); | |
| 146 | await tick(at(7, 28), ["git"]); | |
| 147 | assert.deepEqual(await tick(at(7, 29), []), []); | |
| 148 | assert.deepEqual(await tick(at(7, 38), []), []); | |
| 149 | assert.deepEqual(await tick(at(7, 39), []), [id]); | |
| 150 | ||
| 151 | const detail = (await incidentDetail(db, id, "https://status.g1t.sh", new Map([["git", "Git and repositories"]])))!; | |
| 152 | assert.equal(detail.visibility, "dismissed"); | |
| 153 | assert.equal(detail.status, "resolved"); | |
| 154 | assert.equal(detail.resolved_at, at(7, 29).toISOString(), "resolved when it recovered, not when it was dismissed"); | |
| 155 | assert.equal(detail.durations.to_resolve, 4 * 60); | |
| 156 | assert.equal( | |
| 157 | detail.timeline.at(-1)!.text, | |
| 158 | "Recovered after 4 minutes, at 6 Oct 07:29 UTC, and stayed healthy for 10 minutes; dismissed automatically. It never appeared on the status page.", | |
| 159 | ); | |
| 160 | assert.equal(detail.timeline.at(-1)!.by, "status"); | |
| 161 | assert.equal((await incidentDetail(db, picked, "https://status.g1t.sh", new Map()))!.visibility, "draft"); | |
| 162 | // Nothing is left to watch, and the bookkeeping is gone with it. | |
| 163 | assert.deepEqual(await watchedDrafts(db), []); | |
| 164 | assert.equal(await autoDismiss(db, id, at(7, 29).toISOString(), "again", at(7, 40)), false, "dismissing twice changes nothing"); | |
| 165 | }); |