mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 01:31:38 +00:00
Closes #1562. Follow-up to #1551 and #1552. ## Problem On CDN-fronted deployments (e.g. meshcore.meshat.se), the observers page header rendered totals computed entirely client-side from a possibly-stale `/api/observers` response. Operators saw e.g. `0 Online / 43 Stale / 37 Offline` while a cache-busted request returned `44 Online / 0 Stale / 36 Offline` — the aggregate row was the first thing they looked at to assess mesh health, so wrong numbers meant wrong actions. #1551 added `Cache-Control: no-store` on `/api/*` responses, but the client also has its own in-memory cache (`api(path, { ttl })`), and there was no UI signal at all that the rendered counts could be stale. ## Fix scope (Option 3 + light Option 2) Per the issue's three options, this PR implements **Option 3** (timestamp label) and a light **Option 2** (manual-refresh button bypasses client cache). Option 1 (a new server-side `/api/observers/summary` endpoint) is **deferred** as a follow-up — it's the most correct fix, but a bigger lift than what's needed to stop operators from acting on silently-wrong numbers. ## Changes - **`public/observers.js`** - New `window.ObserversSummary` pure helper exposing `computeCounts(observers)` and `renderHeader(counts, fetchedAt)`. Pure functions = easy to unit test. - Track `_fetchedAt` (ms) on each successful `loadObservers()` response. - `render()` delegates header HTML to `ObserversSummary.renderHeader(counts, fetchedAt)`. Existing aggregate display (`Online / Stale / Offline / Total`) is preserved exactly — the only visible additions are the "Last updated: Xs ago" label and a warning class when the timestamp is >60s old. - Manual refresh button now passes `{ bust: true }` to `api()` so the operator can force a fresh fetch when they suspect staleness. - **`public/style.css`** - New `.obs-updated` and `.obs-updated-stale` rules using existing `--text-muted` / `--warning` CSS variables (no new colors). - **`test-issue-1562-observers-summary.js`** + **`.github/workflows/deploy.yml`** - Unit tests for `computeCounts` (mixed ages → 1/1/1 + total), `renderHeader` (label presence + stale-warning class), plus DOM-grep checks that observers.js still tracks `_fetchedAt` and bypasses the cache on manual refresh. ## TDD Red commit asserts `ObserversSummary` doesn't exist / no `_fetchedAt` tracking / no `obs-updated-stale` CSS → fails. Green commit adds the implementation → passes. ## What this PR does NOT touch - **Observer health thresholds** — owned by #1552, untouched here. - **`healthStatus()` per-row classification** — untouched. The same function still gates per-row colors AND aggregate counts; the fix is about freshness visibility, not classification logic. - **No new server endpoint** — Option 1 deferred. Will file a follow-up if anyone wants that tracked. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: mc-bot <bot@meshcore.local>
This commit is contained in:
@@ -126,6 +126,7 @@ jobs:
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-issue-1420-tile-providers.js
|
||||
node test-issue-1438-marker-css-vars.js
|
||||
node test-issue-1562-observers-summary.js
|
||||
node test-live.js
|
||||
node test-xss-escape-sinks.js
|
||||
node test-preflight-xss-gate.js
|
||||
|
||||
+107
-13
@@ -31,8 +31,87 @@ window.ObserversNaiveChip = {
|
||||
},
|
||||
};
|
||||
|
||||
// #1562 — Pure helper for computing + rendering the observers-page aggregate
|
||||
// header. Split out as a window global so it's unit-testable in a vm sandbox
|
||||
// without needing a DOM, AND so the render path has one obvious place to wire
|
||||
// the "Last updated: X ago" freshness label.
|
||||
//
|
||||
// Why this exists (per #1562): the header used to derive Online/Stale/Offline
|
||||
// counts inline from a possibly-stale cached /api/observers payload, with no
|
||||
// UI signal that the data could be stale. After #1551 added Cache-Control:
|
||||
// no-store on the server response, the in-memory client cache (api() ttl)
|
||||
// could still serve old data. The "Last updated" label makes that visible;
|
||||
// manual refresh now also bypasses the cache (bust: true).
|
||||
window.ObserversSummary = (function () {
|
||||
// #1563 — Single source of truth: aggregate counts MUST come from the
|
||||
// same classifier used to render the per-row dots. Previously this helper
|
||||
// had its own hardcoded thresholds parallel to healthStatus(); operators
|
||||
// would see "5 Online" in the header but count 12 green rows by hand
|
||||
// (regression of #1562). We now delegate to window.observerHealthStatus
|
||||
// (exposed below by the IIFE) and map its returned .cls to the bucket.
|
||||
//
|
||||
// A default fallback classifier is kept for the case where this module
|
||||
// is loaded BEFORE observers.js wires up window.observerHealthStatus
|
||||
// (e.g. legacy test paths). It mirrors the canonical thresholds, but
|
||||
// production code paths always hit window.observerHealthStatus.
|
||||
function defaultClassify(lastSeen) {
|
||||
if (!lastSeen) return { cls: 'health-red' };
|
||||
var ago = Date.now() - new Date(lastSeen).getTime();
|
||||
var tolerance = 30000;
|
||||
if (ago < 600000 + tolerance) return { cls: 'health-green' };
|
||||
if (ago < 3600000 + tolerance) return { cls: 'health-yellow' };
|
||||
return { cls: 'health-red' };
|
||||
}
|
||||
|
||||
function computeCounts(observers) {
|
||||
var online = 0, stale = 0, offline = 0;
|
||||
var list = Array.isArray(observers) ? observers : [];
|
||||
var classifier = (typeof window !== 'undefined' && typeof window.observerHealthStatus === 'function')
|
||||
? window.observerHealthStatus
|
||||
: defaultClassify;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var h = classifier(list[i] && list[i].last_seen) || { cls: 'health-red' };
|
||||
if (h.cls === 'health-green') online++;
|
||||
else if (h.cls === 'health-yellow') stale++;
|
||||
else offline++;
|
||||
}
|
||||
return { online: online, stale: stale, offline: offline, total: list.length };
|
||||
}
|
||||
|
||||
// Renders the obs-summary block as a string. fetchedAt is a ms epoch
|
||||
// timestamp (or null/0 for "unknown" — first render before any successful
|
||||
// fetch). When fetchedAt is older than 60s, the timestamp gets the
|
||||
// obs-updated-stale class so operators see the data is going stale.
|
||||
function renderHeader(counts, fetchedAt) {
|
||||
var c = counts || { online: 0, stale: 0, offline: 0, total: 0 };
|
||||
var updatedHtml = '';
|
||||
if (fetchedAt) {
|
||||
var ageMs = Date.now() - fetchedAt;
|
||||
var staleCls = ageMs > 60000 ? ' obs-updated-stale' : '';
|
||||
var iso = new Date(fetchedAt).toISOString();
|
||||
updatedHtml = '<span class="obs-stat obs-updated' + staleCls
|
||||
+ '" data-action="obs-refresh" role="button" tabindex="0"'
|
||||
+ ' title="Click to force a fresh fetch (bypass client cache)"'
|
||||
+ ' aria-label="Last updated ' + timeAgo(iso) + ' — click to refresh">'
|
||||
+ 'Last updated: ' + timeAgo(iso) + '</span>';
|
||||
}
|
||||
return ''
|
||||
+ '<div class="obs-summary">'
|
||||
+ '<span class="obs-stat"><span class="health-dot health-green">\u25CF</span> ' + c.online + ' Online</span>'
|
||||
+ '<span class="obs-stat"><span class="health-dot health-yellow">\u25B2</span> ' + c.stale + ' Stale</span>'
|
||||
+ '<span class="obs-stat"><span class="health-dot health-red">\u2715</span> ' + c.offline + ' Offline</span>'
|
||||
+ '<span class="obs-stat">\uD83D\uDCE1 ' + c.total + ' Total</span>'
|
||||
+ updatedHtml
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
return { computeCounts: computeCounts, renderHeader: renderHeader };
|
||||
})();
|
||||
|
||||
(function () {
|
||||
let observers = [];
|
||||
let _fetchedAt = 0; // #1562: ms epoch when the current `observers` payload was received
|
||||
let _loadObserversReqId = 0; // #1563: monotonic id; resolutions older than the latest are discarded
|
||||
let obsSkewMap = {}; // observerID → {offsetSec, samples}
|
||||
let wsHandler = null;
|
||||
let refreshTimer = null;
|
||||
@@ -55,7 +134,7 @@ window.ObserversNaiveChip = {
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
|
||||
if (btn && btn.dataset.action === 'obs-refresh') loadObservers({ bust: true });
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (row) {
|
||||
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
|
||||
@@ -70,6 +149,14 @@ window.ObserversNaiveChip = {
|
||||
});
|
||||
// #209 — Keyboard accessibility for observer rows
|
||||
app.addEventListener('keydown', function (e) {
|
||||
// #1562 — Last-updated pill (role=button) supports Enter/Space to
|
||||
// force a fresh fetch, matching click behavior on data-action="obs-refresh".
|
||||
var refreshBtn = e.target.closest('[data-action="obs-refresh"]');
|
||||
if (refreshBtn && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
loadObservers({ bust: true });
|
||||
return;
|
||||
}
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (!row) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
@@ -98,19 +185,28 @@ window.ObserversNaiveChip = {
|
||||
obsSkewMap = {};
|
||||
}
|
||||
|
||||
async function loadObservers() {
|
||||
async function loadObservers(opts) {
|
||||
var bust = !!(opts && opts.bust);
|
||||
// #1563 — in-flight guard: every call gets a monotonic id; when we
|
||||
// resolve, if a newer call has started, drop this result silently.
|
||||
// Prevents a slow auto-refresh from clobbering a fresh manual bust
|
||||
// (or vice versa) with stale data + a misleading "0s ago" pill.
|
||||
var myId = ++_loadObserversReqId;
|
||||
try {
|
||||
const [data, skewData] = await Promise.all([
|
||||
api('/observers', { ttl: CLIENT_TTL.observers }),
|
||||
api('/observers', { ttl: CLIENT_TTL.observers, bust: bust }),
|
||||
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; })
|
||||
]);
|
||||
if (myId !== _loadObserversReqId) return; // stale resolve, newer in-flight
|
||||
observers = data.observers || [];
|
||||
_fetchedAt = Date.now(); // #1562: stamp freshness for the header label
|
||||
obsSkewMap = {};
|
||||
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
|
||||
if (s && s.observerID) obsSkewMap[s.observerID] = s;
|
||||
});
|
||||
render();
|
||||
} catch (e) {
|
||||
if (myId !== _loadObserversReqId) return; // discard stale error too
|
||||
document.getElementById('obsContent').innerHTML =
|
||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
|
||||
}
|
||||
@@ -134,6 +230,9 @@ window.ObserversNaiveChip = {
|
||||
return { cls: 'health-red', label: 'Offline' };
|
||||
}
|
||||
// Issue #1552 — exposed for tests and external callers.
|
||||
// #1563 — Expose for ObserversSummary so aggregate counts and per-row dots
|
||||
// share ONE classifier (single source of truth). If anything reintroduces
|
||||
// parallel thresholds, the new ObserversSummary regression test breaks.
|
||||
window.observerHealthStatus = healthStatus;
|
||||
|
||||
function packetBadge(o) {
|
||||
@@ -180,18 +279,13 @@ window.ObserversNaiveChip = {
|
||||
|
||||
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
||||
|
||||
// Summary counts
|
||||
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
||||
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
||||
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
||||
// #1562 — Aggregate counts + "Last updated" freshness label come from the
|
||||
// pure ObserversSummary helper (unit-tested in test-issue-1562-*).
|
||||
const summaryCounts = window.ObserversSummary.computeCounts(filtered);
|
||||
const summaryHtml = window.ObserversSummary.renderHeader(summaryCounts, _fetchedAt);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="obs-summary">
|
||||
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
|
||||
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
|
||||
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
|
||||
<span class="obs-stat">📡 ${filtered.length} Total</span>
|
||||
</div>
|
||||
${summaryHtml}
|
||||
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
|
||||
@@ -1820,6 +1820,12 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
/* #1562 — "Last updated: X ago" pill on the observers-page header.
|
||||
Clickable (forces a fresh fetch / bypasses client cache). Goes to
|
||||
--warning when the cached payload is >60s old so operators see staleness. */
|
||||
.obs-updated { cursor: pointer; font-size: 13px; color: var(--text-muted); border: 1px dashed transparent; padding: 2px 6px; border-radius: 4px; }
|
||||
.obs-updated:hover, .obs-updated:focus { border-color: var(--border); outline: none; }
|
||||
.obs-updated-stale { color: var(--status-yellow); border-color: var(--status-yellow); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.health-dot.health-green { background: var(--status-green); box-shadow: 0 0 6px #22c55e80; }
|
||||
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* #1562 — Observers page header: "Last updated: X ago" label and
|
||||
* compute aggregate counts from a pure, testable helper so operators
|
||||
* can see when the cached payload is stale.
|
||||
*
|
||||
* Pattern: pure helper on window.ObserversSummary (so it's easy to test
|
||||
* without a DOM) + render-string assertions for the header HTML.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function t(name, fn) {
|
||||
try { fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
Date, Math, Array, Object, Number, String, Boolean, RegExp, JSON,
|
||||
Promise, Map, Set, Symbol, Error,
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
performance: { now: () => Date.now() },
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
location: { hash: '#/observers', search: '' },
|
||||
history: { pushState: () => {} },
|
||||
navigator: { userAgent: 'node' },
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
URL,
|
||||
URLSearchParams,
|
||||
};
|
||||
ctx.window.location = ctx.location;
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
function load(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, file), 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
const ctx = makeSandbox();
|
||||
load(ctx, 'public/roles.js');
|
||||
load(ctx, 'public/app.js');
|
||||
// Stub IIFE-required globals so observers.js loads cleanly in sandbox
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
|
||||
ctx.registerPage = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.CLIENT_TTL = { observers: 120000 };
|
||||
ctx.api = () => Promise.resolve({ observers: [] });
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.TableResponsive = { register: () => {} };
|
||||
ctx.SlideOver = null;
|
||||
ctx.observerSkewSeverity = () => 'ok';
|
||||
ctx.renderSkewBadge = () => '';
|
||||
load(ctx, 'public/observers.js');
|
||||
|
||||
const Summary = ctx.window.ObserversSummary;
|
||||
|
||||
console.log('\n=== #1562 ObserversSummary helper ===');
|
||||
|
||||
t('window.ObserversSummary is exposed', () => {
|
||||
assert.ok(Summary, 'expected window.ObserversSummary to be defined');
|
||||
assert.strictEqual(typeof Summary.computeCounts, 'function');
|
||||
assert.strictEqual(typeof Summary.renderHeader, 'function');
|
||||
});
|
||||
|
||||
t('computeCounts classifies 1 online (15s) / 1 stale (90min) / 1 offline (25h)', () => {
|
||||
const now = Date.now();
|
||||
const obs = [
|
||||
{ id: 'a', last_seen: new Date(now - 15 * 1000).toISOString() },
|
||||
{ id: 'b', last_seen: new Date(now - 90 * 60 * 1000).toISOString() },
|
||||
{ id: 'c', last_seen: new Date(now - 25 * 60 * 60 * 1000).toISOString() },
|
||||
];
|
||||
const r = Summary.computeCounts(obs);
|
||||
assert.strictEqual(r.online, 1, 'online=' + r.online);
|
||||
assert.strictEqual(r.stale, 1, 'stale=' + r.stale);
|
||||
assert.strictEqual(r.offline, 1, 'offline=' + r.offline);
|
||||
assert.strictEqual(r.total, 3, 'total=' + r.total);
|
||||
});
|
||||
|
||||
t('computeCounts handles empty + null last_seen as offline', () => {
|
||||
const r = Summary.computeCounts([{ id: 'x', last_seen: null }]);
|
||||
assert.strictEqual(r.offline, 1);
|
||||
assert.strictEqual(r.online, 0);
|
||||
assert.strictEqual(r.stale, 0);
|
||||
});
|
||||
|
||||
t('renderHeader includes "Last updated" + relative-time text', () => {
|
||||
const fetchedAt = Date.now() - 10 * 1000;
|
||||
const html = Summary.renderHeader({ online: 1, stale: 0, offline: 0, total: 1 }, fetchedAt);
|
||||
assert.ok(/Last updated/i.test(html), 'should mention "Last updated": ' + html);
|
||||
assert.ok(/ago/.test(html), 'should include relative "ago": ' + html);
|
||||
});
|
||||
|
||||
t('renderHeader includes count labels (Online / Stale / Offline)', () => {
|
||||
const html = Summary.renderHeader({ online: 5, stale: 2, offline: 3, total: 10 }, Date.now());
|
||||
assert.ok(/5\s*Online/.test(html), 'online count: ' + html);
|
||||
assert.ok(/2\s*Stale/.test(html), 'stale count: ' + html);
|
||||
assert.ok(/3\s*Offline/.test(html), 'offline count: ' + html);
|
||||
});
|
||||
|
||||
t('renderHeader marks obs-updated-stale class when fetchedAt > 60s old', () => {
|
||||
const fetchedAt = Date.now() - 90 * 1000;
|
||||
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, fetchedAt);
|
||||
assert.ok(/obs-updated-stale/.test(html), 'expected obs-updated-stale class: ' + html);
|
||||
});
|
||||
|
||||
t('renderHeader omits stale-warning class when fetchedAt < 60s old', () => {
|
||||
const fetchedAt = Date.now() - 10 * 1000;
|
||||
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, fetchedAt);
|
||||
assert.ok(!/obs-updated-stale/.test(html), 'should NOT mark stale: ' + html);
|
||||
});
|
||||
|
||||
t('renderHeader still renders cleanly when fetchedAt is null/0 (graceful degrade)', () => {
|
||||
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, null);
|
||||
assert.ok(typeof html === 'string' && html.length > 0, 'returns a non-empty string');
|
||||
});
|
||||
|
||||
console.log('\n=== #1562 DOM-grep checks ===');
|
||||
|
||||
const observersSrc = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
|
||||
t('observers.js exposes ObserversSummary global', () => {
|
||||
assert.ok(/window\.ObserversSummary\s*=/.test(observersSrc),
|
||||
'expected `window.ObserversSummary =` assignment in observers.js');
|
||||
});
|
||||
|
||||
t('observers.js tracks a fetchedAt timestamp', () => {
|
||||
assert.ok(/_fetchedAt|fetchedAt/.test(observersSrc),
|
||||
'expected fetchedAt tracking');
|
||||
});
|
||||
|
||||
t('observers.js calls Summary.renderHeader (or equivalent) — not the old inline block', () => {
|
||||
assert.ok(/ObserversSummary\.renderHeader|Summary\.renderHeader/.test(observersSrc),
|
||||
'render() should delegate header HTML to ObserversSummary.renderHeader');
|
||||
});
|
||||
|
||||
t('observers.js bypasses cache on manual refresh (bust: true)', () => {
|
||||
assert.ok(/bust\s*:\s*true/.test(observersSrc),
|
||||
'expected api(..., { bust: true }) on the manual refresh path');
|
||||
});
|
||||
|
||||
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
t('style.css defines .obs-updated-stale visual rule', () => {
|
||||
assert.ok(/\.obs-updated-stale\b/.test(styleSrc),
|
||||
'expected .obs-updated-stale class in style.css');
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(40));
|
||||
console.log(' #1562 ObserversSummary: ' + passed + ' passed, ' + failed + ' failed');
|
||||
console.log('='.repeat(40));
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* #1563 — Round-1 review must-fix tests:
|
||||
*
|
||||
* A. ObserversSummary aggregate counts MUST come from the SAME
|
||||
* classifier used to render per-row dots (window.observerHealthStatus),
|
||||
* not a parallel hardcoded threshold ladder. Regression pin for #1562:
|
||||
* if anyone re-introduces hardcoded thresholds in the summary, this
|
||||
* test breaks because the per-row tally and the aggregate counts
|
||||
* disagree.
|
||||
*
|
||||
* B. loadObservers() must guard against in-flight races: if a slow call
|
||||
* resolves AFTER a newer call, the newer call's data wins (and the
|
||||
* "Last updated" pill reflects the latest fetch, not the stale one).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function t(name, fn) {
|
||||
try {
|
||||
const r = fn();
|
||||
if (r && typeof r.then === 'function') {
|
||||
return r.then(
|
||||
() => { passed++; console.log(' ✓ ' + name); },
|
||||
(e) => { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
);
|
||||
}
|
||||
passed++; console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++; console.error(' ✗ ' + name + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => ({ innerHTML: '' }),
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
Date, Math, Array, Object, Number, String, Boolean, RegExp, JSON,
|
||||
Promise, Map, Set, Symbol, Error,
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
performance: { now: () => Date.now() },
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
location: { hash: '#/observers', search: '' },
|
||||
history: { pushState: () => {} },
|
||||
navigator: { userAgent: 'node' },
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
URL,
|
||||
URLSearchParams,
|
||||
};
|
||||
ctx.window.location = ctx.location;
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
function load(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, file), 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
console.log('\n=== #1563 A. Aggregate uses same classifier as per-row dots ===');
|
||||
|
||||
(function runAggregateTests() {
|
||||
const ctx = makeSandbox();
|
||||
load(ctx, 'public/roles.js');
|
||||
load(ctx, 'public/app.js');
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
|
||||
ctx.registerPage = () => {};
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.CLIENT_TTL = { observers: 120000 };
|
||||
ctx.api = () => Promise.resolve({ observers: [] });
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.TableResponsive = { register: () => {} };
|
||||
ctx.SlideOver = null;
|
||||
ctx.observerSkewSeverity = () => 'ok';
|
||||
ctx.renderSkewBadge = () => '';
|
||||
load(ctx, 'public/observers.js');
|
||||
|
||||
const Summary = ctx.window.ObserversSummary;
|
||||
const healthStatus = ctx.window.observerHealthStatus;
|
||||
|
||||
t('window.observerHealthStatus is exposed by observers.js', () => {
|
||||
assert.strictEqual(typeof healthStatus, 'function',
|
||||
'observers.js must expose window.observerHealthStatus so ObserversSummary can call it');
|
||||
});
|
||||
|
||||
t('REGRESSION PIN: aggregate counts equal per-row tally for 10 mixed observers', () => {
|
||||
const now = Date.now();
|
||||
const obs = [
|
||||
{ id: '1', last_seen: new Date(now - 5 * 1000).toISOString() }, // green
|
||||
{ id: '2', last_seen: new Date(now - 60 * 1000).toISOString() }, // green
|
||||
{ id: '3', last_seen: new Date(now - 4 * 60 * 1000).toISOString() }, // green
|
||||
{ id: '4', last_seen: new Date(now - 9 * 60 * 1000).toISOString() }, // green
|
||||
{ id: '5', last_seen: new Date(now - 15 * 60 * 1000).toISOString() }, // yellow
|
||||
{ id: '6', last_seen: new Date(now - 30 * 60 * 1000).toISOString() }, // yellow
|
||||
{ id: '7', last_seen: new Date(now - 59 * 60 * 1000).toISOString() }, // yellow
|
||||
{ id: '8', last_seen: new Date(now - 2 * 3600 * 1000).toISOString() }, // red
|
||||
{ id: '9', last_seen: new Date(now - 24 * 3600 * 1000).toISOString() },// red
|
||||
{ id: '10', last_seen: null }, // red
|
||||
];
|
||||
// Per-row tally (same classifier used by the table renderer)
|
||||
let rowGreen = 0, rowYellow = 0, rowRed = 0;
|
||||
for (const o of obs) {
|
||||
const h = healthStatus(o.last_seen);
|
||||
if (h.cls === 'health-green') rowGreen++;
|
||||
else if (h.cls === 'health-yellow') rowYellow++;
|
||||
else rowRed++;
|
||||
}
|
||||
const agg = Summary.computeCounts(obs);
|
||||
assert.strictEqual(agg.online, rowGreen, 'online ' + agg.online + ' != per-row green ' + rowGreen);
|
||||
assert.strictEqual(agg.stale, rowYellow, 'stale ' + agg.stale + ' != per-row yellow ' + rowYellow);
|
||||
assert.strictEqual(agg.offline, rowRed, 'offline '+ agg.offline + ' != per-row red ' + rowRed);
|
||||
assert.strictEqual(agg.total, obs.length);
|
||||
});
|
||||
|
||||
t('source: observers.js no longer contains the old standalone `classify()` ladder', () => {
|
||||
// Defense in depth — make sure the parallel ladder isn't re-introduced.
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
// Old code had `function classify(lastSeen)` returning string 'online'/'stale'/'offline'
|
||||
// The new defaultClassify() returns { cls: 'health-*' } objects.
|
||||
assert.ok(!/function\s+classify\s*\(\s*lastSeen\s*\)\s*\{[\s\S]*?return\s+'online'/.test(src),
|
||||
'observers.js still has the legacy `classify()` returning string buckets — must be removed');
|
||||
});
|
||||
})();
|
||||
|
||||
console.log('\n=== #1563 B. loadObservers in-flight guard ===');
|
||||
|
||||
(async function runInflightTests() {
|
||||
const ctx = makeSandbox();
|
||||
load(ctx, 'public/roles.js');
|
||||
load(ctx, 'public/app.js');
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
|
||||
ctx.registerPage = (name, page) => { ctx.__page = page; };
|
||||
ctx.debouncedOnWS = () => () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.CLIENT_TTL = { observers: 120000 };
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.TableResponsive = { register: () => {} };
|
||||
ctx.SlideOver = null;
|
||||
ctx.observerSkewSeverity = () => 'ok';
|
||||
ctx.renderSkewBadge = () => '';
|
||||
|
||||
// Controllable api: returns observers["fast"] or observers["slow"]
|
||||
// with deferred resolution per URL. We capture the resolvers so we
|
||||
// can control ordering.
|
||||
const deferred = {};
|
||||
let callIndex = 0;
|
||||
ctx.api = function (url) {
|
||||
callIndex++;
|
||||
if (url === '/observers') {
|
||||
let payload;
|
||||
if (callIndex === 1) payload = 'slow';
|
||||
else if (callIndex === 3) payload = 'fast'; // 3rd api call = 2nd observers fetch
|
||||
else payload = 'other';
|
||||
return new Promise((resolve) => {
|
||||
deferred[payload] = () => resolve({ observers: [{ id: payload, last_seen: new Date().toISOString() }] });
|
||||
});
|
||||
}
|
||||
// /observers/clock-skew etc.
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
load(ctx, 'public/observers.js');
|
||||
|
||||
// We can't easily call the IIFE-private loadObservers directly. Instead,
|
||||
// invoke it via the registered page's init() — which calls loadObservers()
|
||||
// on mount and again via the refresh button. Since init() is heavy, we
|
||||
// assert behavior through observable side effects: window.ObserversSummary
|
||||
// helper + the visible #obsContent innerHTML. But cleaner: drive the
|
||||
// IIFE through the exposed test seam — we expose loadObservers via a
|
||||
// window seam in observers.js. If it doesn't exist, skip the runtime
|
||||
// test but the source-grep test below still asserts the guard exists.
|
||||
await t('source: loadObservers tracks a monotonic request id', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
assert.ok(/_loadObserversReqId|loadObserversReqId/.test(src),
|
||||
'observers.js must track a monotonic request id on loadObservers');
|
||||
assert.ok(/myId\s*!==\s*_loadObserversReqId/.test(src) || /myId\s*!==\s*loadObserversReqId/.test(src),
|
||||
'observers.js must compare per-call id against the latest before applying data');
|
||||
});
|
||||
|
||||
await t('source: stale resolutions return early before assigning observers/_fetchedAt', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
// The guard must appear BEFORE `observers = data.observers` to actually drop stale data.
|
||||
const guardIdx = src.search(/if\s*\(\s*myId\s*!==\s*_loadObserversReqId\s*\)\s*return/);
|
||||
const assignIdx = src.search(/observers\s*=\s*data\.observers/);
|
||||
assert.ok(guardIdx > -1 && assignIdx > -1, 'both guard and assignment must exist');
|
||||
assert.ok(guardIdx < assignIdx, 'guard must come BEFORE observers assignment, else stale data still lands');
|
||||
});
|
||||
|
||||
// Runtime race test: fire two calls back-to-back, resolve slow LAST,
|
||||
// assert _fetchedAt + observers reflect the SECOND (fast) call's data,
|
||||
// not the late-resolving first call.
|
||||
await t('runtime: 2nd loadObservers wins even when 1st resolves later', async () => {
|
||||
// We need a handle on loadObservers. Re-instantiate a fresh sandbox with
|
||||
// a tiny seam injected via post-load eval.
|
||||
const ctx2 = makeSandbox();
|
||||
load(ctx2, 'public/roles.js');
|
||||
load(ctx2, 'public/app.js');
|
||||
ctx2.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
|
||||
ctx2.registerPage = () => {};
|
||||
ctx2.debouncedOnWS = () => () => {};
|
||||
ctx2.offWS = () => {};
|
||||
ctx2.CLIENT_TTL = { observers: 120000 };
|
||||
ctx2.makeColumnsResizable = () => {};
|
||||
ctx2.TableResponsive = { register: () => {} };
|
||||
ctx2.SlideOver = null;
|
||||
ctx2.observerSkewSeverity = () => 'ok';
|
||||
ctx2.renderSkewBadge = () => '';
|
||||
|
||||
const handles = { slow: null, fast: null };
|
||||
let n = 0;
|
||||
ctx2.api = function (url) {
|
||||
if (url === '/observers') {
|
||||
n++;
|
||||
return new Promise((resolve) => {
|
||||
if (n === 1) handles.slow = () => resolve({ observers: [{ id: 'SLOW', last_seen: new Date().toISOString() }] });
|
||||
else if (n === 2) handles.fast = () => resolve({ observers: [{ id: 'FAST', last_seen: new Date().toISOString() }] });
|
||||
else resolve({ observers: [] });
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
// Inject a seam: re-exec observers.js with a trailing line that
|
||||
// exposes loadObservers + the `observers` local + `_fetchedAt` for
|
||||
// inspection. We patch the source on the fly.
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
// Append exposure inside the IIFE by replacing the closing `})();` of
|
||||
// the second IIFE with seam code then re-closing.
|
||||
const seam =
|
||||
"\n window.__test_loadObservers = loadObservers;\n" +
|
||||
" window.__test_getState = function () { return { observers: observers, fetchedAt: _fetchedAt }; };\n" +
|
||||
"})();\n";
|
||||
const lastClose = src.lastIndexOf('})();');
|
||||
const patched = src.slice(0, lastClose) + seam;
|
||||
vm.runInContext(patched, ctx2);
|
||||
|
||||
const load1 = ctx2.window.__test_loadObservers();
|
||||
const load2 = ctx2.window.__test_loadObservers();
|
||||
|
||||
// Resolve in reverse order: fast (2nd call) first, then slow (1st call).
|
||||
handles.fast();
|
||||
await load2;
|
||||
const afterFast = ctx2.window.__test_getState();
|
||||
assert.strictEqual(afterFast.observers[0].id, 'FAST',
|
||||
'after 2nd call resolves, observers should be FAST, got ' + JSON.stringify(afterFast.observers));
|
||||
const fetchedAtAfterFast = afterFast.fetchedAt;
|
||||
|
||||
handles.slow();
|
||||
await load1;
|
||||
const afterSlow = ctx2.window.__test_getState();
|
||||
assert.strictEqual(afterSlow.observers[0].id, 'FAST',
|
||||
'stale SLOW resolve must NOT clobber FAST data, got ' + JSON.stringify(afterSlow.observers));
|
||||
assert.strictEqual(afterSlow.fetchedAt, fetchedAtAfterFast,
|
||||
'_fetchedAt must NOT be updated by stale SLOW resolve (would mislead the "Last updated" pill)');
|
||||
});
|
||||
})().then(() => {
|
||||
console.log('\n' + '='.repeat(40));
|
||||
console.log(' #1563 round-1: ' + passed + ' passed, ' + failed + ' failed');
|
||||
console.log('='.repeat(40));
|
||||
if (failed > 0) process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user