From 6aa5146b93aac53e1c8331dfd2c7a8ad34d41991 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 12 Jun 2026 11:38:35 -0700 Subject: [PATCH] fix(#1660): FE warm-up banner reads X-Corescope-Load-Status + polls /api/healthz (#1683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``. 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..lastReceiptUnix` older than 5 min → "No packets from `` 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 `
` with a `
    ` of ≤3 `
  • `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 Co-authored-by: corescope-bot --- .github/workflows/deploy.yml | 1 + public/index.html | 1 + public/style.css | 40 +++++ public/warmup-banner.js | 252 ++++++++++++++++++++++++++++++ test-warmup-banner.js | 294 +++++++++++++++++++++++++++++++++++ 5 files changed, 588 insertions(+) create mode 100644 public/warmup-banner.js create mode 100644 test-warmup-banner.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99134290..0c648fc9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/public/index.html b/public/index.html index 1a1f914f..99f413cc 100644 --- a/public/index.html +++ b/public/index.html @@ -173,6 +173,7 @@ + diff --git a/public/style.css b/public/style.css index 82507244..092f2cef 100644 --- a/public/style.css +++ b/public/style.css @@ -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; +} diff --git a/public/warmup-banner.js b/public/warmup-banner.js new file mode 100644 index 00000000..0a1b962f --- /dev/null +++ b/public/warmup-banner.js @@ -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, '>'); + html += '
  • ' + safe + '
  • '; + } + 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; + } +})(); diff --git a/test-warmup-banner.js b/test-warmup-banner.js new file mode 100644 index 00000000..e1fd2f5f --- /dev/null +++ b/test-warmup-banner.js @@ -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); +})();