278 lines
13 KiB
JavaScript
278 lines
13 KiB
JavaScript
'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);
|
|||
|
|
})();
|