From dbc6cd58e85e34368f5a943f2e2ac9c35b83de0e Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 08:40:05 +0000 Subject: [PATCH 1/5] test(packets): optional Hide CONTROL packets filter (#96) Red tests for the opt-in "Hide CONTROL packets" checkbox on the packets page: - test-issue-96-hide-control.js (unit, in test-all.sh): the saved/URL preference (off by default, explicit "0" kept, URL wins), the CONTROL-only filter (order kept, no copy when off) and hideControl=1 in the URL hash. - test-issue-96-hide-control-e2e.js (Playwright step in deploy.yml, against the e2e fixture's 4 CONTROL packets): default shows them, checking hides exactly them without a new /api/packets request, the choice survives a reload both ways, the URL wins, a direct link still opens a CONTROL packet, and the checkbox is reachable at phone width. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mVL1VB4jtY7caZQnMWJpW --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-issue-96-hide-control-e2e.js | 141 +++++++++++++++++++++++++ test-issue-96-hide-control.js | 165 ++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 test-issue-96-hide-control-e2e.js create mode 100644 test-issue-96-hide-control.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 73474dae1..3bf5f7406 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -434,6 +434,7 @@ jobs: BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1692-packets-init-parallel-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-all.sh b/test-all.sh index 4ea0fa42d..7f845e853 100755 --- a/test-all.sh +++ b/test-all.sh @@ -33,6 +33,7 @@ run test-packet-filter-ux.js run test-issue-121-clear-filters-selection.js run test-clear-filters.js run test-issue-147-packets-url-detail-params.js +run test-issue-96-hide-control.js run test-issue-180-packets-detail-close.js run test-aging.js run test-issue-1065-gesture-hints-gates.js diff --git a/test-issue-96-hide-control-e2e.js b/test-issue-96-hide-control-e2e.js new file mode 100644 index 000000000..8b7ee7e5a --- /dev/null +++ b/test-issue-96-hide-control-e2e.js @@ -0,0 +1,141 @@ +/** + * E2E (#96): optional "Hide CONTROL packets" checkbox on the packets page. + * + * Against the e2e fixture (4 CONTROL packets, all older than the default + * time window, so every step opens #/packets?timeWindow=525600): + * - default: the checkbox is unchecked and CONTROL packets are listed; + * - checking it hides exactly the CONTROL packets, without a new + * /api/packets request, and puts hideControl=1 in the address bar; + * - the choice survives a reload, checked and explicitly unchecked; + * - hideControl in the URL wins over the saved choice; + * - a direct link to one CONTROL packet still opens it while the filter is on; + * - at a phone width the checkbox is reachable through the Filters toggle. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const LIST = BASE + '/#/packets?timeWindow=525600'; +const PREF_KEY = 'meshcore-hide-control'; +const CONTROL = 11; + +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'); } + +// Load url as a new document (goto alone may be a same-document hash change). +async function open(page, url) { + await page.goto('about:blank'); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#fHideControl', { state: 'attached', timeout: 15000 }); + await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 }); +} + +// The packet count the list header shows, "(N)". +async function shownCount(page) { + const txt = await page.textContent('#pktLeft .count'); + const m = /\((\d+)\)/.exec(txt || ''); + if (!m) throw new Error('no count in list header: ' + txt); + return Number(m[1]); +} + +async function hashQuery(page) { + return page.evaluate(() => Object.fromEntries(new URLSearchParams(location.hash.split('?')[1] || ''))); +} + +(async () => { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + let packetsRequests = 0; + page.on('request', (r) => { if (/\/api\/packets\?/.test(r.url())) packetsRequests++; }); + + // Ground truth from the API: the CONTROL packets of the fixture. + const api = await (await page.request.get(BASE + '/api/packets?limit=50000&groupByHash=true')).json(); + const all = api.packets || []; + const controls = all.filter((p) => p.payload_type === CONTROL); + console.log(`fixture: ${all.length} grouped packets, ${controls.length} CONTROL`); + + let countAll = 0; + + await step('no saved choice: checkbox unchecked, CONTROL packets listed', async () => { + assert(controls.length > 0, 'the e2e fixture has no CONTROL packet'); + await open(page, LIST); + assert(!(await page.isChecked('#fHideControl')), 'checkbox is checked by default'); + countAll = await shownCount(page); + assert(countAll === all.length, `list shows ${countAll}, API has ${all.length}`); + const q = await hashQuery(page); + assert(!('hideControl' in q), 'hideControl in the URL by default: ' + JSON.stringify(q)); + }); + + await step('checking it hides exactly the CONTROL packets, without a new API request', async () => { + const before = packetsRequests; + await page.check('#fHideControl'); + await page.waitForFunction((n) => { + const m = /\((\d+)\)/.exec(document.querySelector('#pktLeft .count')?.textContent || ''); + return m && Number(m[1]) === n; + }, countAll - controls.length, { timeout: 5000 }); + assert(packetsRequests === before, `toggling made ${packetsRequests - before} /api/packets request(s)`); + const q = await hashQuery(page); + assert(q.hideControl === '1', 'hideControl=1 not in the URL: ' + JSON.stringify(q)); + const shownHashes = await page.$$eval('table tbody tr[data-hash]', (rows) => rows.map((r) => r.dataset.hash)); + for (const c of controls) assert(!shownHashes.includes(c.hash), 'CONTROL packet ' + c.hash + ' still listed'); + }); + + await step('the checked choice survives a reload (URL without the param)', async () => { + await open(page, LIST); + assert(await page.isChecked('#fHideControl'), 'checkbox not checked after reload'); + assert((await shownCount(page)) === countAll - controls.length, 'CONTROL packets listed after reload'); + const q = await hashQuery(page); + assert(q.hideControl === '1', 'restored choice not written to the URL: ' + JSON.stringify(q)); + }); + + await step('a direct link to a CONTROL packet still opens it while the filter is on', async () => { + const target = controls[0].hash; + await page.goto('about:blank'); + await page.goto(BASE + '/#/packets/' + target + '?timeWindow=525600', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector(`table tbody tr[data-hash="${target}"]`, { timeout: 15000 }); + await page.waitForFunction((h) => (document.getElementById('pktRight')?.textContent || '').includes(h), target, { timeout: 15000 }); + assert(await page.evaluate((k) => localStorage.getItem(k), PREF_KEY) === '1', 'the saved choice changed'); + }); + + await step('unchecking shows them again, and the unchecked choice survives a reload', async () => { + await open(page, LIST); + await page.uncheck('#fHideControl'); + await page.waitForFunction((n) => { + const m = /\((\d+)\)/.exec(document.querySelector('#pktLeft .count')?.textContent || ''); + return m && Number(m[1]) === n; + }, countAll, { timeout: 5000 }); + assert(await page.evaluate((k) => localStorage.getItem(k), PREF_KEY) === '0', 'explicit unchecked choice not saved as "0"'); + await open(page, LIST); + assert(!(await page.isChecked('#fHideControl')), 'checkbox checked after reload'); + assert((await shownCount(page)) === countAll, 'CONTROL packets hidden after reload'); + }); + + await step('hideControl=1 in the URL wins over a saved unchecked choice', async () => { + await open(page, LIST + '&hideControl=1'); + assert(await page.isChecked('#fHideControl'), 'URL hideControl=1 did not check the box'); + assert((await shownCount(page)) === countAll - controls.length, 'CONTROL packets listed with hideControl=1'); + }); + + await step('phone width: the checkbox is reachable through the Filters toggle and fits the screen', async () => { + const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const p = await phone.newPage(); + await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await p.waitForSelector('#fHideControl', { state: 'attached', timeout: 15000 }); + if (!(await p.isVisible('#fHideControl'))) await p.click('#filterToggleBtn'); + await p.waitForSelector('#fHideControl', { state: 'visible', timeout: 5000 }); + const box = await p.locator('label:has(#fHideControl)').boundingBox(); + assert(box && box.x >= 0 && box.x + box.width <= 390, 'checkbox label does not fit the 390 px screen: ' + JSON.stringify(box)); + await phone.close(); + }); + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-issue-96-hide-control.js b/test-issue-96-hide-control.js new file mode 100644 index 000000000..433670b63 --- /dev/null +++ b/test-issue-96-hide-control.js @@ -0,0 +1,165 @@ +/* test-issue-96-hide-control.js — #96 + * + * Optional "Hide CONTROL packets" display filter on the packets page. + * + * Decisions under test: + * - readHideControlPref(urlValue, stored): off unless something turns it on. + * The URL (#/packets?hideControl=1|0) wins over the saved choice; without a + * URL value the saved choice applies, and an explicit saved '0' stays off. + * - filterHiddenControl(list, hide): drops only CONTROL (payload type 11), + * keeps the order of everything else, and returns the same array when off + * (no copy on the 30K-row default path). + * - buildPacketsQuery() puts hideControl=1 in the URL hash while it is on and + * omits it when off (the default), like the other packets filters. + * + * Runs the REAL functions from public/packets.js inside a vm sandbox + * (extracted by name, as test-issue-147-packets-url-detail-params.js does). + * A function that does not exist fails the tests that call it. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +console.log('--- test-issue-96-hide-control.js ---'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +const SRC = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8'); + +function extractFunction(marker) { + const idx = SRC.indexOf(marker); + if (idx === -1) return ''; + const open = SRC.indexOf('{', idx); + let depth = 0; + for (let i = open; i < SRC.length; i++) { + if (SRC[i] === '{') depth++; + else if (SRC[i] === '}' && --depth === 0) return SRC.substring(idx, i + 1); + } + throw new Error('unbalanced braces after ' + marker); +} + +function constLine(name) { + const m = SRC.match(new RegExp('const ' + name + ' = [^;]+;')); + return m ? m[0] : ''; +} + +function makeSandbox() { + const ctx = { + filters: {}, + hideControl: false, + _packetSortColumn: null, + _packetSortDirection: 'desc', + DEFAULT_TIME_WINDOW: 15, + window: {}, + encodeURIComponent, + }; + vm.createContext(ctx); + vm.runInContext([ + constLine('PAYLOAD_TYPE_CONTROL'), + extractFunction('function readHideControlPref('), + extractFunction('function filterHiddenControl('), + extractFunction('function buildPacketsQuery('), + // const declarations are not sandbox properties; expose what the tests read. + 'this.__fns = {' + + ' read: typeof readHideControlPref === "function" ? readHideControlPref : null,' + + ' filter: typeof filterHiddenControl === "function" ? filterHiddenControl : null,' + + ' query: typeof buildPacketsQuery === "function" ? buildPacketsQuery : null,' + + ' control: typeof PAYLOAD_TYPE_CONTROL === "number" ? PAYLOAD_TYPE_CONTROL : null };', + ].join('\n'), ctx); + return ctx; +} + +function fn(ctx, name) { + const f = ctx.__fns[name]; + assert(f, name + ' is not defined in public/packets.js'); + return f; +} + +console.log('\n=== readHideControlPref: default off, saved choice, URL wins ==='); +{ + const ctx = makeSandbox(); + test('no URL value and nothing saved: unchecked (current behaviour kept)', () => { + assert.strictEqual(fn(ctx, 'read')(null, null), false); + }); + test('saved "1": checked after a reload', () => { + assert.strictEqual(fn(ctx, 'read')(null, '1'), true); + }); + test('saved "0" (explicit unchecked choice): stays unchecked after a reload', () => { + assert.strictEqual(fn(ctx, 'read')(null, '0'), false); + }); + test('an unknown saved value is treated as unchecked', () => { + assert.strictEqual(fn(ctx, 'read')(null, 'true'), false); + }); + test('URL hideControl=1 wins over a saved "0"', () => { + assert.strictEqual(fn(ctx, 'read')('1', '0'), true); + }); + test('URL hideControl=0 wins over a saved "1"', () => { + assert.strictEqual(fn(ctx, 'read')('0', '1'), false); + }); + test('a URL value that is neither 1 nor 0 is ignored: the saved choice applies', () => { + assert.strictEqual(fn(ctx, 'read')('yes', '1'), true); + assert.strictEqual(fn(ctx, 'read')('yes', null), false); + }); +} + +console.log('\n=== filterHiddenControl: only CONTROL, order kept, no copy when off ==='); +{ + const ctx = makeSandbox(); + test('CONTROL is payload type 11 (firmware PAYLOAD_TYPE_CONTROL)', () => { + assert.strictEqual(ctx.__fns.control, 11); + }); + const list = [ + { hash: 'a', payload_type: 4 }, + { hash: 'b', payload_type: 11 }, + { hash: 'c', payload_type: 5 }, + { hash: 'd', payload_type: 11 }, + { hash: 'e' }, + ]; + test('on: removes every CONTROL packet and keeps the others in order', () => { + const out = fn(ctx, 'filter')(list, true); + assert.deepStrictEqual(out.map((p) => p.hash), ['a', 'c', 'e']); + assert.strictEqual(list.length, 5, 'the input array is not modified'); + }); + test('off: returns the same array (no copy, nothing hidden)', () => { + assert.strictEqual(fn(ctx, 'filter')(list, false), list); + }); + test('on, with no CONTROL packets: nothing is removed', () => { + const plain = [{ hash: 'x', payload_type: 1 }, { hash: 'y', payload_type: 2 }]; + assert.deepStrictEqual(fn(ctx, 'filter')(plain, true).map((p) => p.hash), ['x', 'y']); + }); +} + +console.log('\n=== buildPacketsQuery: hideControl=1 in the URL hash while on ==='); +{ + test('off (default): no hideControl param', () => { + const ctx = makeSandbox(); + ctx.hideControl = false; + assert.strictEqual(fn(ctx, 'query')(15, '', false), ''); + }); + test('on: hideControl=1', () => { + const ctx = makeSandbox(); + ctx.hideControl = true; + assert.strictEqual(fn(ctx, 'query')(15, '', false), '?hideControl=1'); + }); + test('on, with other filters: kept alongside them', () => { + const ctx = makeSandbox(); + ctx.hideControl = true; + ctx.filters = { observer: 'obs1' }; + const q = fn(ctx, 'query')(60, 'SJC', false); + const params = new URLSearchParams(q.slice(1)); + assert.strictEqual(params.get('hideControl'), '1'); + assert.strictEqual(params.get('observer'), 'obs1'); + assert.strictEqual(params.get('timeWindow'), '60'); + assert.strictEqual(params.get('region'), 'SJC'); + }); +} + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); From a9174c3b9910192d5d6ac015a0950c59fbfe71ab Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 08:48:06 +0000 Subject: [PATCH 2/5] feat(packets): optional Hide CONTROL packets filter (#96) An opt-in "Hide CONTROL packets" checkbox in the packets filter bar (toggles group, next to My Nodes). Unchecked by default, so nothing changes for users without a saved choice. - Display only: the filter runs in renderTableRows' existing chain, right after the type filter, as one pass over the loaded packets (filterHiddenControl). CONTROL packets are still fetched and kept, live ones too, so unchecking shows them again without a reload or a new API request. Ingest, retention, statistics and anomaly detection are untouched. - A pinned hash (a direct link to one packet) bypasses it, like every other client filter. - Saved as meshcore-hide-control = '1' or '0', so an explicit unchecked choice survives a reload. hideControl=1 is in the URL hash while it is on (buildPacketsQuery); a URL value wins over the saved choice for that view (readHideControlPref). - An empty list caused by the filter says so. - Styled with CSS variables only; the checkbox keeps a 16 px box and the label is the 44 px touch target on coarse pointers. Test updates: the #121 and #147 sandboxes declare the new closure state that buildPacketsQuery reads; the E2E also pins the checkbox size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mVL1VB4jtY7caZQnMWJpW --- public/packets.js | 38 ++++++++++++++++++++- public/style.css | 6 ++++ test-issue-121-clear-filters-selection.js | 1 + test-issue-147-packets-url-detail-params.js | 1 + test-issue-96-hide-control-e2e.js | 15 +++++++- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/public/packets.js b/public/packets.js index f501936ec..01e05387c 100644 --- a/public/packets.js +++ b/public/packets.js @@ -717,6 +717,24 @@ let filters = {}; { const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o; const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t; } + // #96 — opt-in "Hide CONTROL packets" display filter, unchecked by default. + // Display only: CONTROL packets are still fetched and kept (also live ones), + // so unchecking shows them again without a reload, and a pinned hash (a + // direct link to one packet) bypasses it like every other client filter. + // Saved as '1' or '0' so an explicit unchecked choice survives a reload. + const PAYLOAD_TYPE_CONTROL = 11; + const HIDE_CONTROL_KEY = 'meshcore-hide-control'; + // The URL value ('1' or '0') wins over the saved choice; default off. + function readHideControlPref(urlValue, stored) { + if (urlValue === '1') return true; + if (urlValue === '0') return false; + return stored === '1'; + } + // One pass, order kept; the same array when off (no copy on the default path). + function filterHiddenControl(list, hide) { + return hide ? list.filter(p => p.payload_type !== PAYLOAD_TYPE_CONTROL) : list; + } + let hideControl = readHideControlPref(null, localStorage.getItem(HIDE_CONTROL_KEY)); let wsHandler = null; let packetsPaused = false; let pauseBuffer = []; @@ -779,6 +797,7 @@ if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer)); if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel)); if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr)); + if (hideControl) parts.push('hideControl=1'); // #96; off (default) is omitted // Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted. if (_packetSortColumn) { var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc'; @@ -1199,6 +1218,9 @@ if (_urlChannel) filters.channel = _urlChannel; var _urlFilterExpr = _initUrlParams.get('filter'); if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr; + // #96 — ?hideControl=1|0 wins over the saved choice for this view; only + // the checkbox changes the saved choice. + hideControl = readHideControlPref(_initUrlParams.get('hideControl'), localStorage.getItem(HIDE_CONTROL_KEY)); // #749 — restore sort state from URL (overrides localStorage). var _urlSort = _initUrlParams.get('sort'); if (_urlSort && window.URLState) { @@ -1671,6 +1693,7 @@
+