Skip to content
Closed
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
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"getFavorites": "readonly",
"getHashParams": "readonly",
"getHealthThresholds": "readonly",
"getNodeFreshness": "readonly",
"getNodeStatus": "readonly",
"getParsedDecoded": "readonly",
"getParsedPath": "readonly",
Expand Down
3 changes: 3 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 Expand Up @@ -251,6 +253,7 @@ jobs:
name: "🎭 Playwright E2E Tests"
needs: [go-test]
runs-on: ubuntu-latest
timeout-minutes: 150
defaults:
run:
shell: bash
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: 1 addition & 1 deletion public/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion test-a11y-axe-1668.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand All @@ -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'];
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);
Loading