diff --git a/public/app.js b/public/app.js
index 498aed3c9..3a9b8e9a8 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1059,7 +1059,12 @@ function connectWS() {
ws = sock;
wsLastFrameAt = Date.now(); // from creation: a stuck handshake is caught too
wsWatchdogTimer = setTimeout(checkWSLiveness, WS_STALE_MS);
- sock.onopen = () => Logo.setConnected(true);
+ sock.onopen = () => {
+ Logo.setConnected(true);
+ // A deploy restarts the server, so a reconnect is a cheap moment to
+ // check for a new version (#614, see version-notice.js).
+ window.dispatchEvent(new Event('corescope:ws-open'));
+ };
sock.onclose = () => {
if (ws !== sock) return;
clearTimeout(wsWatchdogTimer);
diff --git a/public/index.html b/public/index.html
index 9218427c4..1b444045f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -197,6 +197,7 @@
+
diff --git a/public/nav-drawer.js b/public/nav-drawer.js
index 59017b509..b0ce5afdd 100644
--- a/public/nav-drawer.js
+++ b/public/nav-drawer.js
@@ -94,10 +94,10 @@
// ── Version footer (#111) ───────────────────────────────────────────────
// GET /api/health reports {version, commit, buildTime}; the server fills
- // any it cannot resolve with "unknown". Fetched on the first open that
- // passes the width gate (never at page load: the drawer may never open,
- // and cannot at <= NARROW_MAX), then cached for the page lifetime --
- // failures included -- so re-opening adds no requests. Values are written
+ // any it cannot resolve with "unknown". The reading is the shared
+ // page-load one (readHealthOnce, also the baseline for version-notice.js,
+ // #614), cached for the page lifetime -- failures included -- so opening
+ // or re-opening the drawer adds no requests. Values are written
// with textContent / title only.
var RELEASES_URL = 'https://github.com/dborup/CoreScope/releases';
var versionRequested = false;
@@ -121,13 +121,30 @@
if (bits.length) el.title = bits.join(' \u00B7 ');
}
+ // The one /api/health reader. Resolves to the parsed body, or null for a
+ // network error, non-OK (5xx during a restart) or invalid JSON response.
+ // Shared with version-notice.js (#614) as window.CoreHealth.
+ function readHealth() {
+ if (typeof fetch !== 'function') return Promise.resolve(null);
+ return fetch('/api/health', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r && r.ok ? r.json() : null; })
+ .then(function (h) { return h || null; }, function () { return null; });
+ }
+ // The first reading is shared: version-notice.js reads it at page load as
+ // its baseline and the drawer footer reuses it, so opening the drawer adds
+ // no request. The result, failure included, is cached for the page lifetime.
+ var firstHealth = null;
+ function readHealthOnce() {
+ if (!firstHealth) firstHealth = readHealth();
+ return firstHealth;
+ }
+ window.CoreHealth = { read: readHealth, readOnce: readHealthOnce, field: healthField };
+
function requestVersion() {
if (versionRequested || !versionEl || typeof fetch !== 'function') return;
versionRequested = true;
var el = versionEl;
- fetch('/api/health', { headers: { Accept: 'application/json' } })
- .then(function (r) { return r && r.ok ? r.json() : null; })
- .then(function (h) { applyVersion(el, h); }, function () { /* neutral label */ });
+ readHealthOnce().then(function (h) { applyVersion(el, h); });
}
var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone)
diff --git a/public/style.css b/public/style.css
index 48929283d..fd4ddbccc 100644
--- a/public/style.css
+++ b/public/style.css
@@ -6042,3 +6042,45 @@ body.embed #app.app-fixed {
.regions-controls label { flex: 1 1 140px; }
.regions-controls select { min-width: 0; flex: 1; }
}
+
+/* #614 — "new version available" notice (version-notice.js) */
+.version-notice {
+ position: fixed;
+ left: 50%;
+ bottom: calc(16px + var(--bottom-nav-reserve, 0px));
+ transform: translateX(-50%);
+ z-index: var(--z-popover, 300);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 8px 12px;
+ box-sizing: border-box;
+ max-width: calc(100vw - 32px);
+ padding: 10px 14px;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px var(--shadow);
+ font-size: 14px;
+}
+.version-notice-btn {
+ min-height: 32px;
+ padding: 4px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ cursor: pointer;
+}
+.version-notice-reload {
+ background: var(--accent-strong);
+ border-color: var(--accent-strong);
+ color: var(--text-on-accent);
+}
+.version-notice-btn:focus-visible {
+ outline: 2px solid var(--accent-strong);
+ outline-offset: 2px;
+}
diff --git a/public/version-notice.js b/public/version-notice.js
new file mode 100644
index 000000000..3be66eac9
--- /dev/null
+++ b/public/version-notice.js
@@ -0,0 +1,109 @@
+/* version-notice.js — "new version available" notice (#614).
+ *
+ * Remembers the server commit seen at page load (via the shared
+ * window.CoreHealth reader from nav-drawer.js) and shows a non-blocking,
+ * dismissible banner when a later reading reports a different commit. It
+ * never reloads by itself, and shows once per new commit.
+ *
+ * Checks piggyback on events that already happen: the tab becoming visible,
+ * the shared WebSocket (re)opening (app.js dispatches `corescope:ws-open`),
+ * and a slow interval for tabs that stay visible. All are throttled.
+ * Failed or commit-less health readings (restart window) are ignored.
+ *
+ * Intervals are hardcoded for now; candidates for the customizer later.
+ */
+'use strict';
+
+(function () {
+ var MIN_GAP_MS = 30 * 1000; // floor between any two checks
+ var INTERVAL_MS = 5 * 60 * 1000; // periodic check for visible tabs
+
+ function initialState() { return { baseline: null, notified: null }; }
+
+ // Pure. Returns {notify, commit, state}; never mutates `state`.
+ function decide(state, health) {
+ var commit = health && typeof health.commit === 'string' ? health.commit.trim() : '';
+ if (!commit || commit.toLowerCase() === 'unknown') {
+ return { notify: false, commit: '', state: state };
+ }
+ if (state.baseline === null) {
+ return { notify: false, commit: commit, state: { baseline: commit, notified: state.notified } };
+ }
+ if (commit === state.baseline || commit === state.notified) {
+ return { notify: false, commit: commit, state: state };
+ }
+ return { notify: true, commit: commit, state: { baseline: state.baseline, notified: commit } };
+ }
+
+ function shouldCheck(now, last, minGap) {
+ return !(last > 0) || now - last >= minGap;
+ }
+
+ function buildBanner(onReload, onDismiss) {
+ var el = document.createElement('div');
+ el.className = 'version-notice';
+ el.setAttribute('role', 'status');
+ el.setAttribute('data-version-notice', '');
+ var msg = document.createElement('span');
+ msg.className = 'version-notice-text';
+ msg.textContent = 'New version available – reload to update.';
+ var reload = document.createElement('button');
+ reload.type = 'button';
+ reload.className = 'version-notice-btn version-notice-reload';
+ reload.setAttribute('data-version-notice-reload', '');
+ reload.textContent = 'Reload';
+ reload.addEventListener('click', onReload);
+ var close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'version-notice-btn version-notice-dismiss';
+ close.setAttribute('data-version-notice-dismiss', '');
+ close.setAttribute('aria-label', 'Dismiss');
+ close.textContent = 'Dismiss';
+ close.addEventListener('click', onDismiss);
+ el.appendChild(msg);
+ el.appendChild(reload);
+ el.appendChild(close);
+ return el;
+ }
+
+ function start() {
+ var reader = window.CoreHealth;
+ if (!reader || typeof reader.read !== 'function') return;
+ var state = initialState();
+ var lastCheck = 0;
+ var banner = null;
+
+ function hide() {
+ if (banner) { banner.remove(); banner = null; }
+ }
+ function show() {
+ if (banner) return;
+ banner = buildBanner(function () { window.location.reload(); },
+ hide);
+ document.body.appendChild(banner);
+ }
+ function check(baseline) {
+ var now = Date.now();
+ if (!baseline && !shouldCheck(now, lastCheck, MIN_GAP_MS)) return;
+ lastCheck = now;
+ (baseline ? reader.readOnce() : reader.read()).then(function (h) {
+ var r = decide(state, h);
+ state = r.state;
+ if (r.notify) show();
+ });
+ }
+
+ check(true); // baseline
+ document.addEventListener('visibilitychange', function () {
+ if (!document.hidden) check(false);
+ });
+ window.addEventListener('corescope:ws-open', function () { check(false); });
+ setInterval(function () { if (!document.hidden) check(false); }, INTERVAL_MS);
+ }
+
+ window.VersionNotice = {
+ initialState: initialState, decide: decide,
+ shouldCheck: shouldCheck, start: start,
+ };
+ if (typeof document !== 'undefined' && document.body !== undefined) start();
+})();
diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json
index e08900745..820cfe602 100644
--- a/scripts/e2e-shards.json
+++ b/scripts/e2e-shards.json
@@ -1767,6 +1767,16 @@
13.017
]
},
+ {
+ "file": "test-issue-614-version-notice-e2e.js",
+ "shard": 3,
+ "env": {
+ "CHROMIUM_REQUIRE": "1",
+ "BASE_URL": "$BASE_URL"
+ },
+ "output": "e2e",
+ "meanSeconds": 12
+ },
{
"file": "test-issue-111-drawer-version-e2e.js",
"shard": 3,
diff --git a/test-all.sh b/test-all.sh
index 3ec2b3afd..1f9cd7762 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -176,6 +176,7 @@ run test-estimated-positions-config.js
run test-map-scope-filter.js
run test-issue-117-ws-watchdog.js
run test-issue-111-drawer-version.js
+run test-issue-614-version-notice.js
# Previously run only by the JS unit step in deploy.yml (#174)
run test-packet-filter-time.js
diff --git a/test-issue-111-drawer-version-e2e.js b/test-issue-111-drawer-version-e2e.js
index 1aa8c0ffc..92efc03d7 100644
--- a/test-issue-111-drawer-version-e2e.js
+++ b/test-issue-111-drawer-version-e2e.js
@@ -44,10 +44,11 @@ async function page(browser, viewport, errors) {
console.log('\n=== #111 nav-drawer version footer against ' + BASE + ' ===');
for (const vp of [{ width: 1440, height: 900 }, { width: 1024, height: 768 }, { width: 800, height: 900 }, { width: 1280, height: 480 }]) {
- await step(`${vp.width}x${vp.height}: footer inside the drawer, below a reachable route list; one request`, async () => {
+ await step(`${vp.width}x${vp.height}: footer inside the drawer, below a reachable route list; one request in total`, async () => {
const { ctx, p, health } = await page(browser, vp, errors);
try {
- assert(health.length === 0, health.length + ' /api/health requests before the drawer was opened');
+ // #614: version-notice.js reads the baseline at load; the drawer reuses it.
+ assert(health.length <= 1, health.length + ' /api/health requests before the drawer was opened');
await p.evaluate(() => window.__navDrawer.open());
await p.waitForTimeout(400);
const r = await p.evaluate(() => {
@@ -88,7 +89,7 @@ async function page(browser, viewport, errors) {
try {
await p.evaluate(() => { window.__navDrawer.open(); window.__navDrawer.open(); });
await p.waitForTimeout(400);
- assert(health.length === 0, health.length + ' requests at 700px');
+ assert(health.length <= 1, health.length + ' requests at 700px (the page-load baseline only)');
} finally { await ctx.close(); }
});
diff --git a/test-issue-614-version-notice-e2e.js b/test-issue-614-version-notice-e2e.js
new file mode 100644
index 000000000..edb6320e7
--- /dev/null
+++ b/test-issue-614-version-notice-e2e.js
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+/* Issue #614 — "new version available" notice in an already-open tab.
+ *
+ * Stubs /api/health: first reading commit "old", then "new". Fires
+ * visibilitychange and asserts the role=status banner appears, that nothing
+ * reloads by itself, that Reload reloads, that Dismiss hides it for good,
+ * and that it is visible and readable in light, dark and at 390px.
+ */
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const SHOTS = process.env.SHOT_DIR || '';
+const NAME = 'test-issue-614-version-notice-e2e.js';
+let passed = 0, failed = 0;
+function ok(c, m) { if (c) { passed++; console.log(' ✅ ' + m); } else { failed++; console.log(' ❌ ' + m); } }
+
+async function newPage(browser, theme, viewport, healthState) {
+ const ctx = await browser.newContext({ viewport });
+ await ctx.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (e) {} }, theme);
+ const page = await ctx.newPage();
+ await page.route('**/api/health', (route) => {
+ if (healthState.mode === '503') return route.fulfill({ status: 503, body: 'restarting' });
+ route.fulfill({
+ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ version: 'v1', commit: healthState.commit, buildTime: 't' }),
+ });
+ });
+ await page.goto(BASE + '/#/nodes', { waitUntil: 'load' });
+ await page.waitForFunction(() => window.VersionNotice && window.CoreHealth);
+ await page.waitForTimeout(300); // baseline read
+ return { ctx, page };
+}
+async function tick(page) {
+ await page.evaluate(() => {
+ const real = Date.now.bind(Date);
+ const off = (window.__vnOff = (window.__vnOff || 0) + 120000);
+ Date.now = () => real() + off;
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+}
+
+(async () => {
+ let browser;
+ try {
+ browser = await chromium.launch({
+ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ } catch (err) {
+ if (process.env.CHROMIUM_REQUIRE === '1') { console.error(`${NAME}: FAIL — Chromium unavailable: ${err.message}`); process.exit(1); }
+ console.log(`${NAME}: SKIP (Chromium unavailable)`); process.exit(0);
+ }
+
+ const cases = [
+ ['light', { width: 1280, height: 800 }],
+ ['dark', { width: 1280, height: 800 }],
+ ['light', { width: 390, height: 844 }],
+ ];
+ for (const [theme, vp] of cases) {
+ const label = `${theme} ${vp.width}px`;
+ console.log('--- ' + label);
+ const st = { commit: 'old', mode: 'ok' };
+ const { ctx, page } = await newPage(browser, theme, vp, st);
+ let navs = 0;
+ page.on('framenavigated', (f) => { if (f === page.mainFrame()) navs++; });
+ ok(await page.locator('[data-version-notice]').count() === 0, 'no banner on first load');
+
+ st.mode = '503';
+ await tick(page); await page.waitForTimeout(300);
+ ok(await page.locator('[data-version-notice]').count() === 0, 'no banner for a 503 during restart');
+
+ st.mode = 'ok'; st.commit = 'new';
+ await tick(page);
+ const banner = page.locator('[data-version-notice]');
+ await banner.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
+ ok(await banner.count() === 1 && await banner.isVisible(), 'banner visible after a new commit');
+ ok(await banner.getAttribute('role') === 'status', 'role=status');
+ const box = await banner.boundingBox();
+ ok(box && box.x >= 0 && box.x + box.width <= vp.width + 1 && box.y >= 0 && box.y + box.height <= vp.height + 1, 'banner inside the viewport');
+ const colors = await banner.evaluate((el) => { const s = getComputedStyle(el); return [s.backgroundColor, s.color]; });
+ ok(colors[0] !== 'rgba(0, 0, 0, 0)' && colors[0] !== colors[1], 'banner has a themed background distinct from text: ' + colors.join(' / '));
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/614-${theme}-${vp.width}.png` });
+
+ await tick(page); await page.waitForTimeout(200);
+ ok(await page.locator('[data-version-notice]').count() === 1, 'one banner per new commit');
+ ok(navs === 0, 'no automatic reload');
+
+ // keyboard: Reload button reachable by Tab and activates with Enter
+ await page.locator('[data-version-notice-reload]').focus();
+ ok(await page.evaluate(() => document.activeElement && document.activeElement.hasAttribute('data-version-notice-reload')), 'Reload focusable');
+ const [ ] = await Promise.all([
+ page.waitForEvent('framenavigated', { timeout: 5000 }).catch(() => null),
+ page.keyboard.press('Enter'),
+ ]);
+ await page.waitForLoadState('load');
+ ok(navs >= 1, 'Reload reloads the page');
+ await ctx.close();
+ }
+
+ console.log('--- dismiss');
+ const st = { commit: 'old', mode: 'ok' };
+ const { ctx, page } = await newPage(browser, 'light', { width: 1280, height: 800 }, st);
+ st.commit = 'new'; await tick(page);
+ await page.locator('[data-version-notice]').waitFor({ state: 'visible', timeout: 5000 });
+ await page.locator('[data-version-notice-dismiss]').click();
+ ok(await page.locator('[data-version-notice]').count() === 0, 'Dismiss hides the banner');
+ await tick(page); await page.waitForTimeout(300);
+ ok(await page.locator('[data-version-notice]').count() === 0, 'banner does not return for the same commit');
+ st.commit = 'newer'; await tick(page);
+ await page.locator('[data-version-notice]').waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
+ ok(await page.locator('[data-version-notice]').count() === 1, 'a later commit notifies again');
+ await ctx.close();
+
+ await browser.close();
+ console.log(`\n${NAME}: ${passed} passed, ${failed} failed`);
+ process.exit(failed ? 1 : 0);
+})();
diff --git a/test-issue-614-version-notice.js b/test-issue-614-version-notice.js
new file mode 100644
index 000000000..bea2803b8
--- /dev/null
+++ b/test-issue-614-version-notice.js
@@ -0,0 +1,293 @@
+/* test-issue-614-version-notice.js — "new version available" notice (#614).
+ *
+ * Loads the real public/version-notice.js (and public/nav-drawer.js for the
+ * shared /api/health reader) in vm contexts. Checks the pure decision
+ * function (first load, same commit, new commit once, dismiss, failed or
+ * commit-less health), the check throttle, and the wiring: baseline read at
+ * load, checks on visibilitychange / WS open / interval, never an
+ * auto-reload, accessible banner built without HTML strings.
+ */
+'use strict';
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+console.log('--- test-issue-614-version-notice.js ---');
+let passed = 0, failed = 0;
+const pending = [];
+function test(name, fn) {
+ pending.push(async () => {
+ try { await fn(); passed++; console.log(' ✅ ' + name); }
+ catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
+ });
+}
+
+const SRC = fs.readFileSync(__dirname + '/public/version-notice.js', 'utf8');
+const DRAWER = fs.readFileSync(__dirname + '/public/nav-drawer.js', 'utf8');
+
+// ── pure logic ────────────────────────────────────────────────────────────
+function pure() {
+ const ctx = { window: {}, console, Date, Math, Object, String };
+ vm.createContext(ctx);
+ vm.runInContext(SRC, ctx);
+ return ctx.window.VersionNotice;
+}
+const VN = pure();
+const H = (commit) => ({ version: 'v1', commit, buildTime: 't' });
+const fresh = () => VN.initialState();
+
+test('first load records the baseline and does not notify', () => {
+ const r = VN.decide(fresh(), H('aaa'));
+ assert.strictEqual(r.notify, false);
+ assert.strictEqual(r.state.baseline, 'aaa');
+});
+test('the same commit does not notify', () => {
+ const s = VN.decide(fresh(), H('aaa')).state;
+ assert.strictEqual(VN.decide(s, H('aaa')).notify, false);
+});
+test('a new commit notifies, and only once per commit', () => {
+ let s = VN.decide(fresh(), H('aaa')).state;
+ const r1 = VN.decide(s, H('bbb'));
+ assert.strictEqual(r1.notify, true);
+ assert.strictEqual(r1.commit, 'bbb');
+ const r2 = VN.decide(r1.state, H('bbb'));
+ assert.strictEqual(r2.notify, false);
+});
+test('after a notice the same commit stays quiet but a later commit notifies again', () => {
+ let s = VN.decide(fresh(), H('aaa')).state;
+ s = VN.decide(s, H('bbb')).state;
+ assert.strictEqual(VN.decide(s, H('bbb')).notify, false);
+ assert.strictEqual(VN.decide(s, H('ccc')).notify, true);
+});
+for (const [what, h] of Object.entries({
+ 'null (failed or 5xx health)': null,
+ 'undefined': undefined,
+ 'an empty object': {},
+ 'an unknown commit': { commit: 'unknown' },
+ 'a blank commit': { commit: ' ' },
+ 'a non-string commit': { commit: 42 },
+})) {
+ test('health ' + what + ' never notifies and keeps the baseline', () => {
+ const s = VN.decide(fresh(), H('aaa')).state;
+ const r = VN.decide(s, h);
+ assert.strictEqual(r.notify, false);
+ assert.strictEqual(r.state.baseline, 'aaa');
+ });
+ test('health ' + what + ' as the first reading leaves the baseline unset', () => {
+ const r = VN.decide(fresh(), h);
+ assert.strictEqual(r.notify, false);
+ assert.strictEqual(r.state.baseline, null);
+ });
+}
+test('a restart window (failed health) between two readings still yields one notice', () => {
+ let s = VN.decide(fresh(), H('aaa')).state;
+ s = VN.decide(s, null).state;
+ const r = VN.decide(s, H('bbb'));
+ assert.strictEqual(r.notify, true);
+});
+test('shouldCheck throttles within the minimum gap and allows after it', () => {
+ assert.strictEqual(VN.shouldCheck(1000, 0, 30000), true, 'never checked');
+ assert.strictEqual(VN.shouldCheck(10000, 1000, 30000), false);
+ assert.strictEqual(VN.shouldCheck(31000, 1000, 30000), true);
+ assert.strictEqual(VN.shouldCheck(5, NaN, 30000), true);
+});
+
+// ── wiring ────────────────────────────────────────────────────────────────
+function makeDoc() {
+ const listeners = {};
+ function El(tag) {
+ this.tagName = String(tag).toUpperCase();
+ this.children = []; this.attrs = {}; this.parentNode = null;
+ this.className = ''; this.textContent = ''; this.hidden = false;
+ this.listeners = {};
+ }
+ El.prototype = {
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
+ remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((x) => x !== this); this.parentNode = null; },
+ setAttribute(k, v) { this.attrs[k] = String(v); },
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
+ addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); },
+ click() { (this.listeners.click || []).forEach((f) => f({})); },
+ querySelector(sel) {
+ const m = sel.match(/^\[([\w-]+)\]$/);
+ const walk = (n) => { for (const c of n.children) { if (m && m[1] in c.attrs) return c; const r = walk(c); if (r) return r; } return null; };
+ return walk(this);
+ },
+ };
+ let htmlWrites = 0;
+ Object.defineProperty(El.prototype, 'innerHTML', { set() { htmlWrites++; }, get() { return ''; } });
+ const body = new El('body');
+ const doc = {
+ body, hidden: false,
+ createElement: (t) => new El(t),
+ addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },
+ querySelector: (s) => body.querySelector(s),
+ fire(t) { (listeners[t] || []).forEach((f) => f({})); },
+ };
+ return { doc, htmlWrites: () => htmlWrites };
+}
+
+function load(healthSeq, extra) {
+ extra = extra || {};
+ const { doc, htmlWrites } = makeDoc();
+ const calls = [];
+ const wl = {};
+ let now = 1000000;
+ const timers = [];
+ let reloads = 0;
+ const win = {
+ addEventListener(t, f) { (wl[t] = wl[t] || []).push(f); },
+ location: { reload() { reloads++; } },
+ };
+ const ctx = {
+ window: win, document: doc, console, Date: { now: () => now }, Promise, JSON, Object, String, Math, Number,
+ setInterval: (f, ms) => { timers.push({ f, ms }); return timers.length; },
+ clearInterval() {},
+ fetch: (url) => {
+ calls.push(url);
+ const h = healthSeq.length > 1 ? healthSeq.shift() : healthSeq[0];
+ if (h === 'reject') return Promise.reject(new Error('offline'));
+ if (h === '5xx') return Promise.resolve({ ok: false, status: 503, json: () => Promise.resolve({}) });
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(h) });
+ },
+ };
+ win.fetch = ctx.fetch;
+ vm.createContext(ctx);
+ // The drawer's DOM wiring needs a fuller DOM than this fake provides; only
+ // its health reader (assigned before that wiring) is under test here, so a
+ // later wiring error is expected and ignored.
+ ctx.requestAnimationFrame = (f) => { f(); return 1; };
+ ctx.performance = { now: () => 0 };
+ win.matchMedia = () => ({ matches: false, addEventListener() {} });
+ win.innerWidth = 1280;
+ try { vm.runInContext(DRAWER, ctx); } catch (e) { /* drawer UI wiring, not under test */ }
+ vm.runInContext(SRC, ctx);
+ return {
+ calls, doc, htmlWrites, timers, win,
+ reloads: () => reloads,
+ advance(ms) { now += ms; },
+ fireWin(t) { (wl[t] || []).forEach((f) => f({})); },
+ banner: () => doc.body.querySelector('[data-version-notice]'),
+ };
+}
+const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
+
+test('nav-drawer.js exposes the shared health reader and the notice uses it', async () => {
+ const b = load([H('aaa')]);
+ assert.strictEqual(typeof b.win.CoreHealth.read, 'function');
+ await flush();
+ assert.deepStrictEqual(b.calls.slice(), ['/api/health'], 'baseline read at load');
+});
+test('readOnce shares one request between the baseline and the drawer footer', async () => {
+ const b = load([H('aaa')]);
+ await flush();
+ await b.win.CoreHealth.readOnce();
+ await b.win.CoreHealth.readOnce();
+ assert.strictEqual(b.calls.length, 1);
+});
+test('page load reads the baseline once and shows nothing', async () => {
+ const b = load([H('aaa')]);
+ await flush();
+ assert.strictEqual(b.calls.length, 1);
+ assert.strictEqual(b.banner(), null);
+});
+test('visibilitychange to visible with a new commit shows an accessible banner', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ const writesBefore = b.htmlWrites();
+ b.advance(60000);
+ b.doc.hidden = false;
+ b.doc.fire('visibilitychange');
+ await flush();
+ const el = b.banner();
+ assert(el, 'no banner');
+ assert.strictEqual(el.getAttribute('role'), 'status');
+ assert(/new version/i.test(el.textContent + el.children.map((c) => c.textContent).join(' ')));
+ assert.strictEqual(b.reloads(), 0, 'must never auto-reload');
+ assert.strictEqual(b.htmlWrites(), writesBefore, 'no HTML strings');
+});
+test('visibilitychange while hidden does not fetch', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ b.advance(60000);
+ b.doc.hidden = true;
+ b.doc.fire('visibilitychange');
+ await flush();
+ assert.strictEqual(b.calls.length, 1);
+});
+test('a WebSocket open event triggers a check; checks inside the throttle gap are skipped', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ b.fireWin('corescope:ws-open');
+ await flush();
+ assert.strictEqual(b.calls.length, 1, 'initial socket open right after load is throttled');
+ b.advance(60000);
+ b.fireWin('corescope:ws-open');
+ await flush();
+ assert.strictEqual(b.calls.length, 2);
+ assert(b.banner());
+});
+test('the periodic check runs at a multi-minute interval and skips hidden tabs', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ assert.strictEqual(b.timers.length, 1);
+ assert(b.timers[0].ms >= 120000, 'interval too short: ' + b.timers[0].ms);
+ b.advance(b.timers[0].ms);
+ b.doc.hidden = true;
+ b.timers[0].f();
+ await flush();
+ assert.strictEqual(b.calls.length, 1);
+ b.doc.hidden = false;
+ b.timers[0].f();
+ await flush();
+ assert.strictEqual(b.calls.length, 2);
+});
+test('failed and 5xx health during a restart do not notify', async () => {
+ const b = load([H('aaa'), 'reject', '5xx']);
+ await flush();
+ for (let i = 0; i < 2; i++) { b.advance(60000); b.fireWin('corescope:ws-open'); await flush(); }
+ assert.strictEqual(b.calls.length, 3);
+ assert.strictEqual(b.banner(), null);
+});
+test('the same new commit across several checks shows one banner', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ for (let i = 0; i < 3; i++) { b.advance(60000); b.fireWin('corescope:ws-open'); await flush(); }
+ assert.strictEqual(b.doc.body.children.filter((c) => 'data-version-notice' in c.attrs).length, 1);
+});
+test('a second new commit while the banner is open does not stack a second banner', async () => {
+ const b = load([H('aaa'), H('bbb'), H('ccc')]);
+ await flush();
+ for (let i = 0; i < 2; i++) { b.advance(60000); b.fireWin('corescope:ws-open'); await flush(); }
+ assert.strictEqual(b.calls.length, 3);
+ assert.strictEqual(b.doc.body.children.filter((c) => 'data-version-notice' in c.attrs).length, 1);
+});
+test('Reload reloads the page; the notice does not reload by itself', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ b.advance(60000); b.fireWin('corescope:ws-open'); await flush();
+ assert.strictEqual(b.reloads(), 0);
+ b.banner().querySelector('[data-version-notice-reload]').click();
+ assert.strictEqual(b.reloads(), 1);
+});
+test('Dismiss removes the banner and it does not return for the same commit', async () => {
+ const b = load([H('aaa'), H('bbb')]);
+ await flush();
+ b.advance(60000); b.fireWin('corescope:ws-open'); await flush();
+ b.banner().querySelector('[data-version-notice-dismiss]').click();
+ assert.strictEqual(b.banner(), null);
+ b.advance(60000); b.fireWin('corescope:ws-open'); await flush();
+ assert.strictEqual(b.banner(), null);
+ assert.strictEqual(b.reloads(), 0);
+});
+test('the drawer version footer still works and shares the one reader', async () => {
+ const src = fs.readFileSync(__dirname + '/public/nav-drawer.js', 'utf8');
+ assert.strictEqual((src.match(/fetch\(\s*'\/api\/health'/g) || []).length, 1, 'one fetch site in nav-drawer.js');
+ assert.strictEqual((SRC.match(/fetch\(/g) || []).length, 0, 'version-notice.js must not fetch itself');
+});
+
+(async () => {
+ for (const t of pending) await t();
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed ? 1 : 0);
+})();