'use strict'; /** * Drives the shell's alerts through its HTTP API and shell stream: the * defaults, every refusal, raise → `alert` frame → timeout → `alert-clear` * frame, replacement on a channel, an early clear, text tidying, the * channel list riding along in a saved layout, the admin colour save (and * its `alerts-config` frame, and what lands in proddash.json), and the admin * test flash. * * node tools/alerts.test.js * * Plain Node, no dependencies. Starts server.js on a spare port with a * temporary data directory, talks to it over HTTP, exits non-zero on the * first failure and always stops the server it started. */ const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); function freePort() { return new Promise((resolve, reject) => { const probe = net.createServer(); probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); probe.on('error', reject); }); } async function waitFor(url, ms = 15000) { const deadline = Date.now() + ms; let last = ''; while (Date.now() < deadline) { try { const r = await fetch(url); if (r.ok) return; last = 'HTTP ' + r.status; } catch (err) { last = err.message; } await new Promise((r) => setTimeout(r, 200)); } throw new Error('server did not come up: ' + last); } /** A bare SSE reader for /api/events: collects { event, data } frames and waits for one. */ function openEvents(url) { const frames = []; const waiters = []; const req = http.get(url, { headers: { accept: 'text/event-stream' } }, (res) => { let buf = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { buf += chunk; let i; while ((i = buf.indexOf('\n\n')) >= 0) { const block = buf.slice(0, i); buf = buf.slice(i + 2); let event = 'message'; const data = []; for (const line of block.split('\n')) { if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')); } if (!data.length) continue; let parsed = null; try { parsed = JSON.parse(data.join('\n')); } catch { parsed = data.join('\n'); } const frame = { event, data: parsed }; frames.push(frame); for (const w of [...waiters]) { if (w.match(frame)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(frame); } } } }); }); return { frames, /** The next frame (already seen or still to come) that `match` accepts. */ next(match, ms = 5000, label = 'frame') { const seen = frames.find(match); if (seen) { frames.splice(frames.indexOf(seen), 1); return Promise.resolve(seen); } return new Promise((resolve, reject) => { const w = { match, resolve: (f) => { clearTimeout(timer); frames.splice(frames.indexOf(f), 1); resolve(f); } }; const timer = setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); reject(new Error(`no ${label} within ${ms} ms`)); }, ms); waiters.push(w); }); }, close() { req.destroy(); }, }; } (async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-alerts-test-')); const port = await freePort(); const child = spawn(process.execPath, ['server.js'], { cwd: ROOT, env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); let log = ''; child.stdout.on('data', (d) => { log += d; }); child.stderr.on('data', (d) => { log += d; }); const base = `http://127.0.0.1:${port}`; const api = async (p, init = {}) => { const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init }); return { status: r.status, body: await r.json().catch(() => ({})) }; }; const raise = (alert) => api('/api/alerts', { method: 'POST', body: JSON.stringify(alert) }); let events = null; let failed = false; try { await waitFor(base + '/api/alerts'); events = openEvents(base + '/api/events'); // ── defaults ── let r = await api('/api/alerts'); assert.equal(r.status, 200); assert.equal(r.body.channels, 6, 'six channels'); assert.deepEqual(r.body.levels, ['low', 'medium', 'critical']); assert.deepEqual(Object.keys(r.body.colors), ['low', 'medium', 'critical'], 'a pair per level'); for (const pair of Object.values(r.body.colors)) { assert.equal(pair.length, 2); for (const c of pair) assert.match(c, /^#[0-9a-f]{6}$/, 'colours are #rrggbb'); } assert.deepEqual(r.body.active, [], 'nothing active on a fresh server'); const defaults = r.body.colors; // ── refusals say why ── r = await raise({ channel: 7, level: 'low', text: 'x' }); assert.equal(r.status, 400); assert.match(r.body.error, /channel/); r = await raise({ channel: 1, level: 'high', text: 'x' }); assert.equal(r.status, 400); assert.match(r.body.error, /level/); r = await raise({ channel: 1, level: 'low', text: ' ' }); assert.equal(r.status, 400); assert.match(r.body.error, /text/); r = await raise({ channel: 1, level: 'low', text: 'x', duration: -1 }); assert.equal(r.status, 400); assert.match(r.body.error, /duration/); r = await raise({ channel: 1, level: 'low', text: 'x', duration: 'soon' }); assert.equal(r.status, 400); assert.match(r.body.error, /duration/); { const plain = await fetch(base + '/api/alerts', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: '{}' }); assert.equal(plain.status, 415, 'a POST that is not application/json is refused'); } r = await fetch(base + '/api/alerts/9', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() })); assert.equal(r.status, 400, 'clearing a channel that does not exist is refused'); r = await api('/api/alerts'); assert.deepEqual(r.body.active, [], 'a refused alert is not half-raised'); // ── raise → frame → timeout → clear frame ── r = await raise({ channel: 2, level: 'MEDIUM', text: 'Service over by 5:00', duration: 1, source: 'pco-plan' }); assert.equal(r.status, 200, 'raise: ' + JSON.stringify(r.body)); const a = r.body.alert; assert.equal(a.channel, 2); assert.equal(a.level, 'medium', 'level is case-insensitive'); assert.equal(a.text, 'Service over by 5:00'); assert.equal(a.source, 'pco-plan', 'the source is kept'); assert.equal(a.duration, 1); assert.ok(a.remainingMs > 0 && a.remainingMs <= 1000, 'remainingMs counts down from the duration'); let frame = await events.next((f) => f.event === 'alert' && f.data.id === a.id, 5000, '`alert` frame'); assert.equal(frame.data.text, a.text, 'the frame carries the alert'); r = await api('/api/alerts'); assert.equal(r.body.active.length, 1, 'it is active'); assert.equal(r.body.active[0].id, a.id); frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 2, 5000, '`alert-clear` frame at timeout'); assert.equal(frame.data.id, a.id, 'the clear names the alert that ended'); r = await api('/api/alerts'); assert.deepEqual(r.body.active, [], 'gone once its time is up'); // ── defaults for duration; a held alert; replacement; early clear ── r = await raise({ channel: 3, level: 'low', text: 'first' }); assert.equal(r.body.alert.duration, 15, '15 s when omitted'); r = await raise({ channel: 3, level: 'low', text: 'LTC lost', duration: 0 }); assert.equal(r.status, 200); assert.equal(r.body.alert.endsAt, null, 'duration 0 holds the alert'); assert.equal(r.body.alert.remainingMs, null); const held = r.body.alert; r = await api('/api/alerts'); assert.equal(r.body.active.length, 1, 'one alert per channel — the second replaced the first'); assert.equal(r.body.active[0].id, held.id); r = await raise({ channel: 3, level: 'critical', text: 'replaced', duration: 600 }); const replaced = r.body.alert; assert.equal(replaced.duration, 600, 'the cap is 600 s'); r = await raise({ channel: 3, level: 'critical', text: 'capped', duration: 9999 }); assert.equal(r.body.alert.duration, 600, 'longer is clamped to the cap'); r = await api('/api/alerts'); assert.equal(r.body.active.length, 1); assert.equal(r.body.active[0].text, 'capped'); r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() })); assert.equal(r.status, 200); assert.equal(r.body.cleared, true, 'an early clear says it cleared something'); frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 3, 5000, '`alert-clear` frame after DELETE'); r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() })); assert.equal(r.body.cleared, false, 'clearing an empty channel is honest about it'); r = await api('/api/alerts'); assert.deepEqual(r.body.active, []); // ── text is tidied: control characters out, whitespace collapsed, 80 chars at most ── r = await raise({ channel: 4, level: 'low', text: ' SPL\n\t 102 dB\u0007 — over ' + 'x'.repeat(100), duration: 60 }); assert.equal(r.status, 200); assert.equal(r.body.alert.text.length, 80, 'cut at 80'); assert.ok(r.body.alert.text.startsWith('SPL 102 dB — over x'), 'whitespace collapsed, control characters gone'); assert.equal(r.body.alert.source, 'api', 'no source given → api'); r = await raise({ channel: 4, level: 'low', text: 'bad source', duration: 60, source: 'not a module id!' }); assert.equal(r.body.alert.source, 'api', 'a malformed source → api'); await fetch(base + '/api/alerts/4', { method: 'DELETE' }); // ── the channel list rides along in a saved layout ── const tile = { id: 't1', module: 'clock', x: 0, y: 0, w: 2, h: 1, settings: {} }; r = await api('/api/layouts/Stage', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile], alertChannels: [3, 1, '2', 1, 9, 'x'] } }) }); assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body)); r = await api('/api/layouts/Stage'); assert.deepEqual(r.body.layout.alertChannels, [1, 2, 3], 'channels come back whole, deduplicated, sorted, 1–6 only'); r = await api('/api/layouts/Plain', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile] } }) }); r = await api('/api/layouts/Plain'); assert.equal('alertChannels' in r.body.layout, false, 'a layout saved without the list does not grow one'); // ── admin: colours (admin is open here — a fresh data directory has no PIN) ── r = await api('/api/admin/alerts', { method: 'POST', body: JSON.stringify({ colors: { low: ['#112233', 'nonsense'], critical: ['#ABCDEF', '#000000'], bogus: ['#111111', '#222222'] } }), }); assert.equal(r.status, 200, 'colour save: ' + JSON.stringify(r.body)); assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'a malformed colour takes the default'); assert.deepEqual(r.body.colors.medium, defaults.medium, 'a level left out keeps the default'); assert.deepEqual(r.body.colors.critical, ['#abcdef', '#000000'], 'colours are lower-cased'); assert.equal('bogus' in r.body.colors, false, 'an unknown level is dropped'); frame = await events.next((f) => f.event === 'alerts-config', 5000, '`alerts-config` frame'); assert.deepEqual(frame.data.colors, r.body.colors, 'open dashboards hear the new colours'); r = await api('/api/alerts'); assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'GET reflects the save'); const settings = JSON.parse(fs.readFileSync(path.join(dataDir, 'proddash.json'), 'utf8')); assert.deepEqual(settings.alerts.colors.critical, ['#abcdef', '#000000'], 'saved under alerts.colors in proddash.json'); // ── admin: the test flash ── r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'critical', channel: 5 }) }); assert.equal(r.status, 200, 'test: ' + JSON.stringify(r.body)); assert.equal(r.body.alert.channel, 5); assert.equal(r.body.alert.level, 'critical'); assert.equal(r.body.alert.source, 'admin'); assert.match(r.body.alert.text, /^Test — critical alert$/); assert.equal(r.body.alert.duration, 8, 'a test flash is short'); r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'nope', channel: 5 }) }); assert.equal(r.status, 400, 'a bad test level is refused like any other'); await fetch(base + '/api/alerts/5', { method: 'DELETE' }); console.log('alerts: every check passed'); } catch (err) { failed = true; console.error('FAIL:', err.message); console.error('--- last of the server log ---\n' + log.slice(-2000)); } finally { try { events?.close(); } catch { /* already closed */ } child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 400)); fs.rmSync(dataDir, { recursive: true, force: true }); } process.exit(failed ? 1 : 0); })();