diff --git a/.eslintrc.json b/.eslintrc.json index 03a4a5099..635ab181e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -245,6 +245,7 @@ "invalidateApiCache": "readonly", "isFavorite": "readonly", "isPathHashSizeWarn": "readonly", + "isUnscopedFlood": "readonly", "isTransportRoute": "readonly", "makeColumnsResizable": "readonly", "makeRoleMarkerSVG": "readonly", @@ -266,6 +267,7 @@ "pullReconnect": "readonly", "qrcode": "readonly", "registerPage": "readonly", + "renderNoScope": "readonly", "renderPathHashSize": "readonly", "renderVersionCard": "readonly", "renderSkewBadge": "readonly", diff --git a/public/app.js b/public/app.js index a5639f119..76796a91b 100644 --- a/public/app.js +++ b/public/app.js @@ -102,6 +102,34 @@ function renderPathHashSize(size, text, opts) { '' + label + rec + ''; } +/** + * #480: the ONE decision for "this flood carries no region scope". Decided + * from the transmission's route type only: FLOOD (1) is unscoped, + * TRANSPORT_FLOOD (0) is scoped whether or not its name is known (an unknown + * name has scope_name = '' and is NOT "no scope"). DIRECT (2) and + * TRANSPORT_DIRECT (3) are not flood scope, so they get no marker. + */ +function isUnscopedFlood(routeType) { + return routeType === 1 || routeType === '1'; +} +/** + * #480: the ONE markup for the "no scope" recommendation, shared by the + * Channels message meta line and the packet-detail Scope row. Same amber + * --path-hash-warn recommendation style as #353, a warning (not error) icon. + * The explanation states only the firmware behaviour (companion default flood + * scope; repeater flood.max.unscoped / region denyf, firmware + * docs/cli_commands.md) and never attributes a low heard-count to it. Returns + * '' unless the route type is an unscoped FLOOD. + */ +function renderNoScope(routeType) { + if (!isUnscopedFlood(routeType)) return ''; + const tip = 'Sent without a scope. The message floods outside your region, and repeaters that limit ' + + 'unscoped traffic (flood.max.unscoped / region denyf) may stop it. ' + + 'Recommended: set a default scope (region) in the companion app.'; + return '' + + '' + + 'no scope — sent without a scope. Recommended: set a default scope (region) in the companion app'; +} /** * scopeName is optional (callers that don't pass it get the original * unscoped "T" badge). Pass a packet's scope_name to also surface the diff --git a/public/channels.js b/public/channels.js index 11d2492b7..39b0d1149 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2840,6 +2840,11 @@ const isTransportRoute = msg.routeType === 0 || msg.routeType === 3; if (msg.scope) meta.push(`scope: ${escapeHtml(msg.scope)}`); else if (isTransportRoute) meta.push('scope: unknown'); + else if (typeof renderNoScope === 'function') { + // #480: no scope on a plain FLOOD (decided from the route type, not the name). + const noScopeHtml = renderNoScope(msg.routeType); + if (noScopeHtml) meta.push(noScopeHtml); + } // msg.area (set server-side from the message's own path[0] // entry-point repeater, not from the scope string) is where the // SENDER physically was — distinct from the scope-linked area diff --git a/public/packets.js b/public/packets.js index af9c8011e..28fd030d4 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3768,6 +3768,17 @@ return inner ? '
Hash Size
' + inner + '
' : ''; } + // #480: Scope row. A named/unknown scope keeps its existing display; an + // unscoped FLOOD GRP_TXT/GRP_DATA gets the shared "no scope" marker. + function scopeDetailHtml(pkt) { + if (pkt.scope_name != null) { + return '
Scope
' + (pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : 'unknown scope') + '
'; + } + if (pkt.payload_type !== 5 && pkt.payload_type !== 6) return ''; + const marker = renderNoScope(pkt.route_type); + return marker ? '
Scope
' + marker + '
' : ''; + } + async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; const observations = data.observations || []; @@ -4004,7 +4015,7 @@ ${locationHtml ? `
Location
${locationHtml}
` : ''}
SNR / RSSI
${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}
Route Type
${routeTypeName(pkt.route_type)}
- ${pkt.scope_name != null ? `
Scope
${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : 'unknown scope'}
` : ''} + ${scopeDetailHtml(pkt)} ${hashSizeDetailHtml(hashSize)}
Propagation
${propagationHtml}
${transportCodesRow} @@ -4689,6 +4700,7 @@ getDetailPreview, buildDetailMessageHtml, hashSizeDetailHtml, + scopeDetailHtml, sortGroupChildren, getPathHopCount, renderDecodedPacket, diff --git a/public/style.css b/public/style.css index e0a290feb..f4c54856b 100644 --- a/public/style.css +++ b/public/style.css @@ -2036,6 +2036,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } * public/app.js). The icon + sr-only text carry it without colour. */ .path-hash-warn { color: var(--path-hash-warn); font-weight: 600; } .path-hash-warn .ph-icon { margin-right: 3px; vertical-align: -0.125em; } +/* #480 — "no scope" marker (renderNoScope in app.js): reuses the amber + * --path-hash-warn recommendation colour; stays compact on most messages. */ +.no-scope { white-space: nowrap; } .ch-path-hash-badge--warn { border-color: var(--path-hash-warn); align-items: center; } .node-path-hash-badge { background: var(--surface-2); border: 1px solid var(--path-hash-warn); font-size: 10px; } .ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; } diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json index 5b79fa183..c2fcdb171 100644 --- a/scripts/e2e-shards.json +++ b/scripts/e2e-shards.json @@ -1,6 +1,14 @@ { "version": 1, "suites": [ + { + "file": "test-issue-480-no-scope-e2e.js", + "shard": 2, + "env": { "BASE_URL": "$BASE_URL" }, + "output": "e2e", + "meanSeconds": 0, + "measurementNote": "New suite; no CI duration measured yet. Assigned to the lowest current measured shard total." + }, { "file": "test-perf-ws-history-e2e.js", "shard": 2, diff --git a/test-all.sh b/test-all.sh index c4610234c..1934b557f 100755 --- a/test-all.sh +++ b/test-all.sh @@ -197,6 +197,7 @@ run test-issue-1562-observers-summary.js run test-issue-1509-nav-active-bg.js run test-issue-1509-detect-preset.js run test-issue-353-path-hash-warn.js +run test-issue-480-no-scope.js run test-live.js run test-issue-220-live-filter-readiness.js run test-coverage-gate.js diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js index c6e6b8348..e590d0c43 100644 --- a/test-issue-353-path-hash-warn-e2e.js +++ b/test-issue-353-path-hash-warn-e2e.js @@ -245,7 +245,7 @@ async function assertContrast(page, selector, where) { const size = page.locator('dl.detail-meta .detail-hash-size'); await size.waitFor(); assert((await size.textContent()) === '2 bytes', 'text ' + await size.textContent()); - assert(await page.locator('dl.detail-meta .path-hash-warn').count() === 0, '2-byte packet warns'); + assert(await page.locator('dl.detail-meta .detail-hash-size.path-hash-warn').count() === 0, '2-byte packet warns'); }); await step(`${v.name}: node detail shows the 1-byte badge, not on a 2-byte node`, async () => { diff --git a/test-issue-480-no-scope-e2e.js b/test-issue-480-no-scope-e2e.js new file mode 100644 index 000000000..4119dd553 --- /dev/null +++ b/test-issue-480-no-scope-e2e.js @@ -0,0 +1,262 @@ +/** + * #480 — browser regression: a channel message sent without a region scope + * (route type FLOOD) shows a compact amber "no scope" marker on the Channels + * meta line and in the packet-detail Scope row; scoped messages (including a + * scoped one whose name is unknown), DIRECT routes and non-channel packets + * show none. Light and dark theme, desktop and 360x800, axe color-contrast on + * every marker, no horizontal overflow. + * + * Uses the real fixture (an unscoped #wardriving message, the unscoped + * GRP_DATA packet 17910000deadbeef) plus channels.js' own test hooks and a + * stubbed /api/packets/ for the scoped variants. Only the local test + * server is contacted. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-480-no-scope-e2e.js + * Optional: SCREENSHOT_DIR= saves one screenshot per page/theme/viewport. + */ +'use strict'; +const path = require('path'); +const { chromium } = require('playwright'); +const { AxeBuilder } = require('@axe-core/playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOTS = process.env.SCREENSHOT_DIR || ''; +const GRP_DATA_HASH = '17910000deadbeef'; +const H_UNSCOPED = 'e2e480aa11223344'; +const H_SCOPED = 'e2e480bb11223344'; +const H_UNKNOWN = 'e2e480cc11223344'; +const H_DIRECT = 'e2e480dd11223344'; +const H_TFNULL = 'e2e480ff11223344'; +const H_ADVERT = 'e2e480ee11223344'; +const DECODED = JSON.stringify({ type: 'CHAN', channel: '#e2e-480', sender: 'ScopeNode', text: 'scope check' }); +const TIP = /^Sent without a scope\. The message floods outside your region, and repeaters that limit unscoped traffic \(flood\.max\.unscoped \/ region denyf\) may stop it\. Recommended: set a default scope \(region\) in the companion app\.$/; + +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'); } + +function packetBody(hash, o) { + const raw = '15' + '00' + 'aabbccddeeff00112233445566778899'; + const obs = { + id: 948000001, raw_hex: raw, timestamp: '2026-10-07T10:00:00Z', + observer_id: 'e2e-480-obs', observer_name: 'e2e-480-obs', direction: 'rx', + snr: -5, rssi: -101, score: null, hash, route_type: o.route_type, payload_type: o.payload_type, + payload_version: 0, path_json: '[]', decoded_json: DECODED, created_at: '2026-10-07T10:00:00Z', + }; + return { + packet: { + id: 948000, hash, raw_hex: raw, route_type: o.route_type, payload_type: o.payload_type, payload_version: 0, + first_seen: obs.timestamp, timestamp: obs.timestamp, observer_id: obs.observer_id, + snr: -5, rssi: -101, path_json: '[]', decoded_json: DECODED, scope_name: o.scope_name, + }, + path: [], observation_count: 1, observations: [obs], + }; +} +const BODIES = { + [H_UNSCOPED]: packetBody(H_UNSCOPED, { route_type: 1, payload_type: 5, scope_name: null }), + [H_SCOPED]: packetBody(H_SCOPED, { route_type: 0, payload_type: 5, scope_name: '#dk' }), + [H_UNKNOWN]: packetBody(H_UNKNOWN, { route_type: 0, payload_type: 5, scope_name: '' }), + [H_DIRECT]: packetBody(H_DIRECT, { route_type: 2, payload_type: 5, scope_name: null }), + [H_TFNULL]: packetBody(H_TFNULL, { route_type: 0, payload_type: 5, scope_name: null }), + [H_ADVERT]: packetBody(H_ADVERT, { route_type: 1, payload_type: 4, scope_name: null }), +}; +async function stubPackets(context) { + await context.route((url) => /^\/api\/packets\/e2e480/.test(url.pathname), async (route) => { + const hash = new URL(route.request().url()).pathname.split('/').pop(); + try { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BODIES[hash]) }); + } catch (_) { try { await route.abort(); } catch (__) { /* closed */ } } + }); +} + +async function readMarker(locator) { + return locator.evaluate((el) => { + const probe = document.createElement('span'); + probe.style.color = 'var(--path-hash-warn)'; + document.body.appendChild(probe); + const want = getComputedStyle(probe).color; + probe.style.color = 'var(--warning)'; + const warning = getComputedStyle(probe).color; + probe.style.color = 'var(--danger)'; + const danger = getComputedStyle(probe).color; + probe.remove(); + const icon = el.querySelector('svg.ph-icon use[href$="#ph-warning"]'); + const r = el.getBoundingClientRect(); + return { + cls: el.className, title: el.getAttribute('title') || '', color: getComputedStyle(el).color, want, warning, danger, + iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true', + iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0, + visible: Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent).join(''), + sr: (el.querySelector('.sr-only') || {}).textContent || '', + right: r.right, vw: document.documentElement.clientWidth, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); +} +function assertMarker(w, where) { + assert(w.cls.split(/\s+/).includes('no-scope') && w.cls.split(/\s+/).includes('path-hash-warn'), where + ': class ' + w.cls); + assert(w.iconHidden && w.iconVisible, where + ': warning icon missing'); + assert(w.visible === 'no scope', where + ': visible text ' + JSON.stringify(w.visible)); + assert(/^ — sent without a scope\. Recommended: set a default scope/.test(w.sr), where + ': sr text ' + JSON.stringify(w.sr)); + assert(TIP.test(w.title), where + ': tooltip ' + JSON.stringify(w.title)); + assert(w.color === w.want && w.want === w.warning, where + ': colour ' + w.color + ' vs ' + w.want); + assert(w.want !== w.danger, where + ': amber must not equal --danger'); + assert(w.right <= w.vw + 0.5, where + ': runs off-screen'); + assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px'); +} +async function assertContrast(page, selector, where) { + await page.waitForFunction((sel) => { + const el = document.querySelector(sel); + return !!el && document.getAnimations().every((a) => { + const t = a.effect && a.effect.target; + return !(t && t.contains(el)) || a.playState !== 'running'; + }); + }, selector); + const r = await new AxeBuilder({ page }).include(selector).withRules(['color-contrast']).analyze(); + const bad = r.violations.flatMap((v) => v.nodes.map((n) => n.failureSummary || n.html)); + assert(bad.length === 0, where + ': axe color-contrast ' + bad.join(' | ')); + const measured = r.passes.reduce((k, v) => k + v.nodes.length, 0); + if (measured < 1) { + const ratio = await page.$eval(selector, (el) => { + const toRgb = (s) => (s.match(/\d+(\.\d+)?/g) || []).slice(0, 3).map(Number); + const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + const lum = (x) => 0.2126 * lin(x[0]) + 0.7152 * lin(x[1]) + 0.0722 * lin(x[2]); + let bgEl = el, bg = null; + while (bgEl) { const c = getComputedStyle(bgEl).backgroundColor; if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') { bg = c; break; } bgEl = bgEl.parentElement; } + const lf = lum(toRgb(getComputedStyle(el).color)), lb = lum(toRgb(bg || 'rgb(255,255,255)')); + return (Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05); + }); + assert(ratio >= 4.5, where + ': contrast ' + ratio.toFixed(2) + ':1 < AA 4.5'); + } +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== #480 channel messages without a region scope (${BASE}) ===`); + + const variants = [ + { name: 'light desktop', theme: 'light', viewport: { width: 1400, height: 900 } }, + { name: 'dark desktop', theme: 'dark', viewport: { width: 1400, height: 900 } }, + { name: 'light 360x800', theme: 'light', viewport: { width: 360, height: 800 } }, + { name: 'dark 360x800', theme: 'dark', viewport: { width: 360, height: 800 } }, + ]; + + // API shape the UI relies on (AGENTS.md rule 4): routeType, not scope name. + const probe = await browser.newContext(); + const msgs = (await (await probe.request.get(BASE + '/api/channels/%23wardriving/messages?limit=5')).json()).messages || []; + const grp = await (await probe.request.get(BASE + '/api/packets/' + GRP_DATA_HASH)).json(); + await probe.close(); + await step('API: fixture channel messages carry routeType 1 and no scope; GRP_DATA packet route_type 1', async () => { + assert(msgs.length > 0, 'no fixture messages'); + assert(msgs.every((m) => m.routeType === 1 && (m.scope == null)), JSON.stringify(msgs.map((m) => [m.routeType, m.scope]))); + assert(grp.packet.route_type === 1 && grp.packet.payload_type === 6 && grp.packet.scope_name == null, 'GRP_DATA shape'); + }); + + for (const v of variants) { + const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.theme }); + await context.addInitScript((theme) => { try { localStorage.setItem('meshcore-theme', theme); } catch (_) {} }, v.theme); + await stubPackets(context); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + const shot = async (label) => { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, `480-${label}-${v.name.replace(/\W+/g, '-')}.png`) }); + }; + + await step(`${v.name}: theme applied`, async () => { + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + const t = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + assert(t === v.theme, 'data-theme is ' + t); + }); + + await step(`${v.name}: channels — real fixture message from the API shows the marker`, async () => { + await page.goto(BASE + '/#/channels/%23wardriving', { waitUntil: 'load' }); + const marker = page.locator('#chMessages .no-scope').first(); + await marker.waitFor(); + await marker.scrollIntoViewIfNeeded(); + assertMarker(await readMarker(marker), 'channels (fixture)'); + await assertContrast(page, '#chMessages .no-scope', 'channels (fixture)'); + assert(await page.locator('#chMessages .ch-msg-meta', { hasText: 'scope:' }).count() === 0, 'unscoped fixture shows "scope:"'); + await shot('channels-fixture'); + }); + + await step(`${v.name}: channels — marker only on unscoped; scoped/unknown/direct unchanged`, async () => { + await page.waitForFunction(() => typeof window._channelsRenderMessagesForTest === 'function'); + await page.evaluate(() => { + const base = { text: 'hello', timestamp: '2026-10-07T08:00:00Z', observers: [], repeats: 1 }; + window._channelsSetStateForTest({ + channels: [{ hash: '#e2e-480', name: '#e2e-480', messageCount: 5 }], + messages: [ + Object.assign({}, base, { sender: 'Unscoped', packetHash: 'h1', routeType: 1, scope: null }), + Object.assign({}, base, { sender: 'Scoped', packetHash: 'h2', routeType: 0, scope: '#dk' }), + Object.assign({}, base, { sender: 'UnknownScope', packetHash: 'h3', routeType: 0, scope: '' }), + Object.assign({}, base, { sender: 'TransportNoName', packetHash: 'h5', routeType: 0, scope: null }), + Object.assign({}, base, { sender: 'Direct', packetHash: 'h4', routeType: 2, scope: null }), + ], + selectedHash: '#e2e-480', + }); + document.querySelector('.ch-layout')?.classList.add('ch-detail-open'); + window._channelsRenderMessagesForTest(); + }); + const metas = await page.locator('#chMessages .ch-msg-meta').evaluateAll((els) => els.map((el) => ({ + marker: !!el.querySelector('.no-scope'), text: el.textContent }))); + assert(metas.length === 5, 'meta lines ' + metas.length); + assert(JSON.stringify(metas.map((m) => m.marker)) === '[true,false,false,false,false]', JSON.stringify(metas)); + assert(metas[1].text.includes('scope: #dk'), 'scoped meta: ' + metas[1].text); + assert(metas[2].text.includes('scope: unknown'), 'unknown-scope meta: ' + metas[2].text); + assert(metas[3].text.includes('scope: unknown'), 'transport meta: ' + metas[3].text); + assert(!metas[4].text.includes('scope'), 'direct meta: ' + metas[4].text); + const marker = page.locator('#chMessages .no-scope'); + await marker.scrollIntoViewIfNeeded(); + assertMarker(await readMarker(marker), 'channels (injected)'); + await assertContrast(page, '#chMessages .no-scope', 'channels (injected)'); + await shot('channels'); + }); + + await step(`${v.name}: packet detail — unscoped GRP_TXT shows the Scope marker`, async () => { + await page.goto(`${BASE}/#/packet/${H_UNSCOPED}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + const marker = page.locator('dl.detail-meta dd .no-scope'); + await marker.waitFor(); + await marker.scrollIntoViewIfNeeded(); + assertMarker(await readMarker(marker), 'packet detail'); + assert((await page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).count()) === 1, 'Scope row missing'); + await assertContrast(page, 'dl.detail-meta dd .no-scope', 'packet detail'); + await shot('packet'); + }); + + await step(`${v.name}: packet detail — real unscoped GRP_DATA shows the marker`, async () => { + await page.goto(`${BASE}/#/packet/${GRP_DATA_HASH}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + const marker = page.locator('dl.detail-meta dd .no-scope'); + await marker.waitFor(); + assertMarker(await readMarker(marker), 'packet detail (GRP_DATA)'); + }); + + await step(`${v.name}: packet detail — scoped, unknown-scope, direct and non-channel get no marker`, async () => { + const expectRow = { [H_SCOPED]: '#dk', [H_UNKNOWN]: 'unknown scope', [H_DIRECT]: null, [H_TFNULL]: null, [H_ADVERT]: null }; + for (const [hash, text] of Object.entries(expectRow)) { + await page.goto(`${BASE}/#/packet/${hash}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + await page.locator('dl.detail-meta').waitFor(); + assert(await page.locator('dl.detail-meta .no-scope').count() === 0, hash + ' shows the marker'); + const scopeDd = page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).locator('xpath=following-sibling::dd[1]'); + if (text == null) assert(await page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).count() === 0, hash + ' has a Scope row'); + else assert((await scopeDd.textContent()).trim() === text, hash + ' scope text ' + (await scopeDd.textContent())); + } + }); + + await context.close(); + } + + await browser.close(); + console.log(`\n=== ${passed} passed, ${failed} failed ===`); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-issue-480-no-scope.js b/test-issue-480-no-scope.js new file mode 100644 index 000000000..e0bff3af8 --- /dev/null +++ b/test-issue-480-no-scope.js @@ -0,0 +1,195 @@ +/** + * #480 — channel messages sent without a region scope get a compact amber + * "no scope" marker (like the #353 1-byte path-hash recommendation). + * + * Covers the ONE shared decision + markup helper in public/app.js + * (isUnscopedFlood / renderNoScope), its use on the Channels message meta + * line, and the theming contract. The packet-detail Scope row is covered in + * test-packets.js, the browser behaviour in test-issue-480-no-scope-e2e.js. + * + * Run: node test-issue-480-no-scope.js + */ +'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); } +} + +// Extract the REAL functions from public/app.js (no hand-rolled copies). +function loadAppFunctions(names) { + const source = fs.readFileSync('public/app.js', 'utf8'); + let extracted = ''; + for (const name of names) { + const start = source.search(new RegExp('^function ' + name + '\\s*\\(', 'm')); + if (start < 0) throw new Error('public/app.js no longer declares function ' + name); + const open = source.indexOf('{', start); + let depth = 0, end = -1; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) { end = i + 1; break; } + } + extracted += source.slice(start, end) + '\n'; + } + const ctx = { Number, String }; + vm.createContext(ctx); + vm.runInContext(extracted + 'this.__fns = {' + names.join(',') + '};', ctx); + return ctx.__fns; +} +const helpers = loadAppFunctions(['escapeHtml', 'isUnscopedFlood', 'renderNoScope']); +const { isUnscopedFlood, renderNoScope } = helpers; + +console.log('\n=== #480 isUnscopedFlood ==='); + +test('FLOOD (1) is unscoped, including the string form a JSON field can carry', () => { + assert.strictEqual(isUnscopedFlood(1), true); + assert.strictEqual(isUnscopedFlood('1'), true); +}); + +test('TRANSPORT_FLOOD (0) is scoped, whether or not the name is known', () => { + // The scope NAME is deliberately not an input: a scoped transmission with an + // unknown name has scope_name = '' and is NOT "no scope". + assert.strictEqual(isUnscopedFlood(0), false); + assert.strictEqual(isUnscopedFlood('0'), false); + assert.strictEqual(isUnscopedFlood.length, 1); +}); + +test('DIRECT (2) and TRANSPORT_DIRECT (3) are not flood scope: no marker', () => { + assert.strictEqual(isUnscopedFlood(2), false); + assert.strictEqual(isUnscopedFlood(3), false); +}); + +test('missing or invalid route types are not unscoped', () => { + for (const v of [null, undefined, '', 'abc', 4, -1, 1.5, true, {}, [1]]) { + assert.strictEqual(isUnscopedFlood(v), false, 'for ' + JSON.stringify(v)); + } +}); + +console.log('\n=== #480 renderNoScope ==='); + +test('unscoped flood renders the amber marker: icon, "no scope", explanation', () => { + const html = renderNoScope(1); + assert.match(html, /^ { + assert.match(renderNoScope(1), / — sent without a scope\. Recommended: set a default scope[^<]*<\/span>/); +}); + +test('does not claim the missing scope explains a low heard-count', () => { + assert.ok(!/heard|repeats|lower|fewer/i.test(renderNoScope(1))); +}); + +test('scoped, direct and invalid route types render nothing', () => { + for (const v of [0, 2, 3, null, undefined, 'x', 4]) { + assert.strictEqual(renderNoScope(v), '', 'for ' + String(v)); + } +}); + +console.log('\n=== #480 channels meta line ==='); + +const channelsSrc = fs.readFileSync('public/channels.js', 'utf8'); +function makeChannelsSandbox(withHelper) { + const noop = () => {}; + const fakeEl = () => ({ + addEventListener: noop, querySelector: () => null, querySelectorAll: () => [], + classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null, + textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0, + }); + const chMessagesEl = fakeEl(); + const doc = { + readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(), + documentElement: fakeEl(), + getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null), + querySelector: () => null, querySelectorAll: () => [], addEventListener: noop, + }; + const ctx = { + window: { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) }, + document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error, + setTimeout, clearTimeout, setInterval, clearInterval, + history: { replaceState: noop, pushState: noop }, + location: { hash: '', href: '', pathname: '/' }, + navigator: { userAgent: 'node' }, + RegionFilter: { getRegionParam: () => '' }, + api: () => Promise.resolve({ messages: [] }), + CLIENT_TTL: {}, ChannelDecrypt: undefined, + truncate: (s) => s, formatHashHex: (h) => String(h), + channelDisplayName: (c) => c && c.name, + escapeHtml: helpers.escapeHtml, + getSenderColor: () => '#123456', + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'), + isTransportRoute: (rt) => rt === 0 || rt === 3, + registerPage: noop, + }; + if (withHelper) ctx.renderNoScope = renderNoScope; + vm.createContext(ctx); + vm.runInContext(channelsSrc, ctx); + return { ctx, chMessagesEl }; +} +function render(msg, withHelper = true) { + const { ctx, chMessagesEl } = makeChannelsSandbox(withHelper); + ctx.window._channelsSetStateForTest({ messages: [Object.assign({ + sender: 'Alice', text: 'hello', timestamp: '2026-01-15T10:00:00Z' }, msg)] }); + ctx.window._channelsRenderMessagesForTest(); + return chMessagesEl.innerHTML; +} + +test('an unscoped FLOOD message shows the marker where "scope: #dk" would be', () => { + const html = render({ routeType: 1, scope: null }); + assert.match(html, /class="no-scope path-hash-warn"/); + assert.ok(!html.includes('scope: '), 'no "scope: …" text on an unscoped message'); +}); + +test('a scoped message with a name is unchanged: "scope: #dk", no marker', () => { + const html = render({ routeType: 0, scope: '#dk' }); + assert.ok(html.includes('scope: #dk')); + assert.ok(!html.includes('no-scope')); +}); + +test('a scoped message with an unknown name stays "scope: unknown", not "no scope"', () => { + const html = render({ routeType: 0, scope: '' }); + assert.ok(html.includes('scope: unknown')); + assert.ok(!html.includes('no-scope')); +}); + +test('DIRECT and TRANSPORT_DIRECT messages get no marker', () => { + assert.ok(!render({ routeType: 2, scope: null }).includes('no-scope')); + assert.ok(!render({ routeType: 3, scope: '#dk' }).includes('no-scope')); +}); + +test('a message without a route type gets no marker', () => { + assert.ok(!render({ scope: null }).includes('no-scope')); +}); + +test('the marker is guarded when app.js has not provided the helper', () => { + assert.ok(!render({ routeType: 1 }, false).includes('no-scope')); +}); + +test('the meta line uses the shared helper, not a private copy', () => { + assert.ok(channelsSrc.includes('renderNoScope('), 'channels.js must call renderNoScope'); + assert.ok(!/no scope/.test(channelsSrc.replace(/\/\/.*$/gm, '')), 'no inline "no scope" markup in channels.js'); +}); + +console.log('\n=== #480 theming ==='); + +const css = fs.readFileSync('public/style.css', 'utf8'); +test('the marker reuses --path-hash-warn and hardcodes no colour', () => { + const rule = css.match(/\.no-scope\s*\{[^}]*\}/); + assert.ok(rule, '.no-scope rule exists'); + assert.ok(!/#[0-9a-fA-F]{3,8}\b|rgb\(|hsl\(/.test(rule[0]), 'no hardcoded colour: ' + rule[0]); + assert.ok(/\.path-hash-warn\s*\{[^}]*color:\s*var\(--path-hash-warn\)/.test(css)); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +process.exit(failed ? 1 : 0); diff --git a/test-packets.js b/test-packets.js index 4ac735519..a39444afd 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1888,6 +1888,50 @@ console.log('\n=== packets.js: detail Hash Size reads the selected observation = }); } +// ===== packets.js: detail Scope row marks unscoped channel packets (#480) ===== +console.log('\n=== packets.js: detail Scope row marks unscoped channel packets (#480) ==='); +{ + const api = loadPacketsSandbox()._packetsTestAPI; + const src = fs.readFileSync('public/packets.js', 'utf8'); + const pkt = (o) => Object.assign({ payload_type: 5, route_type: 1, scope_name: null }, o); + + test('unscoped GRP_TXT (FLOOD) gets a Scope row with the marker and explanation', () => { + const html = api.scopeDetailHtml(pkt({})); + assert.ok(html.startsWith('
Scope
'), html); + assert.match(html, /class="no-scope path-hash-warn"/); + assert.match(html, /#ph-warning/); + assert.match(html, /title="Sent without a scope\./); + }); + + test('unscoped GRP_DATA (FLOOD) gets the same row', () => { + assert.match(api.scopeDetailHtml(pkt({ payload_type: 6 })), /no-scope/); + }); + + test('scoped packets keep the existing display, with no marker', () => { + assert.strictEqual(api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '#dk' })), '
Scope
#dk
'); + const unknown = api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '' })); + assert.match(unknown, /unknown scope/); + assert.ok(!unknown.includes('no-scope')); + }); + + test('a scoped TRANSPORT_FLOOD with an empty name is NOT marked "no scope"', () => { + assert.ok(!api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '' })).includes('no-scope')); + }); + + test('DIRECT / TRANSPORT_DIRECT channel packets get no marker', () => { + assert.strictEqual(api.scopeDetailHtml(pkt({ route_type: 2 })), ''); + assert.ok(!api.scopeDetailHtml(pkt({ route_type: 3, scope_name: '#dk' })).includes('no-scope')); + }); + + test('non-channel unscoped packets (e.g. adverts) get no Scope row', () => { + for (const t of [0, 2, 4, 8, 9]) assert.strictEqual(api.scopeDetailHtml(pkt({ payload_type: t })), '', 'type ' + t); + }); + + test('renderDetail emits the Scope row through scopeDetailHtml', () => { + assert.ok(src.includes('${scopeDetailHtml(pkt)}'), 'detail-meta list must use the shared row helper'); + }); +} + // ===== packets.js: detail Hash Size warns on 1-byte (#353) ===== console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ==='); {