// The watcher: checks on its own (agreed 2026-10-13 — a manual fix nobody hears about is the outage), keeps
// alert state on disk so "open since" survives a restart, and remembers what resolved.
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import { createWatcher } from '../lib/watch.js'
const tmp = () => fs.mkdtempSync(path.join('/tmp', 'gw-watch-'))
const offline = () => ({ roles: {}, process: { rss: 1 }, bsky: { hostname: 'h', relay: { status: 'offline', seq: 1 }, dids: [] } })
const healthy = () => ({ roles: {}, process: { rss: 1 }, bsky: { hostname: 'h', relay: { status: 'active', seq: 1 }, dids: [] } })
test('★ a check opens alerts, persists them, and a new watcher (a restart) keeps "since"', async (t) => {
const dir = tmp()
let now = 1000
const calls = []
const w1 = createWatcher({ metrics: async (p) => { calls.push(p); return offline() }, stateFile: path.join(dir, 'alerts.json'), now: () => now, log: () => {} })
await w1.checkNow()
t.alike(calls, [{ refresh: true }], 'checks with FRESH probes, not the cache')
t.alike(w1.current().open.map((a) => a.id), ['relay-offline'])
w1.stop()
now = 900000
const w2 = createWatcher({ metrics: async () => offline(), stateFile: path.join(dir, 'alerts.json'), now: () => now, log: () => {} })
t.is(w2.current().open[0].since, 1000, 'read back from disk before any new check')
await w2.checkNow()
t.is(w2.current().open[0].since, 1000, 'and kept by the next check')
t.is(w2.current().lastCheckAt, 900000)
w2.stop()
})
test('resolved alerts are remembered (newest first, bounded) for the panel', async (t) => {
const dir = tmp()
let m = offline()
const w = createWatcher({ metrics: async () => m, stateFile: path.join(dir, 'a.json'), log: () => {} })
t.teardown(() => w.stop())
await w.checkNow()
m = healthy()
await w.checkNow()
t.alike(w.current().open, [])
t.alike(w.current().recentlyResolved.map((a) => a.id), ['relay-offline'])
})
test('a failing metrics call is logged and leaves the state untouched', async (t) => {
const lines = []
const w = createWatcher({ metrics: async () => { throw new Error('boom') }, stateFile: null, log: (o) => lines.push(o) })
t.teardown(() => w.stop())
await w.checkNow()
t.ok(lines.some((l) => /boom/.test(l.error || '')))
t.alike(w.current().open, [])
})
test('start() schedules the first check and the interval; stop() clears both', async (t) => {
let n = 0
const w = createWatcher({ metrics: async () => { n++; return healthy() }, stateFile: null, firstMs: 20, intervalMs: 30, log: () => {} })
w.start()
await new Promise((resolve) => setTimeout(resolve, 120))
w.stop()
const after = n
t.ok(after >= 2, 'first check plus at least one interval check (' + after + ')')
await new Promise((resolve) => setTimeout(resolve, 80))
t.is(n, after, 'nothing runs after stop')
})