Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 16 additions & 4 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions public/live.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions test-analytics-table-ids-unique.js
Original file line number Diff line number Diff line change
@@ -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);
139 changes: 139 additions & 0 deletions test-live-vcr-mode-contrast.js
Original file line number Diff line number Diff line change
@@ -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');
Loading