rterm-backend 3.1.0 → 3.1.2

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.
@@ -0,0 +1,252 @@
1
+ import {
2
+ parseNetdataAlert, mapSeverity, buildFingerprint, toTriggerEvent, correlateWithRterm, register,
3
+ } from './index.mjs'
4
+
5
+ const cases: Array<{ name: string; run: () => void | Promise<void> }> = []
6
+ function test(n: string, r: () => void | Promise<void>) { cases.push({ name: n, run: r }) }
7
+
8
+ // ---- parseNetdataAlert ----
9
+ test('parse: alert notification with all fields', () => {
10
+ const payload = {
11
+ message: 'CPU usage is 95%', alert: 'cpu_usage', info: 'CPU utilization too high',
12
+ chart: 'system.cpu', context: 'system.cpu', space: 'prod-cluster', family: 'cpu',
13
+ class: 'Error', severity: 'critical', date: '2026-07-22T10:00:00Z', duration: '5m',
14
+ additional_active_critical_alerts: 2, additional_active_warning_alerts: 1,
15
+ alert_url: 'https://app.netdata.cloud/alert/123',
16
+ }
17
+ const p = parseNetdataAlert(payload)
18
+ if (!p || p.kind !== 'alert') throw new Error('should parse alert')
19
+ if (p.alert !== 'cpu_usage') throw new Error('alert name')
20
+ if (p.severity !== 'critical') throw new Error('severity')
21
+ if (p.chart !== 'system.cpu') throw new Error('chart')
22
+ if (p.additionalCritical !== 2) throw new Error('additional critical')
23
+ if (p.host !== 'prod-cluster') throw new Error('host from space')
24
+ if (p.alertUrl !== 'https://app.netdata.cloud/alert/123') throw new Error('alert url')
25
+ })
26
+
27
+ test('parse: warning severity', () => {
28
+ const p = parseNetdataAlert({ alert: 'disk_space', severity: 'warning', message: 'Disk 80% full', space: 'web-01' })
29
+ if (!p || p.severity !== 'warning') throw new Error('should be warning')
30
+ })
31
+
32
+ test('parse: clear severity (alert resolved)', () => {
33
+ const p = parseNetdataAlert({ alert: 'cpu_usage', severity: 'clear', message: 'CPU back to normal', space: 'web-01' })
34
+ if (!p || p.severity !== 'clear') throw new Error('should be clear')
35
+ })
36
+
37
+ test('parse: reachability notification (node down)', () => {
38
+ const p = parseNetdataAlert({ message: 'Node unreachable', node: 'web-02', space: 'prod', status: 'down', date: '2026-07-22T10:00:00Z', duration: '2m' })
39
+ if (!p || p.kind !== 'reachability') throw new Error('should parse reachability')
40
+ if (p.status !== 'down') throw new Error('status')
41
+ if (p.host !== 'web-02') throw new Error('host from node')
42
+ })
43
+
44
+ test('parse: reachability notification (node up)', () => {
45
+ const p = parseNetdataAlert({ node: 'web-02', status: 'up', date: '2026-07-22T10:05:00Z' })
46
+ if (!p || p.status !== 'up') throw new Error('should be up')
47
+ })
48
+
49
+ test('parse: null for invalid payload', () => {
50
+ if (parseNetdataAlert(null) !== null) throw new Error('null payload')
51
+ if (parseNetdataAlert({}) !== null) throw new Error('empty object')
52
+ if (parseNetdataAlert('not an object') !== null) throw new Error('string')
53
+ if (parseNetdataAlert({ foo: 'bar' }) !== null) throw new Error('missing required fields')
54
+ })
55
+
56
+ // ---- mapSeverity ----
57
+ test('mapSeverity: critical -> critical', () => {
58
+ if (mapSeverity('critical') !== 'critical') throw new Error('critical')
59
+ })
60
+ test('mapSeverity: warning -> warning', () => {
61
+ if (mapSeverity('warning') !== 'warning') throw new Error('warning')
62
+ })
63
+ test('mapSeverity: clear -> info', () => {
64
+ if (mapSeverity('clear') !== 'info') throw new Error('clear should map to info')
65
+ })
66
+ test('mapSeverity: unknown -> info', () => {
67
+ if (mapSeverity('unknown') !== 'info') throw new Error('unknown')
68
+ })
69
+
70
+ // ---- buildFingerprint ----
71
+ test('buildFingerprint: alert fingerprint', () => {
72
+ const p = parseNetdataAlert({ alert: 'cpu_usage', severity: 'critical', space: 'web-01' })
73
+ const fp = buildFingerprint(p)
74
+ if (fp !== 'netdata:web-01:cpu_usage:critical') throw new Error(`got ${fp}`)
75
+ })
76
+ test('buildFingerprint: reachability fingerprint', () => {
77
+ const p = parseNetdataAlert({ node: 'web-02', status: 'down' })
78
+ const fp = buildFingerprint(p)
79
+ if (fp !== 'netdata:reachability:web-02:down') throw new Error(`got ${fp}`)
80
+ })
81
+ test('buildFingerprint: empty for null', () => {
82
+ if (buildFingerprint(null) !== '') throw new Error('should be empty')
83
+ })
84
+
85
+ // ---- toTriggerEvent ----
86
+ test('toTriggerEvent: alert -> trigger event with correct severity', () => {
87
+ const p = parseNetdataAlert({ alert: 'disk_full', severity: 'critical', space: 'db-01', message: 'Disk 95%', date: '2026-07-22T10:00:00Z' })
88
+ const evt = toTriggerEvent(p)
89
+ if (!evt) throw new Error('should produce event')
90
+ if (evt.source !== 'netdata') throw new Error('source')
91
+ if (evt.severity !== 'critical') throw new Error('severity')
92
+ if (!evt.title.includes('disk_full')) throw new Error('title')
93
+ if (!evt.title.includes('db-01')) throw new Error('title host')
94
+ if (evt.labels.host !== 'db-01') throw new Error('labels host')
95
+ if (evt.labels.alert !== 'disk_full') throw new Error('labels alert')
96
+ })
97
+
98
+ test('toTriggerEvent: reachability down -> critical', () => {
99
+ const p = parseNetdataAlert({ node: 'web-03', status: 'down', date: '2026-07-22T10:00:00Z' })
100
+ const evt = toTriggerEvent(p)
101
+ if (!evt || evt.severity !== 'critical') throw new Error('down should be critical')
102
+ if (!evt.title.includes('DOWN')) throw new Error('title')
103
+ })
104
+
105
+ test('toTriggerEvent: reachability up -> info', () => {
106
+ const p = parseNetdataAlert({ node: 'web-03', status: 'up', date: '2026-07-22T10:00:00Z' })
107
+ const evt = toTriggerEvent(p)
108
+ if (!evt || evt.severity !== 'info') throw new Error('up should be info')
109
+ })
110
+
111
+ test('toTriggerEvent: null parsed -> null event', () => {
112
+ if (toTriggerEvent(null) !== null) throw new Error('should be null')
113
+ })
114
+
115
+ // ---- correlateWithRterm ----
116
+ test('correlate: with metrics + incidents', () => {
117
+ const p = parseNetdataAlert({ alert: 'cpu_usage', severity: 'critical', space: 'web-01', additional_active_critical_alerts: 3 })
118
+ const mockMetrics = { snapshot: (host: string) => ({ host, cpuUsagePercent: 95, memoryUsagePercent: 70 }) }
119
+ const mockIncidents = { list: () => [
120
+ { title: 'web-01 disk full', affected: ['web-01'], status: 'open' },
121
+ { title: 'web-02 network issue', affected: ['web-02'], status: 'open' },
122
+ { title: 'web-01 resolved issue', affected: ['web-01'], status: 'resolved' },
123
+ ] }
124
+ const result = correlateWithRterm(p, mockMetrics as any, mockIncidents as any)
125
+ if (!result.recentMetrics || result.recentMetrics.cpuUsagePercent !== 95) throw new Error('metrics')
126
+ if (result.openIncidents.length !== 1) throw new Error(`expected 1 open incident, got ${result.openIncidents.length}`)
127
+ if (!result.correlation.includes('cpu_usage')) throw new Error('correlation should mention alert')
128
+ if (!result.correlation.includes('disk full')) throw new Error('correlation should mention incident')
129
+ if (!result.correlation.includes('3 additional critical')) throw new Error('correlation should mention additional alerts')
130
+ })
131
+
132
+ test('correlate: no prior context', () => {
133
+ const p = parseNetdataAlert({ alert: 'mem_usage', severity: 'warning', space: 'new-host' })
134
+ const result = correlateWithRterm(p, null, null)
135
+ if (result.recentMetrics !== null) throw new Error('no metrics')
136
+ if (result.openIncidents.length !== 0) throw new Error('no incidents')
137
+ if (!result.correlation.includes('No prior RTerm context')) throw new Error('should say no context')
138
+ })
139
+
140
+ test('correlate: null parsed -> empty', () => {
141
+ const result = correlateWithRterm(null, null, null)
142
+ if (result.recentMetrics !== null || result.openIncidents.length !== 0) throw new Error('should be empty')
143
+ })
144
+
145
+ // ---- register (plugin lifecycle) ----
146
+ test('register: registers 2 tools, 2 triggers, 1 panel', () => {
147
+ const tools: any[] = [], triggers: any[] = [], panels: any[] = [], logs: string[] = []
148
+ register({
149
+ registerTool: (t) => tools.push(t),
150
+ registerTrigger: (t) => triggers.push(t),
151
+ registerPanel: (p) => panels.push(p),
152
+ exec: async () => '',
153
+ readLedger: () => ({}),
154
+ log: (line: string) => logs.push(line),
155
+ } as any)
156
+ if (tools.length !== 2) throw new Error(`expected 2 tools, got ${tools.length}`)
157
+ if (triggers.length !== 2) throw new Error(`expected 2 triggers, got ${triggers.length}`)
158
+ if (panels.length !== 1) throw new Error(`expected 1 panel, got ${panels.length}`)
159
+ if (!tools.some((t) => t.name === 'netdata_alert_summary')) throw new Error('missing alert_summary tool')
160
+ if (!tools.some((t) => t.name === 'netdata_correlate')) throw new Error('missing correlate tool')
161
+ if (!triggers.some((t) => t.name === 'netdata_critical_alert')) throw new Error('missing critical trigger')
162
+ if (!triggers.some((t) => t.name === 'netdata_warning_alert')) throw new Error('missing warning trigger')
163
+ if (!panels.some((p) => p.name === 'netdata-alert-feed')) throw new Error('missing alert feed panel')
164
+ if (!logs.some((l) => l.includes('registered'))) throw new Error('should log registration')
165
+ })
166
+
167
+ test('register: critical trigger matches critical events only', () => {
168
+ const triggers: any[] = []
169
+ register({
170
+ registerTool: () => {}, registerTrigger: (t) => triggers.push(t), registerPanel: () => {},
171
+ exec: async () => '', readLedger: () => ({}), log: () => {},
172
+ } as any)
173
+ const critTrigger = triggers.find((t) => t.name === 'netdata_critical_alert')
174
+ if (!critTrigger) throw new Error('missing critical trigger')
175
+ if (!critTrigger.match({ source: 'netdata', severity: 'critical' })) throw new Error('should match critical')
176
+ if (critTrigger.match({ source: 'netdata', severity: 'warning' })) throw new Error('should NOT match warning')
177
+ if (critTrigger.match({ source: 'other', severity: 'critical' })) throw new Error('should NOT match non-netdata')
178
+ if (critTrigger.match({})) throw new Error('should NOT match empty')
179
+ })
180
+
181
+ test('register: warning trigger matches warning events only', () => {
182
+ const triggers: any[] = []
183
+ register({
184
+ registerTool: () => {}, registerTrigger: (t) => triggers.push(t), registerPanel: () => {},
185
+ exec: async () => '', readLedger: () => ({}), log: () => {},
186
+ } as any)
187
+ const warnTrigger = triggers.find((t) => t.name === 'netdata_warning_alert')
188
+ if (!warnTrigger) throw new Error('missing warning trigger')
189
+ if (!warnTrigger.match({ source: 'netdata', severity: 'warning' })) throw new Error('should match warning')
190
+ if (warnTrigger.match({ source: 'netdata', severity: 'critical' })) throw new Error('should NOT match critical')
191
+ })
192
+
193
+ test('register: panel renders alert rows', () => {
194
+ const panels: any[] = []
195
+ register({
196
+ registerTool: () => {}, registerTrigger: () => {}, registerPanel: (p) => panels.push(p),
197
+ exec: async () => '', readLedger: () => ({}), log: () => {},
198
+ } as any)
199
+ const panel = panels[0]
200
+ const html = panel.render([
201
+ { host: 'web-01', alert: 'cpu_high', severity: 'critical', date: '2026-07-22' },
202
+ { host: 'web-02', alert: 'disk_full', severity: 'warning', date: '2026-07-22' },
203
+ ])
204
+ if (!html.includes('cpu_high') || !html.includes('disk_full')) throw new Error('should contain alert names')
205
+ if (!html.includes('<table>')) throw new Error('should render table')
206
+ })
207
+
208
+ test('register: panel renders empty feed', () => {
209
+ const panels: any[] = []
210
+ register({
211
+ registerTool: () => {}, registerTrigger: () => {}, registerPanel: (p) => panels.push(p),
212
+ exec: async () => '', readLedger: () => ({}), log: () => {},
213
+ } as any)
214
+ const html = panels[0].render(null)
215
+ if (!html.includes('Netdata Alerts')) throw new Error('should have title even when empty')
216
+ })
217
+
218
+ test('register: netdata_correlate tool handles invalid payload', async () => {
219
+ const tools: any[] = []
220
+ register({
221
+ registerTool: (t) => tools.push(t), registerTrigger: () => {}, registerPanel: () => {},
222
+ exec: async () => '', readLedger: () => ({}), log: () => {},
223
+ } as any)
224
+ const correlateTool = tools.find((t) => t.name === 'netdata_correlate')
225
+ const result = await correlateTool.handler({ alert: { foo: 'bar' } })
226
+ if (!result.error) throw new Error('should return error for invalid payload')
227
+ })
228
+
229
+ test('register: netdata_correlate tool correlates valid payload', async () => {
230
+ const tools: any[] = []
231
+ register({
232
+ registerTool: (t) => tools.push(t), registerTrigger: () => {}, registerPanel: () => {},
233
+ exec: async () => '', readLedger: () => null, log: () => {},
234
+ } as any)
235
+ const correlateTool = tools.find((t) => t.name === 'netdata_correlate')
236
+ const result = await correlateTool.handler({
237
+ alert: { alert: 'cpu_usage', severity: 'critical', space: 'web-01', message: 'CPU 95%' },
238
+ })
239
+ if (!result.parsed) throw new Error('should return parsed alert')
240
+ if (result.parsed.alert !== 'cpu_usage') throw new Error('alert name')
241
+ })
242
+
243
+ async function main() {
244
+ let pass = 0, fail = 0
245
+ for (const c of cases) {
246
+ try { await c.run(); pass++; console.log(`PASS ${c.name}`) }
247
+ catch (e: any) { fail++; console.log(`FAIL ${c.name}: ${e?.message ?? e}`) }
248
+ }
249
+ console.log(`\n${pass}/${cases.length} passed, ${fail} failed`)
250
+ if (fail > 0) process.exit(1)
251
+ }
252
+ void main()
@@ -0,0 +1,263 @@
1
+ /**
2
+ * web-intel.extreme.spec.ts — exhaustive offline tests for the web-intel plugin:
3
+ * the dependency-free wigolo REST client (URL building, auth header, error
4
+ * mapping, every endpoint), the sidecar lifecycle (lean-by-default spawn plan,
5
+ * start/stop/status), and the plugin glue (config resolution, tool wiring,
6
+ * unreachable-daemon resilience, result normalization, trigger match). No
7
+ * network — fetch is mocked; spawn is injected/mocked.
8
+ */
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { WigoloClient, WigoloApiError, buildHeaders, joinUrl, DEFAULT_BASE_URL } from './wigoloClient.mjs'
12
+ import { WigoloSidecar, buildServePlan, buildInitPlan } from './sidecar.mjs'
13
+ import {
14
+ register,
15
+ resolveConfig,
16
+ buildClient,
17
+ toResultRows,
18
+ toPageSummary,
19
+ toResearchBrief,
20
+ toWatchRows,
21
+ isPageChangedEvent,
22
+ } from './index.mjs'
23
+
24
+ // ─── mock fetch ─────────────────────────────────────────────────────────────
25
+ function mockFetch(respond) {
26
+ const calls = []
27
+ const fn = async (url, init) => {
28
+ calls.push({ url, init })
29
+ const r = typeof respond === 'function' ? respond(url, init, calls.length) : respond
30
+ return { ok: r.ok !== false && (r.status ?? 200) < 400, status: r.status ?? 200, text: async () => r.text ?? (r.json !== undefined ? JSON.stringify(r.json) : '') }
31
+ }
32
+ fn.calls = calls
33
+ return fn
34
+ }
35
+
36
+ // ─── wigoloClient ───────────────────────────────────────────────────────────
37
+
38
+ test('joinUrl joins base + path with a single slash', () => {
39
+ assert.equal(joinUrl('http://x:3333/', '/health'), 'http://x:3333/health')
40
+ assert.equal(joinUrl('http://x:3333', 'health'), 'http://x:3333/health')
41
+ assert.equal(joinUrl('', '/v1/search'), `${DEFAULT_BASE_URL}/v1/search`)
42
+ })
43
+
44
+ test('buildHeaders adds bearer token only when set', () => {
45
+ assert.equal(buildHeaders().authorization, undefined)
46
+ assert.equal(buildHeaders('tok').authorization, 'Bearer tok')
47
+ })
48
+
49
+ test('client.health() GETs /health without auth and maps ok/error', async () => {
50
+ const up = mockFetch({ json: { status: 'ok', searxng: 'not_configured' } })
51
+ const c = new WigoloClient({ fetchImpl: up })
52
+ const h = await c.health()
53
+ assert.equal(h.ok, true)
54
+ assert.equal(up.calls[0].url, `${DEFAULT_BASE_URL}/health`)
55
+
56
+ const down = mockFetch(() => { throw new Error('ECONNREFUSED') })
57
+ const h2 = await new WigoloClient({ fetchImpl: down }).health()
58
+ assert.equal(h2.ok, false)
59
+ assert.match(h2.error, /ECONNREFUSED/)
60
+ })
61
+
62
+ test('client.search() POSTs query (+ array) to /v1/search with token header', async () => {
63
+ const f = mockFetch({ json: { results: [] } })
64
+ const c = new WigoloClient({ token: 'tok', fetchImpl: f })
65
+ await c.search(['a', 'b'], { time_range: 'week', max_results: 5 })
66
+ assert.equal(f.calls[0].url, `${DEFAULT_BASE_URL}/v1/search`)
67
+ assert.equal(f.calls[0].init.method, 'POST')
68
+ assert.equal(f.calls[0].init.headers.authorization, 'Bearer tok')
69
+ const body = JSON.parse(f.calls[0].init.body)
70
+ assert.deepEqual(body.query, ['a', 'b'])
71
+ assert.equal(body.time_range, 'week')
72
+ assert.equal(body.max_results, 5)
73
+ })
74
+
75
+ test('client maps non-2xx to WigoloApiError with status', async () => {
76
+ const f = mockFetch({ status: 500, text: 'boom' })
77
+ const c = new WigoloClient({ fetchImpl: f })
78
+ await assert.rejects(() => c.fetch('https://x'), (e) => {
79
+ assert.ok(e instanceof WigoloApiError)
80
+ assert.equal(e.status, 500)
81
+ return true
82
+ })
83
+ })
84
+
85
+ test('client.watch() sends action + url for create/list/remove', async () => {
86
+ const f = mockFetch({ json: { id: 'w1' } })
87
+ const c = new WigoloClient({ fetchImpl: f })
88
+ await c.watch('create', { url: 'https://x', interval: '1h' })
89
+ assert.equal(JSON.parse(f.calls[0].init.body).action, 'create')
90
+ await c.watch('list')
91
+ assert.equal(JSON.parse(f.calls[1].init.body).action, 'list')
92
+ await c.watch('remove', { id: 'w1' })
93
+ assert.equal(JSON.parse(f.calls[2].init.body).action, 'remove')
94
+ })
95
+
96
+ // ─── sidecar ────────────────────────────────────────────────────────────────
97
+
98
+ test('buildServePlan is lean by default (WIGOLO_NO_WARMUP=1, no token)', () => {
99
+ const p = buildServePlan({})
100
+ assert.equal(p.command, 'npx')
101
+ assert.deepEqual(p.args.slice(0, 3), ['-y', 'wigolo', 'serve'])
102
+ assert.equal(p.env.WIGOLO_NO_WARMUP, '1')
103
+ assert.equal(p.env.WIGOLO_API_TOKEN, undefined)
104
+ })
105
+
106
+ test('buildServePlan honors port/host/token and warmup=true omits the no-warmup flag', () => {
107
+ const p = buildServePlan({ port: 3477, host: '0.0.0.0', token: 't', warmup: true })
108
+ assert.ok(p.args.includes('3477'))
109
+ assert.equal(p.env.WIGOLO_NO_WARMUP, undefined)
110
+ assert.equal(p.env.WIGOLO_API_TOKEN, 't')
111
+ })
112
+
113
+ test('sidecar.start() spawns detached + unref and is idempotent', async () => {
114
+ const spawned = []
115
+ const spawnImpl = (cmd, args, opts) => { spawned.push({ cmd, args, opts }); return { unref() {}, kill() {} } }
116
+ const sc = new WigoloSidecar({ spawnImpl, log: () => {} })
117
+ await sc.start()
118
+ await sc.start() // idempotent — no second spawn
119
+ assert.equal(spawned.length, 1)
120
+ assert.equal(spawned[0].opts.detached, true)
121
+ assert.equal(sc.isRunning(), true)
122
+ assert.equal(sc.status().warmup, 'lean (no warmup)')
123
+ })
124
+
125
+ test('sidecar.stop() kills the process', async () => {
126
+ let killed = false
127
+ const spawnImpl = () => ({ unref() {}, kill() { killed = true } })
128
+ const sc = new WigoloSidecar({ spawnImpl, log: () => {} })
129
+ await sc.start()
130
+ await sc.stop()
131
+ assert.equal(killed, true)
132
+ assert.equal(sc.isRunning(), false)
133
+ })
134
+
135
+ // ─── plugin glue ────────────────────────────────────────────────────────────
136
+
137
+ function makeCtx(fetchImpl, settings = {}, spawnProcess) {
138
+ const tools = new Map()
139
+ const triggers = []
140
+ const panels = []
141
+ const logs = []
142
+ const ctx = {
143
+ settings: { webIntel: settings },
144
+ fetchImpl,
145
+ registerTool: (t) => tools.set(t.name, t),
146
+ registerTrigger: (t) => triggers.push(t),
147
+ registerPanel: (p) => panels.push(p),
148
+ log: (l) => logs.push(l),
149
+ ...(spawnProcess ? { spawnProcess } : {}),
150
+ }
151
+ return { tools, triggers, panels, logs, ctx }
152
+ }
153
+
154
+ test('resolveConfig reads the webIntel block with lean defaults', () => {
155
+ const c = resolveConfig({ settings: { webIntel: { restUrl: ' http://x:3477/ ', warmupOnInit: true, autoStart: false } } })
156
+ assert.equal(c.restUrl, 'http://x:3477', 'restUrl trimmed of whitespace + trailing slash')
157
+ assert.equal(c.warmupOnInit, true)
158
+ assert.equal(c.autoStart, false)
159
+ const d = resolveConfig({ settings: {} })
160
+ assert.equal(d.enabled, true)
161
+ assert.equal(d.autoStart, true)
162
+ assert.equal(d.warmupOnInit, false)
163
+ })
164
+
165
+ test('register wires 9 tools, 1 trigger, 1 panel', () => {
166
+ const { tools, triggers, panels, ctx } = makeCtx(mockFetch({ json: {} }))
167
+ register(ctx)
168
+ assert.equal(tools.size, 9)
169
+ for (const n of ['webintel_health', 'web_search', 'web_fetch', 'web_crawl', 'web_research', 'web_find_similar', 'web_watch_add', 'web_watch_list', 'web_watch_remove']) assert.ok(tools.has(n), `missing ${n}`)
170
+ assert.equal(triggers.length, 1)
171
+ assert.equal(panels.length, 1)
172
+ })
173
+
174
+ test('web_search returns normalized ranked results', async () => {
175
+ const f = mockFetch((url) => url.endsWith('/health')
176
+ ? { json: { status: 'ok' } }
177
+ : { json: { results: [{ title: 'A', url: 'https://a', excerpt: 'x'.repeat(300), citation_id: 'src-1', evidence_score: { final: 0.9 } }], freshness_signal: { published: '2026-07-01' } } })
178
+ const { tools, ctx } = makeCtx(f)
179
+ register(ctx)
180
+ const r = await tools.get('web_search').handler({ query: 'cisco bgp error' })
181
+ assert.equal(r.results.length, 1)
182
+ assert.equal(r.results[0].citation, 'src-1')
183
+ assert.equal(r.results[0].score, 0.9)
184
+ assert.ok(r.results[0].excerpt.length <= 240)
185
+ assert.equal(r.freshness.published, '2026-07-01')
186
+ })
187
+
188
+ test('web_research returns the evidence brief (synthesis left to RTerm agent)', async () => {
189
+ const f = mockFetch((url) => url.endsWith('/health')
190
+ ? { json: { status: 'ok' } }
191
+ : { json: { question: 'q', evidence: [{ title: 'S', url: 'https://s', snippet: 'ev' }], citations: [{ id: 'src-1', url: 'https://s' }] } })
192
+ const { tools, ctx } = makeCtx(f)
193
+ register(ctx)
194
+ const r = await tools.get('web_research').handler({ question: 'is rijndael128-cbc safe' })
195
+ assert.equal(r.evidence.length, 1)
196
+ assert.equal(r.citations.length, 1)
197
+ assert.match(r.note, /RTerm agent/)
198
+ })
199
+
200
+ test('web_fetch surfaces a blocked page honestly', async () => {
201
+ const f = mockFetch((url) => url.endsWith('/health')
202
+ ? { json: { status: 'ok' } }
203
+ : { json: { url: 'https://x', blocked_by_challenge: true, markdown: '' } })
204
+ const { tools, ctx } = makeCtx(f)
205
+ register(ctx)
206
+ const r = await tools.get('web_fetch').handler({ url: 'https://x' })
207
+ assert.equal(r.blocked, true)
208
+ })
209
+
210
+ test('every web_* tool returns error+hint when daemon is down and no spawn (no throw)', async () => {
211
+ const down = mockFetch(() => { throw new Error('ECONNREFUSED') })
212
+ const { tools, ctx } = makeCtx(down, { autoStart: true }) // no spawnProcess → can't start
213
+ register(ctx)
214
+ const r = await tools.get('web_search').handler({ query: 'x' })
215
+ assert.ok(r.error)
216
+ assert.match(r.hint, /wigolo daemon/)
217
+ })
218
+
219
+ test('web_search auto-starts the daemon via spawnProcess then serves', async () => {
220
+ let healthy = false
221
+ const spawned = []
222
+ const f = mockFetch((url) => url.endsWith('/health')
223
+ ? (healthy ? { json: { status: 'ok' } } : (() => { throw new Error('down') })())
224
+ : { json: { results: [] } })
225
+ const spawnProcess = (cmd, args, opts) => { spawned.push(true); healthy = true; return { unref() {}, kill() {} } }
226
+ const { tools, ctx } = makeCtx(f, { autoStart: true }, spawnProcess)
227
+ register(ctx)
228
+ const r = await tools.get('web_search').handler({ query: 'x' })
229
+ assert.equal(spawned.length, 1)
230
+ assert.ok(Array.isArray(r.results))
231
+ })
232
+
233
+ test('webintel_page_changed trigger matches only webintel change events', () => {
234
+ const { triggers, ctx } = makeCtx(mockFetch({ json: {} }))
235
+ register(ctx)
236
+ const m = triggers[0].match
237
+ assert.equal(m({ source: 'webintel', changed: true }), true)
238
+ assert.equal(m({ source: 'webintel', kind: 'page_changed' }), true)
239
+ assert.equal(m({ source: 'agentspan', changed: true }), false)
240
+ assert.equal(m({ source: 'webintel', changed: false }), false)
241
+ })
242
+
243
+ // ─── normalization helpers ─────────────────────────────────────────────────
244
+
245
+ test('toResultRows normalizes + truncates excerpts', () => {
246
+ const rows = toResultRows({ results: [{ url: 'https://a', description: 'y'.repeat(400) }] })
247
+ assert.equal(rows.length, 1)
248
+ assert.ok(rows[0].excerpt.length <= 240)
249
+ assert.equal(toResultRows({}).length, 0)
250
+ })
251
+
252
+ test('toResearchBrief handles empty payload', () => {
253
+ assert.deepEqual(toResearchBrief(null).evidence, [])
254
+ assert.equal(toResearchBrief({ evidence: [] }).evidence.length, 0)
255
+ })
256
+
257
+ test('toWatchRows normalizes watch entries', () => {
258
+ const rows = toWatchRows({ watches: [{ id: 'w1', url: 'https://x', changed: true, last_checked: '2026-07-28' }] })
259
+ assert.equal(rows.length, 1)
260
+ assert.equal(rows[0].changed, true)
261
+ })
262
+
263
+ console.log('web-intel: all cases passed')