diff --git a/.eslintrc.json b/.eslintrc.json index 655cbcbd0..1348d27ee 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -243,6 +243,7 @@ "initTabBar": "readonly", "invalidateApiCache": "readonly", "isFavorite": "readonly", + "isPathHashSizeWarn": "readonly", "isTransportRoute": "readonly", "makeColumnsResizable": "readonly", "makeRoleMarkerSVG": "readonly", @@ -264,6 +265,7 @@ "pullReconnect": "readonly", "qrcode": "readonly", "registerPage": "readonly", + "renderPathHashSize": "readonly", "renderVersionCard": "readonly", "renderSkewBadge": "readonly", "renderSkewSparkline": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d39d38b9c..f8ebc528d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -447,6 +447,7 @@ jobs: 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-165-grouped-hop-warn-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-353-path-hash-warn-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-242-type-exclusions-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 diff --git a/public/app.js b/public/app.js index 5a0519b4b..98102468c 100644 --- a/public/app.js +++ b/public/app.js @@ -50,6 +50,58 @@ function senderPathHashSize(rawHex) { const pathByte = parseInt(pathHex, 16); return pathHashSizeFromByte(pathByte, route, header); } +/** + * #353: the ONE decision for "this path-hash width reads as a warning". A + * 1-byte hash collides often across nodes; the operator recommends 2/3-byte. + * The threshold is hardcoded for now (AGENTS.md rule 8: customizer later). + */ +function isPathHashSizeWarn(size) { + return pathHashSizeValue(size) === 1; +} +/** A 1/2/3 path-hash width from a number or numeric string, else null. */ +function pathHashSizeValue(size) { + if (typeof size !== 'number' && typeof size !== 'string') return null; + const n = Number(size); + return n === 1 || n === 2 || n === 3 ? n : null; +} +/** + * #353: the ONE markup for a path-hash width, shared by the channel "Sent + * with" badge, the packet-detail Hash Size row and the node-detail badge. + * Returns '' unless size is 1/2/3. opts.block is the caller's BEM class (the + * warn variant adds `${block}--warn` plus the shared `path-hash-warn`), + * opts.cls extra leading classes, opts.title the neutral tooltip. + * + * Round 3 (operator decision on #353): a 1-byte hash is a *recommendation*, + * not an error. The variant uses the amber warning palette (--path-hash-warn + * -> --warning), a warning (not error) icon, and recommendation wording. So it + * never relies on the amber colour alone, the recommendation is also spelled + * out in text: a visible label when opts.recInLabel is set (the standalone + * node badge), otherwise an sr-only clause appended after the factual label + * (the inline channel badge and the packet-detail value row). + * + * The setting names come from firmware/docs/faq.md 3.9.3 (companion app) and + * 3.9.4 / cli_commands.md (repeater `path.hash.mode`); faq.md 3.9.6 is why the + * tooltip says to switch only once most repeaters run 1.14+. + */ +function renderPathHashSize(size, text, opts) { + const n = pathHashSizeValue(size); + if (n == null) return ''; + const o = opts || {}; + const cls = (o.cls ? o.cls + ' ' : '') + o.block; + const label = escapeHtml(text); + if (!isPathHashSizeWarn(n)) { + return '' + label + ''; + } + const tip = 'Recommended: use a 2- or 3-byte path hash. A 1-byte hash is the firmware default, but its ' + + 'prefixes collide more often across nodes, so hops can be ambiguous. ' + + 'Companion: MeshCore app Settings → Experimental Settings (message path hash size). ' + + 'Repeater adverts: set path.hash.mode 1 (2-byte) or 2 (3-byte). ' + + 'Repeaters older than firmware 1.14 drop 2/3-byte packets, so switch once most of your mesh runs 1.14+.'; + const rec = o.recInLabel ? '' : ' — recommended: 2- or 3-byte path hash'; + return '' + + '' + + label + rec + ''; +} /** * 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 ac27ca50a..61a5ed4d3 100644 --- a/public/channels.js +++ b/public/channels.js @@ -66,10 +66,15 @@ // The header records the sender's choice even before a flood has relayed. // Observation-path evidence is retained internally, but is not the label. + // #353: a 1-byte width renders the shared warning variant (app.js). Guarded + // like senderSizeFromRawHex below: app.js always loads first in the page. function renderSenderPathHashBadge(message) { + if (typeof renderPathHashSize !== 'function') return ''; var size = Number(message && message.senderPathHashSize); - if (size !== 1 && size !== 2 && size !== 3) return ''; - return 'Sent with: ' + size + '-byte'; + return renderPathHashSize(size, 'Sent with: ' + size + '-byte', { + block: 'ch-path-hash-badge', + title: 'Path hash size encoded in the sender’s packet header', + }); } function senderSizeFromRawHex(rawHex) { diff --git a/public/customize-v2.js b/public/customize-v2.js index 54eedfc9c..1136fe0ca 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -52,6 +52,7 @@ sectionBg: '--section-bg', cardBg: '--card-bg', contentBg: '--content-bg', detailBg: '--detail-bg', inputBg: '--input-bg', rowStripe: '--row-stripe', rowHover: '--row-hover', selectedBg: '--selected-bg', + pathHashWarn: '--path-hash-warn', font: '--font', mono: '--mono' }; @@ -300,7 +301,7 @@ statusGreen: 'Healthy', statusYellow: 'Warning', statusRed: 'Error', surface1: 'Cards', surface2: 'Panels', surface3: 'Tertiary Surface', sectionBg: 'Section Header', cardBg: 'Card Fill', contentBg: 'Content Area', detailBg: 'Detail Panels', inputBg: 'Inputs', rowStripe: 'Table Stripe', - rowHover: 'Row Hover', selectedBg: 'Selected', + rowHover: 'Row Hover', selectedBg: 'Selected', pathHashWarn: '1-byte Path Hash', font: 'Body Font', mono: 'Mono Font' }; @@ -318,6 +319,7 @@ cardBg: 'Detail panels, modals', contentBg: 'Content area behind cards', detailBg: 'Modal, packet detail, side panels', inputBg: 'Text inputs, dropdowns', rowStripe: 'Alternating table rows', rowHover: 'Table row hover', selectedBg: 'Selected/active rows', + pathHashWarn: 'Amber accent recommending a 2/3-byte over a 1-byte path hash (channels, packet detail, node badge)', font: 'System font stack for body text', mono: 'Monospace font for hex, code, hashes' }; @@ -379,7 +381,7 @@ } var BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed']; - var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg']; + var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg', 'pathHashWarn']; var FONT_KEYS = ['font', 'mono']; // ── Validation helpers ── diff --git a/public/nodes.js b/public/nodes.js index d1cece996..f0c360d66 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -334,6 +334,13 @@ if (n.hash_size && Number(n.hash_size) >= 2) { html += ` Multibyte: ${Number(n.hash_size)}-byte`; } + // #353: a 1-byte advertised path hash collides often -- shared amber + // recommendation badge (app.js). The standalone badge spells the + // recommendation out as its visible label (recInLabel), so it needs no + // separate sr-only clause. + if (isPathHashSizeWarn(n.hash_size)) { + html += ' ' + renderPathHashSize(1, 'Recommended: 2- or 3-byte path hash', { block: 'node-path-hash-badge', cls: 'badge', recInLabel: true }); + } if (n.hash_size_inconsistent) { html += ` variable hash size`; } diff --git a/public/packets.js b/public/packets.js index 98997e085..21b371a67 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3639,6 +3639,16 @@ return ''; } + // Detail-meta "Hash Size" row for the sender-selected width (null = not + // encoded, no row). #353: a 1-byte width renders the shared amber + // recommendation (app.js). The row is gated on the helper's output, so a + // width the helper rejects (null/0 or an out-of-range value) renders no row + // at all rather than an empty
(#356 review round 2, finding 2). + function hashSizeDetailHtml(hashSize) { + const inner = renderPathHashSize(hashSize, hashSize + ' byte' + (hashSize !== 1 ? 's' : ''), { block: 'detail-hash-size' }); + return inner ? '
Hash Size
' + inner + '
' : ''; + } + async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; const observations = data.observations || []; @@ -3893,7 +3903,7 @@
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'}
` : ''} - ${hashSize ? `
Hash Size
${hashSize} byte${hashSize !== 1 ? 's' : ''}
` : ''} + ${hashSizeDetailHtml(hashSize)}
Propagation
${propagationHtml}
${transportCodesRow} ${rawCustomRow} @@ -4567,6 +4577,7 @@ reconcileVisibleCols, getDetailPreview, buildDetailMessageHtml, + hashSizeDetailHtml, sortGroupChildren, getPathHopCount, renderDecodedPacket, diff --git a/public/style.css b/public/style.css index a3d8e1db4..9fb2ca15d 100644 --- a/public/style.css +++ b/public/style.css @@ -242,6 +242,13 @@ --success: var(--palette-green-700, #15803d); --warning: #b45309; --danger: var(--palette-red-600, #dc2626); + /* #353 — 1-byte path hash recommendation (renderPathHashSize in app.js). + Round 3 (operator decision): a 1-byte hash is a recommendation, not an + error, so it follows the amber --warning palette (not --danger) in both + themes. WCAG AA (>= 4.5:1) on every surface it renders on: channel badge + and node badge 5.02:1 light / 10.51:1 dark, packet-detail row 4.60:1 light + / 13.09:1 dark. Themeable via the customizer (pathHashWarn). */ + --path-hash-warn: var(--warning); --link-color: var(--palette-blue-600); --accent-bg: rgba(59, 130, 246, 0.12); --accent-border: rgba(59, 130, 246, 0.25); @@ -1974,6 +1981,13 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } overflow-wrap: anywhere; vertical-align: baseline; } +/* #353 — 1-byte path hash warning, shared by the channel "Sent with" badge, + * the packet-detail Hash Size row and the node badge (renderPathHashSize in + * 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; } +.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; } .ch-analyze-link:hover { text-decoration: underline; } .ch-scroll-btn { diff --git a/test-all.sh b/test-all.sh index 0fee6b9cc..3cfdc43a7 100755 --- a/test-all.sh +++ b/test-all.sh @@ -186,6 +186,7 @@ run test-issue-1846-observers-width.js 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-live.js run test-issue-220-live-filter-readiness.js run test-coverage-gate.js diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index 96cd03e76..5ef105f6e 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -108,6 +108,9 @@ const ctx = { // assertions below would have passed against a contract nothing ships. senderPathHashSize: loadAppHelper('senderPathHashSize', ['isTransportRoute', 'getPathLenOffset', 'pathHashSizeFromByte', 'senderPathHashSize']), + // #353: the shared 1-byte warn decision + markup, also the real app.js code. + renderPathHashSize: loadAppHelper('renderPathHashSize', + ['escapeHtml', 'pathHashSizeValue', 'isPathHashSizeWarn', 'renderPathHashSize']), }; vm.createContext(ctx); // Expose the cache fetch helper only inside this VM so the regression can @@ -181,6 +184,34 @@ test('sender badge uses only a valid encoded width', () => { assert.strictEqual(senderBadge({ senderPathHashSize: '' }), ''); }); +// #353: a 1-byte sender width reads as an amber recommendation; 2/3-byte stay neutral. +test('1-byte sender badge gets the warn class, the warning icon and the recommendation', () => { + const html = senderBadge({ senderPathHashSize: 1 }); + assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + assert.match(html, /aria-hidden="true"/); + assert.match(html, /Sent with: 1-byte — recommended: 2- or 3-byte path hash<\/span>/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte[^"]*Experimental Settings/); +}); + +test('2- and 3-byte sender badges stay neutral with the header tooltip', () => { + for (const size of [2, 3]) { + const html = senderBadge({ senderPathHashSize: size }); + assert.ok(!html.includes('--warn'), size + '-byte got the warn class'); + assert.ok(!html.includes('ph-warning'), size + '-byte got the warning icon'); + assert.match(html, /title="Path hash size encoded in the sender’s packet header"/); + assert.match(html, new RegExp('>Sent with: ' + size + '-byte$')); + } +}); + +test('missing or invalid sender widths still render no badge', () => { + for (const v of [undefined, null, 0, 4, 'abc']) { + assert.strictEqual(senderBadge({ senderPathHashSize: v }), '', 'for ' + String(v)); + } + assert.strictEqual(senderBadge(null), ''); +}); + test('REST refresh retains a sender width from the matching live message', () => { const current = [{ packetHash: 'sender', senderPathHashSize: 3, _fromWS: true, _wsAt: Date.now() }]; const rest = [{ packetHash: 'sender', text: 'REST' }]; diff --git a/test-customizer-v2.js b/test-customizer-v2.js index be902e694..a2ebc6cb8 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -353,6 +353,28 @@ test('includes sectionBg mapping', () => { assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg'); }); +// #353: the 1-byte path hash warning colour is themeable. +test('maps pathHashWarn → --path-hash-warn (#353)', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api.THEME_CSS_MAP.pathHashWarn, '--path-hash-warn'); +}); + +test('applyCSS writes --path-hash-warn when overridden (#353)', () => { + const { ctx, api } = loadCustomizer(); + const written = {}; + ctx.document.documentElement.style.setProperty = (k, v) => { written[k] = v; }; + api.applyCSS({ theme: { pathHashWarn: '#123456' } }, {}); + assert.strictEqual(written['--path-hash-warn'], '#123456'); +}); + +test('pathHashWarn has a label, a hint and an Advanced row (#353)', () => { + const src = fs.readFileSync('public/customize-v2.js', 'utf8'); + assert.match(src, /pathHashWarn: '1-byte Path Hash'/); + // #353 round 3: the hint describes a recommendation accent, not an error. + assert.match(src, /pathHashWarn: '[^']*recommend[^']*1-byte path hash[^']*'/); + assert.match(src, /var ADVANCED_KEYS = \[[^\]]*'pathHashWarn'/); +}); + test('matches all keys from old app.js varMap', () => { const { api } = loadCustomizer(); const expectedKeys = [ diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 7781a54b0..37598b44c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -519,6 +519,38 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ==='); assert.ok(html.includes('room')); assert.ok(!html.includes('variable hash size')); }); + // #353: a node advertising a 1-byte path hash gets a warning badge. + const nodeWithHash = (hash_size) => ({ + role: 'repeater', public_key: 'abcdef1234567890', hash_size, + last_heard: new Date().toISOString() + }); + test('renderNodeBadges adds the 1-byte path hash recommendation badge (#353)', () => { + for (const size of [1, '1']) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.match(html, /class="badge node-path-hash-badge node-path-hash-badge--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + // #353 round 3: the standalone badge shows the recommendation as its + // visible label (no duplicate sr-only clause); amber, not red. + assert.match(html, />Recommended: 2- or 3-byte path hash<\/span>/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte[^"]*path\.hash\.mode/); + assert.ok(!html.includes('multibyte-badge'), '1-byte must not claim Multibyte'); + } + }); + test('renderNodeBadges adds no path hash warning for 2/3-byte nodes (#353)', () => { + for (const size of [2, 3]) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.ok(!html.includes('node-path-hash-badge'), size + '-byte got the warning badge'); + assert.ok(!html.includes('path-hash-warn'), size + '-byte got the warn class'); + assert.ok(html.includes('multibyte-badge'), size + '-byte lost the Multibyte badge'); + } + }); + test('renderNodeBadges adds no path hash warning when hash_size is missing/invalid (#353)', () => { + for (const size of [null, undefined, 0, 4]) { + const html = ex.renderNodeBadges(nodeWithHash(size), '#dc2626'); + assert.ok(!html.includes('node-path-hash-badge'), 'for ' + size); + } + }); test('renderNodeBadges handles string hash_sizes_seen gracefully', () => { const html = ex.renderNodeBadges({ role: 'repeater', public_key: 'abcdef1234567890', diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js new file mode 100644 index 000000000..c6e6b8348 --- /dev/null +++ b/test-issue-353-path-hash-warn-e2e.js @@ -0,0 +1,273 @@ +/** + * #353 — browser regression: a 1-byte path hash reads as an amber + * recommendation (not a red error) in the channel "Sent with" badge, the + * packet-detail Hash Size row and the node detail badges; 2/3-byte stay + * neutral. Checked in the light and dark theme (OS-level and manual toggle on + * a light OS) and at a 375×812 phone viewport (no horizontal page overflow). + * The accent is --path-hash-warn = --warning (amber), not --danger, and each + * recommendation passes axe `color-contrast` (WCAG AA) on the surface it + * renders on (PR #356 review F1: --danger failed 4.43:1 on the light packet + * page; amber is 4.60:1). + * + * Channels use channels.js' own test hooks, packet detail a stubbed + * /api/packets/, node detail real fixture nodes picked by hash_size. + * Only the local test server is contacted. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-353-path-hash-warn-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 HASH_1 = 'e2e353aa11223344'; +const HASH_2 = 'e2e353bb11223344'; +const PAYLOAD = 'aabbccddeeff00112233445566778899'; +const DECODED = JSON.stringify({ type: 'CHAN', channel: '#e2e-353', sender: 'WarnNode', text: 'path hash warn' }); + +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, rawHex) { + const obs = { + id: 935300001, raw_hex: rawHex, timestamp: '2026-10-07T10:00:00Z', + observer_id: 'e2e-353-obs', observer_name: 'e2e-353-obs', direction: 'rx', + snr: -5, rssi: -101, score: null, hash: hash, route_type: 1, payload_type: 5, + payload_version: 0, path_json: '[]', decoded_json: DECODED, created_at: '2026-10-07T10:00:00Z', + }; + return { + packet: { + id: 935300, hash: hash, raw_hex: rawHex, route_type: 1, payload_type: 5, 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: null, + }, + path: [], observation_count: 1, observations: [obs], + }; +} + +async function stubPackets(context) { + // Flood (header 0x15) with path byte 0x00 -> 1-byte; 0x40 -> 2-byte. + const bodies = { [HASH_1]: packetBody(HASH_1, '1500' + PAYLOAD), [HASH_2]: packetBody(HASH_2, '1540' + PAYLOAD) }; + await context.route((url) => /^\/api\/packets\/e2e353/.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 */ } } + }); +} + +// Shared assertions for a rendered recommendation element: modifier class, +// icon hidden from AT, recommendation text, firmware-named tooltip, and colour +// = --path-hash-warn = --warning (amber, not --danger). The WCAG AA contrast +// itself is checked by assertContrast() with real axe. +async function readWarn(locator) { + return locator.evaluate((el) => { + const cs = getComputedStyle(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: cs.color, want, warning, danger, + iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true', + iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0, + sr: (el.querySelector('.sr-only') || {}).textContent || '', + right: r.right, vw: document.documentElement.clientWidth, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); +} +// opts.sr: expected sr-only clause ('' when the recommendation is the visible label). +function assertWarn(w, block, where, opts) { + const sr = (opts && 'sr' in opts) ? opts.sr : ' — recommended: 2- or 3-byte path hash'; + assert(w.cls.split(/\s+/).includes(block + '--warn'), where + ': no ' + block + '--warn in ' + w.cls); + assert(w.cls.split(/\s+/).includes('path-hash-warn'), where + ': no path-hash-warn'); + assert(w.iconHidden && w.iconVisible, where + ': warning icon missing, visible=' + w.iconVisible); + assert(w.sr === sr, where + ': sr-only text ' + JSON.stringify(w.sr)); + assert(/^Recommended: /.test(w.title) && /2- or 3-byte/.test(w.title) && /Experimental Settings/.test(w.title) && /path\.hash\.mode/.test(w.title), + where + ': tooltip ' + JSON.stringify(w.title)); + // #353 round 3: amber recommendation palette, not the red error palette. + assert(w.color === w.want && w.want === w.warning, where + ': colour ' + w.color + ' vs --path-hash-warn ' + w.want + ' / --warning ' + w.warning); + assert(w.want !== w.danger, where + ': --path-hash-warn still equals --danger (' + w.danger + ')'); + assert(w.right <= w.vw + 0.5, where + ': recommendation runs off-screen (' + w.right + ' > ' + w.vw + ')'); + assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px'); +} +// Direct sRGB-luminance WCAG contrast of an element's text against its first +// opaque ancestor background. Used as the fallback when axe leaves the element +// "incomplete" (see assertContrast). +async function measuredContrast(page, selector) { + return 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 = (rgb) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); + const fg = toRgb(getComputedStyle(el).color); + 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 bgRgb = toRgb(bg || 'rgb(255,255,255)'); + const lf = lum(fg), lb = lum(bgRgb); + return { ratio: Math.round((Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05) * 100) / 100, fg: getComputedStyle(el).color, bg: bg || '(none)' }; + }); +} +// axe color-contrast on the warning element itself: no violation, and axe +// must actually have measured it (a pass), not left it "incomplete". +// Waits for page/pane fade-ins first: mid-animation, axe samples a blended +// (lighter) foreground and reports a contrast the settled page never has. +// When axe cannot settle a background (its "incomplete" overlap heuristic can +// fire for a short inline badge sitting among others at a narrow viewport), it +// is not a contrast failure — fall back to a direct WCAG computation on the +// element's own text and its first opaque ancestor, which is what the user +// actually sees, and still require AA (>= 4.5:1). +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 m = await measuredContrast(page, selector); + assert(m.ratio >= 4.5, where + ': contrast ' + m.ratio + ':1 < AA 4.5 (fg ' + m.fg + ' on bg ' + m.bg + ')'); + } +} + +(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=== #353 1-byte path hash warning (${BASE}) ===`); + + // Real fixture nodes: one advertising 1-byte, one 2-byte. + const probeCtx = await browser.newContext(); + const nodesResp = await probeCtx.request.get(BASE + '/api/nodes?limit=500'); + const nodes = ((await nodesResp.json()).nodes) || []; + await probeCtx.close(); + const node1 = nodes.find((n) => Number(n.hash_size) === 1); + const node2 = nodes.find((n) => Number(n.hash_size) === 2); + + const variants = [ + { name: 'light desktop', theme: 'light', viewport: { width: 1400, height: 900 } }, + { name: 'dark desktop', theme: 'dark', viewport: { width: 1400, height: 900 } }, + { name: 'light 375x812', theme: 'light', viewport: { width: 375, height: 812 } }, + { name: 'dark 375x812', theme: 'dark', viewport: { width: 375, height: 812 } }, + // Manual dark toggle on a light OS: only the [data-theme="dark"] block + // applies, not the prefers-color-scheme one. + { name: 'dark toggle on light OS', theme: 'dark', os: 'light', viewport: { width: 1400, height: 900 } }, + ]; + + for (const v of variants) { + const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.os || 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, `353-${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}: channel 1-byte badge warns, 2/3-byte stay neutral`, 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-353', name: '#e2e-353', messageCount: 3 }], + messages: [ + Object.assign({}, base, { sender: 'OneByte', packetHash: 'h1', senderPathHashSize: 1 }), + Object.assign({}, base, { sender: 'TwoByte', packetHash: 'h2', senderPathHashSize: 2 }), + Object.assign({}, base, { sender: 'ThreeByte', packetHash: 'h3', senderPathHashSize: 3 }), + ], + selectedHash: '#e2e-353', + }); + document.querySelector('.ch-layout')?.classList.add('ch-detail-open'); + window._channelsRenderMessagesForTest(); + }); + const badges = page.locator('.ch-path-hash-badge'); + assert(await badges.count() === 3, 'badge count ' + await badges.count()); + const warn = page.locator('.ch-path-hash-badge--warn'); + assert(await warn.count() === 1, 'warn count ' + await warn.count()); + await warn.scrollIntoViewIfNeeded(); + assertWarn(await readWarn(warn), 'ch-path-hash-badge', 'channel'); + await assertContrast(page, '.ch-path-hash-badge--warn', 'channel'); + assert(await warn.textContent() === 'Sent with: 1-byte — recommended: 2- or 3-byte path hash', 'warn text ' + await warn.textContent()); + const neutral = await page.locator('.ch-path-hash-badge:not(.ch-path-hash-badge--warn)').evaluateAll((els) => + els.map((el) => ({ text: el.textContent, icon: !!el.querySelector('svg'), title: el.title }))); + assert(JSON.stringify(neutral.map((n) => n.text)) === '["Sent with: 2-byte","Sent with: 3-byte"]', JSON.stringify(neutral)); + assert(neutral.every((n) => !n.icon && n.title === 'Path hash size encoded in the sender’s packet header'), JSON.stringify(neutral)); + await shot('channels'); + }); + + await step(`${v.name}: packet detail Hash Size warns on 1-byte`, async () => { + await page.goto(`${BASE}/#/packet/${HASH_1}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + const warn = page.locator('dl.detail-meta .detail-hash-size--warn'); + await warn.waitFor(); + await warn.scrollIntoViewIfNeeded(); + assertWarn(await readWarn(warn), 'detail-hash-size', 'packet detail'); + await assertContrast(page, 'dl.detail-meta .detail-hash-size--warn', 'packet detail'); + assert(await warn.textContent() === '1 byte — recommended: 2- or 3-byte path hash', 'text ' + await warn.textContent()); + await shot('packet'); + }); + + await step(`${v.name}: packet detail Hash Size stays neutral on 2-byte`, async () => { + await page.goto(`${BASE}/#/packet/${HASH_2}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + 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'); + }); + + await step(`${v.name}: node detail shows the 1-byte badge, not on a 2-byte node`, async () => { + assert(node1 && node2, 'fixture lacks a 1-byte or 2-byte node'); + await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node1.public_key)}`, { waitUntil: 'load' }); + const warn = page.locator('.node-full-card .node-path-hash-badge--warn'); + await warn.waitFor(); + // The node badge shows the recommendation as its visible label, so no sr-only clause. + assertWarn(await readWarn(warn), 'node-path-hash-badge', 'node detail', { sr: '' }); + await assertContrast(page, '.node-full-card .node-path-hash-badge--warn', 'node detail'); + assert(await warn.textContent() === 'Recommended: 2- or 3-byte path hash', 'text ' + await warn.textContent()); + assert(await page.locator('.node-full-card .multibyte-badge').count() === 0, '1-byte node claims Multibyte'); + await shot('node'); + await page.goto(`${BASE}/#/nodes/${encodeURIComponent(node2.public_key)}`, { waitUntil: 'load' }); + await page.locator('.node-full-card .multibyte-badge').waitFor(); + assert(await page.locator('.node-full-card .node-path-hash-badge').count() === 0, '2-byte node warns'); + }); + + 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-353-path-hash-warn.js b/test-issue-353-path-hash-warn.js new file mode 100644 index 000000000..8853e42fe --- /dev/null +++ b/test-issue-353-path-hash-warn.js @@ -0,0 +1,171 @@ +/** + * #353 — a 1-byte path hash reads as a warning. + * + * Covers the ONE shared decision + markup helper in public/app.js + * (isPathHashSizeWarn / renderPathHashSize) and its theming contract + * (--path-hash-warn in style.css). The three call sites are covered next to + * their existing tests: + * - channels "Sent with" badge -> test-channels-observed-path-hash-size.js + * - packet detail Hash Size row -> test-packets.js + * - node detail badge -> test-frontend-helpers.js + * - customizer mapping -> test-customizer-v2.js + * + * Run: node test-issue-353-path-hash-warn.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 { isPathHashSizeWarn, renderPathHashSize } = + loadAppFunctions(['escapeHtml', 'pathHashSizeValue', 'isPathHashSizeWarn', 'renderPathHashSize']); + +console.log('\n=== #353 isPathHashSizeWarn ==='); + +test('1-byte is a warning, including the string form a JSON field can carry', () => { + assert.strictEqual(isPathHashSizeWarn(1), true); + assert.strictEqual(isPathHashSizeWarn('1'), true); +}); + +test('2- and 3-byte are not a warning', () => { + assert.strictEqual(isPathHashSizeWarn(2), false); + assert.strictEqual(isPathHashSizeWarn(3), false); +}); + +test('missing or invalid sizes are not a warning', () => { + for (const v of [0, 4, -1, null, undefined, NaN, '', 'x', true]) { + assert.strictEqual(isPathHashSizeWarn(v), false, 'for ' + String(v)); + } +}); + +console.log('\n=== #353 renderPathHashSize ==='); + +test('1-byte renders the warn modifier, the shared warn class, an icon and a recommendation for screen readers', () => { + const html = renderPathHashSize(1, 'Sent with: 1-byte', { block: 'ch-path-hash-badge' }); + assert.match(html, /class="ch-path-hash-badge ch-path-hash-badge--warn path-hash-warn"/); + assert.match(html, / — recommended: 2- or 3-byte path hash<\/span><\/span>$/); + assert.ok(!/Warning: /.test(html), 'must not read as an error ("Warning:")'); +}); + +test('a recommendation-labelled call omits the sr-only clause to avoid repeating itself', () => { + // The node badge (opts.recInLabel) shows the recommendation as its visible + // label already, so the duplicate sr-only clause is suppressed. + const html = renderPathHashSize(1, 'Recommended: 2- or 3-byte path hash', + { block: 'node-path-hash-badge', cls: 'badge', recInLabel: true }); + assert.match(html, />Recommended: 2- or 3-byte path hash<\/span>$/); + assert.ok(!html.includes('sr-only'), 'recInLabel must not add a duplicate sr-only clause'); +}); + +test('1-byte tooltip leads with the recommendation, keeps the collision reason and names the firmware settings', () => { + const html = renderPathHashSize(1, '1 byte', { block: 'detail-hash-size' }); + const title = (html.match(/title="([^"]*)"/) || [])[1] || ''; + assert.match(title, /^Recommended: /); + assert.match(title, /collide/); + assert.match(title, /2- or 3-byte/); + // firmware/docs/faq.md 3.9.3 (companion) and cli_commands.md (repeater) + assert.match(title, /Settings → Experimental Settings/); + assert.match(title, /path\.hash\.mode/); + // faq.md 3.9.3/3.9.6: pre-1.14 repeaters drop 2/3-byte packets + assert.match(title, /1\.14/); +}); + +test('2- and 3-byte render neutral: no warn class, no icon, caller title kept', () => { + for (const size of [2, 3]) { + const html = renderPathHashSize(size, 'Sent with: ' + size + '-byte', + { block: 'ch-path-hash-badge', title: 'neutral tip' }); + assert.strictEqual(html, + 'Sent with: ' + size + '-byte'); + } +}); + +test('neutral render without a caller title emits no title attribute', () => { + assert.strictEqual(renderPathHashSize(2, '2 bytes', { block: 'detail-hash-size' }), + '2 bytes'); +}); + +test('the warn tooltip replaces the neutral caller title', () => { + const html = renderPathHashSize(1, 'x', { block: 'b', title: 'neutral tip' }); + assert.ok(!html.includes('neutral tip')); +}); + +test('extra classes are kept on both variants', () => { + assert.match(renderPathHashSize(1, 'x', { block: 'node-path-hash-badge', cls: 'badge' }), + /class="badge node-path-hash-badge node-path-hash-badge--warn path-hash-warn"/); + assert.match(renderPathHashSize(2, 'x', { block: 'node-path-hash-badge', cls: 'badge' }), + /class="badge node-path-hash-badge"/); +}); + +test('missing or invalid sizes render nothing', () => { + for (const v of [0, 4, null, undefined, NaN, '', '']) { + assert.strictEqual(renderPathHashSize(v, 'x', { block: 'b' }), '', 'for ' + String(v)); + } +}); + +test('label and title are escaped', () => { + const html = renderPathHashSize(2, '', { block: 'b', title: '"x' }); + assert.ok(html.includes('<b>') && html.includes('"x')); + assert.ok(!html.includes('')); +}); + +console.log('\n=== #353 theming: --path-hash-warn ==='); +{ + const css = fs.readFileSync('public/style.css', 'utf8'); + // The light-theme semantic :root block (the one declaring --danger); the + // dark blocks redefine --danger, which the variable then follows. + const dangerAt = css.indexOf('--danger: var(--palette-red-600'); + const rootBlock = css.slice(css.lastIndexOf('\n:root {', dangerAt), css.indexOf('\n}', dangerAt)); + + test(':root declares --path-hash-warn defaulting to var(--warning)', () => { + // #353 round 3: 1-byte is a recommendation, so it follows the amber + // warning palette, not the red danger palette, in both themes. + assert.match(rootBlock, /--path-hash-warn:\s*var\(--warning\);/); + assert.ok(!/--path-hash-warn:\s*var\(--danger\)/.test(rootBlock), 'must not default to --danger'); + }); + + test('.path-hash-warn colours its text with the variable, never a hardcoded colour', () => { + const m = css.match(/\.path-hash-warn\s*\{([^}]*)\}/); + assert.ok(m, '.path-hash-warn rule missing'); + assert.match(m[1], /color:\s*var\(--path-hash-warn\)/); + assert.ok(!/#[0-9a-f]{3,8}\b|rgba?\(/i.test(m[1]), 'hardcoded colour in .path-hash-warn'); + }); + + test('the channel badge warn modifier uses the variable for its border', () => { + const m = css.match(/\.ch-path-hash-badge--warn\s*\{([^}]*)\}/); + assert.ok(m, '.ch-path-hash-badge--warn rule missing'); + assert.match(m[1], /border-color:\s*var\(--path-hash-warn\)/); + }); +} + +console.log('\n' + passed + '/' + (passed + failed) + ' tests passed'); +process.exit(failed > 0 ? 1 : 0); diff --git a/test-packets.js b/test-packets.js index c20e81e55..be1297ac5 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1841,6 +1841,50 @@ console.log('\n=== packets.js: detail Hash Size reads the selected observation = }); } +// ===== packets.js: detail Hash Size warns on 1-byte (#353) ===== +console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ==='); +{ + const api = loadPacketsSandbox()._packetsTestAPI; + const src = fs.readFileSync('public/packets.js', 'utf8'); + + test('1-byte Hash Size row gets the warn class, the warning icon and the recommendation', () => { + const html = api.hashSizeDetailHtml(1); + assert.ok(html.startsWith('
Hash Size
'), html); + assert.match(html, /class="detail-hash-size detail-hash-size--warn path-hash-warn"/); + assert.match(html, /#ph-warning/); + // #353 round 3: amber recommendation; factual value kept, recommendation + // in an sr-only clause so it never relies on the amber colour. + assert.match(html, /1 byte — recommended: 2- or 3-byte path hash<\/span><\/span><\/dd>$/); + assert.ok(!/Warning: /.test(html), 'must not read as an error'); + assert.match(html, /title="Recommended: [^"]*2- or 3-byte/); + }); + + test('2- and 3-byte Hash Size rows stay neutral', () => { + assert.strictEqual(api.hashSizeDetailHtml(2), + '
Hash Size
2 bytes
'); + assert.strictEqual(api.hashSizeDetailHtml(3), + '
Hash Size
3 bytes
'); + }); + + test('no encoded width renders no Hash Size row, as before', () => { + for (const v of [null, undefined, 0]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); + }); + + // #353 review (round 2, finding 2): an out-of-range width must not leave an + // empty
. The row is gated on the shared helper's output, so a size + // the helper rejects (e.g. 4) renders no row at all, not a blank one. + test('an out-of-range width renders no empty Hash Size row', () => { + for (const v of [4, 5, -1]) assert.strictEqual(api.hashSizeDetailHtml(v), '', 'for ' + v); + }); + + test('renderDetail emits the Hash Size row through hashSizeDetailHtml', () => { + assert.ok(src.includes('${hashSizeDetailHtml(hashSize)}'), + 'the detail-meta list must use the shared #353 row helper'); + assert.ok(!src.includes('
Hash Size
${hashSize}'), + 'the old inline Hash Size row must be gone'); + }); +} + // ===== packets.js: View Path button (detail panel) ===== console.log('\n=== packets.js: View Path button ==='); {