diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 35e29204b..0d9aefb90 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -436,6 +436,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/public/packets.js b/public/packets.js
index f501936ec..8cfa2824f 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -715,8 +715,58 @@
function _hashStripeStyle(hash) { return _isColorByHash() && hash && window.HashColor ? 'border-left:4px solid ' + HashColor.hashToHsl(hash, _currentTheme()) + ';' : ''; }
let groupByHash = true;
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; }
+ // Storage can throw (blocked, private mode); the filters then start empty.
+ try {
+ const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o;
+ const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t;
+ } catch (e) { /* storage unavailable */ }
+ // #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';
+ }
+ // The saved choice, or null when storage throws (blocked, private mode).
+ function readStoredHideControl() {
+ try { return localStorage.getItem(HIDE_CONTROL_KEY); } catch (e) { return null; }
+ }
+ // Saves the choice; false when storage throws (quota, blocked). Best effort:
+ // the checkbox still works for this view.
+ function saveHideControlPref(hide) {
+ try { localStorage.setItem(HIDE_CONTROL_KEY, hide ? '1' : '0'); return true; } catch (e) { return false; }
+ }
+ // 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;
+ }
+ // Whether hiding CONTROL is what emptied the list: some CONTROL packet from
+ // before the CONTROL pass gets through the filters after it (laterFilters).
+ // Called only for an empty list, and runs the CONTROL packets only.
+ function controlHidingEmptiedList(beforeHide, laterFilters) {
+ const hidden = beforeHide.filter(p => p.payload_type === PAYLOAD_TYPE_CONTROL);
+ return hidden.length > 0 && laterFilters(hidden).length > 0;
+ }
+ // The checkbox's change: re-filters the loaded packets, no new request. The
+ // save comes first but cannot stop the filter or the URL update (#211).
+ function setHideControl(hide) {
+ hideControl = hide;
+ if (saveHideControlPref(hide)) savedHideControl = hide;
+ updatePacketsUrl();
+ renderTableRows();
+ }
+ // hideControl is what this view shows; savedHideControl is the saved choice
+ // as far as this page knows (it changes only when a save succeeds).
+ // buildPacketsQuery reads both, so a test sandbox that extracts it must
+ // declare both (test-issue-121/147-…, test-issue-96-hide-control.js).
+ let savedHideControl = readHideControlPref(null, readStoredHideControl());
+ let hideControl = savedHideControl;
let wsHandler = null;
let packetsPaused = false;
let pauseBuffer = [];
@@ -779,6 +829,11 @@
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));
+ // #96: hideControl=1 while on. Off is the default and is omitted, except
+ // while the saved choice is on: an explicit hideControl=0 then stays, so a
+ // reload of this URL still shows CONTROL (#211).
+ if (hideControl) parts.push('hideControl=1');
+ else if (savedHideControl) parts.push('hideControl=0');
// Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted.
if (_packetSortColumn) {
var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc';
@@ -1199,6 +1254,11 @@
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.
+ var _storedHideControl = readStoredHideControl();
+ savedHideControl = readHideControlPref(null, _storedHideControl);
+ hideControl = readHideControlPref(_initUrlParams.get('hideControl'), _storedHideControl);
// #749 — restore sort state from URL (overrides localStorage).
var _urlSort = _initUrlParams.get('sort');
if (_urlSort && window.URLState) {
@@ -1671,6 +1731,7 @@
Group by Hash
My Nodes
+ Hide CONTROL packets
Last 15 min
Last 30 min
@@ -2065,6 +2126,8 @@
this.classList.toggle('active', filters.myNodes);
loadPackets();
});
+ // #96 — re-filters the loaded packets; no new request.
+ document.getElementById('fHideControl').addEventListener('change', function () { setHideControl(this.checked); });
// Observation sort dropdown
const obsSortSel = document.getElementById('fObsSort');
@@ -2980,6 +3043,10 @@
const types = filters.type.split(',').map(Number);
displayPackets = displayPackets.filter(p => types.includes(p.payload_type));
}
+ // #96 — Hide CONTROL packets (display only; a pinned hash bypasses it).
+ const beforeHideControl = displayPackets;
+ if (!hashOnly) displayPackets = filterHiddenControl(displayPackets, hideControl);
+ const controlHidden = displayPackets.length < beforeHideControl.length;
displayPackets = applyObserverFilter(displayPackets, filters, groupByHash, hashOnly);
// Packet Filter Language
@@ -3007,7 +3074,13 @@
_lastVisibleEnd = -1;
detachVScrollListener();
const colCount = _getColCount();
- tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + ' ';
+ // #211: name CONTROL only when hiding it emptied the list, i.e. a CONTROL
+ // packet would get through the filters below the CONTROL pass.
+ const emptiedByControl = controlHidden && controlHidingEmptiedList(beforeHideControl, (list) => {
+ list = applyObserverFilter(list, filters, groupByHash, hashOnly);
+ return filters._packetFilter ? list.filter(filters._packetFilter) : list;
+ });
+ tbody.innerHTML = '' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : emptiedByControl ? 'No packets found (CONTROL packets are hidden)' : 'No packets found') + ' ';
// Restore scroll position after DOM rebuild (#431)
if (scrollContainer) scrollContainer.scrollTop = savedScrollTop;
return;
@@ -4219,6 +4292,8 @@
_calcVisibleRange,
buildPacketsParams,
applyObserverFilter,
+ readHideControlPref,
+ filterHiddenControl,
buildReplayPackets,
renderTableRows,
_setPackets: function(p) { packets = p; },
diff --git a/public/style.css b/public/style.css
index 5a72043eb..75b358575 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1058,6 +1058,12 @@ img.brand-logo {
}
.sort-help:hover .sort-help-tip { display: block; }
.filter-bar .btn:hover { background: var(--row-hover); }
+/* #96 — "Hide CONTROL packets" checkbox in the packets filter bar. */
+.filter-bar .filter-checkbox { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text); white-space: nowrap; cursor: pointer; }
+.filter-bar .filter-checkbox input[type="checkbox"] { /* not the text-input box above */
+ width: 16px; height: 16px; min-width: 0; min-height: 0; padding: 0; margin: 0; cursor: pointer; accent-color: var(--accent);
+}
+@media (pointer: coarse) { .filter-bar .filter-checkbox { min-height: 44px; } } /* the label is the touch target */
.filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); }
.filter-bar .col-toggle-btn { height: 34px; min-height: 34px; }
diff --git a/test-all.sh b/test-all.sh
index f5727f71e..a505c5a17 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-121-clear-filters-selection.js b/test-issue-121-clear-filters-selection.js
index 4e3deb429..646774c43 100644
--- a/test-issue-121-clear-filters-selection.js
+++ b/test-issue-121-clear-filters-selection.js
@@ -136,6 +136,8 @@ function mount(state, filters) {
_rebuildObserverMenu: null,
_packetSortColumn: null,
_packetSortDirection: 'desc',
+ hideControl: false, // #96 closure state read by buildPacketsQuery
+ savedHideControl: false, // #211 likewise
};
sandbox.observerMap = new Map(sandbox.observers.map((o) => [o.id, o]));
vm.createContext(sandbox);
diff --git a/test-issue-147-packets-url-detail-params.js b/test-issue-147-packets-url-detail-params.js
index 36ad31b91..b8f624c6c 100644
--- a/test-issue-147-packets-url-detail-params.js
+++ b/test-issue-147-packets-url-detail-params.js
@@ -103,6 +103,7 @@ function makeSandbox(startHash, opts) {
vm.runInContext(
'var filters = {}; var savedTimeWindowMin = 15; var DEFAULT_TIME_WINDOW = 15;\n' +
"var _packetSortColumn = null; var _packetSortDirection = 'desc';\n" +
+ "var hideControl = false; var savedHideControl = false;\n" + // #96/#211 closure state read by buildPacketsQuery
'var _observerFilterSet = null; var selectedObservers = new Set(); var selectedTypes = new Set();\n' +
'function buildObserverMenu() {} function updateObsTrigger() {} function buildTypeMenu() {}\n' +
'function updateTypeTrigger() {} function loadPackets() {}\n' +
diff --git a/test-issue-96-hide-control-e2e.js b/test-issue-96-hide-control-e2e.js
new file mode 100644
index 000000000..6e054860e
--- /dev/null
+++ b/test-issue-96-hide-control-e2e.js
@@ -0,0 +1,218 @@
+/**
+ * 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.
+ * #211 review:
+ * - an explicit hideControl=0 over a saved "1" survives the page's own URL
+ * rewrite and a reload;
+ * - the empty-list note names CONTROL only when hiding CONTROL emptied it;
+ * - a localStorage.setItem that throws does not stop the checkbox.
+ *
+ * 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]);
+}
+
+// Load url as a new document and wait for the empty-list row.
+async function openEmpty(page, url) {
+ await page.goto('about:blank');
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#fHideControl', { state: 'attached', timeout: 15000 });
+ await page.waitForFunction(() => /No packets/.test(document.querySelector('#pktBody td')?.textContent || ''), null, { timeout: 15000 });
+ return (await page.textContent('#pktBody td')).trim();
+}
+
+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');
+ const box = await page.locator('#fHideControl').boundingBox();
+ assert(box && box.width <= 24 && box.height <= 24, 'the checkbox is drawn as a text-input box: ' + JSON.stringify(box));
+ countAll = await shownCount(page);
+ // Earlier E2E steps in the same CI job can add packets to the server, so
+ // the list may hold more than the snapshot above, never fewer.
+ assert(countAll >= all.length, `list shows ${countAll}, API had ${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('#211: an explicit hideControl=0 over a saved "1" survives the URL rewrite and a reload', async () => {
+ await page.evaluate((k) => localStorage.setItem(k, '1'), PREF_KEY);
+ await open(page, LIST + '&hideControl=0');
+ assert(!(await page.isChecked('#fHideControl')), 'URL hideControl=0 did not uncheck the box');
+ assert((await shownCount(page)) === countAll, 'CONTROL packets hidden with hideControl=0');
+ // renderLeft() rewrites the URL on load; the override must still be there.
+ let q = await hashQuery(page);
+ assert(q.hideControl === '0', 'hideControl=0 dropped by the URL rewrite: ' + JSON.stringify(q));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
+ assert(!(await page.isChecked('#fHideControl')), 'the saved "1" won after a reload of the same URL');
+ assert((await shownCount(page)) === countAll, 'CONTROL packets hidden after a reload of the same URL');
+ q = await hashQuery(page);
+ assert(q.hideControl === '0', 'hideControl=0 lost after the reload: ' + JSON.stringify(q));
+ assert(await page.evaluate((k) => localStorage.getItem(k), PREF_KEY) === '1', 'the URL override changed the saved choice');
+ });
+
+ await step('#211: the empty-list note names CONTROL only when hiding CONTROL emptied the list', async () => {
+ const onlyControl = await openEmpty(page, LIST + '&hideControl=1&filter=' + encodeURIComponent('type == CONTROL'));
+ assert(onlyControl === 'No packets found (CONTROL packets are hidden)', 'expression matching only CONTROL: ' + onlyControl);
+ const nothing = await openEmpty(page, LIST + '&hideControl=1&filter=' + encodeURIComponent('snr > 999'));
+ assert(nothing === 'No packets found', 'expression matching nothing blamed CONTROL: ' + nothing);
+ });
+
+ await step('#211: a localStorage.setItem that throws does not stop the checkbox', async () => {
+ const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ await ctx2.addInitScript((k) => {
+ const orig = Storage.prototype.setItem;
+ Storage.prototype.setItem = function (key, value) {
+ if (key === k) throw new DOMException('quota exceeded', 'QuotaExceededError');
+ return orig.call(this, key, value);
+ };
+ }, PREF_KEY);
+ const p = await ctx2.newPage();
+ const errors = [];
+ p.on('pageerror', (e) => errors.push(e.message));
+ await open(p, LIST);
+ await p.check('#fHideControl');
+ await p.waitForFunction((n) => {
+ const m = /\((\d+)\)/.exec(document.querySelector('#pktLeft .count')?.textContent || '');
+ return m && Number(m[1]) === n;
+ }, countAll - controls.length, { timeout: 5000 });
+ const q = await hashQuery(p);
+ assert(q.hideControl === '1', 'hideControl=1 not in the URL: ' + JSON.stringify(q));
+ assert(!errors.some((m) => /quota/i.test(m)), 'the storage error escaped the handler: ' + errors.join(' | '));
+ await ctx2.close();
+ });
+
+ 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'))) {
+ // On mobile the in-page Filters button is replaced by a navbar mirror
+ // (mobile-page-actions.js); the in-page one is the fallback.
+ const toggle = (await p.$('.filter-toggle-btn-mirror')) ? '.filter-toggle-btn-mirror' : '#filterToggleBtn';
+ await p.click(toggle);
+ }
+ 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));
+ const input = await p.locator('#fHideControl').boundingBox();
+ // The label, not the box, is the 44 px touch target.
+ assert(input && input.width <= 24 && input.height <= 24, 'the checkbox is drawn as a text-input box: ' + JSON.stringify(input));
+ assert(box.height >= 44, 'the label is not a 44 px touch target: ' + JSON.stringify(box));
+ await p.check('#fHideControl');
+ assert(await p.evaluate((k) => localStorage.getItem(k), PREF_KEY) === '1', 'checking it at phone width did not save the choice');
+ 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..91576a7fb
--- /dev/null
+++ b/test-issue-96-hide-control.js
@@ -0,0 +1,292 @@
+/* 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. Off is
+ * kept as hideControl=0 while the saved choice is on, so the next URL
+ * rewrite does not drop an explicit hideControl=0 (#211 review).
+ * - The checkbox's change (setHideControl) filters and updates the URL even
+ * when localStorage throws; the saved choice is best effort (#211 review).
+ * - controlHidingEmptiedList: the empty-list note blames CONTROL only when a
+ * hidden CONTROL packet would pass the later filters (#211 review).
+ *
+ * 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] : '';
+}
+
+// storage: the localStorage the sandbox sees (default: an in-memory one).
+function makeSandbox(storage) {
+ const store = {};
+ const ctx = {
+ localStorage: storage || {
+ getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ },
+ calls: [],
+ updatePacketsUrl() { ctx.calls.push('url'); },
+ renderTableRows() { ctx.calls.push('render'); },
+ filters: {},
+ hideControl: false,
+ savedHideControl: false,
+ _packetSortColumn: null,
+ _packetSortDirection: 'desc',
+ DEFAULT_TIME_WINDOW: 15,
+ window: {},
+ encodeURIComponent,
+ };
+ vm.createContext(ctx);
+ vm.runInContext([
+ constLine('PAYLOAD_TYPE_CONTROL'),
+ constLine('HIDE_CONTROL_KEY'),
+ extractFunction('function readHideControlPref('),
+ extractFunction('function readStoredHideControl('),
+ extractFunction('function saveHideControlPref('),
+ extractFunction('function filterHiddenControl('),
+ extractFunction('function controlHidingEmptiedList('),
+ extractFunction('function setHideControl('),
+ 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,' +
+ ' readStored: typeof readStoredHideControl === "function" ? readStoredHideControl : null,' +
+ ' save: typeof saveHideControlPref === "function" ? saveHideControlPref : null,' +
+ ' set: typeof setHideControl === "function" ? setHideControl : null,' +
+ ' emptied: typeof controlHidingEmptiedList === "function" ? controlHidingEmptiedList : null,' +
+ ' key: typeof HIDE_CONTROL_KEY === "string" ? HIDE_CONTROL_KEY : 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=== #211 review, nit 1: a throwing localStorage does not stop the checkbox ===');
+{
+ const throwing = {
+ getItem() { throw new Error('SecurityError: storage blocked'); },
+ setItem() { throw new Error('QuotaExceededError'); },
+ };
+ test('readStoredHideControl: a throwing getItem reads as no saved choice', () => {
+ const ctx = makeSandbox(throwing);
+ assert.strictEqual(fn(ctx, 'readStored')(), null);
+ });
+ test('saveHideControlPref: a throwing setItem is caught and reported as not saved', () => {
+ const ctx = makeSandbox(throwing);
+ assert.strictEqual(fn(ctx, 'save')(true), false);
+ });
+ test('saveHideControlPref: saves "1"/"0" under the key and reports success', () => {
+ const ctx = makeSandbox();
+ assert.strictEqual(fn(ctx, 'save')(true), true);
+ assert.strictEqual(ctx.localStorage.getItem(ctx.__fns.key), '1');
+ assert.strictEqual(fn(ctx, 'save')(false), true);
+ assert.strictEqual(ctx.localStorage.getItem(ctx.__fns.key), '0');
+ });
+ test('setHideControl with a throwing setItem: still filters and updates the URL', () => {
+ const ctx = makeSandbox(throwing);
+ fn(ctx, 'set')(true);
+ assert.strictEqual(ctx.hideControl, true, 'hideControl not set');
+ assert.deepStrictEqual(ctx.calls, ['url', 'render'], 'URL update and re-render must still run');
+ });
+ test('setHideControl: saves the choice, then updates the URL, then re-renders', () => {
+ const ctx = makeSandbox();
+ fn(ctx, 'set')(true);
+ assert.strictEqual(ctx.localStorage.getItem(ctx.__fns.key), '1');
+ assert.strictEqual(ctx.savedHideControl, true);
+ assert.deepStrictEqual(ctx.calls, ['url', 'render']);
+ });
+}
+
+console.log('\n=== #211 review, nit 2: an explicit hideControl=0 survives a URL rewrite ===');
+{
+ test('off while the saved choice is on (URL override): hideControl=0 stays in the URL', () => {
+ const ctx = makeSandbox();
+ ctx.hideControl = false;
+ ctx.savedHideControl = true;
+ assert.strictEqual(fn(ctx, 'query')(15, '', false), '?hideControl=0');
+ });
+ test('off and saved off: still omitted (the default)', () => {
+ const ctx = makeSandbox();
+ ctx.hideControl = false;
+ ctx.savedHideControl = false;
+ assert.strictEqual(fn(ctx, 'query')(15, '', false), '');
+ });
+ test('on while the saved choice is off: hideControl=1 (unchanged)', () => {
+ const ctx = makeSandbox();
+ ctx.hideControl = true;
+ ctx.savedHideControl = false;
+ assert.strictEqual(fn(ctx, 'query')(15, '', false), '?hideControl=1');
+ });
+ test('unchecking with a saved "1": saved becomes "0", so the param is dropped', () => {
+ const ctx = makeSandbox();
+ ctx.localStorage.setItem(ctx.__fns.key, '1');
+ ctx.hideControl = true;
+ ctx.savedHideControl = true;
+ fn(ctx, 'set')(false);
+ assert.strictEqual(ctx.savedHideControl, false);
+ assert.strictEqual(fn(ctx, 'query')(15, '', false), '');
+ });
+ test('unchecking when the save fails: the saved "1" still applies, so hideControl=0 stays', () => {
+ const ctx = makeSandbox({ getItem: () => '1', setItem() { throw new Error('QuotaExceededError'); } });
+ ctx.hideControl = true;
+ ctx.savedHideControl = true;
+ fn(ctx, 'set')(false);
+ assert.strictEqual(ctx.savedHideControl, true, 'a failed save must not change what is saved');
+ assert.strictEqual(fn(ctx, 'query')(15, '', false), '?hideControl=0');
+ });
+}
+
+console.log('\n=== #211 review, nit 3: the empty-list note blames CONTROL only when it is the cause ===');
+{
+ const ctx = makeSandbox();
+ const beforeHide = [
+ { hash: 'a', payload_type: 4, snr: 1 },
+ { hash: 'b', payload_type: 11, snr: 9 },
+ { hash: 'c', payload_type: 11, snr: 2 },
+ ];
+ test('a hidden CONTROL packet passes the later filters: hiding CONTROL emptied the list', () => {
+ assert.strictEqual(fn(ctx, 'emptied')(beforeHide, (l) => l.filter((p) => p.snr > 5)), true);
+ });
+ test('no hidden CONTROL packet passes the later filters: another filter emptied it', () => {
+ assert.strictEqual(fn(ctx, 'emptied')(beforeHide, (l) => l.filter((p) => p.snr > 50)), false);
+ });
+ test('only CONTROL packets are run through the later filters', () => {
+ const seen = [];
+ fn(ctx, 'emptied')(beforeHide, (l) => { seen.push(...l.map((p) => p.hash)); return []; });
+ assert.deepStrictEqual(seen, ['b', 'c']);
+ });
+ test('no CONTROL packet before hiding: not blamed, later filters not run', () => {
+ let ran = false;
+ assert.strictEqual(fn(ctx, 'emptied')([{ hash: 'a', payload_type: 4 }], (l) => { ran = true; return l; }), false);
+ assert.strictEqual(ran, false);
+ });
+}
+
+console.log(`\n${passed} passed, ${failed} failed`);
+process.exit(failed > 0 ? 1 : 0);