From 874e81ab8b05f4885e1a04534e9acb219cabfc2b Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Mon, 30 Mar 2026 08:21:39 +0200
Subject: [PATCH] web: prepare frontend for multi protocol (#657)
* web: prepare frontend for multi protocol
* web: address review comments
* fix: address review feedback on multi-protocol frontend prep
- Replace iconHtml/innerHTML in renderChatTabs with iconSrc + DOM APIs;
the img element is now built attribute-by-attribute so no innerHTML trust
boundary exists even if iconSrc were to receive external input
- Add MESHTASTIC_ICON_SRC / MESHCORE_ICON_SRC constants to protocol-helpers;
meshtasticIconHtml() and meshcoreIconHtml() reference these so the asset
path has a single source of truth
- Use meshtasticIconHtml() in the map legend via a temp span to eliminate
the 7-setAttribute duplication
- Add getRoleColors(protocol) to role-helpers, making meshcoreRoleColors
reachable through a tested code path rather than a dead export
- Rename __test__ export in main.js to __testUtils for consistency
- Add JSDoc cross-reference on normalizeNodeNameValue vs stringOrNull
* web: address review comments
* web: address review comments
* web: address review comments
---
web/public/assets/img/meshcore.svg | 5 +
web/public/assets/img/meshtastic.svg | 16 ++
.../assets/js/app/__tests__/chat-tabs.test.js | 64 ++++-
.../js/app/__tests__/dom-environment.js | 29 +++
.../js/app/__tests__/main-protocol.test.js | 230 +++++++++++++++++
.../app/__tests__/node-link-helpers.test.js | 159 ++++++++++++
.../assets/js/app/__tests__/node-page.test.js | 127 +++++++++-
.../js/app/__tests__/protocol-helpers.test.js | 103 ++++++++
.../js/app/__tests__/role-helpers.test.js | 18 ++
web/public/assets/js/app/chat-log-tabs.js | 6 +-
web/public/assets/js/app/chat-tabs.js | 24 +-
web/public/assets/js/app/main.js | 232 +++++++++++-------
web/public/assets/js/app/node-page.js | 21 +-
web/public/assets/js/app/protocol-helpers.js | 82 +++++++
web/public/assets/js/app/role-helpers.js | 67 ++++-
web/public/assets/styles/base.css | 6 +
16 files changed, 1077 insertions(+), 112 deletions(-)
create mode 100644 web/public/assets/img/meshcore.svg
create mode 100644 web/public/assets/img/meshtastic.svg
create mode 100644 web/public/assets/js/app/__tests__/main-protocol.test.js
create mode 100644 web/public/assets/js/app/__tests__/node-link-helpers.test.js
create mode 100644 web/public/assets/js/app/__tests__/protocol-helpers.test.js
create mode 100644 web/public/assets/js/app/protocol-helpers.js
diff --git a/web/public/assets/img/meshcore.svg b/web/public/assets/img/meshcore.svg
new file mode 100644
index 0000000..51aa36b
--- /dev/null
+++ b/web/public/assets/img/meshcore.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/web/public/assets/img/meshtastic.svg b/web/public/assets/img/meshtastic.svg
new file mode 100644
index 0000000..721b0b2
--- /dev/null
+++ b/web/public/assets/img/meshtastic.svg
@@ -0,0 +1,16 @@
+
+
+
diff --git a/web/public/assets/js/app/__tests__/chat-tabs.test.js b/web/public/assets/js/app/__tests__/chat-tabs.test.js
index 52a78d4..99f529d 100644
--- a/web/public/assets/js/app/__tests__/chat-tabs.test.js
+++ b/web/public/assets/js/app/__tests__/chat-tabs.test.js
@@ -56,7 +56,10 @@ class MockFragment {
class MockElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
+ // children mirrors HTMLElement.children: element nodes only.
this.children = [];
+ // childNodes mirrors HTMLElement.childNodes: all nodes including text.
+ this.childNodes = [];
this.attributes = new Map();
this.dataset = {};
this.classList = new MockClassList();
@@ -67,18 +70,26 @@ class MockElement {
}
appendChild(node) {
- this.children.push(node);
+ this.childNodes.push(node);
+ if (node instanceof MockElement) {
+ this.children.push(node);
+ }
return node;
}
replaceChildren(...nodes) {
this.children = [];
+ this.childNodes = [];
for (const node of nodes) {
if (!node) continue;
if (node.isFragment && Array.isArray(node.children)) {
this.children.push(...node.children);
+ this.childNodes.push(...node.children);
} else {
- this.children.push(node);
+ this.childNodes.push(node);
+ if (node instanceof MockElement) {
+ this.children.push(node);
+ }
}
}
}
@@ -113,6 +124,13 @@ class MockElement {
}
}
+class MockTextNode {
+ constructor(text) {
+ this.textContent = String(text);
+ this.nodeType = 3;
+ }
+}
+
function createMockDocument() {
return {
createElement(tag) {
@@ -120,6 +138,9 @@ function createMockDocument() {
},
createDocumentFragment() {
return new MockFragment();
+ },
+ createTextNode(text) {
+ return new MockTextNode(text);
}
};
}
@@ -192,3 +213,42 @@ test('renderChatTabs clears container when no tabs exist', () => {
assert.equal(container.children.length, 0);
assert.equal(container.dataset.activeTab, '');
});
+
+test('renderChatTabs renders icon img child when tab.iconSrc is provided', () => {
+ const document = createMockDocument();
+ const container = new MockElement('div');
+
+ const tabs = [
+ { id: 'channel-0', label: 'LongFast', iconSrc: '/assets/img/meshtastic.svg' }
+ ];
+
+ renderChatTabs({ document, container, tabs });
+
+ const [tabList] = container.children;
+ const button = tabList.children[0];
+ // Button has one element child (the icon
) and one text node — two childNodes total.
+ assert.equal(button.children.length, 1, 'should have exactly one element child (icon img)');
+ assert.equal(button.childNodes.length, 2, 'should have two child nodes (icon img + text node)');
+ const iconImg = button.children[0];
+ assert.equal(iconImg.tagName, 'IMG', 'first element child should be an img');
+ assert.equal(iconImg.getAttribute('src'), '/assets/img/meshtastic.svg', 'img src should match iconSrc');
+ assert.equal(iconImg.getAttribute('aria-hidden'), 'true', 'img should be hidden from AT');
+ const textNode = button.childNodes[1];
+ assert.equal(textNode.nodeType, 3, 'second child node should be a text node');
+ assert.equal(textNode.textContent, 'LongFast');
+});
+
+test('renderChatTabs uses textContent when no iconSrc is provided', () => {
+ const document = createMockDocument();
+ const container = new MockElement('div');
+
+ const tabs = [{ id: 'log', label: 'Log' }];
+
+ renderChatTabs({ document, container, tabs });
+
+ const [tabList] = container.children;
+ const button = tabList.children[0];
+ assert.equal(button.textContent, 'Log');
+ // No icon child elements
+ assert.equal(button.children.length, 0);
+});
diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js
index a82e4fb..63f2c50 100644
--- a/web/public/assets/js/app/__tests__/dom-environment.js
+++ b/web/public/assets/js/app/__tests__/dom-environment.js
@@ -211,6 +211,32 @@ class MockElement {
this.childNodes = [String(value)];
}
+ /**
+ * Register an event handler for the given event type.
+ *
+ * @param {string} event Event type.
+ * @param {Function} handler Callback to invoke.
+ * @returns {void}
+ */
+ addEventListener(event, handler) {
+ if (!this._listeners) this._listeners = new Map();
+ if (!this._listeners.has(event)) this._listeners.set(event, []);
+ this._listeners.get(event).push(handler);
+ }
+
+ /**
+ * Remove a previously registered event handler.
+ *
+ * @param {string} event Event type.
+ * @param {Function} handler Callback to remove.
+ * @returns {void}
+ */
+ removeEventListener(event, handler) {
+ if (!this._listeners || !this._listeners.has(event)) return;
+ const remaining = this._listeners.get(event).filter(h => h !== handler);
+ this._listeners.set(event, remaining);
+ }
+
/**
* Very small querySelectorAll implementation that supports ``.class`` lookups
* used in unit tests.
@@ -303,6 +329,9 @@ export function createDomEnvironment(options = {}) {
createElement(tagName) {
return new MockElement(tagName, registry);
},
+ createTextNode(text) {
+ return String(text);
+ },
createDocumentFragment() {
const fragment = new MockElement('fragment', null);
fragment.childNodes = [];
diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js
new file mode 100644
index 0000000..ed56ff3
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/main-protocol.test.js
@@ -0,0 +1,230 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createDomEnvironment } from './dom-environment.js';
+import { initializeApp } from '../main.js';
+
+const MINIMAL_CONFIG = Object.freeze({
+ channel: 'Primary',
+ frequency: '915MHz',
+ refreshMs: 0,
+ refreshIntervalSeconds: 30,
+ chatEnabled: true,
+ mapCenter: { lat: 0, lon: 0 },
+ mapZoom: null,
+ maxDistanceKm: 0,
+ tileFilters: { light: '', dark: '' },
+ instancesFeatureEnabled: false,
+ instanceDomain: null,
+ snapshotWindowSeconds: 3600,
+});
+
+/**
+ * Spin up a minimal DOM environment, call initializeApp with a stub config,
+ * and return the inner test utilities alongside an env.cleanup() handle.
+ *
+ * @returns {{ testUtils: Object, cleanup: Function }}
+ */
+function setupApp() {
+ const env = createDomEnvironment({ includeBody: true });
+ // themeToggle is accessed without a null guard in initializeApp.
+ env.createElement('button', 'themeToggle');
+ const { _testUtils } = initializeApp(MINIMAL_CONFIG);
+ return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
+}
+
+// --- normalizeOverlaySource ---
+
+test('normalizeOverlaySource propagates string protocol field', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const result = testUtils.normalizeOverlaySource({ protocol: 'meshcore' });
+ assert.equal(result.protocol, 'meshcore');
+ } finally {
+ cleanup();
+ }
+});
+
+test('normalizeOverlaySource propagates "meshtastic" protocol', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const result = testUtils.normalizeOverlaySource({ protocol: 'meshtastic' });
+ assert.equal(result.protocol, 'meshtastic');
+ } finally {
+ cleanup();
+ }
+});
+
+test('normalizeOverlaySource omits protocol when absent', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const result = testUtils.normalizeOverlaySource({ longName: 'Alice' });
+ assert.ok(!('protocol' in result), 'protocol should not be set when source has none');
+ } finally {
+ cleanup();
+ }
+});
+
+test('normalizeOverlaySource omits protocol when value is not a string', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const result = testUtils.normalizeOverlaySource({ protocol: 42 });
+ assert.ok(!('protocol' in result), 'protocol should not be set for non-string values');
+ } finally {
+ cleanup();
+ }
+});
+
+// --- buildMapPopupHtml ---
+
+test('buildMapPopupHtml includes meshtastic icon for null protocol', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const html = testUtils.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0);
+ assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol');
+ } finally {
+ cleanup();
+ }
+});
+
+test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const html = testUtils.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0);
+ assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent');
+ } finally {
+ cleanup();
+ }
+});
+
+test('buildMapPopupHtml omits meshtastic icon for meshcore protocol', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const html = testUtils.buildMapPopupHtml({ long_name: 'Eve', node_id: '!abc789', protocol: 'meshcore' }, 0);
+ assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon for meshcore nodes');
+ } finally {
+ cleanup();
+ }
+});
+
+// --- createAnnouncementEntry ---
+
+test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtastic', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createAnnouncementEntry({
+ timestampSeconds: 1000,
+ shortName: 'ALI',
+ longName: 'Alice',
+ role: 'CLIENT',
+ metadataSource: { protocol: 'meshtastic' },
+ nodeData: null,
+ messageHtml: 'joined the mesh',
+ });
+ const html = typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '';
+ assert.ok(String(html).includes('meshtastic.svg'), 'announcement should include meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
+
+test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createAnnouncementEntry({
+ timestampSeconds: 1000,
+ shortName: 'BOB',
+ longName: 'Bob',
+ role: 'ROUTER',
+ metadataSource: {},
+ nodeData: null,
+ messageHtml: 'detected',
+ });
+ const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
+ assert.ok(html.includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
+
+test('createAnnouncementEntry omits meshtastic icon for meshcore protocol', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createAnnouncementEntry({
+ timestampSeconds: 1000,
+ shortName: 'MC1',
+ longName: 'MeshCore Node',
+ role: 'REPEATER',
+ metadataSource: { protocol: 'meshcore' },
+ nodeData: null,
+ messageHtml: 'seen',
+ });
+ const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
+ assert.ok(!html.includes('meshtastic.svg'), 'announcement for meshcore should not include meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
+
+// --- createMessageChatEntry ---
+
+test('createMessageChatEntry prefixes meshtastic icon when node protocol is meshtastic', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createMessageChatEntry({
+ text: 'hello mesh',
+ rx_time: 1000,
+ node: { short_name: 'ALI', role: 'CLIENT', protocol: 'meshtastic' },
+ });
+ const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
+ assert.ok(html.includes('meshtastic.svg'), 'chat entry should include meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
+
+test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createMessageChatEntry({
+ text: 'hi',
+ rx_time: 2000,
+ node: { short_name: 'BOB', role: 'ROUTER' },
+ });
+ const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
+ assert.ok(html.includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
+
+test('createMessageChatEntry omits meshtastic icon for meshcore node', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const div = testUtils.createMessageChatEntry({
+ text: 'test',
+ rx_time: 3000,
+ node: { short_name: 'MC1', role: 'REPEATER', protocol: 'meshcore' },
+ });
+ const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? '');
+ assert.ok(!html.includes('meshtastic.svg'), 'chat entry for meshcore node should not show meshtastic icon');
+ } finally {
+ cleanup();
+ }
+});
diff --git a/web/public/assets/js/app/__tests__/node-link-helpers.test.js b/web/public/assets/js/app/__tests__/node-link-helpers.test.js
new file mode 100644
index 0000000..71ad0bc
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/node-link-helpers.test.js
@@ -0,0 +1,159 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ escapeHtml,
+ normalizeNodeNameValue,
+ buildNodeDetailHref,
+ canonicalNodeIdentifier,
+ renderNodeLongNameLink,
+} from '../main.js';
+
+// --- escapeHtml ---
+
+test('escapeHtml escapes & < > " and single-quote', () => {
+ assert.equal(escapeHtml('a & b'), 'a & b');
+ assert.equal(escapeHtml('