From b74a64ccfab7abd448875f4fbec2993ab23caa26 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 30 Jun 2026 05:48:47 -0700 Subject: [PATCH] fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the three drifted per-surface payload-type label vocabularies with a single canonical map keyed by firmware enum name. Per the locked triage comment on #1799 ([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)): > Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS legend` to consume it. E2E that scrapes each surface and asserts label equality. ## Changes - **`public/payload-labels.js`** (new) — canonical map exposed as `window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware enum names; values carry `{short, long, enumId}` plus derived `SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers. - **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from `PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive fallback for the case where the script tag fails to load. - **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES` now sourced from `PayloadLabelsApi`. Literal fallback retained so `node test-packet-filter.js` still works headlessly. - **`public/live.js`** — legend `
  • ` rows are now generated from `window.PayloadLabels` in stable order, killing the third-vocabulary `Message — Group text` / `Direct — Direct message` drift the #1797 review surfaced. - **`public/index.html`** — ` + diff --git a/public/live.js b/public/live.js index 0969a455..027ed381 100644 --- a/public/live.js +++ b/public/live.js @@ -1,6 +1,14 @@ (function() { 'use strict'; + // #1799 PR #1804 r1 item 9 (adv6): payload-labels.js is loaded + // synchronously before this script in index.html. Missing module is + // a packaging bug — crash loud rather than render with stale inline + // fallback data. + if (!window.PayloadLabels) { + throw new Error('live.js: window.PayloadLabels missing — payload-labels.js failed to load'); + } + // getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file) var getParsedPath = window.getParsedPath; var getParsedDecoded = window.getParsedDecoded; @@ -152,14 +160,38 @@ const TYPE_COLORS = window.TYPE_COLORS || { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', - REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6', + REQ: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6', ANON_REQ: '#f43f5e', GRP_DATA: '#8b5cf6', MULTIPART: '#0d9488', CONTROL: '#b45309', RAW_CUSTOM: '#c026d3' }; + // #1804 r1 item 7 (adv3): legend builder extracted from the live-overlay + // template IIFE so it is testable in isolation. See + // test-live-legend-helper.js. Emits one
  • + // per entry in ORDER, each row formatted as `SHORT — LONG`. + // + // #1804 r1 item 9 (adv6): inline fallback dropped. The top-of-file + // guard already crashed if PayloadLabels was missing, so by the time + // we reach here the canonical map is guaranteed to be there. + function buildLegendHtml(PL) { + var src = (PL && PL.api) ? PL.api : PL; + var enums = (PL && PL.enums) ? PL.enums : PL; + var order = src.ORDER; + return order.map(function (k) { + var e = enums[k]; + if (!e) return ''; + var color = TYPE_COLORS[k] || '#888'; + var label = e.short + ' \u2014 ' + e.long; + return '
  • ' + label + '
  • '; + }).join(''); + } + // Expose for tests + downstream consumers. The unit test reads this + // via vm; in-page consumers can pull it off window for debugging. + if (typeof window !== 'undefined') { window.buildLegendHtml = buildLegendHtml; } + const PAYLOAD_ICONS = { ADVERT: '', GRP_TXT: '', TXT_MSG: '', ACK: '', - REQUEST: '', RESPONSE: '', TRACE: '', PATH: '' + REQ: '', RESPONSE: '', TRACE: '', PATH: '' }; /* ---- Panel Corner Positioning (#608 M0) ---- */ @@ -1167,19 +1199,7 @@

    PACKET TYPES

    NODE ROLES

    diff --git a/public/packet-filter.js b/public/packet-filter.js index d2082910..4185a287 100644 --- a/public/packet-filter.js +++ b/public/packet-filter.js @@ -4,11 +4,26 @@ (function() { 'use strict'; - // Local copies of type maps (also available as window globals from app.js) - // Standard firmware payload type names (canonical) - var FW_PAYLOAD_TYPES = { 0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT', 5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH', 9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 15: 'RAW_CUSTOM' }; - // Aliases: display names → firmware names (for user convenience) - var TYPE_ALIASES = { 'request': 'REQ', 'response': 'RESPONSE', 'direct msg': 'TXT_MSG', 'dm': 'TXT_MSG', 'ack': 'ACK', 'advert': 'ADVERT', 'channel msg': 'GRP_TXT', 'channel': 'GRP_TXT', 'group data': 'GRP_DATA', 'anon req': 'ANON_REQ', 'path': 'PATH', 'trace': 'TRACE', 'multipart': 'MULTIPART', 'control': 'CONTROL', 'raw': 'RAW_CUSTOM', 'custom': 'RAW_CUSTOM' }; + // Canonical payload-label module is the single source of truth (#1799). + // + // #1799 PR #1804 r1 item 9 (adv6): inline fallbacks dropped. In the + // browser, payload-labels.js is loaded synchronously before this + // script in index.html — a missing module is a packaging bug that + // should crash loud, not silently mask itself as stale labels. + var _PL; + if (typeof window !== 'undefined') { + if (!window.PayloadLabels) { + throw new Error('packet-filter.js: window.PayloadLabels missing — payload-labels.js failed to load'); + } + _PL = window.PayloadLabels; + } else { + _PL = require('./payload-labels.js'); + } + // Prefer the .api namespace introduced in PR #1804 r1 item 8; legacy + // root-level properties remain as a fall-through for older callers. + var _src = _PL.api || _PL; + var FW_PAYLOAD_TYPES = _src.FW_PAYLOAD_TYPES; + var TYPE_ALIASES = _src.TYPE_ALIASES; var ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' }; // Aliases: shorthand → canonical route name (issue #339) var ROUTE_ALIASES = { 't_flood': 'TRANSPORT_FLOOD', 't_direct': 'TRANSPORT_DIRECT' }; diff --git a/public/packets.js b/public/packets.js index f55977a9..4c8e6fca 100644 --- a/public/packets.js +++ b/public/packets.js @@ -623,6 +623,16 @@ let packets = []; let hashIndex = new Map(); // hash → packet group for O(1) dedup + // #1799 PR #1804 r1 item 9 (adv6): payload-labels.js is loaded + // synchronously before this script in index.html. A missing module + // is a packaging bug, not a runtime degradation — fail loud. + if (typeof window !== 'undefined' && !window.PayloadLabels) { + throw new Error('packets.js: window.PayloadLabels missing — payload-labels.js failed to load'); + } + const SHORT_BY_ID = window.PayloadLabels.api + ? window.PayloadLabels.api.SHORT_BY_ID + : window.PayloadLabels.SHORT_BY_ID; + // Resolve observer_id to friendly name from loaded observers list function obsName(id) { if (!id) return '—'; @@ -717,7 +727,7 @@ // already built (decouples observer fetch from row render). let _rebuildObserverMenu = null; let regionMap = {}; - const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 6:'Group Data', 7:'Anon Req', 8:'Path', 9:'Trace', 10:'Multipart', 11:'Control', 15:'Raw Custom' }; + const TYPE_NAMES = SHORT_BY_ID; function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; } const isMobile = window.innerWidth <= 1024; const PACKET_LIMIT = isMobile ? 1000 : 50000; @@ -1745,7 +1755,7 @@ // --- Type multi-select --- const typeMenu = document.getElementById('typeMenu'); const typeTrigger = document.getElementById('typeTrigger'); - const typeMap = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',6:'Group Data',7:'Anon Req',8:'Path',9:'Trace',10:'Multipart',11:'Control',15:'Raw Custom'}; + const typeMap = SHORT_BY_ID; const selectedTypes = new Set(filters.type ? String(filters.type).split(',') : []); function buildTypeMenu() { const allChecked = selectedTypes.size === 0; diff --git a/public/payload-labels.js b/public/payload-labels.js new file mode 100644 index 00000000..297c2eea --- /dev/null +++ b/public/payload-labels.js @@ -0,0 +1,163 @@ +/* payload-labels.js — canonical MeshCore payload-type label map. + * + * Single source of truth for human-readable labels of firmware payload type + * enums. Surfaces that previously hand-rolled their own vocabularies + * (packets.js typeMap, packet-filter.js FW_PAYLOAD_TYPES/TYPE_ALIASES, + * live.js legend) consume this map so the same payload reads identically + * everywhere the operator sees it. + * + * ABBREVIATION POLICY (#1799 PR #1804 r1 item 5): + * - `short` is the compact label that appears in dropdowns, table cells, + * legend titles, badges. Policy is <=12 characters, Title Case, with + * ONE documented exception: 'ACK' (wire-protocol acronym). Abbreviate + * only when the un-abbreviated form would exceed the cap or would be + * ambiguous (e.g. 'Direct Msg' for 'Direct Message'). Apply the same + * contraction rule across all entries — do not mix 'Direct Msg' with + * 'Channel Message'. + * - `long` is the descriptive sentence used in tooltips and legend + * sub-text. It MUST describe the packet's purpose/behaviour, NOT + * echo the short label. Reviewers reject `Path — Path discovery`- + * shaped tautologies. + * - When adding a new enum: pick a short that obeys the policy, then + * write a long that a new operator could read aloud and learn what + * the packet is for. + * + * Keyed by the firmware enum name; values carry: + * enumName — firmware enum name (mirrors the key; #1799 r1 item 9) + * short — compact label used in dropdowns, table cells, legend titles + * long — descriptive label used in tooltips and legend sub-text + * enumId — numeric firmware payload_type value (the wire byte) + * + * Refs #1799. + */ +(function () { + 'use strict'; + + // PAYLOAD_LABELS — every entry carries enumName for shape uniformity with + // BY_ID (#1799 r1 item 9). `long` values are behavioural descriptions + // (PR #1804 r1 item 2 / tufte2). Source for semantics: cmd/server/ + // decoder.go + cmd/ingestor/decoder.go + firmware Packet.h enum. + var PAYLOAD_LABELS = { + REQ: { enumName: 'REQ', short: 'Request', long: 'Encrypted data request to a remote node', enumId: 0 }, + RESPONSE: { enumName: 'RESPONSE', short: 'Response', long: 'Encrypted data response from a remote node', enumId: 1 }, + TXT_MSG: { enumName: 'TXT_MSG', short: 'Direct Msg', long: 'Encrypted point-to-point text message', enumId: 2 }, + ACK: { enumName: 'ACK', short: 'ACK', long: 'Acknowledgment of a prior message or request', enumId: 3 }, + ADVERT: { enumName: 'ADVERT', short: 'Advert', long: 'Node identity and capability advertisement', enumId: 4 }, + GRP_TXT: { enumName: 'GRP_TXT', short: 'Channel Msg', long: 'Channel-scoped group text message', enumId: 5 }, + GRP_DATA: { enumName: 'GRP_DATA', short: 'Group Data', long: 'Channel-scoped group datagram (non-text payload)', enumId: 6 }, + ANON_REQ: { enumName: 'ANON_REQ', short: 'Anon Req', long: 'Anonymous encrypted request via ephemeral key', enumId: 7 }, + PATH: { enumName: 'PATH', short: 'Path', long: 'Network path discovery and return-path advertisement', enumId: 8 }, + TRACE: { enumName: 'TRACE', short: 'Trace', long: 'Per-hop route trace with SNR samples', enumId: 9 }, + MULTIPART: { enumName: 'MULTIPART', short: 'Multipart', long: 'Fragmented payload reassembled across multiple packets', enumId: 10 }, + CONTROL: { enumName: 'CONTROL', short: 'Control', long: 'Mesh control-plane signalling (e.g. zero-hop direct)', enumId: 11 }, + RAW_CUSTOM: { enumName: 'RAW_CUSTOM', short: 'Raw Custom', long: 'Application-defined raw payload, no firmware envelope', enumId: 15 } + }; + + // Legend display order (#1799 r1 item 5) — keeps Advert/GRP_TXT/TXT_MSG up + // top per the historical Live legend layout. + var ORDER = [ + 'ADVERT', 'GRP_TXT', 'TXT_MSG', 'REQ', 'RESPONSE', 'TRACE', 'PATH', + 'ANON_REQ', 'GRP_DATA', 'MULTIPART', 'CONTROL', 'RAW_CUSTOM', 'ACK' + ]; + + // Reverse lookup: numeric enumId → entry. Built from PAYLOAD_LABELS. + // Single `Object.entries` loop pattern across the module (#1799 r1 item 8). + var BY_ID = {}; + Object.entries(PAYLOAD_LABELS).forEach(function (kv) { + BY_ID[kv[1].enumId] = kv[1]; + }); + + // Numeric id → firmware enum name. Mirrors what packet-filter.js used to + // hand-roll as FW_PAYLOAD_TYPES. + var FW_PAYLOAD_TYPES = {}; + Object.entries(BY_ID).forEach(function (kv) { + FW_PAYLOAD_TYPES[kv[0]] = kv[1].enumName; + }); + + // Numeric id → short prose label. Replaces packets.js typeMap/TYPE_NAMES. + var SHORT_BY_ID = {}; + Object.entries(BY_ID).forEach(function (kv) { + SHORT_BY_ID[kv[0]] = kv[1].short; + }); + + // User-input alias map (lowercased short label & legacy aliases) → enum. + // Replaces packet-filter.js TYPE_ALIASES while staying backward-compatible + // with the legacy free-text inputs operators have memorised. + // + // NOTE: 'raw custom' is intentionally listed alongside 'raw'/'custom' so + // the documented filter syntax mirrors the rendered short label + // ("Raw Custom"). (#1799 r1 item 11.) + var TYPE_ALIASES = { + 'request': 'REQ', + 'response': 'RESPONSE', + 'direct msg': 'TXT_MSG', + 'dm': 'TXT_MSG', + 'ack': 'ACK', + 'advert': 'ADVERT', + 'channel msg': 'GRP_TXT', + 'channel': 'GRP_TXT', + 'group data': 'GRP_DATA', + 'anon req': 'ANON_REQ', + 'path': 'PATH', + 'trace': 'TRACE', + 'multipart': 'MULTIPART', + 'control': 'CONTROL', + 'raw': 'RAW_CUSTOM', + 'custom': 'RAW_CUSTOM', + 'raw custom': 'RAW_CUSTOM' + }; + + // Public namespace (#1799 PR #1804 r1 item 8 / adv4): separate the + // CANONICAL ENUM MAP from the HELPERS/DERIVED collections. + // window.PayloadLabels.enums[ENUM] → entry { enumName, short, long, enumId } + // window.PayloadLabels.api → helpers + derived (SHORT_BY_ID, + // ORDER, TYPE_ALIASES, shortById(), …) + // A future enum named 'BY_ID' or 'ORDER' is now impossible to confuse + // with a helper key. + // + // BACK-COMPAT: every legacy access shape still works because we also + // hang the enums + helpers at the root of the same object — direct + // `PayloadLabels.GRP_TXT.short` and `PayloadLabels.SHORT_BY_ID` + // continue to resolve. Removed in a future cycle. + var api = { + LABELS: PAYLOAD_LABELS, + ORDER: ORDER, + BY_ID: BY_ID, + FW_PAYLOAD_TYPES: FW_PAYLOAD_TYPES, + SHORT_BY_ID: SHORT_BY_ID, + TYPE_ALIASES: TYPE_ALIASES, + shortById: function (id) { return SHORT_BY_ID[id]; }, + longById: function (id) { return (BY_ID[id] && BY_ID[id].long) || ''; }, + enumNameById: function (id) { return FW_PAYLOAD_TYPES[id]; } + }; + var ns = { + enums: PAYLOAD_LABELS, + api: api, + // Legacy root-level helpers — kept for callers that haven't migrated + // to .api yet. Same object references, so a new enum can't shadow + // them at runtime (collisions still possible structurally, but the + // .enums/.api split is the safe path). + LABELS: PAYLOAD_LABELS, + ORDER: ORDER, + BY_ID: BY_ID, + FW_PAYLOAD_TYPES: FW_PAYLOAD_TYPES, + SHORT_BY_ID: SHORT_BY_ID, + TYPE_ALIASES: TYPE_ALIASES, + shortById: api.shortById, + longById: api.longById, + enumNameById: api.enumNameById + }; + // Mirror each enum at the root for legacy direct-prop access + // (`window.PayloadLabels.GRP_TXT.short`). New code should read + // `.enums[GRP_TXT].short` and let this mirror retire. + Object.entries(PAYLOAD_LABELS).forEach(function (kv) { + ns[kv[0]] = kv[1]; + }); + + if (typeof window !== 'undefined') { + window.PayloadLabels = ns; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = ns; + } +})(); diff --git a/public/roles.js b/public/roles.js index 90d63e2a..2e9c527e 100644 --- a/public/roles.js +++ b/public/roles.js @@ -163,9 +163,16 @@ value: _roleOverrides, writable: false, enumerable: false, configurable: false }); + // PR #1804 r1 item 4 (tufte4+adv5): ACK and UNKNOWN deliberately share + // the neutral #6b7280 swatch (both render as the "Other" bucket). The + // legend now identifies rows via data-enum="", so the reverse + // color→enum lookup is no longer needed for tests or for the runtime + // — the prior insertion-order workaround is gone, restoring natural + // declaration order. window.TYPE_COLORS = { - ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b', ACK: '#6b7280', - REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6', + ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b', + ACK: '#6b7280', + REQ: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6', ANON_REQ: '#f43f5e', MULTIPART: '#0d9488', CONTROL: '#b45309', RAW_CUSTOM: '#c026d3', UNKNOWN: '#6b7280' }; @@ -173,7 +180,7 @@ // Badge CSS class name mapping const TYPE_BADGE_MAP = { ADVERT: 'advert', GRP_TXT: 'grp-txt', GRP_DATA: 'grp-data', TXT_MSG: 'txt-msg', ACK: 'ack', - REQUEST: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path', + REQ: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path', ANON_REQ: 'anon-req', MULTIPART: 'multipart', CONTROL: 'control', RAW_CUSTOM: 'raw-custom', UNKNOWN: 'unknown' }; diff --git a/public/route-view-utils.js b/public/route-view-utils.js index db39de86..ab8da5ba 100644 --- a/public/route-view-utils.js +++ b/public/route-view-utils.js @@ -69,7 +69,9 @@ case 'RESPONSE': case 'ANON_REQ': var typeGlyphNames = { 'TXT_MSG': 'ph-envelope', 'REQ': 'ph-lock', 'RESPONSE': 'ph-lock-open', 'ANON_REQ': 'ph-lock' }; - var typeLabels = { 'TXT_MSG': 'DM', 'REQ': 'REQUEST', 'RESPONSE': 'RESPONSE', 'ANON_REQ': 'ANON REQ' }; + // PR #1804 r1 item 10: REQ label uses the canonical short + // ('Request') instead of the legacy 'REQUEST' caps string. + var typeLabels = { 'TXT_MSG': 'DM', 'REQ': 'Request', 'RESPONSE': 'Response', 'ANON_REQ': 'Anon Req' }; glyph = (typeGlyphNames[t] ? '' : ''); label = typeLabels[t] || t; var src = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : (t === 'ANON_REQ' ? 'anon' : '?')); diff --git a/test-customizer-v2.js b/test-customizer-v2.js index 03de5446..a7163039 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -63,8 +63,10 @@ function makeSandbox() { function loadCustomizer() { const ctx = makeSandbox(); + const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/customize-v2.js', 'utf8'); vm.createContext(ctx); + vm.runInContext(labelsCode, ctx, { filename: 'payload-labels.js' }); vm.runInContext(code, ctx, { filename: 'customize-v2.js' }); return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage }; } diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 7861f81b..bd998942 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -89,6 +89,13 @@ function makeSandbox() { } function loadInCtx(ctx, file) { + // PR #1804 r1 item 9: live.js / packets.js / customize-v2.js / packet-filter.js + // now hard-fail if window.PayloadLabels is missing. Auto-preload payload-labels.js + // (idempotent) before any drift-gated file so tests exercise the prod path. + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); // Copy window.* to global context so bare references work for (const k of Object.keys(ctx.window)) { diff --git a/test-issue-1136-observer-iata-map.js b/test-issue-1136-observer-iata-map.js index e3f81a3c..1b663ae7 100644 --- a/test-issue-1136-observer-iata-map.js +++ b/test-issue-1136-observer-iata-map.js @@ -84,6 +84,7 @@ function load(ctx, file) { console.log('\n=== live.js: /api/observers parse (#1136) ==='); const ctx = makeSandbox(); +load(ctx, 'public/payload-labels.js'); load(ctx, 'public/roles.js'); load(ctx, 'public/live.js'); diff --git a/test-issue-1279-p2-code-filter.js b/test-issue-1279-p2-code-filter.js index cb17c32d..33d13c95 100644 --- a/test-issue-1279-p2-code-filter.js +++ b/test-issue-1279-p2-code-filter.js @@ -3,9 +3,14 @@ const vm = require('vm'); const fs = require('fs'); +const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/packet-filter.js', 'utf8'); const ctx = { window: {}, console }; vm.createContext(ctx); +// PR #1804 r1 item 9: packet-filter.js hard-fails if window.PayloadLabels +// is missing. Pre-load the canonical module so the test exercises the +// production code path. +vm.runInContext(labelsCode, ctx); vm.runInContext(code, ctx); const PF = ctx.window.PacketFilter; diff --git a/test-issue-1380-cb-sim-overlay.js b/test-issue-1380-cb-sim-overlay.js index 8f82c974..8b024f39 100644 --- a/test-issue-1380-cb-sim-overlay.js +++ b/test-issue-1380-cb-sim-overlay.js @@ -28,6 +28,7 @@ function assert(cond, msg) { const indexSrc = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'); const customSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8'); +const labelsSrc = fs.readFileSync(path.join(__dirname, 'public', 'payload-labels.js'), 'utf8'); console.log('\n=== #1380 A: index.html has inline SVG filters for the 4 sim classes ==='); ['cb-deut', 'cb-prot', 'cb-trit', 'cb-achromat'].forEach(function (id) { @@ -137,6 +138,7 @@ let envOK = false, env, exposed; try { env = makeSandbox(); vm.createContext(env.sandbox); + vm.runInContext(labelsSrc, env.sandbox, { filename: 'payload-labels.js' }); vm.runInContext(customSrc, env.sandbox, { filename: 'customize-v2.js' }); exposed = env.sandbox.window._customizerV2; envOK = !!exposed; diff --git a/test-issue-1509-detect-preset.js b/test-issue-1509-detect-preset.js index ded46616..b84b3809 100644 --- a/test-issue-1509-detect-preset.js +++ b/test-issue-1509-detect-preset.js @@ -87,8 +87,10 @@ function makeSandbox(opts) { function loadCustomizer(opts) { const { ctx, cssProps } = makeSandbox(opts); + const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/customize-v2.js', 'utf8'); vm.createContext(ctx); + vm.runInContext(labelsCode, ctx, { filename: 'payload-labels.js' }); vm.runInContext(code, ctx, { filename: 'customize-v2.js' }); return { api: ctx.window._customizerV2, cssProps, ls: ctx.localStorage }; } diff --git a/test-issue-1509-nav-active-bg.js b/test-issue-1509-nav-active-bg.js index 35dbef23..40dc8792 100644 --- a/test-issue-1509-nav-active-bg.js +++ b/test-issue-1509-nav-active-bg.js @@ -83,8 +83,10 @@ function makeSandbox(opts) { function loadCustomizer(opts) { const { ctx, cssProps } = makeSandbox(opts); + const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/customize-v2.js', 'utf8'); vm.createContext(ctx); + vm.runInContext(labelsCode, ctx, { filename: 'payload-labels.js' }); vm.runInContext(code, ctx, { filename: 'customize-v2.js' }); return { api: ctx.window._customizerV2, cssProps, ls: ctx.localStorage }; } diff --git a/test-issue-1518-home-url.js b/test-issue-1518-home-url.js index 3516a687..308aa9c5 100644 --- a/test-issue-1518-home-url.js +++ b/test-issue-1518-home-url.js @@ -73,8 +73,10 @@ function makeSandbox() { function load() { const ctx = makeSandbox(); + const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/customize-v2.js', 'utf8'); vm.createContext(ctx); + vm.runInContext(labelsCode, ctx, { filename: 'payload-labels.js' }); vm.runInContext(code, ctx, { filename: 'customize-v2.js' }); return { ctx, api: ctx.window._customizerV2 }; } diff --git a/test-issue-1633-hide-1byte-hops.js b/test-issue-1633-hide-1byte-hops.js index 221831c7..d121cd99 100644 --- a/test-issue-1633-hide-1byte-hops.js +++ b/test-issue-1633-hide-1byte-hops.js @@ -146,6 +146,7 @@ function makeLiveSandbox() { ctx.window.localStorage = ctx.localStorage; vm.createContext(ctx); // Load filter helpers first so live.js sees window.MC_* on import. + load(ctx, 'public/payload-labels.js'); load(ctx, 'public/hop-filter.js'); // Mirror window.* onto top-level for files that reference bare names. for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; diff --git a/test-issue-1668-m4-per-route.js b/test-issue-1668-m4-per-route.js index e5eada22..0aa8ea0d 100644 --- a/test-issue-1668-m4-per-route.js +++ b/test-issue-1668-m4-per-route.js @@ -275,7 +275,7 @@ function parseRgbFromCss(token) { const TYPE_BADGE_MAP_LOCAL = { ADVERT: 'advert', GRP_TXT: 'grp-txt', GRP_DATA: 'grp-data', TXT_MSG: 'txt-msg', ACK: 'ack', - REQUEST: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path', + REQ: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path', ANON_REQ: 'anon-req', MULTIPART: 'multipart', CONTROL: 'control', RAW_CUSTOM: 'raw-custom', UNKNOWN: 'unknown', }; diff --git a/test-issue-1799-label-vocab-e2e.js b/test-issue-1799-label-vocab-e2e.js new file mode 100644 index 00000000..d02b8da4 --- /dev/null +++ b/test-issue-1799-label-vocab-e2e.js @@ -0,0 +1,315 @@ +/** + * E2E for #1799 — canonical payload label vocabulary across surfaces. + * + * After PR #1804 round-1 review: + * - Item 12: literal pinned expected labels (not derived from the same + * map being tested) + inline-fallback drift gate. + * - Item 13: explicit TYPE_ALIASES coverage through PacketFilter. + * - Item 14: every key in the canonical map is exercised against pinned + * literals — not just the original 3 enums. + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1799-label-vocab-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(' \u2713 ' + name); } + catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// PINNED expected literals — these MUST match public/payload-labels.js by +// hand. The whole point of pinning (round-1 review item 12) is that if +// either side drifts, the test fails — NOT because both sides derive from +// the same object. +const EXPECTED_SHORT = { + REQ: 'Request', + RESPONSE: 'Response', + TXT_MSG: 'Direct Msg', + ACK: 'ACK', + ADVERT: 'Advert', + GRP_TXT: 'Channel Msg', + GRP_DATA: 'Group Data', + ANON_REQ: 'Anon Req', + PATH: 'Path', + TRACE: 'Trace', + MULTIPART: 'Multipart', + CONTROL: 'Control', + RAW_CUSTOM: 'Raw Custom' +}; +// PR #1804 r1 item 2 (tufte2): every `long` must DESCRIBE the packet's +// behaviour, not echo the short label. Pinned literals — drift here +// fails the E2E. +const EXPECTED_LONG = { + REQ: 'Encrypted data request to a remote node', + RESPONSE: 'Encrypted data response from a remote node', + TXT_MSG: 'Encrypted point-to-point text message', + ACK: 'Acknowledgment of a prior message or request', + ADVERT: 'Node identity and capability advertisement', + GRP_TXT: 'Channel-scoped group text message', + GRP_DATA: 'Channel-scoped group datagram (non-text payload)', + ANON_REQ: 'Anonymous encrypted request via ephemeral key', + PATH: 'Network path discovery and return-path advertisement', + TRACE: 'Per-hop route trace with SNR samples', + MULTIPART: 'Fragmented payload reassembled across multiple packets', + CONTROL: 'Mesh control-plane signalling (e.g. zero-hop direct)', + RAW_CUSTOM: 'Application-defined raw payload, no firmware envelope' +}; +const EXPECTED_ID = { + REQ: 0, RESPONSE: 1, TXT_MSG: 2, ACK: 3, ADVERT: 4, GRP_TXT: 5, + GRP_DATA: 6, ANON_REQ: 7, PATH: 8, TRACE: 9, MULTIPART: 10, + CONTROL: 11, RAW_CUSTOM: 15 +}; +const ALL_ENUMS = Object.keys(EXPECTED_SHORT); + +async function gotoLive(page) { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveLegend', { timeout: 10000, state: 'attached' }); + await page.evaluate(() => { + try { localStorage.removeItem('live-legend-hidden'); } catch (_) {} + const el = document.getElementById('liveLegend'); + if (el) el.classList.remove('hidden'); + }); + await page.waitForTimeout(300); +} + +// Pull "short" label from each legend row. Build the color→enum reverse +// map from TYPE_COLORS so we can identify rows by enum without trusting +// the rendered text. +async function legendShortLabels(page) { + return page.evaluate(() => { + const out = {}; + const el = document.getElementById('liveLegend'); + if (!el) return out; + const lis = el.querySelectorAll('.legend-list li'); + const TYPE_COLORS = window.TYPE_COLORS || {}; + const colorToEnum = {}; + for (const k of Object.keys(TYPE_COLORS)) colorToEnum[String(TYPE_COLORS[k]).toLowerCase()] = k; + for (const li of lis) { + // PR #1804 r1 item 4 (tufte4+adv5): rows now carry data-enum, so we + // identify by enum directly instead of reverse-mapping via the + // shared #6b7280 color (which forced an insertion-order workaround + // in roles.js). Fall back to the color path only if data-enum is + // missing, for robustness while the change rolls out. + const enumAttr = li.getAttribute('data-enum'); + const dot = li.querySelector('.live-dot'); + let enumName = enumAttr || ''; + if (!enumName) { + if (!dot) continue; + const styleAttr = dot.getAttribute('style') || ''; + const mhex = styleAttr.match(/#([0-9a-f]{3,8})/i); + const color = mhex ? ('#' + mhex[1].toLowerCase()) : ''; + enumName = colorToEnum[color]; + if (!enumName) continue; + } + const txt = (li.textContent || '').trim(); + // PR #1804 r1 item 1 (tufte1+adv1): all rows render with the same + // em-dash separator now (no slash special-case for ACK), so a + // single split rule applies. + const parts = txt.split(/\s+\u2014\s+/); + out[enumName] = parts[0].trim(); + } + return out; + }); +} + +async function gotoPackets(page) { + await page.evaluate(() => { + try { + localStorage.removeItem('meshcore-groupbyhash'); + localStorage.setItem('meshcore-time-window', '525600'); + } catch (_) {} + }); + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('#typeTrigger', { timeout: 15000 }); + await page.click('#typeTrigger'); + await page.waitForSelector('#typeMenu .multi-select-item', { timeout: 5000 }); +} + +async function packetsTypeLabels(page) { + return page.evaluate(() => { + const out = {}; + const items = document.querySelectorAll('#typeMenu .multi-select-item'); + for (const lab of items) { + const cb = lab.querySelector('input[type=checkbox]'); + if (!cb) continue; + const id = cb.getAttribute('data-type-id'); + if (id === '__all__') continue; + out[id] = (lab.textContent || '').trim(); + } + return out; + }); +} + +(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=== #1799 canonical payload label vocabulary — E2E against ${BASE} ===`); + + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(10000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + await step('navigate to /live and read legend short labels', async () => { await gotoLive(page); }); + const legend = await legendShortLabels(page); + + await step('canonical map exposed as window.PayloadLabels on /live', async () => { + const pl = await page.evaluate(() => window.PayloadLabels || null); + assert(pl && typeof pl === 'object', 'window.PayloadLabels missing'); + // Pinned-literal check, NOT a self-derived comparison (round-1 item 12). + for (const name of ALL_ENUMS) { + assert(pl[name], `PayloadLabels.${name} missing`); + assert(pl[name].short === EXPECTED_SHORT[name], + `PayloadLabels.${name}.short: expected "${EXPECTED_SHORT[name]}", got "${pl[name].short}"`); + assert(pl[name].enumId === EXPECTED_ID[name], + `PayloadLabels.${name}.enumId: expected ${EXPECTED_ID[name]}, got ${pl[name].enumId}`); + assert(pl[name].enumName === name, + `PayloadLabels.${name}.enumName: expected "${name}", got "${pl[name].enumName}"`); + // PR #1804 r1 item 2: long must be the behavioural description, not + // a tautological echo of short. Pinned literals. + assert(pl[name].long === EXPECTED_LONG[name], + `PayloadLabels.${name}.long: expected "${EXPECTED_LONG[name]}", got "${pl[name].long}"`); + } + }); + + await step('every legend row matches the pinned canonical short label', async () => { + // Round-1 item 14: cover ALL 13 enums, not just 3. + for (const name of ALL_ENUMS) { + const got = legend[name]; + assert(got === EXPECTED_SHORT[name], + `legend[${name}]: expected "${EXPECTED_SHORT[name]}", got "${got}" (full legend: ${JSON.stringify(legend)})`); + } + }); + + await step('every legend row carries data-enum= (PR #1804 r1 item 4)', async () => { + // tufte4+adv5: rows must be identifiable by enum, not by reverse- + // mapping the shared #6b7280 color. + const rows = await page.evaluate(() => { + const el = document.getElementById('liveLegend'); + if (!el) return []; + return Array.from(el.querySelectorAll('.legend-list li')).map(li => ({ + en: li.getAttribute('data-enum'), + text: (li.textContent || '').trim() + })); + }); + const enumsSeen = new Set(); + for (const r of rows) { + // Only legend rows with a live-dot are payload-type rows. Other + //
  • s (e.g. role legend, ring legend) may not carry data-enum. + if (!r.en) continue; + enumsSeen.add(r.en); + } + for (const name of ALL_ENUMS) { + assert(enumsSeen.has(name), + `data-enum="${name}" missing on legend row (rows=${JSON.stringify(rows)})`); + } + }); + + await step('all legend rows render with the uniform em-dash separator (PR #1804 r1 item 1)', async () => { + // tufte1+adv1: ACK row used to render with a slash + 'Other —' + // wrapper. Now every row is `SHORT — LONG`. + const rows = await page.evaluate(() => { + const el = document.getElementById('liveLegend'); + if (!el) return []; + return Array.from(el.querySelectorAll('.legend-list li[data-enum]')) + .map(li => (li.textContent || '').trim()); + }); + for (const t of rows) { + assert(t.indexOf('\u2014') !== -1, `legend row missing em-dash: "${t}"`); + assert(t.indexOf(' / ') === -1, `legend row uses slash separator: "${t}"`); + } + }); + + await step('canonical map exposed at window.PayloadLabels.enums (PR #1804 r1 item 8)', async () => { + const ok = await page.evaluate((enums) => { + const PL = window.PayloadLabels; + if (!PL || !PL.enums || !PL.api) return { ok: false, why: 'missing PL/enums/api' }; + for (const k of enums) { + if (!PL.enums[k]) return { ok: false, why: 'PL.enums.' + k + ' missing' }; + if (!PL.api.SHORT_BY_ID) return { ok: false, why: 'PL.api.SHORT_BY_ID missing' }; + } + return { ok: true }; + }, ALL_ENUMS); + assert(ok.ok, 'namespace check: ' + (ok.why || 'unknown')); + }); + + await step('navigate to /packets and open type filter', async () => { await gotoPackets(page); }); + const packetsLabels = await packetsTypeLabels(page); + + await step('canonical map also exposed on /packets', async () => { + const pl = await page.evaluate(() => window.PayloadLabels || null); + assert(pl && typeof pl === 'object', 'window.PayloadLabels missing on /packets'); + }); + + await step('every packets type-filter row matches the pinned canonical short label', async () => { + // Round-1 item 14: cover ALL enums on the packets page too. + for (const name of ALL_ENUMS) { + const id = String(EXPECTED_ID[name]); + const got = packetsLabels[id]; + assert(got === EXPECTED_SHORT[name], + `packets type-menu[id=${id} (${name})]: expected "${EXPECTED_SHORT[name]}", got "${got}"`); + } + }); + + await step('PacketFilter recognises every enum name (round-trip)', async () => { + const pf = await page.evaluate((enums) => { + const pf = window.PacketFilter; if (!pf) return null; + const out = {}; + for (const e of enums) { + const c = pf.compile('type == ' + e.name); + out[e.name] = !c.error && c.filter({ payload_type: e.id }) === true; + } + return out; + }, ALL_ENUMS.map(n => ({ name: n, id: EXPECTED_ID[n] }))); + assert(pf, 'window.PacketFilter missing'); + for (const name of ALL_ENUMS) { + assert(pf[name], `packet-filter does not recognise enum name "${name}"`); + } + }); + + await step('PacketFilter resolves TYPE_ALIASES through PacketFilter.compile (round-1 item 13)', async () => { + // Map of alias → expected enumId. Each must resolve via the filter + // language (quoted alias values use the same alias table). Covers the + // path that round-1 item 13 flagged as untested. + const ALIAS_CASES = [ + { alias: 'channel msg', id: 5 }, + { alias: 'dm', id: 2 }, + { alias: 'direct msg', id: 2 }, + { alias: 'group data', id: 6 }, + { alias: 'raw custom', id: 15 }, + { alias: 'anon req', id: 7 }, + { alias: 'request', id: 0 } + ]; + const got = await page.evaluate((cases) => { + const pf = window.PacketFilter; if (!pf) return null; + return cases.map(c => { + const compiled = pf.compile('type == "' + c.alias + '"'); + return { + alias: c.alias, + id: c.id, + ok: !compiled.error && compiled.filter({ payload_type: c.id }) === true, + err: compiled.error || null + }; + }); + }, ALIAS_CASES); + assert(got, 'window.PacketFilter missing'); + for (const r of got) { + assert(r.ok, `alias "${r.alias}" → payload_type=${r.id} failed (err=${r.err})`); + } + }); + + await ctx.close(); + await browser.close(); + console.log(`\n=== ${passed} passed, ${failed} failed ===`); + process.exit(failed === 0 ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-live-dt-cap-1524.js b/test-live-dt-cap-1524.js index 8a7eec81..b0426f3d 100644 --- a/test-live-dt-cap-1524.js +++ b/test-live-dt-cap-1524.js @@ -61,6 +61,7 @@ const ctx = { }; vm.createContext(ctx); +vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); const src = fs.readFileSync('public/live.js', 'utf8'); vm.runInContext(src, ctx); diff --git a/test-live-legend-helper.js b/test-live-legend-helper.js new file mode 100644 index 00000000..f987b2c4 --- /dev/null +++ b/test-live-legend-helper.js @@ -0,0 +1,106 @@ +/* Unit test for buildLegendHtml() — the helper extracted from live.js + * legend rendering IIFE per PR #1804 r1 item 7 (adv3). + * + * Loads the helper via vm with a stubbed window so the function is + * directly testable without spinning Playwright + chromium. + */ +'use strict'; +const fs = require('fs'); +const vm = require('vm'); + +const labelsSrc = fs.readFileSync('public/payload-labels.js', 'utf8'); +const liveSrc = fs.readFileSync('public/live.js', 'utf8'); + +const ctx = { + window: {}, + console, + document: { addEventListener: () => {}, getElementById: () => null, querySelector: () => null, querySelectorAll: () => [] }, + navigator: { userAgent: 'node' }, + location: { hash: '', pathname: '/' }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + setTimeout, clearTimeout, setInterval, clearInterval, + fetch: () => Promise.resolve({ ok: true, json: () => ({}) }), +}; +ctx.self = ctx; ctx.globalThis = ctx; +vm.createContext(ctx); + +// Load canonical labels first. +vm.runInContext(labelsSrc, ctx); + +// live.js is a huge file with many module-scope side effects (router, +// websocket, etc). We only need buildLegendHtml. Extract it by locating +// `function buildLegendHtml(...)` and brace-counting to the matching '}'. +function extractHelper(src) { + const startMarker = 'function buildLegendHtml'; + const idx = src.indexOf(startMarker); + if (idx === -1) return null; + // find the '{' opening the body + let i = src.indexOf('{', idx); + if (i === -1) return null; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) { return src.slice(idx, i + 1); } } + } + return null; +} +const helperSrc = extractHelper(liveSrc); +if (!helperSrc) { + // Helper hasn't been extracted yet — that's the RED state. + console.log(' ✗ buildLegendHtml helper not found in public/live.js — extract it (PR #1804 r1 item 7)'); + console.log('=== 0 passed, 1 failed ==='); + process.exit(1); +} +vm.runInContext('var TYPE_COLORS = { ADVERT: "#22c55e", GRP_TXT: "#3b82f6", GRP_DATA: "#8b5cf6", TXT_MSG: "#f59e0b", ACK: "#6b7280", REQ: "#a855f7", RESPONSE: "#06b6d4", TRACE: "#ec4899", PATH: "#14b8a6", ANON_REQ: "#f43f5e", MULTIPART: "#0d9488", CONTROL: "#b45309", RAW_CUSTOM: "#c026d3" };', ctx); +vm.runInContext(helperSrc, ctx); +const buildLegendHtml = ctx.buildLegendHtml; + +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' \u2713 ' + name); } + catch (e) { fail++; console.log(' \u2717 ' + name + ' — ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const html = buildLegendHtml(ctx.window.PayloadLabels); + +test('returns a non-empty string', () => { + assert(typeof html === 'string' && html.length > 0, 'got ' + typeof html); +}); + +test('emits one
  • per enum in PayloadLabels.ORDER', () => { + const liCount = (html.match(/ { + for (const k of ctx.window.PayloadLabels.ORDER) { + assert(html.indexOf('data-enum="' + k + '"') !== -1, + 'data-enum="' + k + '" missing'); + } +}); + +test('every row uses the em-dash separator (uniform typography, item 1)', () => { + for (const k of ctx.window.PayloadLabels.ORDER) { + const entry = ctx.window.PayloadLabels[k]; + const wantSnippet = entry.short + ' \u2014 ' + entry.long; + assert(html.indexOf(wantSnippet) !== -1, + k + ': expected snippet "' + wantSnippet + '" not found in legend html'); + } +}); + +test('no row uses " / " as the SHORT/LONG separator (uniform typography)', () => { + // Slashes are fine inside long descriptions (e.g. "path discovery / + // return-path advertisement"). Forbid only the legacy `SHORT / Other —` + // structure that used to render ACK differently. + for (const k of ctx.window.PayloadLabels.ORDER) { + const entry = ctx.window.PayloadLabels[k]; + const badSnippet = entry.short + ' / '; + assert(html.indexOf(badSnippet) === -1, + k + ': legacy slash-after-short separator survived ("' + badSnippet + '")'); + } +}); + +console.log('=== ' + pass + ' passed, ' + fail + ' failed ==='); +process.exit(fail === 0 ? 0 : 1); diff --git a/test-live-region-filter.js b/test-live-region-filter.js index c32fb5e3..cf914727 100644 --- a/test-live-region-filter.js +++ b/test-live-region-filter.js @@ -83,6 +83,7 @@ function load(ctx, file) { console.log('\n=== live.js: region filter (#1045) ==='); const ctx = makeSandbox(); +load(ctx, 'public/payload-labels.js'); load(ctx, 'public/roles.js'); load(ctx, 'public/live.js'); diff --git a/test-live.js b/test-live.js index f582e298..d7e65764 100644 --- a/test-live.js +++ b/test-live.js @@ -157,6 +157,7 @@ function makeLiveSandbox({ withAppJs = false } = {}) { const ctx = makeSandbox(); addLiveGlobals(ctx); + loadInCtx(ctx, 'public/payload-labels.js'); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/packet-helpers.js'); if (withAppJs) loadInCtx(ctx, 'public/app.js'); @@ -288,6 +289,7 @@ console.log('\n=== live.js: expandToBufferEntriesAsync ==='); // Build a sandbox with packet-helpers loaded so expandToBufferEntries can call dbPacketToLive const ctx = makeSandbox(); addLiveGlobals(ctx); + loadInCtx(ctx, 'public/payload-labels.js'); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/packet-helpers.js'); try { loadInCtx(ctx, 'public/live.js'); } catch (e) { diff --git a/test-packet-filter-time.js b/test-packet-filter-time.js index 132159c3..40d031f7 100644 --- a/test-packet-filter-time.js +++ b/test-packet-filter-time.js @@ -3,9 +3,14 @@ const vm = require('vm'); const fs = require('fs'); +const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/packet-filter.js', 'utf8'); const ctx = { window: {}, console }; vm.createContext(ctx); +// PR #1804 r1 item 9: packet-filter.js hard-fails if window.PayloadLabels +// is missing. Pre-load the canonical module so the test exercises the +// production code path. +vm.runInContext(labelsCode, ctx); vm.runInContext(code, ctx); const PF = ctx.window.PacketFilter; diff --git a/test-packet-filter-ux.js b/test-packet-filter-ux.js index 042221ec..c027d5f3 100644 --- a/test-packet-filter-ux.js +++ b/test-packet-filter-ux.js @@ -35,7 +35,7 @@ function test(name, fn) { function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } const ctx = makeCtx(); -loadInCtx(['public/packet-filter.js', 'public/filter-ux.js'], ctx); +loadInCtx(['public/payload-labels.js', 'public/packet-filter.js', 'public/filter-ux.js'], ctx); const PF = ctx.window.PacketFilter; const UX = ctx.window.FilterUX; diff --git a/test-packet-filter.js b/test-packet-filter.js index 80fe64b7..d94488d8 100644 --- a/test-packet-filter.js +++ b/test-packet-filter.js @@ -3,9 +3,14 @@ const vm = require('vm'); const fs = require('fs'); +const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8'); const code = fs.readFileSync('public/packet-filter.js', 'utf8'); const ctx = { window: {}, console }; vm.createContext(ctx); +// PR #1804 r1 item 9: packet-filter.js now hard-fails if +// window.PayloadLabels is missing. Pre-load the canonical module so the +// test exercises the production code path. +vm.runInContext(labelsCode, ctx); vm.runInContext(code, ctx); const PF = ctx.window.PacketFilter; diff --git a/test-packets.js b/test-packets.js index 6734005b..ee8a9100 100644 --- a/test-packets.js +++ b/test-packets.js @@ -105,6 +105,7 @@ function loadInCtx(ctx, file) { function loadPacketsSandbox() { const ctx = makeSandbox(); // Load dependencies first + loadInCtx(ctx, 'public/payload-labels.js'); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); loadInCtx(ctx, 'public/packet-helpers.js'); @@ -1205,6 +1206,7 @@ console.log('\n=== packets.js: scroll position preserved across renderTableRows return null; }; + loadInCtx(ctx, 'public/payload-labels.js'); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); loadInCtx(ctx, 'public/packet-helpers.js'); diff --git a/test-panel-corner.js b/test-panel-corner.js index a287fc96..06d4da8e 100644 --- a/test-panel-corner.js +++ b/test-panel-corner.js @@ -106,7 +106,7 @@ function loadLiveModule(ctx) { ctx.timeAgo = () => '—'; ctx.getParsedPath = () => []; ctx.getParsedDecoded = () => ({}); - ctx.TYPE_COLORS = { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6' }; + ctx.TYPE_COLORS = { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', REQ: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6' }; ctx.ROLE_COLORS = {}; ctx.ROLE_LABELS = {}; ctx.ROLE_STYLE = {}; diff --git a/test-payload-labels-content.js b/test-payload-labels-content.js new file mode 100644 index 00000000..250ae019 --- /dev/null +++ b/test-payload-labels-content.js @@ -0,0 +1,91 @@ +/* Unit test for public/payload-labels.js — pins long descriptions and the + * abbreviation-style policy declared at the top of the file. + * #1799 PR #1804 r1 items 2 (tufte2: long must describe, not echo) and + * 5 (tufte5: uniform abbreviation policy + top-of-file comment). + */ +'use strict'; +const fs = require('fs'); +const vm = require('vm'); + +const src = fs.readFileSync('public/payload-labels.js', 'utf8'); +const ctx = { window: {}, console }; +vm.createContext(ctx); +vm.runInContext(src, ctx); +const PL = ctx.window.PayloadLabels; + +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' \u2713 ' + name); } + catch (e) { fail++; console.log(' \u2717 ' + name + ' — ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// Item 2 — long must describe behaviour, not echo the short label. +const EXPECTED_LONG = { + REQ: 'Encrypted data request to a remote node', + RESPONSE: 'Encrypted data response from a remote node', + TXT_MSG: 'Encrypted point-to-point text message', + ACK: 'Acknowledgment of a prior message or request', + ADVERT: 'Node identity and capability advertisement', + GRP_TXT: 'Channel-scoped group text message', + GRP_DATA: 'Channel-scoped group datagram (non-text payload)', + ANON_REQ: 'Anonymous encrypted request via ephemeral key', + PATH: 'Network path discovery and return-path advertisement', + TRACE: 'Per-hop route trace with SNR samples', + MULTIPART: 'Fragmented payload reassembled across multiple packets', + CONTROL: 'Mesh control-plane signalling (e.g. zero-hop direct)', + RAW_CUSTOM: 'Application-defined raw payload, no firmware envelope' +}; + +console.log('=== payload-labels content + policy ==='); + +test('every enum has the pinned long description (item 2)', () => { + for (const name of Object.keys(EXPECTED_LONG)) { + assert(PL[name], 'missing entry ' + name); + assert(PL[name].long === EXPECTED_LONG[name], + name + '.long: expected "' + EXPECTED_LONG[name] + '", got "' + PL[name].long + '"'); + } +}); + +test('no long tautologically echoes its short (item 2)', () => { + for (const name of Object.keys(EXPECTED_LONG)) { + const s = (PL[name].short || '').toLowerCase().replace(/\s+/g, ' ').trim(); + const l = (PL[name].long || '').toLowerCase().replace(/\s+/g, ' ').trim(); + assert(l !== s, name + ': long equals short (' + s + ')'); + assert(l !== s + ' ' + s, name + ': long is just doubled short'); + // soft heuristic: long must be at least 2 words longer than short + const shortWords = s.split(/\s+/).length; + const longWords = l.split(/\s+/).length; + assert(longWords >= shortWords + 2, + name + ': long ("' + l + '") not meaningfully longer than short ("' + s + '")'); + } +}); + +test('abbreviation policy comment is present at top of file (item 5)', () => { + // Top-of-file comment must declare the uniform policy so future editors + // know the rule before adding a new enum. + const head = src.slice(0, 1500); + assert(/abbreviation policy|ABBREVIATION POLICY|Abbreviation policy/.test(head), + 'top-of-file comment missing an "abbreviation policy" section'); +}); + +test('every short respects the declared length cap (<=12 chars)', () => { + for (const name of Object.keys(EXPECTED_LONG)) { + assert(PL[name].short.length <= 12, + name + '.short ("' + PL[name].short + '") exceeds 12-char cap'); + } +}); + +test('all shorts use Title Case (no all-caps except documented exceptions)', () => { + // ACK is the documented exception (a wire-protocol acronym). + const allowedAllCaps = new Set(['ACK']); + for (const name of Object.keys(EXPECTED_LONG)) { + const s = PL[name].short; + if (allowedAllCaps.has(name)) continue; + assert(s !== s.toUpperCase() || s.length === 0, + name + '.short ("' + s + '") is all-caps and not in the documented exception set'); + } +}); + +console.log('=== ' + pass + ' passed, ' + fail + ' failed ==='); +process.exit(fail === 0 ? 0 : 1); diff --git a/test-payload-labels-namespace.js b/test-payload-labels-namespace.js new file mode 100644 index 00000000..57b9f3ef --- /dev/null +++ b/test-payload-labels-namespace.js @@ -0,0 +1,111 @@ +/* Unit test for the PayloadLabels namespace restructure (PR #1804 r1 + * items 8 & 9). + * + * Item 8: canonical enum map at window.PayloadLabels.enums[ENUM] and + * helpers/derived at window.PayloadLabels.api — direct-prop + * collisions (e.g. an enum named BY_ID or ORDER) become + * impossible. + * Item 9: payload-labels.js is loaded synchronously before its + * consumers in index.html. Drop the inline fallbacks in + * packets.js / packet-filter.js / live.js and add a top-of-file + * guard so a missing module is a loud crash, not silent stale + * data. + */ +'use strict'; +const fs = require('fs'); +const vm = require('vm'); + +const labelsSrc = fs.readFileSync('public/payload-labels.js', 'utf8'); +const packetsSrc = fs.readFileSync('public/packets.js', 'utf8'); +const filterSrc = fs.readFileSync('public/packet-filter.js', 'utf8'); +const liveSrc = fs.readFileSync('public/live.js', 'utf8'); + +const ctx = { window: {}, console }; +vm.createContext(ctx); +vm.runInContext(labelsSrc, ctx); +const PL = ctx.window.PayloadLabels; + +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' \u2713 ' + name); } + catch (e) { fail++; console.log(' \u2717 ' + name + ' — ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const EXPECTED_ENUMS = [ + 'REQ','RESPONSE','TXT_MSG','ACK','ADVERT','GRP_TXT','GRP_DATA', + 'ANON_REQ','PATH','TRACE','MULTIPART','CONTROL','RAW_CUSTOM' +]; + +console.log('=== PayloadLabels namespace (item 8) ==='); + +test('window.PayloadLabels exposes .enums namespace', () => { + assert(PL && typeof PL.enums === 'object', 'PayloadLabels.enums missing'); + for (const k of EXPECTED_ENUMS) { + assert(PL.enums[k], 'PayloadLabels.enums.' + k + ' missing'); + assert(PL.enums[k].short, 'PayloadLabels.enums.' + k + '.short missing'); + assert(PL.enums[k].enumId !== undefined, 'PayloadLabels.enums.' + k + '.enumId missing'); + } +}); + +test('window.PayloadLabels exposes .api namespace with helpers', () => { + assert(PL.api && typeof PL.api === 'object', 'PayloadLabels.api missing'); + assert(typeof PL.api.shortById === 'function', 'PayloadLabels.api.shortById missing'); + assert(typeof PL.api.longById === 'function', 'PayloadLabels.api.longById missing'); + assert(typeof PL.api.enumNameById === 'function', 'PayloadLabels.api.enumNameById missing'); + assert(PL.api.SHORT_BY_ID, 'PayloadLabels.api.SHORT_BY_ID missing'); + assert(PL.api.FW_PAYLOAD_TYPES, 'PayloadLabels.api.FW_PAYLOAD_TYPES missing'); + assert(PL.api.TYPE_ALIASES, 'PayloadLabels.api.TYPE_ALIASES missing'); + assert(Array.isArray(PL.api.ORDER), 'PayloadLabels.api.ORDER missing'); +}); + +test('legacy direct-prop access still works (back-compat)', () => { + // Existing callers (live.js, customize*.js, the E2E that pins literals) + // read pl[ENUM].short directly. Keep that working — restructure adds + // .enums/.api, doesn't break the direct shape. + for (const k of EXPECTED_ENUMS) { + assert(PL[k] && PL[k].short, 'legacy PayloadLabels.' + k + '.short broken'); + assert(PL[k] === PL.enums[k], 'legacy ' + k + ' should alias .enums.' + k); + } + // Helpers still reachable at root for back-compat too. + assert(PL.SHORT_BY_ID, 'legacy PayloadLabels.SHORT_BY_ID missing'); + assert(PL.ORDER, 'legacy PayloadLabels.ORDER missing'); +}); + +console.log('=== inline-fallback maps (item 9) ==='); + +test('packets.js: DEFAULT_TYPE_NAMES inline fallback is gone', () => { + assert(!/DEFAULT_TYPE_NAMES\s*=\s*\{/.test(packetsSrc), + 'packets.js still declares DEFAULT_TYPE_NAMES fallback map'); +}); + +test('packets.js: hard guard for missing PayloadLabels', () => { + // Must throw, not silently fall back. + assert(/!\s*window\.PayloadLabels[\s\S]{0,80}throw/.test(packetsSrc), + 'packets.js missing `!window.PayloadLabels → throw` guard'); +}); + +test('packet-filter.js: _FALLBACK_FW + _FALLBACK_ALIASES gone', () => { + assert(!/_FALLBACK_FW\s*=\s*\{/.test(filterSrc), + 'packet-filter.js still declares _FALLBACK_FW'); + assert(!/_FALLBACK_ALIASES\s*=\s*\{/.test(filterSrc), + 'packet-filter.js still declares _FALLBACK_ALIASES'); +}); + +test('packet-filter.js: hard guard for missing PayloadLabels', () => { + assert(/!\s*window\.PayloadLabels[\s\S]{0,120}throw/.test(filterSrc), + 'packet-filter.js missing throw-guard'); +}); + +test('live.js buildLegendHtml: INLINE_LABELS fallback is gone', () => { + assert(!/INLINE_LABELS\s*=\s*\{/.test(liveSrc), + 'live.js still declares INLINE_LABELS fallback inside buildLegendHtml'); +}); + +test('live.js: hard guard for missing PayloadLabels at top of file', () => { + assert(/!\s*window\.PayloadLabels[\s\S]{0,120}throw/.test(liveSrc), + 'live.js missing throw-guard'); +}); + +console.log('=== ' + pass + ' passed, ' + fail + ' failed ==='); +process.exit(fail === 0 ? 0 : 1);