diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e41ae498..fc6621c6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -466,6 +466,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1799-label-vocab-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 6f8af2ef..bc64d01c 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -583,18 +583,29 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) { "surface3": "#2d2d50", "sectionBg": "#1e1e34", }, s.cfg.ThemeDark, theme.ThemeDark) + // #1799 PR #1804 r1 item 6: REQUEST→REQ rename is a BREAKING change to + // the /api/config/theme shape. Compat policy for >=1 release cycle: + // - INCOMING: if an operator's config.json / theme.json carries the + // legacy "REQUEST" key, normalise it to "REQ" before mergeMap so + // the override wins the canonical slot. + // - OUTGOING: dual-emit REQ AND REQUEST in the GET response so any + // consumer still reading the legacy key keeps working. typeColors := mergeMap(map[string]interface{}{ "ADVERT": "#22c55e", "GRP_TXT": "#3b82f6", "TXT_MSG": "#f59e0b", "ACK": "#6b7280", - "REQUEST": "#a855f7", + "REQ": "#a855f7", "RESPONSE": "#06b6d4", "TRACE": "#ec4899", "PATH": "#14b8a6", "ANON_REQ": "#f43f5e", "UNKNOWN": "#6b7280", - }, s.cfg.TypeColors, theme.TypeColors) + }, normaliseTypeColorsLegacyKeys(s.cfg.TypeColors), normaliseTypeColorsLegacyKeys(theme.TypeColors)) + // Dual-emit REQUEST = REQ for back-compat (drop after >=1 release cycle). + if v, ok := typeColors["REQ"]; ok { + typeColors["REQUEST"] = v + } defaultHome := map[string]interface{}{ "heroTitle": "CoreScope", @@ -3101,6 +3112,29 @@ func queryInt(r *http.Request, key string, def int) int { return n } +// normaliseTypeColorsLegacyKeys returns a copy of `m` with the legacy +// "REQUEST" key renamed to the canonical "REQ". If both keys are present +// the canonical wins (caller already chose the new name explicitly). +// #1799 PR #1804 r1 item 6: keeps stale operator config.json working +// after the REQUEST→REQ rename for >=1 release cycle. Returns nil for +// a nil input so mergeMap's nil-skip continues to work. +func normaliseTypeColorsLegacyKeys(m map[string]interface{}) map[string]interface{} { + if m == nil { + return nil + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + if legacy, ok := out["REQUEST"]; ok { + if _, hasCanon := out["REQ"]; !hasCanon { + out["REQ"] = legacy + } + delete(out, "REQUEST") + } + return out +} + func mergeMap(base map[string]interface{}, overlays ...map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}) for k, v := range base { diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 3765ae0f..ed271584 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4807,3 +4807,49 @@ func TestPostPacketPersistsV3Schema(t *testing.T) { t.Errorf("timestamp: want unix int near %d, got %d", nowSec, gotTS) } } + +// TestConfigThemeTypeColorsLegacyRequestKey verifies the REQUEST→REQ rename +// (#1799 PR #1804 r1 item 6) doesn't break operators whose config.json still +// carries the legacy `typeColors.REQUEST` key. The GET response must: +// - accept a config that supplies "REQUEST" and have that value win the +// mergeMap precedence for the corresponding logical slot +// - emit BOTH "REQ" and "REQUEST" in typeColors for ≥1 release cycle so +// downstream consumers reading the legacy key keep working +func TestConfigThemeTypeColorsLegacyRequestKey(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{ + Port: 3000, + TypeColors: map[string]interface{}{ + // Operator's stale config — legacy key only. + "REQUEST": "#deadbe", + }, + } + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/config/theme", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + tc, ok := body["typeColors"].(map[string]interface{}) + if !ok { + t.Fatalf("typeColors missing or wrong shape: %v", body["typeColors"]) + } + // Legacy operator override must propagate to the canonical REQ slot. + if tc["REQ"] != "#deadbe" { + t.Errorf("typeColors.REQ: want #deadbe (from legacy REQUEST override), got %v", tc["REQ"]) + } + // Back-compat emission: REQUEST also present, equal to REQ. + if tc["REQUEST"] != "#deadbe" { + t.Errorf("typeColors.REQUEST: want #deadbe (back-compat dual-emit), got %v", tc["REQUEST"]) + } +} diff --git a/docs/CUSTOMIZATION.md b/docs/CUSTOMIZATION.md index 6de40ffb..ff7fa371 100644 --- a/docs/CUSTOMIZATION.md +++ b/docs/CUSTOMIZATION.md @@ -137,7 +137,7 @@ Affects map markers, packet path badges, node lists, and legends. "GRP_TXT": "#3b82f6", "TXT_MSG": "#f59e0b", "ACK": "#6b7280", - "REQUEST": "#a855f7", + "REQ": "#a855f7", "RESPONSE": "#06b6d4", "TRACE": "#ec4899", "PATH": "#14b8a6", diff --git a/docs/specs/customizer-rework.md b/docs/specs/customizer-rework.md index 02e0ac37..aee48035 100644 --- a/docs/specs/customizer-rework.md +++ b/docs/specs/customizer-rework.md @@ -119,7 +119,7 @@ The user override delta is a sparse object — it only contains fields the user "GRP_TXT": "string — CSS color", "TXT_MSG": "string — CSS color", "ACK": "string — CSS color", - "REQUEST": "string — CSS color", + "REQ": "string — CSS color", "RESPONSE": "string — CSS color", "TRACE": "string — CSS color", "PATH": "string — CSS color", diff --git a/public/customize-v2.js b/public/customize-v2.js index d2df7d5f..d1d844cf 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -245,7 +245,7 @@ nodeColors: { repeater: '#ff0000', companion: '#0066ff', room: '#009900', sensor: '#cc8800', observer: '#9933ff' }, typeColors: { ADVERT: '#009900', GRP_TXT: '#0066ff', TXT_MSG: '#cc8800', ACK: '#666666', - REQUEST: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355' + REQ: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355' } }, midnight: { @@ -328,19 +328,35 @@ }; var NODE_EMOJI = { repeater: 'ph:diamond', companion: 'ph:circle-fill', room: 'ph:square-fill', sensor: 'ph:triangle', observer: 'ph:star-fill' }; - var TYPE_LABELS = { - ADVERT: 'Advertisement', GRP_TXT: 'Channel Message', TXT_MSG: 'Direct Message', ACK: 'Acknowledgment', - REQUEST: 'Request', RESPONSE: 'Response', TRACE: 'Traceroute', PATH: 'Path', ANON_REQ: 'Anonymous Request' - }; + // PR #1804 r1 item 3 (tufte3): consume canonical PayloadLabels shorts so + // the v2 customizer matches every other surface. Defensive literal + // fallback mirrors packets.js. + var TYPE_LABELS = (function () { + var FALLBACK = { + ADVERT: 'Advert', GRP_TXT: 'Channel Msg', TXT_MSG: 'Direct Msg', ACK: 'ACK', + REQ: 'Request', RESPONSE: 'Response', TRACE: 'Trace', PATH: 'Path', + ANON_REQ: 'Anon Req' + }; + var PL = window.PayloadLabels; + if (!PL || !PL.SHORT_BY_ID) { + console.error('customize-v2.js: window.PayloadLabels missing — using inline TYPE_LABELS fallback.'); + return FALLBACK; + } + var out = {}; + for (var k in FALLBACK) { + out[k] = (PL[k] && PL[k].short) || FALLBACK[k]; + } + return out; + })(); var TYPE_HINTS = { ADVERT: 'Node advertisements', GRP_TXT: 'Group/channel messages', TXT_MSG: 'Direct messages', - ACK: 'Acknowledgments', REQUEST: 'Requests', RESPONSE: 'Responses', + ACK: 'Acknowledgments', REQ: 'Requests', RESPONSE: 'Responses', TRACE: 'Traceroute', PATH: 'Path packets', ANON_REQ: 'Encrypted anonymous requests' }; // #1648 M5: defaults use 'ph:' tokens. renderConfigGlyph() below // accepts both new ph tokens AND legacy emoji strings (back-compat). var TYPE_EMOJI = { - ADVERT: 'ph:broadcast', GRP_TXT: 'ph:chat-circle', TXT_MSG: 'ph:envelope', ACK: 'ph:check', REQUEST: 'ph:question', + ADVERT: 'ph:broadcast', GRP_TXT: 'ph:chat-circle', TXT_MSG: 'ph:envelope', ACK: 'ph:check', REQ: 'ph:question', RESPONSE: 'ph:envelope-simple', TRACE: 'ph:magnifying-glass', PATH: 'ph:path', ANON_REQ: 'ph:lock' }; @@ -710,8 +726,12 @@ var tc = effectiveConfig.typeColors; if (tc) { for (var type in tc) { - root.setProperty('--type-' + type.toLowerCase(), tc[type]); - if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = tc[type]; + // #1799 r1 item 6: legacy stored configs use 'REQUEST'; canonical + // key is 'REQ'. Migrate at read time so operator color choices + // survive the rename. + var canon = (type === 'REQUEST') ? 'REQ' : type; + root.setProperty('--type-' + canon.toLowerCase(), tc[type]); + if (window.TYPE_COLORS && canon in window.TYPE_COLORS) window.TYPE_COLORS[canon] = tc[type]; } if (window.syncBadgeColors) window.syncBadgeColors(); } @@ -2699,7 +2719,9 @@ } if (earlyOverrides.typeColors && window.TYPE_COLORS) { for (var type in earlyOverrides.typeColors) { - if (type in window.TYPE_COLORS) window.TYPE_COLORS[type] = earlyOverrides.typeColors[type]; + // #1799 r1 item 6: REQUEST → REQ migration (see comment above). + var canon = (type === 'REQUEST') ? 'REQ' : type; + if (canon in window.TYPE_COLORS) window.TYPE_COLORS[canon] = earlyOverrides.typeColors[type]; } if (window.syncBadgeColors) window.syncBadgeColors(); } diff --git a/public/customize.js b/public/customize.js index 8538cbba..c3247905 100644 --- a/public/customize.js +++ b/public/customize.js @@ -75,7 +75,7 @@ }, typeColors: { 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' }, home: { @@ -259,7 +259,7 @@ nodeColors: { repeater: '#ff0000', companion: '#0066ff', room: '#009900', sensor: '#cc8800', observer: '#9933ff' }, typeColors: { ADVERT: '#009900', GRP_TXT: '#0066ff', TXT_MSG: '#cc8800', ACK: '#666666', - REQUEST: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355' + REQ: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355' } }, midnight: { @@ -461,17 +461,33 @@ // renderConfigGlyph() for operators with stored config (back-compat). const NODE_EMOJI = { repeater: 'ph:diamond', companion: 'ph:circle-fill', room: 'ph:square-fill', sensor: 'ph:triangle', observer: 'ph:star-fill' }; - const TYPE_LABELS = { - ADVERT: 'Advertisement', GRP_TXT: 'Channel Message', TXT_MSG: 'Direct Message', ACK: 'Acknowledgment', - REQUEST: 'Request', RESPONSE: 'Response', TRACE: 'Traceroute', PATH: 'Path', - ANON_REQ: 'Anonymous Request' - }; + // PR #1804 r1 item 3 (tufte3): TYPE_LABELS now consumes the canonical + // PayloadLabels short labels so the customizer matches every other + // surface. Defensive literal fallback mirrors the policy used in + // packets.js — drift gate keeps these byte-equal to canonical. + const TYPE_LABELS = (function () { + const FALLBACK = { + ADVERT: 'Advert', GRP_TXT: 'Channel Msg', TXT_MSG: 'Direct Msg', ACK: 'ACK', + REQ: 'Request', RESPONSE: 'Response', TRACE: 'Trace', PATH: 'Path', + ANON_REQ: 'Anon Req' + }; + const PL = window.PayloadLabels; + if (!PL || !PL.SHORT_BY_ID) { + console.error('customize.js: window.PayloadLabels missing — using inline TYPE_LABELS fallback.'); + return FALLBACK; + } + const out = {}; + for (const k of Object.keys(FALLBACK)) { + out[k] = (PL[k] && PL[k].short) || FALLBACK[k]; + } + return out; + })(); const TYPE_HINTS = { ADVERT: 'Node advertisements — map, feed, packet list', GRP_TXT: 'Group/channel messages — map, feed, channels', TXT_MSG: 'Direct messages — map, feed', ACK: 'Acknowledgments — packet list', - REQUEST: 'Requests — packet list, feed', + REQ: 'Requests — packet list, feed', RESPONSE: 'Responses — packet list', TRACE: 'Traceroute — map, traces page', PATH: 'Path packets — packet list', @@ -479,7 +495,7 @@ }; // #1648 M5: defaults are ph:; renderConfigGlyph handles legacy emoji. const TYPE_EMOJI = { - ADVERT: 'ph:broadcast', GRP_TXT: 'ph:chat-circle', TXT_MSG: 'ph:envelope', ACK: 'ph:check', REQUEST: 'ph:question', RESPONSE: 'ph:envelope-simple', TRACE: 'ph:magnifying-glass', PATH: 'ph:path', ANON_REQ: 'ph:lock' + ADVERT: 'ph:broadcast', GRP_TXT: 'ph:chat-circle', TXT_MSG: 'ph:envelope', ACK: 'ph:check', REQ: 'ph:question', RESPONSE: 'ph:envelope-simple', TRACE: 'ph:magnifying-glass', PATH: 'ph:path', ANON_REQ: 'ph:lock' }; // renderConfigGlyph(value): given an operator-customizable config string, diff --git a/public/index.html b/public/index.html index f272581c..aea7556b 100644 --- a/public/index.html +++ b/public/index.html @@ -166,6 +166,7 @@
+ 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

      -
    • Advert — Node advertisement
    • -
    • Message — Group text
    • -
    • Direct — Direct message
    • -
    • Request — Data request
    • -
    • Response — Data response
    • -
    • Trace — Route trace
    • -
    • Path — Path discovery
    • -
    • Anon Req — Anonymous request
    • -
    • Group Data — Group datagram
    • -
    • Multipart — Multi-fragment payload
    • -
    • Control — Control plane
    • -
    • Raw Custom — Application-defined payload
    • -
    • Ack / Other — Acknowledgment or unknown type
    • + ${buildLegendHtml(window.PayloadLabels || null)}

    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);