mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards
Addresses the high-effort review of PR #1082: - SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..` segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400, before attaching the server-side calibration bearer. - CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS allowlist (build_cors_layer, exported from homecore-api) + TraceLayer — previously merged outside router()'s layers (no CORS, no tracing). - §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe (not hardcoded "connected"); room anomaly threshold passed through / null, not a fabricated 0.5. - ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer. - CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled in production (§2.2). - New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -10,7 +10,14 @@
|
||||
//
|
||||
// Gateway route map: ADR-131 §11.2.
|
||||
|
||||
import * as mock from './mock.js';
|
||||
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
|
||||
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
|
||||
// after first use so repeated demo calls don't re-import.
|
||||
let _mock = null;
|
||||
async function loadMock() {
|
||||
if (!_mock) _mock = await import('./mock.js');
|
||||
return _mock;
|
||||
}
|
||||
|
||||
const demoFlags = {};
|
||||
|
||||
@@ -50,8 +57,10 @@ export const api = {
|
||||
},
|
||||
|
||||
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
||||
// The mock module is dynamically imported ONLY on the demo branch, so prod
|
||||
// never loads it. `mockFn` receives the loaded module.
|
||||
async _data(key, path, mockFn) {
|
||||
if (demoMode()) { demoFlags[key] = true; return mockFn(); }
|
||||
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
|
||||
delete demoFlags[key];
|
||||
return this._get(path);
|
||||
},
|
||||
@@ -68,28 +77,28 @@ export const api = {
|
||||
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
||||
|
||||
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
||||
async appliance() { return this._data('appliance', '/api/homecore/appliance', () => mock.applianceHealth()); },
|
||||
async seeds() { return this._data('fleet', '/api/homecore/seeds', () => mock.seeds()); },
|
||||
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), () => mock.seed(id)); },
|
||||
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
|
||||
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
|
||||
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
|
||||
async esp32Warnings() {
|
||||
if (demoMode()) { demoFlags.fleet = true; return mock.esp32Warnings(); }
|
||||
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
|
||||
const seeds = await this._get('/api/homecore/seeds');
|
||||
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
||||
},
|
||||
async cogs() { return this._data('cogs', '/api/homecore/cogs', () => mock.cogs()); },
|
||||
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', () => mock.cogUpdates()); },
|
||||
async hailo() { return this._data('cogs', '/api/homecore/hailo', () => ({ worker: 'connected', cogs: mock.cogs().filter((c) => c.arch === 'hailo10') })); },
|
||||
async roomStates() { return this._data('rooms', '/api/homecore/rooms', () => mock.roomStates()); },
|
||||
async federation() { return this._data('fleet', '/api/homecore/federation', () => mock.federation()); },
|
||||
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, () => mock.witnessLog(page, size)); },
|
||||
async privacyModes() { return this._data('audit', '/api/homecore/privacy', () => mock.privacyModes()); },
|
||||
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
|
||||
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
|
||||
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
|
||||
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
|
||||
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
|
||||
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
|
||||
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
|
||||
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
||||
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, () => mock.recentEvents(n)); },
|
||||
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
|
||||
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
||||
async settings() { return this._data('settings', '/api/homecore/settings', () => mock.settings()); },
|
||||
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
|
||||
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
||||
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
||||
async tokens() { return this._data('settings', '/api/homecore/tokens', () => mock.settings().tokens); },
|
||||
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
|
||||
|
||||
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
||||
calibration: makeCalibration(),
|
||||
@@ -123,8 +132,12 @@ export function entityProvenance(entity) {
|
||||
const nodeMatch = src.match(/esp32[-\w]*/i);
|
||||
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
||||
let seed = attrs.seed || null;
|
||||
if (!seed && demoMode() && node) {
|
||||
const cfg = mock.settings().esp32.find((n) => n.node_id === node);
|
||||
// Demo-only enrichment: consult the mock node registry IF it has already
|
||||
// been dynamically loaded by a prior demo data call (this fn is sync, so it
|
||||
// cannot await the import). Prod never has `_mock` set → seed stays null
|
||||
// (never fabricated).
|
||||
if (!seed && demoMode() && node && _mock) {
|
||||
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
|
||||
seed = cfg ? cfg.seed : null;
|
||||
}
|
||||
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
||||
|
||||
@@ -9,6 +9,14 @@ export default {
|
||||
const { api } = ctx;
|
||||
const cal = api.calibration;
|
||||
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
|
||||
// Track the active baseline poll so it can be cancelled on Restart, on a
|
||||
// step change, and when the panel itself is torn down (the router only
|
||||
// calls the cleanup this render() returns — a per-card _cleanup was never
|
||||
// invoked, leaking the setTimeout loop).
|
||||
let activePoll = null;
|
||||
function stopPoll() {
|
||||
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
|
||||
}
|
||||
|
||||
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
|
||||
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
|
||||
@@ -26,7 +34,7 @@ export default {
|
||||
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
|
||||
});
|
||||
}
|
||||
function go(step) { state.step = step; paintStepper(); render(); }
|
||||
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
|
||||
function render() {
|
||||
clear(body);
|
||||
if (state.step === 1) body.appendChild(step1());
|
||||
@@ -74,35 +82,51 @@ export default {
|
||||
const c = card({
|
||||
title: 'Step 2 — Baseline capture (room must be empty)', children: [
|
||||
progress, meta, baselineLine,
|
||||
h('.mt', button('Restart', { variant: 'ghost', onClick: () => { cal.reset(); poll.started = false; } })),
|
||||
h('.mt', button('Restart', {
|
||||
variant: 'ghost',
|
||||
// Cancel the in-flight poll loop (was leaked before), reset the
|
||||
// session, and start a fresh capture.
|
||||
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
|
||||
})),
|
||||
],
|
||||
});
|
||||
const poll = { started: false, timer: null };
|
||||
(async () => {
|
||||
let startRes;
|
||||
try { startRes = await cal.start(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
||||
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
||||
const loop = async () => {
|
||||
let st;
|
||||
try { st = await cal.status(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
||||
progress.firstChild.style.width = (st.frames / st.target * 100).toFixed(0) + '%';
|
||||
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
||||
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
||||
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
||||
if (st.frames >= st.target) {
|
||||
state.baseline_id = state.baseline_id || 'bl-unknown';
|
||||
clear(baselineLine);
|
||||
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
||||
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
||||
return;
|
||||
}
|
||||
poll.timer = setTimeout(loop, 600);
|
||||
};
|
||||
loop();
|
||||
})();
|
||||
c._cleanup = () => poll.timer && clearTimeout(poll.timer);
|
||||
|
||||
// Single-flight: stopPoll() before (re)arming guarantees one loop.
|
||||
function startCapture() {
|
||||
stopPoll();
|
||||
const session = { cancelled: false, timer: null };
|
||||
activePoll = session;
|
||||
(async () => {
|
||||
let startRes;
|
||||
try { startRes = await cal.start(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
||||
const loop = async () => {
|
||||
if (session.cancelled) return;
|
||||
let st;
|
||||
try { st = await cal.status(); }
|
||||
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
||||
if (session.cancelled) return;
|
||||
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
|
||||
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
||||
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
||||
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
||||
if (st.target > 0 && st.frames >= st.target) {
|
||||
activePoll = null;
|
||||
state.baseline_id = state.baseline_id || 'bl-unknown';
|
||||
clear(baselineLine);
|
||||
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
||||
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
||||
return;
|
||||
}
|
||||
session.timer = setTimeout(loop, 600);
|
||||
};
|
||||
loop();
|
||||
})();
|
||||
}
|
||||
|
||||
startCapture();
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -206,10 +230,17 @@ export default {
|
||||
|
||||
paintStepper();
|
||||
render();
|
||||
return () => {};
|
||||
// The router invokes this on navigation away — tear down any live poll.
|
||||
return () => stopPoll();
|
||||
},
|
||||
};
|
||||
|
||||
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
|
||||
function pct(frames, target) {
|
||||
if (!(target > 0)) return 0;
|
||||
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
|
||||
}
|
||||
|
||||
function instruction(label) {
|
||||
const map = {
|
||||
empty: 'Leave the room empty and still.',
|
||||
|
||||
@@ -39,8 +39,19 @@ export default {
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ───────────────────────────────────────────
|
||||
// §6 honesty: the worker pill must reflect the REAL probe, not a
|
||||
// hardcoded "connected". Probe the appliance services for the
|
||||
// ruvector-hailo-worker; if that upstream is unavailable, show the
|
||||
// status as unknown rather than fabricating "connected".
|
||||
let workerStatus = 'unknown';
|
||||
try {
|
||||
const appliance = await api.appliance();
|
||||
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
|
||||
if (svc && svc.status) workerStatus = svc.status;
|
||||
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
|
||||
|
||||
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
|
||||
root.appendChild(hailoStatus(cogs));
|
||||
root.appendChild(hailoStatus(cogs, workerStatus));
|
||||
|
||||
return () => {};
|
||||
},
|
||||
@@ -107,7 +118,7 @@ function logText(c) {
|
||||
return [
|
||||
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
|
||||
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
|
||||
c.arch === 'hailo10' ? `[info] hailo: ${(c.hef || []).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
||||
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
||||
'[info] heartbeat ok',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -120,12 +131,21 @@ function configJson(c) {
|
||||
autostart: c.status !== 'stopped',
|
||||
};
|
||||
if (c.arch === 'hailo10') {
|
||||
cfg.hef = c.hef || [];
|
||||
cfg.hef = asArray(c.hef);
|
||||
cfg.target_fps = c.throughput_fps || null;
|
||||
}
|
||||
return JSON.stringify(cfg, null, 2);
|
||||
}
|
||||
|
||||
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
|
||||
// array so a non-array value degrades gracefully instead of throwing on
|
||||
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
|
||||
function asArray(v) {
|
||||
if (Array.isArray(v)) return v;
|
||||
if (v == null || v === '') return [];
|
||||
return [v];
|
||||
}
|
||||
|
||||
// ── OTA update diff card ─────────────────────────────────────────────
|
||||
function updateCard(u) {
|
||||
const diff = h('div',
|
||||
@@ -148,19 +168,22 @@ function diffList(title, items, color) {
|
||||
}
|
||||
|
||||
// ── Hailo HEF status ─────────────────────────────────────────────────
|
||||
function hailoStatus(cogs) {
|
||||
function hailoStatus(cogs, workerStatus = 'unknown') {
|
||||
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
|
||||
const worker = h('.flex.gap-sm', statusPill('connected'), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
||||
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
|
||||
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
|
||||
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
||||
const body = h('div', worker);
|
||||
|
||||
if (!hailoCogs.length) {
|
||||
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
|
||||
} else {
|
||||
hailoCogs.forEach((c) => {
|
||||
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
|
||||
const hefRows = h('div',
|
||||
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
|
||||
(c.hef || []).forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
||||
if (!(c.hef || []).length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
||||
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
||||
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
||||
body.appendChild(h('.mt', hefRows));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ export default {
|
||||
await section(root, 'v0 Appliance health', async () => {
|
||||
const a = await api.appliance();
|
||||
const strip = h('.metric-grid',
|
||||
metric({ icon: '🖥', value: a.cpu_pct + '%', label: 'CPU' }),
|
||||
metric({ icon: '🧠', value: a.ram_pct + '%', label: 'RAM' }),
|
||||
metric({ icon: '⚡', value: a.hailo_load_pct + '%', label: 'Hailo-10H load' }),
|
||||
metric({ icon: '🌡', value: a.hailo_temp_c + '°C', label: 'Hailo temp' }),
|
||||
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
|
||||
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
|
||||
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
|
||||
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
|
||||
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
|
||||
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
|
||||
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
|
||||
@@ -135,7 +135,13 @@ function eventBus(a, ctx, setCleanup) {
|
||||
return card({ title: 'Event Bus activity', children: [body] });
|
||||
}
|
||||
|
||||
// §6 honesty: a null/undefined metric must render a distinct not-available
|
||||
// state ('—'), never a fabricated value like "null%"/"null°C".
|
||||
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
|
||||
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
|
||||
|
||||
function fmtUptime(s) {
|
||||
if (s == null) return '—';
|
||||
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
|
||||
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
|
||||
}
|
||||
|
||||
@@ -88,8 +88,17 @@ function vitalRow(label, spec, unit, range, r) {
|
||||
|
||||
function anomalyRow(a) {
|
||||
if (!a) return specRow('Anomaly', notTrainedNode(), null);
|
||||
const over = a.value > (a.threshold ?? 0.8);
|
||||
const b = bar(a.value, 1, [{ lt: a.threshold ?? 0.8, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
||||
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
|
||||
// none) — show the value but flag the threshold as unavailable rather than
|
||||
// judging anomalous/normal against a fabricated 0.8 default.
|
||||
if (a.threshold == null) {
|
||||
const wrap = h('div', { style: { width: '160px' } },
|
||||
bar(a.value, 1),
|
||||
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
}
|
||||
const over = a.value > a.threshold;
|
||||
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
||||
const wrap = h('div', { style: { width: '160px' } }, b,
|
||||
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
|
||||
return specRow('Anomaly', wrap, a);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
|
||||
"scripts": {
|
||||
"check": "node tests/verify-imports.mjs",
|
||||
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs",
|
||||
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
|
||||
"bench": "node tests/benchmark.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Regression tests pinning the ADR-131 PR-1082 review fixes:
|
||||
// * dashboard renders a not-available state ('—') for null appliance
|
||||
// metrics — never "null%"/"null°C" (§6 honesty / fabricated-data fix).
|
||||
// * cogs panel does NOT throw when the gateway forwards a `hef` that is a
|
||||
// string (or other non-array) instead of an array (crash/robustness fix).
|
||||
// * cogs Hailo worker pill reflects the real probe, not a hardcoded
|
||||
// "connected" (§6 honesty fix).
|
||||
// Run: node tests/unit-fixes.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = false; // production path — no fixtures
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
const { api } = await import('../js/api.js');
|
||||
|
||||
// Shared ctx; per-test we override the api accessors we need.
|
||||
function ctxWith(overrides) {
|
||||
return {
|
||||
api: Object.assign(Object.create(api), overrides),
|
||||
navigate() {},
|
||||
params: {},
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
|
||||
};
|
||||
}
|
||||
|
||||
// ── dashboard: null metrics → '—', never "null%"/"null°C" ─────────────
|
||||
await t('dashboard renders not-available for null hailo metrics (no "null%")', async () => {
|
||||
const mod = await import('../js/panels/dashboard.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
appliance: async () => ({
|
||||
cpu_pct: 12.5, ram_pct: 40.1,
|
||||
hailo_load_pct: null, hailo_temp_c: null, // the fabricated-data trap
|
||||
uptime_s: null,
|
||||
services: [{ name: 'ruview-mcp-brain', port: 9876, status: 'unreachable' }],
|
||||
event_rate: [], channel_capacity: 4096, channel_lag: 0,
|
||||
}),
|
||||
seeds: async () => [],
|
||||
esp32Warnings: async () => [],
|
||||
cogs: async () => [],
|
||||
anyDemo: () => false,
|
||||
});
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
const text = root.textContent;
|
||||
assert(!/null\s*%/.test(text), `dashboard showed "null%": ${text.slice(0, 200)}`);
|
||||
assert(!/null\s*°C/.test(text), `dashboard showed "null°C": ${text.slice(0, 200)}`);
|
||||
assert(text.includes('—'), 'dashboard should render the "—" not-available marker for null metrics');
|
||||
// real values must still concatenate their unit
|
||||
assert(text.includes('12.5%'), 'real CPU value must still render with its unit');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
// ── cogs: string `hef` must not throw ─────────────────────────────────
|
||||
await t('cogs does not throw when hef is a string (non-array)', async () => {
|
||||
const mod = await import('../js/panels/cogs.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
cogs: async () => [
|
||||
{ id: 'cog-pose', version: '1.0', arch: 'hailo10', status: 'running', pid: 42,
|
||||
sha256_verified: true, signature_verified: true, throughput_fps: 30,
|
||||
hef: 'pose_estimation.hef' }, // STRING, not array — the crash trap
|
||||
],
|
||||
cogUpdates: async () => [],
|
||||
appliance: async () => ({ services: [{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' }] }),
|
||||
isDemo: () => false,
|
||||
});
|
||||
// If asArray() weren't applied, .forEach/.join/.length on a string would throw.
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
assert(root.children.length > 0, 'cogs rendered nothing');
|
||||
// The string hef should surface as a single loaded HEF row.
|
||||
assert(root.textContent.includes('pose_estimation.hef'), 'string hef should render as one HEF entry');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
// ── cogs: Hailo worker pill reflects the real probe, not hardcoded ────
|
||||
await t('cogs Hailo worker pill is unknown when appliance probe is unavailable', async () => {
|
||||
const mod = await import('../js/panels/cogs.js');
|
||||
const root = document.createElement('div');
|
||||
const ctx = ctxWith({
|
||||
cogs: async () => [],
|
||||
cogUpdates: async () => [],
|
||||
appliance: async () => { throw new Error('appliance upstream down'); }, // probe fails
|
||||
isDemo: () => false,
|
||||
});
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
// statusPill('unknown') → grey pill containing the literal label "unknown".
|
||||
assert(root.textContent.includes('unknown'), 'worker status should be honestly "unknown" when probe fails');
|
||||
assert(!/connected/.test(root.textContent), 'worker pill must not fabricate "connected"');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — dashboard not-available, cogs string-hef + honest worker pill pinned');
|
||||
Reference in New Issue
Block a user