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);
+})();