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('