Files
meshcore-analyzer/test-live-multibyte-only-e2e.js
T
efiten 5c0de8fb41 feat(live): optional "Multibyte only" view filter (#1780) (#1781)
Closes #1780.

## What

Adds an opt-in **"Multibyte only"** toggle to the live map controls.
When ON, packets whose path hash size is `< 2` bytes (single-byte, or
unresolvable) are excluded from the entire live view — feed, map
polylines/rain, and the packet counter — in both LIVE and REPLAY modes.

- **Default OFF** — no behavior change for existing users.
- Persisted in `localStorage` under `live-multibyte-only`.
- Distinct from the existing global "hide 1-byte path hops" toggle: that
filters individual hops within a path at every render site; this filters
whole packets, on the live view only. They share no state.

## How

- **`public/hop-filter.js`** — new pure, dependency-free classifier
`MC_packetHashSize(rawHex, routeType)` returning `1|2|3`, or `0` when
unresolvable. Reads the path-length byte from `raw_hex` (`(pathByte >>
6) + 1`), offset `5` for transport routes (route_type 0/3) else `1` —
mirroring the existing `getPathLenOffset`/`computeBreakdownRanges` logic
in `app.js`. Lives next to the existing `hopByteLen`/`MC_*` family;
`app.js` is untouched (no duplication of the byte math).
- **`public/live.js`** — `groupIsMultibyte(packets)` consumes that
helper; applied at two render-time sites: the top of `renderPacketTree`
(above the counter increment, so the counter reflects multibyte-only)
and inside the `rebuildFeedList` group loop (so toggling re-filters the
buffered feed). Toggle markup + change handler mirror the existing
`liveFavoritesToggle` pattern.

## Why read from `raw_hex` and not the path hops

The hash size is a property of the whole packet and is present even for
zero-hop packets (where there are no hops to inspect), so reading the
path-length byte is correct in all cases. Unresolvable size is treated
as single-byte (excluded when ON) — we only show packets we can
positively confirm are multibyte.

## Performance (hot path)

The filter runs in the packet-render hot path, so: classification is
**O(1) per packet group** — it reads the first resolvable observation's
`raw_hex` (a short hex string, single `parseInt` of one byte) and
short-circuits. No per-packet API calls, no allocation in the loop, no
added O(n²). When the toggle is OFF (default) the check is a single
boolean guard and does nothing else. The buffered-feed re-filter reuses
the existing `rebuildFeedList` pass — no extra traversal.

## Tests

- **Unit** (`test-live-multibyte-filter.js`, 9 cases):
single/2-byte/3-byte classification, transport-route offset,
missing/short/garbage `raw_hex` → 0, whitespace tolerance.
- **E2E** (`test-live-multibyte-only-e2e.js`, Playwright): toggle
present and defaults OFF; ON hides a single-byte packet while a
multibyte one renders; OFF restores it; setting persists across reload.
Registered in the CI live-E2E block in `deploy.yml`.

## Docs

User-guide entry added in `docs/user-guide/live.md`.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:56:08 -07:00

132 lines
5.9 KiB
JavaScript

/**
* E2E: live "Multibyte only" toggle.
* 1. Toggle exists in live controls and defaults OFF.
* 2. With it ON, a single-byte synthetic packet does NOT create a feed item,
* while a multibyte one does.
* 3. Turning it OFF and rebuilding shows the previously-hidden single-byte pkt.
* 4. The setting persists across a reload (localStorage round-trip).
*
* Usage: BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
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'); }
// raw_hex builders (non-transport, route_type 1 => path-len byte at offset 1).
// byte0 = 0x10 header; byte1 top bits set hash size.
const SINGLE_HEX = '1000'; // (0x00>>6)+1 = 1
const MULTI_HEX = '1040'; // (0x40>>6)+1 = 2
function makePkt(hash, rawHex) {
return {
id: Math.floor(Math.random() * 1e9),
hash: hash,
raw_hex: rawHex,
route_type: 1,
path_json: '[]',
observer_id: 'mb-e2e-obs',
observer_name: 'mb-e2e',
timestamp: new Date().toISOString(),
snr: 5, rssi: -90,
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { text: 'mb-probe' },
path: { hops: [] },
},
};
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== live multibyte-only E2E against ' + BASE + ' ===');
await step('navigate to /#/live, toggle exists and defaults OFF', async () => {
await page.addInitScript(() => {
try { localStorage.removeItem('live-multibyte-only'); } catch (e) {}
});
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window._liveBufferPacket, { timeout: 15000 });
const cb = await page.$('#liveMultibyteToggle');
assert(cb, '#liveMultibyteToggle must exist in the live controls');
const checked = await page.evaluate(() => document.getElementById('liveMultibyteToggle').checked);
assert(checked === false, 'multibyte toggle must default OFF');
});
const singleHash = 'mb-single-' + Date.now().toString(16);
const multiHash = 'mb-multi-' + Date.now().toString(16);
await step('turn toggle ON: single-byte packet hidden, multibyte shown', async () => {
await page.evaluate(() => {
const cb = document.getElementById('liveMultibyteToggle');
cb.checked = true;
cb.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.evaluate((args) => {
window._liveBufferPacket(args.single);
window._liveBufferPacket(args.multi);
}, { single: makePkt(singleHash, SINGLE_HEX), multi: makePkt(multiHash, MULTI_HEX) });
await page.waitForFunction((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'),
multiHash, { timeout: 5000 }).catch(() => {});
const multiShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), multiHash);
const singleShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), singleHash);
assert(multiShown, 'multibyte packet should render a feed item when toggle ON');
assert(!singleShown, 'single-byte packet must NOT render a feed item when toggle ON');
});
await step('turn toggle OFF: previously-hidden single-byte packet appears', async () => {
await page.evaluate(() => {
const cb = document.getElementById('liveMultibyteToggle');
cb.checked = false;
cb.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForFunction((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'),
singleHash, { timeout: 5000 }).catch(() => {});
const singleShown = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), singleHash);
assert(singleShown, 'single-byte packet should reappear after toggle OFF + feed rebuild');
});
await step('setting persists across reload', async () => {
// Ensure the toggle is ON and localStorage is written.
await page.evaluate(() => {
const cb = document.getElementById('liveMultibyteToggle');
cb.checked = true;
cb.dispatchEvent(new Event('change', { bubbles: true }));
});
// Open a fresh context (no addInitScript) to simulate a cold reload.
const persistCtx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const persistPage = await persistCtx.newPage();
persistPage.setDefaultTimeout(15000);
// Seed localStorage before navigation so the page boots with it set.
await persistPage.addInitScript(() => {
localStorage.setItem('live-multibyte-only', 'true');
});
await persistPage.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await persistPage.waitForFunction(() => !!document.getElementById('liveMultibyteToggle'), { timeout: 15000 });
const checked = await persistPage.evaluate(() => document.getElementById('liveMultibyteToggle').checked);
await persistCtx.close();
assert(checked === true, 'multibyte toggle should restore checked=true from localStorage');
});
await browser.close();
console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n');
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });