spinuptui 1.3.0-alpha.4 → 1.3.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -2
  3. package/package.json +1 -1
  4. package/src/config.ts +29 -0
  5. package/src/index.tsx +8 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +4 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +30 -1
  11. package/src/lib/beaconListen.ts +40 -17
  12. package/src/lib/beaconListenInstall.ts +13 -4
  13. package/src/lib/cliBeacon.ts +25 -1
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliSentinel.ts +451 -0
  16. package/src/lib/heartbeat.ts +5 -0
  17. package/src/lib/schedule.ts +4 -1
  18. package/src/lib/scheduler.test.ts +88 -1
  19. package/src/lib/schedulerJobs.ts +4 -2
  20. package/src/lib/schedulerRun.ts +61 -15
  21. package/src/lib/schedulerTick.ts +35 -13
  22. package/src/lib/sentinelAttach.test.ts +45 -0
  23. package/src/lib/sentinelAttach.ts +98 -0
  24. package/src/lib/sentinelDigest.test.ts +82 -0
  25. package/src/lib/sentinelDigest.ts +261 -0
  26. package/src/lib/sentinelExpect.test.ts +82 -0
  27. package/src/lib/sentinelExpect.ts +197 -0
  28. package/src/lib/sentinelIncidents.test.ts +145 -0
  29. package/src/lib/sentinelIncidents.ts +194 -0
  30. package/src/lib/sentinelPrompt.ts +89 -0
  31. package/src/lib/sentinelReply.ts +36 -0
  32. package/src/lib/sentinelState.ts +246 -0
  33. package/src/lib/sentinelTick.test.ts +214 -0
  34. package/src/lib/sentinelTick.ts +265 -0
  35. package/src/lib/sentinelWorker.test.ts +170 -0
  36. package/src/lib/sentinelWorker.ts +291 -0
  37. package/src/modules/beacon.ts +3 -0
  38. package/src/modules/index.ts +2 -7
  39. package/src/modules/scheduler.ts +31 -8
  40. package/src/modules/sentinel.ts +90 -0
  41. package/src/ui/App.tsx +4 -0
  42. package/src/ui/store.tsx +5 -0
  43. package/src/ui/views/Modules.tsx +56 -3
  44. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -0,0 +1,246 @@
1
+ // Sentinel's machine state and the shape of an incident
2
+ // (docs/2026-10-30_sentinel-spec.md). Everything here lives in the module's state
3
+ // directory and is replaced atomically, so the tick, the worker and the listener
4
+ // can each write without sharing a mutable file: one file per incident.
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
7
+ import { dirname, join } from "node:path"
8
+ import type { AppConfig } from "../config.ts"
9
+ import { moduleStateDir, moduleWorkDir } from "../modules/contract.ts"
10
+ import type { IncidentKind } from "./cliIncidents.ts"
11
+ import { expandHome } from "./schedulerJobs.ts"
12
+
13
+ export const DEFAULT_ALLOW = "Read,Grep,Glob,Edit(sites/**),Write(sites/**),Bash(spinuptui ssh-exec *),Bash(spinuptui siblings *),Bash(spinuptui incidents *)"
14
+ const LOG_MAX_BYTES = 1_000_000
15
+ export const INCIDENT_KEEP_DAYS = 30
16
+ export const SEEN_KEEP_DAYS = 8
17
+
18
+ export interface SentinelSettings {
19
+ enabled: boolean
20
+ shadow: boolean
21
+ channel: string | null
22
+ reportTo: string | null
23
+ workspace: string
24
+ recurrenceHours: number
25
+ maxDiagnosesPerDay: number
26
+ agent: "claude" | "codex" | null
27
+ model: string | null
28
+ timeoutMin: number
29
+ allow: string
30
+ }
31
+
32
+ export function sentinelSettings(cfg: AppConfig): SentinelSettings {
33
+ const s = cfg.sentinel ?? {}
34
+ return {
35
+ enabled: !!s.enabled,
36
+ shadow: s.mode === "shadow",
37
+ channel: s.channel ?? null,
38
+ reportTo: s.reportTo ?? null,
39
+ workspace: s.workspace ? expandHome(s.workspace) : moduleWorkDir("sentinel"),
40
+ recurrenceHours: s.recurrenceHours ?? 6,
41
+ maxDiagnosesPerDay: s.maxDiagnosesPerDay ?? 15,
42
+ agent: s.diagnosis?.agent ?? null,
43
+ model: s.diagnosis?.model ?? null,
44
+ timeoutMin: s.diagnosis?.timeoutMin ?? 30,
45
+ allow: s.diagnosis?.allow ? `${DEFAULT_ALLOW},${s.diagnosis.allow}` : DEFAULT_ALLOW,
46
+ }
47
+ }
48
+
49
+ export const sentinelDir = () => moduleStateDir("sentinel")
50
+ export const incidentsDir = () => join(sentinelDir(), "incidents")
51
+ export const transcriptsDir = () => join(sentinelDir(), "transcripts")
52
+ export const workerLockPath = () => join(sentinelDir(), "worker.lock")
53
+ export const expectationsPath = () => join(moduleWorkDir("sentinel"), "expectations.json")
54
+ export const trendsPath = () => join(moduleWorkDir("sentinel"), "trends.csv")
55
+ const statePath = (name: string) => join(sentinelDir(), name)
56
+
57
+ export function readJson<T>(path: string, fallback: T): T {
58
+ try {
59
+ return JSON.parse(readFileSync(path, "utf8")) as T
60
+ } catch {
61
+ return fallback
62
+ }
63
+ }
64
+
65
+ export function writeJson(path: string, value: unknown): void {
66
+ mkdirSync(dirname(path), { recursive: true })
67
+ writeFileSync(`${path}.tmp`, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
68
+ renameSync(`${path}.tmp`, path)
69
+ }
70
+
71
+ // ---- incidents -----------------------------------------------------------------------
72
+
73
+ export type IncidentState =
74
+ | "queued"
75
+ | "diagnosing"
76
+ | "reported"
77
+ | "waiting_agent" // the login lapsed: pages once, retries, drains when it's back
78
+ | "blocked" // the agent couldn't do the work
79
+ | "failed" // after two attempts
80
+ | "recurrence" // covered by an earlier report; no diagnosis
81
+ | "shadow" // shadow mode: what would have been queued
82
+
83
+ export interface IncidentEvent {
84
+ domain: string
85
+ kind: IncidentKind
86
+ time: string
87
+ message: string
88
+ monitorId: number
89
+ }
90
+
91
+ export interface Incident {
92
+ id: string
93
+ key: string // what groups it: the server's name, or the domain if unknown
94
+ server: string | null
95
+ state: IncidentState
96
+ createdAt: string
97
+ events: IncidentEvent[]
98
+ down: number[] // monitors still down
99
+ lastDownAt: string
100
+ closedAt?: string
101
+ attempts: number
102
+ startedAt?: string
103
+ retryAfter?: string
104
+ why?: string // blocked or failed: what's needed
105
+ refersTo?: string // a recurrence's earlier incident
106
+ evidence?: string
107
+ site?: string // the site the report is filed under
108
+ reportPath?: string
109
+ reportedAt?: string
110
+ summary?: string
111
+ // Basecamp has no idempotency key: "posting" with no id means the outcome is
112
+ // unknown, and nothing may repost it automatically.
113
+ post?: "posting" | "posted" | "failed" | "unknown"
114
+ postId?: string
115
+ postUrl?: string
116
+ postError?: string
117
+ }
118
+
119
+ const incidentPath = (id: string) => join(incidentsDir(), `${id}.json`)
120
+
121
+ export function saveIncident(i: Incident): void {
122
+ writeJson(incidentPath(i.id), i)
123
+ }
124
+
125
+ export function loadIncident(id: string): Incident | null {
126
+ return readJson<Incident | null>(incidentPath(id), null)
127
+ }
128
+
129
+ export function deleteIncident(id: string): void {
130
+ try {
131
+ unlinkSync(incidentPath(id))
132
+ } catch {
133
+ // Already gone.
134
+ }
135
+ }
136
+
137
+ export function loadIncidents(): Incident[] {
138
+ if (!existsSync(incidentsDir())) return []
139
+ const out: Incident[] = []
140
+ for (const f of readdirSync(incidentsDir())) {
141
+ if (!f.endsWith(".json")) continue
142
+ const i = readJson<Incident | null>(join(incidentsDir(), f), null)
143
+ if (i) out.push(i)
144
+ }
145
+ return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
146
+ }
147
+
148
+ export const isWaiting = (i: Incident) => ["queued", "diagnosing", "waiting_agent", "blocked", "failed"].includes(i.state)
149
+
150
+ // ---- the tick's own record ---------------------------------------------------------
151
+
152
+ export interface TickState {
153
+ // Set by the first tick, which marks history as seen rather than paging for it.
154
+ started?: boolean
155
+ lastCleanAt?: string
156
+ lastTickAt?: string
157
+ lastError?: string
158
+ blindSince?: string
159
+ // Paged once each until the condition clears.
160
+ authPagedAt?: string
161
+ capPagedFor?: string
162
+ }
163
+
164
+ export const loadTickState = () => readJson<TickState>(statePath("tick-state.json"), {})
165
+ export const saveTickState = (s: TickState) => writeJson(statePath("tick-state.json"), s)
166
+
167
+ export type Seen = Record<string, string>
168
+ export const seenKey = (e: { monitorId: number; time: string; domain: string }) => `${e.monitorId}|${e.time}|${e.domain}`
169
+ export const loadSeen = () => readJson<Seen>(statePath("seen.json"), {})
170
+ export const saveSeen = (s: Seen) => writeJson(statePath("seen.json"), s)
171
+
172
+ export interface PauseState {
173
+ until: string
174
+ events?: number
175
+ }
176
+ export const loadPause = () => readJson<PauseState | null>(statePath("pause.json"), null)
177
+ export const savePause = (p: PauseState) => writeJson(statePath("pause.json"), p)
178
+ export function clearPause(): void {
179
+ try {
180
+ unlinkSync(statePath("pause.json"))
181
+ } catch {
182
+ // Not paused.
183
+ }
184
+ }
185
+
186
+ export interface FlagRecord {
187
+ firstSeen: string // YYYY-MM-DD
188
+ text: string
189
+ acked?: boolean
190
+ }
191
+ export type Flags = Record<string, FlagRecord>
192
+ export const loadFlags = () => readJson<Flags>(statePath("flags.json"), {})
193
+ export const saveFlags = (f: Flags) => writeJson(statePath("flags.json"), f)
194
+
195
+ export interface FleetEntry {
196
+ server: string
197
+ site: string // the site's primary domain
198
+ vanity?: boolean
199
+ }
200
+ export interface Fleet {
201
+ fetchedAt?: string
202
+ domains: Record<string, FleetEntry>
203
+ }
204
+ export const loadFleet = () => readJson<Fleet>(statePath("fleet.json"), { domains: {} })
205
+ export const saveFleet = (f: Fleet) => writeJson(statePath("fleet.json"), f)
206
+
207
+ // ---- logs ----------------------------------------------------------------------------
208
+
209
+ function appendLine(name: string, line: string): void {
210
+ try {
211
+ mkdirSync(sentinelDir(), { recursive: true })
212
+ const path = statePath(name)
213
+ if (existsSync(path) && statSync(path).size > LOG_MAX_BYTES) renameSync(path, `${path}.1`)
214
+ appendFileSync(path, line + "\n", { mode: 0o600 })
215
+ } catch {
216
+ // A log line must never fail a tick.
217
+ }
218
+ }
219
+
220
+ export interface TickLine {
221
+ at: string
222
+ ok: boolean
223
+ events: number
224
+ incidents: number
225
+ retries: number
226
+ error?: string
227
+ }
228
+
229
+ export const logTick = (line: TickLine) => appendLine("tick.log", JSON.stringify(line))
230
+ export const logShadow = (line: string) => appendLine("shadow.log", `${new Date().toISOString()} ${line}`)
231
+
232
+ export function readTickLog(sinceMs: number): TickLine[] {
233
+ const out: TickLine[] = []
234
+ for (const name of ["tick.log.1", "tick.log"]) {
235
+ try {
236
+ for (const l of readFileSync(statePath(name), "utf8").split("\n")) {
237
+ if (!l) continue
238
+ const t = JSON.parse(l) as TickLine
239
+ if (Date.parse(t.at) >= sinceMs) out.push(t)
240
+ }
241
+ } catch {
242
+ // Not there yet.
243
+ }
244
+ }
245
+ return out
246
+ }
@@ -0,0 +1,214 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdtempSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import type { IncidentEvent, IncidentsResult } from "./cliIncidents.ts"
6
+ import { DEFAULT_ALLOW, loadIncidents, loadPause, loadSeen, loadTickState, saveFleet, saveIncident, savePause, saveTickState, type Incident, type SentinelSettings } from "./sentinelState.ts"
7
+ import { sentinelTick, type TickDeps } from "./sentinelTick.ts"
8
+
9
+ const settings = (over: Partial<SentinelSettings> = {}): SentinelSettings => ({ enabled: true, shadow: false, channel: null, reportTo: null, workspace: "/tmp/x", recurrenceHours: 6, maxDiagnosesPerDay: 15, agent: "claude", model: null, timeoutMin: 30, allow: DEFAULT_ALLOW, ...over })
10
+ const now = new Date("2026-10-30T14:00:00Z")
11
+ const down = (domain: string, monitorId: number, time = "2026-10-30 13:58:00.000"): IncidentEvent => ({ domain, kind: "health", status: "down", time, message: "", monitorId })
12
+ const ok = (events: IncidentEvent[], failedMonitors: string[] = []): IncidentsResult => ({ ok: true, windowHours: 1, events, failedMonitors, kumaAlerted: [] })
13
+
14
+ function harness(results: IncidentsResult[], over: Partial<TickDeps> = {}) {
15
+ const pages: string[] = []
16
+ const started: number[] = []
17
+ const slept: number[] = []
18
+ let call = 0
19
+ const deps: TickDeps = {
20
+ incidents: async () => results[Math.min(call++, results.length - 1)]!,
21
+ page: async (p) => (pages.push(p.text), "sent"),
22
+ refreshFleet: async () => null,
23
+ startWorker: () => started.push(1),
24
+ workerRunning: () => false,
25
+ sleep: async (ms) => slept.push(ms),
26
+ now: () => now,
27
+ ...over,
28
+ }
29
+ return { deps, pages, started, slept }
30
+ }
31
+
32
+ beforeEach(() => {
33
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "sentinel-t-"))
34
+ process.env.SPINUPTUI_HOME = mkdtempSync(join(tmpdir(), "sentinel-th-"))
35
+ saveFleet({ fetchedAt: now.toISOString(), domains: { "a.example.com": { server: "web1", site: "a.example.com" } } })
36
+ })
37
+
38
+ const begun = () => saveTickState({ started: true, lastCleanAt: "2026-10-30T13:55:00Z" })
39
+
40
+ describe("the first tick", () => {
41
+ test("marks history as seen rather than paging for it", async () => {
42
+ const h = harness([ok([down("a.example.com", 1)])])
43
+ expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: true, firstRun: true })
44
+ expect(h.pages).toEqual([])
45
+ expect(loadIncidents()).toEqual([])
46
+ expect(Object.keys(loadSeen()).length).toBe(1)
47
+ })
48
+ })
49
+
50
+ describe("a normal tick", () => {
51
+ test("a new down event pages, queues and starts the worker, once", async () => {
52
+ begun()
53
+ const h = harness([ok([down("a.example.com", 1)])])
54
+ expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: true, events: 1, incidents: 1, started: true })
55
+ expect(h.pages.length).toBe(1)
56
+ expect(h.started.length).toBe(1)
57
+ expect(loadIncidents()[0]!.state).toBe("queued")
58
+ await sentinelTick(settings(), h.deps)
59
+ expect(h.pages.length).toBe(1)
60
+ expect(loadIncidents().length).toBe(1)
61
+ })
62
+
63
+ test("doesn't start a second worker", async () => {
64
+ begun()
65
+ const h = harness([ok([down("a.example.com", 1)])], { workerRunning: () => true })
66
+ await sentinelTick(settings(), h.deps)
67
+ expect(h.started).toEqual([])
68
+ })
69
+
70
+ test("a monitor that couldn't be read keeps the window open, and its event still arrives", async () => {
71
+ begun()
72
+ const late = down("b.example.com", 2, "2026-10-30 13:40:00.000")
73
+ const h = harness([ok([down("a.example.com", 1)], ["b.example.com health"]), ok([down("a.example.com", 1), late])])
74
+ await sentinelTick(settings(), h.deps)
75
+ expect(loadTickState().lastCleanAt).toBe("2026-10-30T13:55:00Z")
76
+ await sentinelTick(settings(), h.deps)
77
+ expect(loadIncidents().map((i) => i.key).sort()).toEqual(["b.example.com", "web1"])
78
+ expect(loadTickState().lastCleanAt).toBe(now.toISOString())
79
+ })
80
+ })
81
+
82
+ describe("when Kuma can't be read", () => {
83
+ const bad: IncidentsResult = { ok: false, reason: "kuma_error", message: "websocket error" }
84
+
85
+ test("a cold start recovers without saying anything", async () => {
86
+ begun()
87
+ const h = harness([bad, ok([])])
88
+ expect((await sentinelTick(settings(), h.deps)).ok).toBe(true)
89
+ expect(h.slept.length).toBe(1)
90
+ expect(h.pages).toEqual([])
91
+ })
92
+
93
+ test("after three failures it's blind, and exits so Scheduler pages", async () => {
94
+ begun()
95
+ const h = harness([bad])
96
+ expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: false, needsPerson: false })
97
+ expect(h.slept.length).toBe(2)
98
+ expect(loadTickState().blindSince).toBeTruthy()
99
+ })
100
+
101
+ test("what's already queued still starts", async () => {
102
+ begun()
103
+ saveIncident({ id: "q", key: "k", server: null, state: "queued", createdAt: now.toISOString(), events: [], down: [], lastDownAt: now.toISOString(), attempts: 0 })
104
+ const h = harness([bad])
105
+ await sentinelTick(settings(), h.deps)
106
+ expect(h.started.length).toBe(1)
107
+ })
108
+
109
+ test("a configuration problem needs a person, and isn't retried", async () => {
110
+ begun()
111
+ const h = harness([{ ok: false, reason: "no_monitors_registered", message: "none" }])
112
+ expect(await sentinelTick(settings(), h.deps)).toMatchObject({ ok: false, needsPerson: true })
113
+ expect(h.slept).toEqual([])
114
+ })
115
+ })
116
+
117
+ describe("recovering work", () => {
118
+ const stale = (over: Partial<Incident>): Incident => ({ id: "x", key: "web1", server: "web1", state: "diagnosing", createdAt: now.toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [1], lastDownAt: now.toISOString(), attempts: 1, ...over })
119
+
120
+ test("a crashed worker's incident is requeued, then failed", async () => {
121
+ begun()
122
+ saveIncident(stale({}))
123
+ const h = harness([ok([])])
124
+ await sentinelTick(settings(), h.deps)
125
+ expect(loadIncidents()[0]!.state).toBe("queued")
126
+ expect(h.started.length).toBe(1)
127
+ saveIncident(stale({ attempts: 2 }))
128
+ await sentinelTick(settings(), h.deps)
129
+ expect(loadIncidents()[0]!.state).toBe("failed")
130
+ })
131
+
132
+ test("a live worker's incident is left alone", async () => {
133
+ begun()
134
+ saveIncident(stale({}))
135
+ await sentinelTick(settings(), harness([ok([])], { workerRunning: () => true }).deps)
136
+ expect(loadIncidents()[0]!.state).toBe("diagnosing")
137
+ })
138
+
139
+ test("a login wait retries when it's due", async () => {
140
+ begun()
141
+ saveIncident(stale({ state: "waiting_agent", retryAfter: "2026-10-30T13:59:00Z" }))
142
+ await sentinelTick(settings(), harness([ok([])]).deps)
143
+ expect(loadIncidents()[0]!.state).toBe("queued")
144
+ })
145
+
146
+ test("a post cut off midway isn't retried: it asks a person", async () => {
147
+ begun()
148
+ saveIncident(stale({ state: "reported", post: "posting" }))
149
+ const h = harness([ok([])])
150
+ await sentinelTick(settings(), h.deps)
151
+ expect(loadIncidents()[0]!.post).toBe("unknown")
152
+ expect(h.pages[0]).toContain("may or may not exist")
153
+ await sentinelTick(settings(), h.deps)
154
+ expect(h.pages.length).toBe(1)
155
+ })
156
+ })
157
+
158
+ describe("the daily cap", () => {
159
+ test("pages once and doesn't start the worker", async () => {
160
+ begun()
161
+ saveIncident({ id: "d", key: "z", server: null, state: "reported", createdAt: now.toISOString(), events: [], down: [], lastDownAt: now.toISOString(), attempts: 1, startedAt: now.toISOString() })
162
+ const h = harness([ok([down("a.example.com", 1)])])
163
+ await sentinelTick(settings({ maxDiagnosesPerDay: 1 }), h.deps)
164
+ expect(h.started).toEqual([])
165
+ expect(h.pages.some((p) => p.includes("limit"))).toBe(true)
166
+ await sentinelTick(settings({ maxDiagnosesPerDay: 1 }), h.deps)
167
+ expect(h.pages.filter((p) => p.includes("limit")).length).toBe(1)
168
+ })
169
+ })
170
+
171
+ describe("a pause", () => {
172
+ test("marks events seen without acting, and says so when it ends", async () => {
173
+ begun()
174
+ savePause({ until: "2026-10-30T15:00:00Z" })
175
+ const h = harness([ok([down("a.example.com", 1)])])
176
+ expect(await sentinelTick(settings(), h.deps)).toMatchObject({ paused: true })
177
+ expect(h.pages).toEqual([])
178
+ expect(loadIncidents()).toEqual([])
179
+ expect(loadPause()!.events).toBe(1)
180
+ const later = harness([ok([down("a.example.com", 1)])], { now: () => new Date("2026-10-30T15:05:00Z") })
181
+ await sentinelTick(settings(), later.deps)
182
+ expect(loadPause()).toBeNull()
183
+ expect(loadIncidents()).toEqual([])
184
+ expect(later.pages[0]).toContain("watching again")
185
+ })
186
+ })
187
+
188
+ describe("shadow mode", () => {
189
+ test("pages nothing, starts nothing, and records what it would do", async () => {
190
+ begun()
191
+ const h = harness([ok([down("a.example.com", 1)])])
192
+ await sentinelTick(settings({ shadow: true }), h.deps)
193
+ expect(h.pages).toEqual([])
194
+ expect(h.started).toEqual([])
195
+ expect(loadIncidents()[0]!.state).toBe("shadow")
196
+ })
197
+
198
+ test("going blind in shadow mode doesn't page", async () => {
199
+ begun()
200
+ const h = harness([{ ok: false, reason: "kuma_error", message: "x" }])
201
+ expect((await sentinelTick(settings({ shadow: true }), h.deps)).ok).toBe(true)
202
+ })
203
+ })
204
+
205
+ test("a dry run changes nothing", async () => {
206
+ begun()
207
+ const h = harness([ok([down("a.example.com", 1)])])
208
+ const before = loadTickState()
209
+ expect(await sentinelTick(settings(), h.deps, { dryRun: true })).toMatchObject({ ok: true, incidents: 1 })
210
+ expect(h.pages).toEqual([])
211
+ expect(loadIncidents()).toEqual([])
212
+ expect(loadTickState()).toEqual(before)
213
+ expect(loadSeen()).toEqual({})
214
+ })