diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b3ef229..f14c6aba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/public/observers.js b/public/observers.js index 059d33f1..7c19f35f 100644 --- a/public/observers.js +++ b/public/observers.js @@ -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 = '' + + 'Last updated: ' + timeAgo(iso) + ''; + } + return '' + + '
' + + '\u25CF ' + c.online + ' Online' + + '\u25B2 ' + c.stale + ' Stale' + + '\u2715 ' + c.offline + ' Offline' + + '\uD83D\uDCE1 ' + c.total + ' Total' + + updatedHtml + + '
'; + } + + 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 = ``; } @@ -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 = ` -
- ${online} Online - ${stale} Stale - ${offline} Offline - 📡 ${filtered.length} Total -
+ ${summaryHtml}
diff --git a/public/style.css b/public/style.css index 9b0ce228..76968f8e 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/test-issue-1562-observers-summary.js b/test-issue-1562-observers-summary.js new file mode 100644 index 00000000..adf74ff0 --- /dev/null +++ b/test-issue-1562-observers-summary.js @@ -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); diff --git a/test-issue-1563-aggregate-and-inflight.js b/test-issue-1563-aggregate-and-inflight.js new file mode 100644 index 00000000..40630ad7 --- /dev/null +++ b/test-issue-1563-aggregate-and-inflight.js @@ -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); +});
Observer status and statistics