fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804)

## 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 `<li>` 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`** — `<script src="payload-labels.js">` loaded
before `roles.js` / `packet-filter.js` / `packets.js`.
- **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E.
Scrapes `#liveLegend` rows and the `/packets` type-filter checklist,
asserts each label matches `window.PayloadLabels[ENUM].short` for
`TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter`
still recognises the enum names.
- **`.github/workflows/deploy.yml`** — wired the new E2E into the
existing Playwright block.

## TDD trail

- Red commit `eb392d4` — adds the failing E2E only (asserts
`window.PayloadLabels` exists and labels match; both fail).
- Green commit `44e902a` — introduces the canonical map and migrates the
three surfaces.

## Verification

- `node test-packet-filter.js` — 92/92 pass with the new fallback
wiring.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — clean.

Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises
`/live` legend + `/packets` type filter against a Playwright headless
Chromium; CI's Playwright block runs it on every push.

E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` —
`assert(fromLegend === canon, ...)` and `assert(fromPackets === canon,
...)` per enum.

Fixes #1799

---------

Co-authored-by: mc-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@kpa.com>
Co-authored-by: clawbot <bot@clawbot.local>
This commit is contained in:
Kpa-clawbot
2026-06-30 05:48:47 -07:00
committed by GitHub
co-authored by mc-bot openclaw-bot clawbot clawbot
parent 4654ce3386
commit b74a64ccfa
36 changed files with 1052 additions and 52 deletions
+1
View File
@@ -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
+36 -2
View File
@@ -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 {
+46
View File
@@ -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"])
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+32 -10
View File
@@ -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:<name>' 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();
}
+25 -9
View File
@@ -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:<name>; 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,
+1
View File
@@ -166,6 +166,7 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="payload-labels.js?v=__BUST__"></script>
<script src="roles.js?v=__BUST__"></script>
<script src="map-tile-providers.js?v=__BUST__"></script>
<script src="cb-presets.js?v=__BUST__"></script>
+35 -15
View File
@@ -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 <li data-enum="<ENUM>">…</li>
// 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 '<li data-enum="' + k + '"><span class="live-dot" style="background:' + color + '" aria-hidden="true"></span> ' + label + '</li>';
}).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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>', GRP_TXT: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chat-circle"/></svg>', TXT_MSG: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>', ACK: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg>',
REQUEST: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>', RESPONSE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>', TRACE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg>', PATH: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg>'
REQ: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-question"/></svg>', RESPONSE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg>', TRACE: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg>', PATH: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-path"/></svg>'
};
/* ---- Panel Corner Positioning (#608 M0) ---- */
@@ -1167,19 +1199,7 @@
<div class="panel-content">
<h3 class="legend-title">PACKET TYPES</h3>
<ul class="legend-list">
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert Node advertisement</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_TXT}" aria-hidden="true"></span> Message Group text</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.TXT_MSG}" aria-hidden="true"></span> Direct Direct message</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.REQUEST}" aria-hidden="true"></span> Request Data request</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.RESPONSE}" aria-hidden="true"></span> Response Data response</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.TRACE}" aria-hidden="true"></span> Trace Route trace</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.PATH}" aria-hidden="true"></span> Path Path discovery</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.ANON_REQ}" aria-hidden="true"></span> Anon Req Anonymous request</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_DATA}" aria-hidden="true"></span> Group Data Group datagram</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.MULTIPART}" aria-hidden="true"></span> Multipart Multi-fragment payload</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.CONTROL}" aria-hidden="true"></span> Control Control plane</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.RAW_CUSTOM}" aria-hidden="true"></span> Raw Custom Application-defined payload</li>
<li><span class="live-dot" style="background:${TYPE_COLORS.ACK}" aria-hidden="true"></span> Ack / Other Acknowledgment or unknown type</li>
${buildLegendHtml(window.PayloadLabels || null)}
</ul>
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
<ul class="legend-list" id="roleLegendList"></ul>
+20 -5
View File
@@ -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' };
+12 -2
View File
@@ -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;
+163
View File
@@ -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;
}
})();
+10 -3
View File
@@ -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="<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'
};
+3 -1
View File
@@ -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] ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#' + typeGlyphNames[t] + '"/></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle"/></svg>');
label = typeLabels[t] || t;
var src = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : (t === 'ANON_REQ' ? 'anon' : '?'));
+2
View File
@@ -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 };
}
+7
View File
@@ -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)) {
+1
View File
@@ -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');
+5
View File
@@ -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;
+2
View File
@@ -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;
+2
View File
@@ -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 };
}
+2
View File
@@ -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 };
}
+2
View File
@@ -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 };
}
+1
View File
@@ -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];
+1 -1
View File
@@ -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',
};
+315
View File
@@ -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=<ENUM_NAME> (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
// <li>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); });
+1
View File
@@ -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);
+106
View File
@@ -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 <li> per enum in PayloadLabels.ORDER', () => {
const liCount = (html.match(/<li\b/g) || []).length;
const want = ctx.window.PayloadLabels.ORDER.length;
assert(liCount === want, 'li count: want ' + want + ', got ' + liCount);
});
test('every row carries data-enum="<ENUM>" attribute', () => {
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);
+1
View File
@@ -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');
+2
View File
@@ -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) {
+5
View File
@@ -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;
+1 -1
View File
@@ -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;
+5
View File
@@ -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;
+2
View File
@@ -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');
+1 -1
View File
@@ -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 = {};
+91
View File
@@ -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);
+111
View File
@@ -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);