diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a79c2112e..bb13fd5d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -159,6 +159,7 @@ jobs: node test-channel-issue-1101.js node test-observer-iata-1188.js node test-pull-to-reconnect-1091.js + node test-issue-111-drawer-version.js node test-channel-fluid-layout.js node test-issue-1279-p2-code-filter.js node test-area-filter.js @@ -546,6 +547,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-111-drawer-version-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/nav-drawer.css b/public/nav-drawer.css index 154f90ad7..dd556b299 100644 --- a/public/nav-drawer.css +++ b/public/nav-drawer.css @@ -160,6 +160,35 @@ white-space: nowrap; } +/* ── Footer: running version (#111) ────────────────────────────────────── */ +.nav-drawer-footer { + flex-shrink: 0; /* the list above scrolls; the footer stays at the bottom */ + padding: 10px 16px 12px; + border-top: 1px solid var(--border); +} + +.nav-drawer-version { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--nav-text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + text-decoration: none; +} + +.nav-drawer-version:hover, +.nav-drawer-version:focus-visible { + color: var(--nav-text); + text-decoration: underline; +} + +.nav-drawer-version:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + @media (prefers-reduced-motion: reduce) { .nav-drawer-item, .nav-drawer-close { diff --git a/public/nav-drawer.js b/public/nav-drawer.js index 4d59534ea..59017b509 100644 --- a/public/nav-drawer.js +++ b/public/nav-drawer.js @@ -39,6 +39,7 @@ var wired = false; var drawerEl = null; var backdropEl = null; + var versionEl = null; var dragging = false; var startX = 0; var startY = 0; @@ -91,6 +92,44 @@ ''; } + // ── 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 + // with textContent / title only. + var RELEASES_URL = 'https://github.com/dborup/CoreScope/releases'; + var versionRequested = false; + + function healthField(h, k) { + var v = h && h[k]; + if (typeof v !== 'string') return ''; + v = v.trim(); + return (v && v.toLowerCase() !== 'unknown') ? v : ''; + } + + function applyVersion(el, h) { + var version = healthField(h, 'version'); + if (!version) return; // keep the neutral "CoreScope" label + el.textContent = 'CoreScope ' + version; + var bits = []; + var commit = healthField(h, 'commit'); + var built = healthField(h, 'buildTime'); + if (commit) bits.push('commit ' + commit); + if (built) bits.push('built ' + built); + if (bits.length) el.title = bits.join(' \u00B7 '); + } + + 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 */ }); + } + var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone) var EDGE_MIN_PX = 24; // first N px reserved for iOS Safari back-swipe (do not claim) var NARROW_MAX = 768; // Option A: disabled at ≤ this width @@ -203,6 +242,19 @@ renderRoutes(list); drawerEl.appendChild(list); + var footer = document.createElement('div'); + footer.className = 'nav-drawer-footer'; + var ver = document.createElement('a'); + ver.className = 'nav-drawer-version'; + ver.setAttribute('data-nav-drawer-version', ''); + ver.setAttribute('href', RELEASES_URL); + ver.setAttribute('target', '_blank'); + ver.setAttribute('rel', 'noopener noreferrer'); + ver.textContent = 'CoreScope'; + footer.appendChild(ver); + drawerEl.appendChild(footer); + versionEl = ver; + document.body.appendChild(backdropEl); document.body.appendChild(drawerEl); @@ -229,6 +281,7 @@ function open() { buildDom(); if (!isWide()) return; // Option A + requestVersion(); // #111: first open only if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320; // Capture the previously-focused element BEFORE we move focus, so close() // can restore it. Guard against opening twice (don't overwrite on re-open). diff --git a/test-all.sh b/test-all.sh index 5c4e9bafb..6aa09c17c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -110,6 +110,7 @@ node test-network-digest-tool.js node test-position-gaps-tool.js node test-gps-sanity-tool.js node test-map-scope-filter.js +node test-issue-111-drawer-version.js echo "" echo "═══════════════════════════════════════" diff --git a/test-issue-111-drawer-version-e2e.js b/test-issue-111-drawer-version-e2e.js new file mode 100644 index 000000000..1aa8c0ffc --- /dev/null +++ b/test-issue-111-drawer-version-e2e.js @@ -0,0 +1,122 @@ +/** + * E2E (#111): running version in the nav-drawer footer. + * + * At drawer viewports (> 768px, incl. a short one) the footer sits at the + * bottom of the drawer, inside it, below the route list, which keeps every + * route reachable. /api/health is requested only on the first open and never + * again; at a narrow width (drawer disabled) nothing is requested. With the + * local server's "unknown" version the label stays "CoreScope"; with a routed + * response it shows the version as text. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-111-drawer-version-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function page(browser, viewport, errors) { + const ctx = await browser.newContext({ viewport }); + const p = await ctx.newPage(); + p.setDefaultTimeout(15000); + const health = []; + p.on('request', (r) => { if (new URL(r.url()).pathname === '/api/health') health.push(r.url()); }); + p.on('pageerror', (e) => errors.push(e.message)); + await p.goto(BASE + '/#/packets'); + await p.waitForFunction(() => !!window.__navDrawer && !!document.querySelector('[data-nav-drawer]')); + await p.waitForTimeout(500); + return { ctx, p, health }; +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const 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 () => { + const { ctx, p, health } = await page(browser, vp, errors); + try { + assert(health.length === 0, 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(() => { + const d = document.querySelector('[data-nav-drawer]'); + const a = d.querySelector('[data-nav-drawer-version]'); + if (!a) return null; + const dr = d.getBoundingClientRect(), fr = a.closest('.nav-drawer-footer').getBoundingClientRect(); + const list = d.querySelector('.nav-drawer-list'); + const lr = list.getBoundingClientRect(); + const items = [...list.querySelectorAll('[data-nav-drawer-item]')]; + const last = items[items.length - 1]; + last.scrollIntoView({ block: 'nearest' }); + const lastR = last.getBoundingClientRect(); + const hit = document.elementFromPoint(lastR.left + 10, lastR.top + lastR.height / 2); + return { + text: a.textContent, href: a.getAttribute('href'), + inside: fr.left >= dr.left - 1 && fr.right <= dr.right + 1 && fr.bottom <= dr.bottom + 1 && fr.top >= dr.top, + belowList: fr.top >= lr.bottom - 1, + lastReachable: last.contains(hit), + footerVisible: fr.height > 0 && getComputedStyle(a).visibility !== 'hidden', + }; + }); + assert(r, 'no version link in the drawer'); + assert(/^CoreScope( \S.*)?$/.test(r.text) && !/undefined|null|unknown/i.test(r.text), 'label: ' + r.text); + assert(r.href === 'https://github.com/dborup/CoreScope/releases', 'href: ' + r.href); + assert(r.inside && r.footerVisible, 'footer not inside the drawer: ' + JSON.stringify(r)); + assert(r.belowList, 'footer overlaps the route list'); + assert(r.lastReachable, 'the last route is covered by the footer'); + await p.evaluate(() => { window.__navDrawer.close(); window.__navDrawer.open(); window.__navDrawer.close(); window.__navDrawer.open(); }); + await p.waitForTimeout(300); + assert(health.length === 1, health.length + ' /api/health requests after opening four times'); + } finally { await ctx.close(); } + }); + } + + await step('at a narrow width (drawer disabled) opening requests nothing', async () => { + const { ctx, p, health } = await page(browser, { width: 700, height: 900 }, errors); + try { + await p.evaluate(() => { window.__navDrawer.open(); window.__navDrawer.open(); }); + await p.waitForTimeout(400); + assert(health.length === 0, health.length + ' requests at 700px'); + } finally { await ctx.close(); } + }); + + await step('a routed health response shows the version as text with a tooltip', async () => { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const p = await ctx.newPage(); + p.on('pageerror', (e) => errors.push(e.message)); + await p.route('**/api/health', (route) => route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ version: 'v3.1.4x', commit: 'abc1234', buildTime: '2026-09-01T10:00:00Z' }), + })); + try { + await p.goto(BASE + '/#/packets'); + await p.waitForFunction(() => !!window.__navDrawer); + await p.evaluate(() => window.__navDrawer.open()); + await p.waitForFunction(() => /v3/.test((document.querySelector('[data-nav-drawer-version]') || {}).textContent || '')); + const r = await p.evaluate(() => { + const a = document.querySelector('[data-nav-drawer-version]'); + return { text: a.textContent, title: a.title, kids: a.children.length }; + }); + assert(r.text === 'CoreScope v3.1.4x' && r.kids === 0, JSON.stringify(r)); + assert(/abc1234/.test(r.title) && /2026-09-01/.test(r.title), 'tooltip: ' + r.title); + } finally { await ctx.close(); } + }); + + await step('no page errors', async () => { assert(errors.length === 0, errors.join(' | ')); }); + + await browser.close(); + console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---'); + process.exit(failed ? 1 : 0); +})(); diff --git a/test-issue-111-drawer-version.js b/test-issue-111-drawer-version.js new file mode 100644 index 000000000..4f06feb61 --- /dev/null +++ b/test-issue-111-drawer-version.js @@ -0,0 +1,227 @@ +/* test-issue-111-drawer-version.js — running version in the nav-drawer footer (#111). + * + * Loads the real public/nav-drawer.js in a vm with a small fake DOM and a + * counting fetch stub. Checks: footer label + fork releases link; no + * /api/health request at page load or while the drawer is gated off + * (narrow viewport); one request on the first wide open and none on + * re-open; success fills "CoreScope " and a commit/build tooltip; + * rejected, non-OK, invalid-JSON, version-less and "unknown" responses keep + * the neutral "CoreScope"; remote values only ever go through textContent. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-issue-111-drawer-version.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/nav-drawer.js', 'utf8'); +const RELEASES = 'https://github.com/dborup/CoreScope/releases'; + +// ── minimal DOM ─────────────────────────────────────────────────────────── +function makeDoc() { + let htmlWrites = 0; + function El(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; + this.parentNode = null; + this.attrs = {}; + this.style = {}; + this.dataset = {}; + this._text = ''; + this._html = ''; + this.hidden = false; + this.title = ''; + this.className = ''; + const self = this; + this.classList = { + add(c) { const s = new Set(self.className.split(/\s+/).filter(Boolean)); s.add(c); self.className = [...s].join(' '); }, + remove(c) { self.className = self.className.split(/\s+/).filter((x) => x && x !== c).join(' '); }, + contains(c) { return self.className.split(/\s+/).includes(c); }, + toggle(c) { if (this.contains(c)) this.remove(c); else this.add(c); }, + }; + } + El.prototype = { + appendChild(c) { c.parentNode = this; this.children.push(c); return c; }, + removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; }, + get firstChild() { return this.children[0] || null; }, + setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'href') this._href = String(v); }, + getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }, + removeAttribute(k) { delete this.attrs[k]; }, + hasAttribute(k) { return k in this.attrs; }, + addEventListener() {}, removeEventListener() {}, + focus() {}, getBoundingClientRect() { return { left: 0, right: 320, width: 320, top: 0, bottom: 800 }; }, + contains(n) { for (let x = n; x; x = x.parentNode) if (x === this) return true; return false; }, + get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }, + set textContent(v) { this.children = []; this._text = String(v); }, + get innerHTML() { return this._html; }, + set innerHTML(v) { htmlWrites++; this._html = String(v); this.children = []; }, + get href() { return this._href || this.attrs.href || ''; }, + set href(v) { this._href = String(v); this.attrs.href = String(v); }, + querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }, + querySelectorAll(sel) { + const parts = sel.split(',').map((s) => s.trim()); + const out = []; + (function walk(n) { + for (const c of n.children) { + if (parts.some((p) => matches(c, p))) out.push(c); + walk(c); + } + })(this); + return out; + }, + }; + function matches(el, sel) { + const m = sel.match(/^([a-z]*)(?:\.([\w-]+))?(?:\[([\w-]+)(?:="([^"]*)")?\])?/i); + if (!m) return false; + if (m[1] && el.tagName !== m[1].toUpperCase()) return false; + if (m[2] && !el.classList.contains(m[2])) return false; + if (m[3] && !(m[3] in el.attrs) && !(m[3] === 'href' && el._href)) return false; + if (m[4] != null && el.attrs[m[3]] !== m[4]) return false; + return !!(m[1] || m[2] || m[3]); + } + const body = new El('body'); + const doc = { + readyState: 'complete', body, activeElement: body, + createElement: (t) => new El(t), + addEventListener() {}, removeEventListener() {}, + querySelector: (s) => body.querySelector(s), + querySelectorAll: (s) => body.querySelectorAll(s), + }; + return { doc, htmlWrites: () => htmlWrites }; +} + +// ── harness ─────────────────────────────────────────────────────────────── +function load(opts) { + opts = opts || {}; + const { doc, htmlWrites } = makeDoc(); + const calls = []; + let narrow = !!opts.narrow; + const win = { + innerWidth: narrow ? 700 : 1280, + matchMedia: (q) => ({ get matches() { return /max-width/.test(q) ? narrow : false; }, addEventListener() {} }), + }; + const ctx = { + window: win, document: doc, console, + requestAnimationFrame: (fn) => { fn(); return 1; }, + performance: { now: () => 0 }, + Promise, JSON, Object, Array, String, Number, Math, Error, Set, + fetch: (url, init) => { calls.push(url); return opts.fetch ? opts.fetch(url, init) : new Promise(() => {}); }, + }; + win.fetch = ctx.fetch; + vm.createContext(ctx); + vm.runInContext(SRC, ctx); + const drawer = () => doc.body.querySelector('[data-nav-drawer]'); + return { + calls, doc, htmlWrites, win, + api: win.__navDrawer, + setNarrow(v) { narrow = v; win.innerWidth = v ? 700 : 1280; }, + footerLink: () => drawer() && drawer().querySelector('[data-nav-drawer-version]'), + }; +} +const flush = () => new Promise((r) => setTimeout(r, 0)); +const ok = (body) => () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }); + +// ── tests ───────────────────────────────────────────────────────────────── +test('the footer link is built with the neutral label and the fork releases URL, without a request', async () => { + const b = load(); + await flush(); + const a = b.footerLink(); + assert(a, 'no [data-nav-drawer-version] footer link in the drawer'); + assert.strictEqual(a.tagName, 'A'); + assert.strictEqual(a.textContent, 'CoreScope'); + assert.strictEqual(a.href, RELEASES); + assert.strictEqual(a.getAttribute('target') || a.target, '_blank'); + assert(/noopener/.test(a.getAttribute('rel') || a.rel || ''), 'rel lacks noopener'); + assert(a.parentNode && a.parentNode.classList.contains('nav-drawer-footer'), 'link is not inside .nav-drawer-footer'); + assert.strictEqual(b.calls.length, 0, 'fetched at page load'); +}); + +test('no request while the drawer is gated off at narrow widths', async () => { + const b = load({ narrow: true, fetch: ok({ version: 'v1.2.3' }) }); + b.api.open(); b.api.open(); + await flush(); + assert.strictEqual(b.calls.length, 0, b.calls.length + ' requests at a narrow width'); +}); + +test('the first wide open fetches /api/health once and fills version and tooltip', async () => { + const b = load({ fetch: ok({ version: 'v1.2.3', commit: 'abc1234', buildTime: '2026-09-01T10:00:00Z' }) }); + b.api.open(); + await flush(); await flush(); + assert.deepStrictEqual(b.calls.slice(), ['/api/health']); + const a = b.footerLink(); + assert.strictEqual(a.textContent, 'CoreScope v1.2.3'); + assert(a.title.includes('abc1234') && a.title.includes('2026-09-01T10:00:00Z'), 'tooltip: ' + a.title); +}); + +test('re-opening (also after narrowing and widening) adds no requests', async () => { + const b = load({ fetch: ok({ version: 'v1.2.3' }) }); + b.api.open(); b.api.close(); + await flush(); + b.api.open(); b.api.close(); b.api.toggle(); b.api.toggle(); + b.setNarrow(true); b.api.open(); b.setNarrow(false); b.api.open(); + await flush(); await flush(); + assert.strictEqual(b.calls.length, 1, b.calls.length + ' requests'); + assert.strictEqual(b.footerLink().textContent, 'CoreScope v1.2.3'); +}); + +const neutral = { + 'a rejected fetch': () => Promise.reject(new Error('offline')), + 'a non-OK response': () => Promise.resolve({ ok: false, status: 503, json: () => Promise.resolve({ version: 'v9' }) }), + 'invalid JSON': () => Promise.resolve({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('bad')) }), + 'a missing version': ok({ commit: 'abc' }), + 'an empty version': ok({ version: ' ' }), + 'the server placeholder "unknown"': ok({ version: 'unknown', commit: 'unknown', buildTime: 'unknown' }), + 'a non-string version': ok({ version: { toString() { return 'x'; } } }), + 'a null body': ok(null), +}; +for (const [what, fetch] of Object.entries(neutral)) { + test(what + ' keeps the neutral "CoreScope" label and no tooltip, and is not retried', async () => { + const b = load({ fetch }); + b.api.open(); + await flush(); await flush(); + const a = b.footerLink(); + assert.strictEqual(a.textContent, 'CoreScope'); + assert(!/undefined|null|unknown|object/i.test(a.textContent + ' ' + a.title), 'label/tooltip: ' + a.textContent + ' / ' + a.title); + b.api.close(); b.api.open(); + await flush(); + assert.strictEqual(b.calls.length, 1, 'the failed request was retried on re-open'); + }); +} + +test('"unknown" commit/build are left out of the tooltip', async () => { + const b = load({ fetch: ok({ version: 'v2', commit: 'unknown', buildTime: '2026-09-01' }) }); + b.api.open(); + await flush(); await flush(); + const a = b.footerLink(); + assert.strictEqual(a.textContent, 'CoreScope v2'); + assert(!/unknown/.test(a.title) && /2026-09-01/.test(a.title), 'tooltip: ' + a.title); +}); + +test('markup-shaped health values are rendered as text, never parsed as HTML', async () => { + const evil = ''; + const b = load({ fetch: ok({ version: evil, commit: 'c', buildTime: '">' }) }); + const before = b.htmlWrites(); + b.api.open(); + await flush(); await flush(); + const a = b.footerLink(); + assert.strictEqual(a.textContent, 'CoreScope ' + evil); + assert.strictEqual(a.children.length, 0, 'the version created child elements'); + assert.strictEqual(a.innerHTML, '', 'innerHTML was used on the footer link'); + // opening the drawer writes no HTML except the static route icons + assert.strictEqual(b.htmlWrites(), before, 'open() wrote HTML after the health response'); +}); + +(async () => { + for (const t of pending) await t(); + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +})();