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