Files
ruvnet--RuView/v2/crates/homecore-server/ui/tests/render-smoke.mjs
T
rUv 20ad75f30c feat(ADR-131): HOMECORE-UI dashboard + BFF gateway — review-fixed (supersedes #1082) (#1099)
* feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway

Complete two-tier Cognitum operator dashboard (ADR-131), served by
homecore-server at /homecore, plus the single-origin BFF gateway that
wires it to real backends.

Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens):
- All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map,
  entities (live WS subscribe_events, never polls), rooms, COGs,
  calibration wizard, events + automation builder, witness/audit, settings.
- §6 UX invariants in code: first-class provenance, prominent stale/veto/
  fragility, null(not-trained) vs withheld vs error, --mono everywhere,
  Hailo vs CPU COG distinction.
- api.js calls the gateway routes in production; mock demoted to a
  dev-only ?demo=1 fixture (no mock in prod); typed error states.
- Tests under plain node: import-graph, boot, render-smoke (22),
  interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB
  (~37x smaller than HA), <2 ms/cold-render.

BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89):
- /api/cal/* reverse-proxy to the calibration API (ADR-151).
- GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm,
  heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id).
- GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/.
- GET /api/homecore/appliance from /proc + TCP service probes.
- SEED-device/appliance routes return typed 503 upstream_unavailable.
- cargo test -p homecore-server = 12/12; run live (curl-verified);
  fixed a real double-v1 proxy-URL bug found during live testing.

Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation
return typed 503 (depend on services/hardware not in this repo).

Co-Authored-By: claude-flow <ruv@ruv.net>

* 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>

---------

Co-authored-by: Nick Ruest <127058086+nicholas-ruest@users.noreply.github.com>
2026-06-15 11:11:19 -04:00

110 lines
5.7 KiB
JavaScript

// Render-smoke test — actually executes every HOMECORE-UI panel against
// the DOM shim and asserts each builds a non-empty DOM subtree without
// throwing. Also exercises the ui.js helpers and the mock contract.
// Run: node tests/render-smoke.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
const fails = [];
const passes = [];
function check(name, fn) {
try { fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
async function checkAsync(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 ui = await import('../js/ui.js');
const { api, entityProvenance } = await import('../js/api.js');
const mock = await import('../js/mock.js');
// ── ui.js helper unit checks ────────────────────────────────────────
check('ui.h builds element with class/id', () => {
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
if (n.tagName !== 'DIV') throw new Error('tag');
if (!n.classList.contains('card')) throw new Error('class');
if (n.id !== 'x') throw new Error('id');
});
check('ui.statusPill maps running→green', () => {
const p = ui.statusPill('running');
if (!p.classList.contains('green')) throw new Error('expected green pill');
});
check('ui.statusPill maps offline→red', () => {
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
});
check('ui.bar applies threshold colour', () => {
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
});
check('ui.confidenceBar amber under 0.4', () => {
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
});
check('ui.provenanceBadge marks hailo', () => {
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
});
check('ui.sparkline yields svg polyline', () => {
const s = ui.sparkline([1, 2, 3, 4]);
if (!s.querySelector('polyline')) throw new Error('no polyline');
});
// ── mock contract checks ────────────────────────────────────────────
check('mock RoomState distinguishes null vs withheld', () => {
const rs = mock.roomStates();
const office = rs.find((r) => r.room_id === 'office');
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
const kitchen = rs.find((r) => r.room_id === 'kitchen');
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
});
check('analysis covers at least 3 bedrooms', () => {
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
});
check('mock fleet has an offline seed with red tint semantics', () => {
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
});
check('mock federation states the raw-CSI invariant', () => {
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
});
check('entityProvenance derives node→seed chain', () => {
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
if (!prov.seed) throw new Error('seed mapping failed');
});
// ── render every panel ──────────────────────────────────────────────
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api,
navigate() {},
params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
wsStatus: () => ({ state: 'open', lagged: false }),
bus: new globalThis.EventTarget(),
};
for (const name of PANELS) {
await checkAsync(`render panel: ${name}`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const panel = mod.default;
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
const root = document.createElement('div');
const cleanup = await panel.render(root, ctx);
if (root.children.length === 0) throw new Error('rendered nothing into root');
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
});
}
// ── report ──────────────────────────────────────────────────────────
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 — all ui helpers, mock contracts, and 10 panels render without throwing');