mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-29 08:39:23 +00:00
## Summary Partial fix for #1660 — adds an FE-only global warm-up banner that surfaces server-side load state to users instead of letting "data may be incomplete" look like silent breakage. Implements sub-deliverables **(1)** and **(3)** from the triage. Sub-deliverable (2) (per-card "recomputing" pill) is deferred — it depends on a new server-side `recomputer.first_pass_done` flag that pairs with #1659. ## What it does - New `public/warmup-banner.js` mounts a sticky `role="status"` live region at the top of `<body>`. Pure helper `getWarmupMessages()` is fully unit-tested in isolation. - Consumes both signals the server already exposes: - `X-Corescope-Load-Status` response header (set by `cmd/server/chunked_load.go:446` on every API response) — captured via a thin `window.fetch` wrapper. - `GET /api/healthz` — polled every 30s while not in steady-state, torn down once `ready=true` AND `from_pubkey_backfill.done=true`. - Messages per acceptance criteria: - `loading` → "⏳ Loading historical data — counts may be incomplete." - `from_pubkey_backfill.done=false` → "Backfilling pubkey index: 12,400 / 87,500 (14%)" - `ingest_liveness.<src>.lastReceiptUnix` older than 5 min → "No packets from `<src>` in N min." - Banner fades out (opacity + max-height transition) once steady-state is reached. ## Files - `public/warmup-banner.js` — new module (pure helpers + DOM mount + poll + fetch interceptor). - `public/style.css` — `.warmup-banner` rules; all colors via existing `--warn-bg` / `--warn-text` / `--warning` CSS variables (customizer-safe, no inline hexes). - `public/index.html` — loads `warmup-banner.js` immediately before `app.js` so the fetch wrapper is installed before other modules issue requests. - `test-warmup-banner.js` — 8 tests: 6 pure-helper + 2 vm-DOM E2E that stub `/api/healthz` returning `ready:false` → asserts banner visible, then flips to `ready:true` → asserts the `warmup-banner--hidden` class is applied (sub-deliverable 3). ## TDD red → green - **Red:** `ca5f9837` — `test(#1660): RED — failing tests for warmup banner message derivation` — stub `getWarmupMessages` returns `[]`; CI fails on 3 assertion failures (compiles cleanly, fails on `assert.ok(msgs.length >= 1)` etc — not on import/build). - **Green:** `0d07efdf` — `feat(#1660): GREEN — warmup banner reads X-Corescope-Load-Status + polls /api/healthz` — implementation lands; all 8 tests pass. ## Test output ``` warmup-banner.js (#1660): ✅ exports getWarmupMessages and shouldShowBanner ✅ loading header alone produces a "historical data" message ✅ from_pubkey_backfill.done=false produces a progress message with pct ✅ stale ingest source >5min produces a "No packets from" message ✅ steady-state ready=true + backfill done + fresh ingest → no banner ✅ isSteadyState reflects ready+backfill predicate ✅ E2E: stub /api/healthz ready=false → banner visible ✅ E2E: flip /api/healthz to ready=true → banner fades (hidden class) passed=8 failed=0 ``` ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — **clean** (PII / branch scope / red commit / CSS-var defined / CSS self-fallback / LIKE-on-JSON / sync migration / async-migration gate / XSS sinks all PASS, no warnings). ## Performance - Poll runs every 30s and only while `ready=false || from_pubkey_backfill.done=false`. Stops immediately on steady state. No hot-path impact. - Fetch wrapper adds one `.then()` per response to read a single header — O(1). - Banner DOM is one `<div>` with a `<ul>` of ≤3 `<li>`s. Re-render is a single innerHTML set. ## Out of scope (explicit) - Sub-deliverable (2) — per-card "↻ Recomputing…" pill. Requires a new `recomputer.first_pass_done` field on `/api/healthz` (small `cmd/server/analytics_recomputer.go` addition) and is grouped with the #1659 recomputer redesign. Not in this PR. - No backend code changed. Partial fix for #1660. --------- Co-authored-by: Kpa-clawbot <bot@kpa-clawbot> Co-authored-by: corescope-bot <bot@corescope.local>
This commit is contained in:
co-authored by
Kpa-clawbot
corescope-bot
parent
efd66ea3f5
commit
6aa5146b93
@@ -142,6 +142,7 @@ jobs:
|
||||
node test-issue-1648-m4-emoji-scan.js
|
||||
node test-issue-1668-m3-typography.js
|
||||
node test-mqtt-status-panel.js
|
||||
node test-warmup-banner.js
|
||||
|
||||
- name: 🛡️ Preflight XSS gate — actual --diff check (PR only)
|
||||
# The fixture self-test above (test-preflight-xss-gate.js) only
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
<script src="area-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="warmup-banner.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="bottom-nav.js?v=__BUST__"></script>
|
||||
<script src="nav-drawer.js?v=__BUST__"></script>
|
||||
|
||||
@@ -5319,3 +5319,43 @@ body.embed #app.app-fixed {
|
||||
min-width: 1.6em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* warmup-banner.js (#1660) — global server warm-up status pill.
|
||||
* a11y: role="status" live region in JS; CSS makes it visible.
|
||||
* All colors via CSS variables — customizer-safe.
|
||||
* ============================================================ */
|
||||
.warmup-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9000;
|
||||
background: var(--warn-bg, #fef3c7);
|
||||
color: var(--warn-text, #92400e);
|
||||
border-bottom: 1px solid var(--warning, #eab308);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
transition: opacity 0.4s ease, max-height 0.4s ease;
|
||||
opacity: 1;
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.warmup-banner--hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
border-bottom-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.warmup-banner__inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
.warmup-banner__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.warmup-banner__item {
|
||||
margin: 0;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* warmup-banner.js — global top-banner / status pill surfacing server warm-up state.
|
||||
*
|
||||
* Issue: #1660. FE-only sub-deliverables (1)+(3); per-card pill (2) is deferred
|
||||
* pending the recomputer.first_pass_done server flag (#1659).
|
||||
*
|
||||
* Consumes:
|
||||
* - X-Corescope-Load-Status response header ("loading" | "ready"), set by
|
||||
* cmd/server/chunked_load.go on every response.
|
||||
* - GET /api/healthz, polled every 30s while not in steady-state.
|
||||
*
|
||||
* Renders a role="status" live region so screen readers announce transitions.
|
||||
* Auto-dismisses (fades out) once ready=true AND from_pubkey_backfill.done=true.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STALE_INGEST_MS = 5 * 60 * 1000; // 5 min — matches acceptance criteria
|
||||
var POLL_INTERVAL_MS = 30 * 1000; // 30s while warming up
|
||||
|
||||
// -------- Pure helpers (testable in isolation) ----------------------------
|
||||
|
||||
function fmtNum(n) {
|
||||
try { return Number(n).toLocaleString('en-US'); }
|
||||
catch (e) { return String(n); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ordered list of human-readable warm-up messages from current state.
|
||||
*
|
||||
* @param {object|null} healthz - parsed /api/healthz body, or null when unknown
|
||||
* @param {string|null} loadStatus - last seen X-Corescope-Load-Status header value
|
||||
* @param {number} nowMs - current time in ms (injectable for tests)
|
||||
* @returns {string[]} ordered list of messages; empty when steady-state.
|
||||
*/
|
||||
function getWarmupMessages(healthz, loadStatus, nowMs) {
|
||||
var msgs = [];
|
||||
var h = healthz || {};
|
||||
var backfill = h.from_pubkey_backfill || null;
|
||||
|
||||
var loading = loadStatus === 'loading' || h.ready === false;
|
||||
if (loading) {
|
||||
msgs.push('\u23F3 Loading historical data \u2014 counts may be incomplete.');
|
||||
}
|
||||
|
||||
if (backfill && backfill.done === false) {
|
||||
var processed = Number(backfill.processed) || 0;
|
||||
var total = Number(backfill.total) || 0;
|
||||
// Clamp pct: total=0 → 0%; processed>total (race) → 100%.
|
||||
// Never NaN, never >100%.
|
||||
var rawPct = total > 0 ? Math.floor((processed / total) * 100) : 0;
|
||||
var pct = Math.max(0, Math.min(100, rawPct));
|
||||
msgs.push('Backfilling pubkey index: ' + fmtNum(processed) +
|
||||
' / ' + fmtNum(total) + ' (' + pct + '%)');
|
||||
}
|
||||
|
||||
var liveness = h.ingest_liveness || {};
|
||||
var srcs = Object.keys(liveness).sort();
|
||||
for (var i = 0; i < srcs.length; i++) {
|
||||
var src = srcs[i];
|
||||
var info = liveness[src] || {};
|
||||
var lastUnix = Number(info.lastReceiptUnix);
|
||||
if (!lastUnix || !isFinite(lastUnix)) continue;
|
||||
var ageMs = nowMs - lastUnix * 1000;
|
||||
if (ageMs > STALE_INGEST_MS) {
|
||||
var ageMin = Math.floor(ageMs / 60000);
|
||||
msgs.push('No packets from ' + src + ' in ' + ageMin + ' min.');
|
||||
}
|
||||
}
|
||||
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function shouldShowBanner(healthz, loadStatus, nowMs) {
|
||||
return getWarmupMessages(healthz, loadStatus, nowMs).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steady-state predicate: ready=true AND from_pubkey_backfill.done=true.
|
||||
* Once true, banner is dismissed and polling is torn down.
|
||||
*/
|
||||
function isSteadyState(healthz) {
|
||||
if (!healthz) return false;
|
||||
if (healthz.ready !== true) return false;
|
||||
var bf = healthz.from_pubkey_backfill;
|
||||
if (bf && bf.done === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------- Browser integration (mount + poll + fetch intercept) ------------
|
||||
|
||||
var state = {
|
||||
healthz: null,
|
||||
loadStatus: null,
|
||||
el: null,
|
||||
listEl: null,
|
||||
timer: null,
|
||||
inflight: false,
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
function ensureMounted() {
|
||||
if (state.mounted || typeof document === 'undefined') return;
|
||||
var body = document.body;
|
||||
if (!body) return;
|
||||
var el = document.createElement('div');
|
||||
el.id = 'warmup-banner';
|
||||
el.setAttribute('role', 'status');
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
el.setAttribute('aria-atomic', 'true');
|
||||
el.className = 'warmup-banner warmup-banner--hidden';
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'warmup-banner__inner';
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'warmup-banner__list';
|
||||
inner.appendChild(list);
|
||||
el.appendChild(inner);
|
||||
body.insertBefore(el, body.firstChild);
|
||||
state.el = el;
|
||||
state.listEl = list;
|
||||
state.mounted = true;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.mounted) ensureMounted();
|
||||
if (!state.el || !state.listEl) return;
|
||||
var now = Date.now();
|
||||
var msgs = getWarmupMessages(state.healthz, state.loadStatus, now);
|
||||
if (msgs.length === 0) {
|
||||
// Fade out.
|
||||
state.el.classList.add('warmup-banner--hidden');
|
||||
state.listEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
// Diff-render: rebuild list (always small — <=3 items).
|
||||
var html = '';
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
var safe = String(msgs[i])
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
html += '<li class="warmup-banner__item">' + safe + '</li>';
|
||||
}
|
||||
state.listEl.innerHTML = html;
|
||||
state.el.classList.remove('warmup-banner--hidden');
|
||||
}
|
||||
|
||||
function noteLoadStatus(value) {
|
||||
if (!value) return;
|
||||
if (value !== 'loading' && value !== 'ready') return;
|
||||
if (state.loadStatus === value) return;
|
||||
state.loadStatus = value;
|
||||
render();
|
||||
// First time we observe "loading" — make sure polling is on.
|
||||
if (value === 'loading') startPolling();
|
||||
}
|
||||
|
||||
function pollOnce() {
|
||||
if (state.inflight || typeof fetch !== 'function') return;
|
||||
state.inflight = true;
|
||||
fetch('/api/healthz', { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(function (resp) {
|
||||
if (resp && resp.headers && resp.headers.get) {
|
||||
noteLoadStatus(resp.headers.get('X-Corescope-Load-Status'));
|
||||
}
|
||||
if (!resp || !resp.ok) return null;
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (body) {
|
||||
if (body) state.healthz = body;
|
||||
render();
|
||||
if (isSteadyState(state.healthz) && state.loadStatus !== 'loading') {
|
||||
stopPolling();
|
||||
}
|
||||
})
|
||||
.catch(function () { /* swallow — banner is best-effort */ })
|
||||
.then(function () { state.inflight = false; });
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (state.timer) return;
|
||||
pollOnce();
|
||||
state.timer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function installFetchInterceptor() {
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
||||
// Double-install guard: check both the module flag AND a marker stamped
|
||||
// onto the wrapper itself. Prevents nested wrap if window.fetch was
|
||||
// reassigned externally between installs.
|
||||
if (window.__warmupBannerFetchPatched && window.fetch.__warmupWrapped) return;
|
||||
if (window.fetch.__warmupWrapped) { window.__warmupBannerFetchPatched = true; return; }
|
||||
var orig = window.fetch.bind(window);
|
||||
var wrapped = function () {
|
||||
var p = orig.apply(null, arguments);
|
||||
try {
|
||||
p.then(function (resp) {
|
||||
if (resp && resp.headers && resp.headers.get) {
|
||||
noteLoadStatus(resp.headers.get('X-Corescope-Load-Status'));
|
||||
}
|
||||
}, function () {});
|
||||
} catch (e) { /* ignore */ }
|
||||
return p;
|
||||
};
|
||||
wrapped.__warmupWrapped = true;
|
||||
window.fetch = wrapped;
|
||||
window.__warmupBannerFetchPatched = true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (typeof document === 'undefined') return;
|
||||
ensureMounted();
|
||||
installFetchInterceptor();
|
||||
// Kick off an immediate health check so the banner appears within ~2s
|
||||
// of first paint (acceptance criterion).
|
||||
startPolling();
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Exports ---------------------------------------------------------
|
||||
|
||||
var api = {
|
||||
getWarmupMessages: getWarmupMessages,
|
||||
shouldShowBanner: shouldShowBanner,
|
||||
isSteadyState: isSteadyState,
|
||||
STALE_INGEST_MS: STALE_INGEST_MS,
|
||||
POLL_INTERVAL_MS: POLL_INTERVAL_MS,
|
||||
// Test hooks
|
||||
_state: state,
|
||||
_pollOnce: pollOnce,
|
||||
_installFetchInterceptor: installFetchInterceptor,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__warmupBanner = api;
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,294 @@
|
||||
/* Unit + integration tests for warmup-banner.js — issue #1660
|
||||
*
|
||||
* Sub-deliverable (3) E2E: stub /api/healthz returning ready:false → assert
|
||||
* banner visible; flip to ready:true → assert banner fades.
|
||||
*
|
||||
* Uses a vm sandbox with a minimal DOM and a stubbed fetch — same pattern as
|
||||
* test-frontend-helpers.js. No Playwright needed for the FE-only contract.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(' \u2705 ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(' \u274C ' + name + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPureModule() {
|
||||
const ctx = { window: {}, module: { exports: {} }, console, Date };
|
||||
vm.createContext(ctx);
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'warmup-banner.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
return ctx.window.__warmupBanner || ctx.module.exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sandbox with a minimal DOM and a stubbable fetch.
|
||||
*/
|
||||
function bootDomSandbox(initialHealthz, initialHeader) {
|
||||
let currentHealthz = initialHealthz;
|
||||
let currentHeader = initialHeader;
|
||||
const nodes = [];
|
||||
function makeNode(tag) {
|
||||
const node = {
|
||||
tagName: tag, id: '', className: '', attrs: {}, children: [], _innerHTML: '',
|
||||
get innerHTML() { return this._innerHTML; },
|
||||
set innerHTML(v) { this._innerHTML = String(v); },
|
||||
classList: {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c); },
|
||||
remove(c) { this._set.delete(c); },
|
||||
contains(c) { return this._set.has(c); },
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
insertBefore(c) { this.children.unshift(c); return c; },
|
||||
get firstChild() { return this.children[0] || null; },
|
||||
};
|
||||
nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
const body = makeNode('body');
|
||||
const doc = {
|
||||
readyState: 'complete', body: body,
|
||||
createElement: (tag) => makeNode(tag),
|
||||
addEventListener: () => {},
|
||||
getElementById: (id) => nodes.find(n => n.id === id) || null,
|
||||
};
|
||||
function makeFetch() {
|
||||
return function () {
|
||||
const headers = {
|
||||
get(name) {
|
||||
if (String(name).toLowerCase() === 'x-corescope-load-status') return currentHeader;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return Promise.resolve({
|
||||
ok: true, headers, json: () => Promise.resolve(currentHealthz),
|
||||
});
|
||||
};
|
||||
}
|
||||
const ctx = {
|
||||
window: {}, document: doc, console, Date, Math, Number, Object, String, JSON, isFinite,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
setInterval: () => 1, clearInterval: () => {},
|
||||
fetch: makeFetch(), Promise,
|
||||
module: { exports: {} },
|
||||
};
|
||||
ctx.window.fetch = ctx.fetch;
|
||||
vm.createContext(ctx);
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'warmup-banner.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
const sapi = ctx.window.__warmupBanner;
|
||||
return {
|
||||
api: sapi, body, nodes,
|
||||
setHealthz(h, header) {
|
||||
currentHealthz = h;
|
||||
if (header !== undefined) currentHeader = header;
|
||||
},
|
||||
async runPoll() {
|
||||
sapi._pollOnce();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
},
|
||||
getBanner() { return nodes.find(n => n.id === 'warmup-banner') || null; },
|
||||
};
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
console.log('warmup-banner.js (#1660):');
|
||||
|
||||
const api = loadPureModule();
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
await test('exports getWarmupMessages and shouldShowBanner', () => {
|
||||
assert.strictEqual(typeof api.getWarmupMessages, 'function');
|
||||
assert.strictEqual(typeof api.shouldShowBanner, 'function');
|
||||
});
|
||||
|
||||
await test('loading header alone produces a "historical data" message', () => {
|
||||
const msgs = api.getWarmupMessages(null, 'loading', NOW);
|
||||
assert.ok(msgs.length >= 1, 'expected at least one message, got 0');
|
||||
assert.ok(msgs.some(m => /historical data/i.test(m)),
|
||||
'expected "historical data" message, got: ' + JSON.stringify(msgs));
|
||||
});
|
||||
|
||||
await test('from_pubkey_backfill.done=false produces a progress message with pct', () => {
|
||||
const h = { ready: true,
|
||||
from_pubkey_backfill: { done: false, processed: 12400, total: 87500 },
|
||||
ingest_liveness: {} };
|
||||
const msgs = api.getWarmupMessages(h, 'ready', NOW);
|
||||
assert.ok(msgs.some(m => /Backfilling pubkey index/i.test(m)),
|
||||
'expected backfill message, got: ' + JSON.stringify(msgs));
|
||||
assert.ok(msgs.some(m => m.includes('14%')),
|
||||
'expected "14%", got: ' + JSON.stringify(msgs));
|
||||
});
|
||||
|
||||
await test('stale ingest source >5min produces a "No packets from" message', () => {
|
||||
const h = { ready: true,
|
||||
from_pubkey_backfill: { done: true, processed: 1, total: 1 },
|
||||
ingest_liveness: {
|
||||
'mqtt-eu': { lastReceiptUnix: Math.floor((NOW - 6 * 60 * 1000) / 1000) } } };
|
||||
const msgs = api.getWarmupMessages(h, 'ready', NOW);
|
||||
assert.ok(msgs.some(m => /No packets from mqtt-eu/.test(m)),
|
||||
'expected stale-ingest message, got: ' + JSON.stringify(msgs));
|
||||
});
|
||||
|
||||
await test('steady-state ready=true + backfill done + fresh ingest → no banner', () => {
|
||||
const h = { ready: true,
|
||||
from_pubkey_backfill: { done: true, processed: 1, total: 1 },
|
||||
ingest_liveness: {
|
||||
'mqtt-eu': { lastReceiptUnix: Math.floor((NOW - 30 * 1000) / 1000) } } };
|
||||
const msgs = api.getWarmupMessages(h, 'ready', NOW);
|
||||
assert.strictEqual(msgs.length, 0,
|
||||
'expected no messages, got: ' + JSON.stringify(msgs));
|
||||
assert.strictEqual(api.shouldShowBanner(h, 'ready', NOW), false);
|
||||
});
|
||||
|
||||
await test('isSteadyState reflects ready+backfill predicate', () => {
|
||||
assert.strictEqual(api.isSteadyState(null), false);
|
||||
assert.strictEqual(api.isSteadyState({ ready: false }), false);
|
||||
assert.strictEqual(api.isSteadyState({ ready: true,
|
||||
from_pubkey_backfill: { done: false } }), false);
|
||||
assert.strictEqual(api.isSteadyState({ ready: true,
|
||||
from_pubkey_backfill: { done: true } }), true);
|
||||
assert.strictEqual(api.isSteadyState({ ready: true }), true);
|
||||
});
|
||||
|
||||
await test('E2E: stub /api/healthz ready=false → banner visible', async () => {
|
||||
const env = bootDomSandbox({ ready: false,
|
||||
from_pubkey_backfill: { done: false, processed: 100, total: 1000 },
|
||||
ingest_liveness: {} }, 'loading');
|
||||
await env.runPoll();
|
||||
const banner = env.getBanner();
|
||||
assert.ok(banner, 'expected #warmup-banner to be mounted');
|
||||
assert.strictEqual(banner.getAttribute('role'), 'status',
|
||||
'banner must be role="status" live region (a11y requirement)');
|
||||
assert.strictEqual(banner.classList.contains('warmup-banner--hidden'), false,
|
||||
'banner should be visible when ready=false');
|
||||
const hasText = banner.children.some(c =>
|
||||
c.children && c.children.some(li => /Loading|Backfilling/i.test(li.innerHTML || '')) ||
|
||||
/Loading|Backfilling/i.test(c.innerHTML || ''));
|
||||
assert.ok(hasText, 'banner should contain warm-up text');
|
||||
});
|
||||
|
||||
await test('E2E: flip /api/healthz to ready=true → banner fades (hidden class)', async () => {
|
||||
const env = bootDomSandbox({ ready: false,
|
||||
from_pubkey_backfill: { done: false, processed: 100, total: 1000 },
|
||||
ingest_liveness: {} }, 'loading');
|
||||
await env.runPoll();
|
||||
env.setHealthz({ ready: true,
|
||||
from_pubkey_backfill: { done: true, processed: 1000, total: 1000 },
|
||||
ingest_liveness: {} }, 'ready');
|
||||
await env.runPoll();
|
||||
const banner = env.getBanner();
|
||||
assert.ok(banner, 'expected #warmup-banner to remain in DOM');
|
||||
assert.strictEqual(banner.classList.contains('warmup-banner--hidden'), true,
|
||||
'banner should be hidden after steady state');
|
||||
});
|
||||
|
||||
// -------- kb #3: staleness boundary (> vs >=) ---------------------------
|
||||
await test('staleness boundary: age == STALE_INGEST_MS is NOT stale (uses >)', () => {
|
||||
const STALE = api.STALE_INGEST_MS;
|
||||
const h = { ready: true,
|
||||
from_pubkey_backfill: { done: true, processed: 1, total: 1 },
|
||||
ingest_liveness: {
|
||||
'mqtt-eu': { lastReceiptUnix: (NOW - STALE) / 1000 } } };
|
||||
const msgs = api.getWarmupMessages(h, 'ready', NOW);
|
||||
assert.ok(!msgs.some(m => /No packets from mqtt-eu/.test(m)),
|
||||
'at exactly STALE_INGEST_MS the source must NOT be reported stale; got: ' + JSON.stringify(msgs));
|
||||
});
|
||||
|
||||
await test('staleness boundary: age == STALE_INGEST_MS + 1ms IS stale', () => {
|
||||
const STALE = api.STALE_INGEST_MS;
|
||||
const h = { ready: true,
|
||||
from_pubkey_backfill: { done: true, processed: 1, total: 1 },
|
||||
ingest_liveness: {
|
||||
'mqtt-eu': { lastReceiptUnix: (NOW - (STALE + 1)) / 1000 } } };
|
||||
const msgs = api.getWarmupMessages(h, 'ready', NOW);
|
||||
assert.ok(msgs.some(m => /No packets from mqtt-eu/.test(m)),
|
||||
'at STALE_INGEST_MS+1ms the source must be reported stale; got: ' + JSON.stringify(msgs));
|
||||
});
|
||||
|
||||
// -------- kb #4: backfill formatter edge cases --------------------------
|
||||
await test('backfill total=0 → no NaN%, no divide-by-zero (pct=0)', () => {
|
||||
const h = { ready: false,
|
||||
from_pubkey_backfill: { done: false, processed: 0, total: 0 },
|
||||
ingest_liveness: {} };
|
||||
const msgs = api.getWarmupMessages(h, 'loading', NOW);
|
||||
const backfillMsg = msgs.find(m => /Backfilling pubkey index/.test(m));
|
||||
assert.ok(backfillMsg, 'expected backfill message present, got: ' + JSON.stringify(msgs));
|
||||
assert.ok(!/NaN/.test(backfillMsg),
|
||||
'backfill msg must not contain NaN; got: ' + backfillMsg);
|
||||
assert.ok(/\(0%\)/.test(backfillMsg),
|
||||
'total=0 must render as 0%; got: ' + backfillMsg);
|
||||
});
|
||||
|
||||
await test('backfill processed>total (race) → clamps at 100%, no overshoot', () => {
|
||||
const h = { ready: false,
|
||||
from_pubkey_backfill: { done: false, processed: 1500, total: 1000 },
|
||||
ingest_liveness: {} };
|
||||
const msgs = api.getWarmupMessages(h, 'loading', NOW);
|
||||
const backfillMsg = msgs.find(m => /Backfilling pubkey index/.test(m));
|
||||
assert.ok(backfillMsg, 'expected backfill message');
|
||||
assert.ok(/\(100%\)/.test(backfillMsg),
|
||||
'processed>total must clamp at 100%; got: ' + backfillMsg);
|
||||
assert.ok(!/15\d%|1\d\d%(?!00)/.test(backfillMsg.replace('100%', '')),
|
||||
'no >100% should appear; got: ' + backfillMsg);
|
||||
});
|
||||
|
||||
// -------- kb #5: fetch-wrapper double-install ---------------------------
|
||||
await test('fetch interceptor: double-install does not nest wrappers', () => {
|
||||
let callCount = 0;
|
||||
const baseFetch = function () {
|
||||
callCount++;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({ ready: true }),
|
||||
});
|
||||
};
|
||||
const ctx = {
|
||||
window: {}, document: undefined, console, Date, Math, Number, Object, String, JSON, isFinite,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
setInterval: () => 1, clearInterval: () => {},
|
||||
Promise, module: { exports: {} },
|
||||
};
|
||||
ctx.window.fetch = baseFetch;
|
||||
vm.createContext(ctx);
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'warmup-banner.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
const a = ctx.window.__warmupBanner;
|
||||
// First install
|
||||
a._installFetchInterceptor.call(ctx);
|
||||
const f1 = ctx.window.fetch;
|
||||
assert.strictEqual(f1.__warmupWrapped, true, 'wrapper must mark window.fetch.__warmupWrapped');
|
||||
// Second install — must be a no-op
|
||||
a._installFetchInterceptor.call(ctx);
|
||||
const f2 = ctx.window.fetch;
|
||||
assert.strictEqual(f2, f1, 'second install must not replace the wrapper (no nesting)');
|
||||
// Verify underlying call chain didn't grow: one call should invoke baseFetch exactly once
|
||||
return f2().then(() => {
|
||||
assert.strictEqual(callCount, 1,
|
||||
'one fetch() must invoke baseFetch exactly once (nested wrap would call >1 or 0)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
console.log('');
|
||||
console.log('passed=' + passed + ' failed=' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})();
|
||||
Reference in New Issue
Block a user