diff --git a/.eslintrc.json b/.eslintrc.json index 1043e18c3..6c4252dce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -226,6 +226,7 @@ "getFavorites": "readonly", "getHashParams": "readonly", "getHealthThresholds": "readonly", + "getNodeFreshness": "readonly", "getNodeStatus": "readonly", "getParsedDecoded": "readonly", "getParsedPath": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a86dd21fb..38b661f75 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -165,6 +165,8 @@ jobs: node test-a11y-axe-1668-selftest.js node test-a11y-1716-rf-range-btn-active.js node test-issue-1705-subpath-contrast.js + node test-analytics-table-ids-unique.js + node test-live-vcr-mode-contrast.js node test-issue-1770-mobile-row-clamp.js node test-a11y-axe-routes-coverage.js @@ -251,6 +253,7 @@ jobs: name: "🎭 Playwright E2E Tests" needs: [go-test] runs-on: ubuntu-latest + timeout-minutes: 150 defaults: run: shell: bash diff --git a/public/analytics.js b/public/analytics.js index 846ca04bb..9478cded7 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -292,6 +292,20 @@ } } + // This runs again after tabs insert tables asynchronously (and on theme + // refresh), so a table added ahead of an already-numbered one would reuse + // that table's positional index; skip ids that are already taken. + function assignAnalyticsTableIds(el, tab) { + el.querySelectorAll('.analytics-table').forEach((tbl, i) => { + if (!tbl.id) { + let n = i; + while (document.getElementById(`analytics-tbl-${tab}-${n}`)) n++; + tbl.id = `analytics-tbl-${tab}-${n}`; + } + if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); + }); + } + async function renderTab(tab) { const el = document.getElementById('analyticsContent'); const d = _analyticsData; @@ -319,10 +333,7 @@ } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { - el.querySelectorAll('.analytics-table').forEach((tbl, i) => { - tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`; - if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); - }); + assignAnalyticsTableIds(el, tab); // #206 — Wrap analytics tables in scroll containers on mobile el.querySelectorAll('.analytics-table').forEach(tbl => { if (!tbl.parentElement.classList.contains('analytics-table-scroll')) { @@ -3008,6 +3019,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // Expose for testing if (typeof window !== 'undefined') { + window._analyticsAssignTableIds = assignAnalyticsTableIds; window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; diff --git a/public/live.css b/public/live.css index cd172f9d6..a76d00d35 100644 --- a/public/live.css +++ b/public/live.css @@ -1053,9 +1053,9 @@ input.live-node-filter-input:focus { border-radius: 4px; margin-left: auto; } -.vcr-mode-live { color: var(--status-green); } -.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); } -.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); } +.vcr-mode-live { color: var(--status-green-text); } +.vcr-mode-paused { color: var(--warning); background: rgba(251,191,36,0.1); } +.vcr-mode-replay { color: var(--link-color); background: rgba(96,165,250,0.1); } .vcr-live-dot { display: inline-block; diff --git a/public/map.js b/public/map.js index 0ee44f8c3..b59922c58 100644 --- a/public/map.js +++ b/public/map.js @@ -678,7 +678,7 @@ })(); // Selected area outline — distinct from the geo filter boundary above: - // this draws whichever area is currently picked via the "Area: X ▾" + // this draws whichever area is currently picked via the "Area: X" // dropdown (AreaFilter, shared with Nodes/Live/Analytics), not // necessarily the homeArea-linked geo_filter. Falls back to a plain // box when the area has no polygon. Only one area can be selected at diff --git a/test-a11y-axe-1668.js b/test-a11y-axe-1668.js index f260ff5f3..a30ab88d4 100644 --- a/test-a11y-axe-1668.js +++ b/test-a11y-axe-1668.js @@ -80,6 +80,9 @@ const ROUTES = [ '/analytics?tab=prefix-tool', '/analytics?tab=my-repeaters', '/analytics?tab=repeater-metrics', + '/analytics?tab=foreign-traffic', + '/analytics?tab=wardriving', + '/analytics?tab=areas', '/audio-lab', ]; @@ -95,7 +98,7 @@ const REGISTERED_ANALYTICS_TABS = [ 'overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance', 'neighbor-graph', 'rf-health', 'clock-health', 'roles', 'prefix-tool', 'scopes', 'my-repeaters', - 'repeater-metrics', + 'repeater-metrics', 'foreign-traffic', 'wardriving', 'areas', ]; const THEMES = ['dark', 'light']; diff --git a/test-all.sh b/test-all.sh index b89a1db68..0d35b5e9d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -44,6 +44,8 @@ node test-issue-1409-no-encrypted-flood.js node test-analytics-channels-integration.js node test-analytics-foreign-traffic-tab.js node test-analytics-nodes-without-scope.js +node test-analytics-table-ids-unique.js +node test-live-vcr-mode-contrast.js node test-observers-headings.js node test-issue-1789-observer-firmware-cols.js node test-issue-1648-m1-emoji-scan.js diff --git a/test-analytics-table-ids-unique.js b/test-analytics-table-ids-unique.js new file mode 100644 index 000000000..cf1e51733 --- /dev/null +++ b/test-analytics-table-ids-unique.js @@ -0,0 +1,146 @@ +/** + * Analytics table ids must stay unique across repeated id-assignment passes. + * + * renderTab() numbers `.analytics-table` elements by position once the tab + * has rendered, and the pass runs again (async sections, theme refresh). + * The Scopes tab first renders one static table, then inserts its async + * sections ahead of it; the second pass used to hand the first inserted + * table the static table's positional id, so axe reported + * `duplicate-id analytics-tbl-scopes-0`. + * + * Drives the real assignAnalyticsTableIds from public/analytics.js against a + * minimal fake DOM that mirrors the observed Scopes insertion order. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeCtx() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: () => {}, + RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' }, + onWS: () => {}, offWS: () => {}, connectWS: () => {}, + invalidateApiCache: () => {}, initTabBar: () => {}, + IATA_COORDS_GEO: {}, + }; + vm.createContext(ctx); + const load = (file) => { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + }; + load('public/payload-labels.js'); + load('public/roles.js'); + load('public/app.js'); + try { load('public/analytics.js'); } catch (_) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +// A container whose tables are listed in document order; the fake document +// resolves ids across every table currently attached. +function makeDom(ctx) { + const dom = { tables: [], resizeCalls: [] }; + dom.el = { querySelectorAll: (sel) => (sel === '.analytics-table' ? dom.tables.slice() : []) }; + ctx.document.getElementById = (id) => dom.tables.find(t => t.id === id) || null; + ctx.makeColumnsResizable = (selector, key) => { dom.resizeCalls.push({ selector, key }); }; + return dom; +} + +const table = (name) => ({ name, id: '' }); +const ids = (tables) => tables.map(t => t.id); +const dupes = (tables) => { + const seen = new Map(); + for (const t of tables) seen.set(t.id, (seen.get(t.id) || 0) + 1); + return [...seen].filter(([, n]) => n > 1).map(([id]) => id); +}; + +console.log('\n=== analytics.js: assignAnalyticsTableIds ==='); +const ctx = makeCtx(); +const assign = ctx._analyticsAssignTableIds; + +test('assignAnalyticsTableIds is exposed for tests', () => { + assert.strictEqual(typeof assign, 'function', 'window._analyticsAssignTableIds must be a function'); +}); + +if (typeof assign === 'function') { + test('single pass keeps the positional numbering', () => { + const dom = makeDom(ctx); + dom.tables = [table('a'), table('b'), table('c')]; + assign(dom.el, 'rf'); + assert.deepStrictEqual(ids(dom.tables), ['analytics-tbl-rf-0', 'analytics-tbl-rf-1', 'analytics-tbl-rf-2']); + assert.deepStrictEqual(dom.resizeCalls.map(c => c.key), + ['meshcore-analytics-rf-0-col-widths', 'meshcore-analytics-rf-1-col-widths', 'meshcore-analytics-rf-2-col-widths']); + }); + + test('Scopes: async tables inserted ahead of a numbered table get unique ids', () => { + const dom = makeDom(ctx); + const staticTbl = table('overview-static'); + dom.tables = [staticTbl]; + assign(dom.el, 'scopes'); + assert.strictEqual(staticTbl.id, 'analytics-tbl-scopes-0'); + + // Observed second-pass document order on the Scopes tab. + dom.tables = [table('scope-adoption'), staticTbl, table('no-scope'), table('never-relay')]; + assign(dom.el, 'scopes'); + assert.deepStrictEqual(dupes(dom.tables), [], `duplicate ids: ${ids(dom.tables).join(', ')}`); + assert.strictEqual(staticTbl.id, 'analytics-tbl-scopes-0', 'an already-assigned id must not change'); + // Same sequence the browser shows on the Scopes tab after the fix. + assert.deepStrictEqual(ids(dom.tables), + ['analytics-tbl-scopes-1', 'analytics-tbl-scopes-0', 'analytics-tbl-scopes-2', 'analytics-tbl-scopes-3']); + for (const t of dom.tables) { + assert.match(t.id, /^analytics-tbl-scopes-\d+$/); + assert.ok(dom.resizeCalls.some(c => c.selector === '#' + t.id), `makeColumnsResizable not called for #${t.id}`); + } + }); + + test('re-running with no new tables leaves ids unchanged', () => { + const dom = makeDom(ctx); + dom.tables = [table('a'), table('b')]; + assign(dom.el, 'nodes'); + const first = ids(dom.tables); + assign(dom.el, 'nodes'); + assert.deepStrictEqual(ids(dom.tables), first); + }); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/test-live-vcr-mode-contrast.js b/test-live-vcr-mode-contrast.js new file mode 100644 index 000000000..cef2ad74d --- /dev/null +++ b/test-live-vcr-mode-contrast.js @@ -0,0 +1,139 @@ +/* WCAG AA contrast for the Live page VCR mode badge (#vcrMode) in all three + * modes: LIVE, PAUSED and REPLAY. + * + * The badge text used swatch hues (--status-green, --status-yellow, + * --accent) that fail AA on the light-theme VCR bar (2.27:1, 1.81:1, + * 2.52:1); the axe gate (test-a11y-axe-1668.js) reported LIVE on + * /live desktop light. Text now uses the semantic text tokens + * (--status-green-text, --warning, --link-color). + * + * Background model: the badge sits on `.vcr-bar`, whose background is + * `color-mix(in srgb, var(--surface-1) N%, transparent)`, i.e. surface-1 at + * N% alpha over the page. PAUSED/REPLAY add their own translucent tint. + * The bar pixel is composed over both --surface-0 and --surface-1 as the + * page underneath, the mode tint over that, and the text must reach 4.5:1 + * in light theme and in BOTH dark token blocks ([data-theme="dark"] and the + * prefers-color-scheme: dark :root block). + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +const styleCss = fs.readFileSync('public/style.css', 'utf8'); +const liveCss = fs.readFileSync('public/live.css', 'utf8'); + +function extractBlockTokens(blockRegex, label) { + const tokens = {}; + const re = new RegExp(blockRegex.source, 'g'); + let m, matched = false; + while ((m = re.exec(styleCss)) !== null) { + matched = true; + const decl = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim; + let d; + while ((d = decl.exec(m[1])) !== null) tokens[d[1]] = d[2].trim(); + } + if (!matched) throw new Error(`no \`${label} { ... }\` block found in style.css — parser needs updating`); + return tokens; +} + +const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/, ':root'); +const THEMES = { + light: lightTokens, + 'dark [data-theme]': { ...lightTokens, ...extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]') }, + 'dark prefers-color-scheme': { ...lightTokens, ...extractBlockTokens( + /@media \(prefers-color-scheme: dark\)\s*\{\s*:root:not\(\[data-theme="light"\]\)\s*\{([\s\S]*?)\n\s*\}/, + '@media (prefers-color-scheme: dark) :root:not([data-theme="light"])') }, +}; + +function resolve(value, tokens) { + let v = value.trim(); + for (let i = 0; i < 6; i++) { + const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i); + if (!m) return v; + v = (tokens[m[1]] || m[2] || '').trim(); + if (!v) return null; + } + return v; +} + +function parseColor(s) { + if (!s) return null; + let m = s.match(/^#([0-9a-f]{6})$/i); + if (m) return [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16)).concat(1); + m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)$/i); + return m ? [+m[1], +m[2], +m[3], m[4] != null ? parseFloat(m[4]) : 1] : null; +} + +const over = (fg, bg) => fg.slice(0, 3).map((v, i) => v * fg[3] + bg[i] * (1 - fg[3])).concat(1); + +function relLum(rgb) { + const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); +} + +function contrast(a, b) { + const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const hex = (c) => '#' + c.slice(0, 3).map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); + +function ruleBodies(selector) { + const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return [...liveCss.matchAll(new RegExp(`(?:^|\\n)\\s*${esc}\\s*\\{([^}]*)\\}`, 'g'))].map(r => r[1]); +} + +function ruleBody(selector) { + const bodies = ruleBodies(selector); + assert.strictEqual(bodies.length, 1, `expected exactly one \`${selector} { ... }\` rule in live.css, found ${bodies.length}`); + return bodies[0]; +} + +function decl(body, prop) { + const m = body.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+?)\\s*;`)); + return m ? m[1] : null; +} + +// .vcr-bar has several rules (padding/layout overrides); exactly one may set +// its background, otherwise this static model cannot tell which one wins. +const barBgs = ruleBodies('.vcr-bar').map(b => decl(b, 'background')).filter(Boolean); +assert.strictEqual(barBgs.length, 1, `expected exactly one \`.vcr-bar\` background declaration in live.css, found ${barBgs.length}`); +const barBg = barBgs[0]; +const barMix = barBg && barBg.match(/^color-mix\(in srgb,\s*(var\(--[a-z0-9-]+\))\s+(\d+(?:\.\d+)?)%,\s*transparent\)$/); +assert.ok(barMix, `.vcr-bar background must be \`color-mix(in srgb, var(--token) N%, transparent)\` for this model (got "${barBg}")`); + +const MODES = ['.vcr-mode-live', '.vcr-mode-paused', '.vcr-mode-replay']; + +console.log('\nLive VCR mode badge contrast'); +let failures = 0; +for (const selector of MODES) { + const body = ruleBody(selector); + const color = decl(body, 'color'); + assert.ok(color, `\`${selector}\` must declare a text color`); + const tint = decl(body, 'background'); + for (const [theme, tokens] of Object.entries(THEMES)) { + const fg = parseColor(resolve(color, tokens)); + assert.ok(fg && fg[3] === 1, `[${theme}] ${selector}: could not resolve opaque color "${color}"`); + const barColor = parseColor(resolve(barMix[1], tokens)); + assert.ok(barColor, `[${theme}] could not resolve .vcr-bar ${barMix[1]}`); + barColor[3] = parseFloat(barMix[2]) / 100; + const tintColor = tint ? parseColor(resolve(tint, tokens)) : null; + assert.ok(!tint || tintColor, `[${theme}] ${selector}: could not parse background "${tint}"`); + for (const page of ['--surface-0', '--surface-1']) { + const pageColor = parseColor(resolve(`var(${page})`, tokens)); + assert.ok(pageColor, `[${theme}] could not resolve ${page}`); + let bg = over(barColor, pageColor); + if (tintColor) bg = over(tintColor, bg); + const ratio = contrast(fg, bg); + const ok = ratio >= 4.5; + if (!ok) failures++; + console.log(` ${ok ? '✅' : '❌'} ${selector.padEnd(17)} ${theme.padEnd(26)} ${hex(fg)} on ${hex(bg)} (bar over ${page}): ${ratio.toFixed(2)}:1`); + } + } +} + +if (failures) { + console.error(`\nFAIL: ${failures} VCR mode badge theme/background combination(s) below WCAG AA 4.5:1`); + process.exit(1); +} +console.log('\nPASS: LIVE, PAUSED and REPLAY badge text ≥4.5:1 in light and both dark theme blocks'); diff --git a/test-slideover-1056-e2e.js b/test-slideover-1056-e2e.js index 4b4a14111..c4964d61c 100644 --- a/test-slideover-1056-e2e.js +++ b/test-slideover-1056-e2e.js @@ -56,6 +56,16 @@ const PAGES = [ // ---- Narrow viewport: slide-over MUST appear ---- for (const p of PAGES) { const ctx = await browser.newContext({ viewport: { width: 800, height: 800 } }); + if (p.name === 'packets') { + // The CI fixture is static: timestamps are freshened once before the + // server starts. The packets page defaults to a 15-min window, so rows + // vanish ~15 min into the job; 180 is the largest window packets.js + // keeps at <=1024px (larger values reset to 15). The e2e job's + // timeout-minutes keeps the run inside that window. + await ctx.addInitScript(() => { + try { localStorage.setItem('meshcore-time-window', '180'); } catch (_) {} + }); + } const page = await ctx.newPage(); page.setDefaultTimeout(8000); page.on('pageerror', (e) => console.error('[pageerror]', e.message));