mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 01:07:53 +00:00
GetPacketPath used to resolve only the single farthest-traveled observation and discard the rest. It now returns one branch per distinct station (kept at that station's own deepest observation), each with its own hop count and, where resolvable, relay chain -- so the map visualizes the packet's full flood spread instead of one route. Stations whose path never resolved, or who heard the packet directly (0 hops), still contribute a branch via their own position, rather than being silently dropped. The "View path" link in the ping-bot reply no longer requires hops > 0 to show, since even an all-direct ping still has observer spread worth plotting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
/**
|
|
* Unit tests for the CoreScope-only "ping" bot reply bubble
|
|
* (public/channels.js renderMessages' botReplyHtml block).
|
|
*
|
|
* The backend (cmd/server/db.go pingBotReply) attaches a synthetic
|
|
* `botReply` field to a channel message whose text is exactly "ping" --
|
|
* this file covers that the frontend renders it distinctly, includes the
|
|
* "not sent to the mesh" caveat, and escapes attacker-controlled fields
|
|
* (observer names flow into botReply.text server-side, so it must not be
|
|
* trusted blindly).
|
|
*
|
|
* Sandbox pattern borrowed from test-channels-merge-1498-unit.js: load
|
|
* channels.js in a tolerant vm context, grab the test-only export.
|
|
*/
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const noop = () => {};
|
|
const fakeEl = () => ({
|
|
addEventListener: noop, querySelector: () => null, querySelectorAll: () => [],
|
|
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
|
appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null,
|
|
textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0,
|
|
});
|
|
const chMessagesEl = fakeEl();
|
|
const doc = {
|
|
readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(),
|
|
documentElement: fakeEl(),
|
|
getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null),
|
|
querySelector: () => null, querySelectorAll: () => [],
|
|
addEventListener: noop,
|
|
};
|
|
const win = { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) };
|
|
const ctx = {
|
|
window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error,
|
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
history: { replaceState: noop, pushState: noop },
|
|
location: { hash: '', href: '', pathname: '/' },
|
|
navigator: { userAgent: 'node' },
|
|
RegionFilter: { getRegionParam: () => '' },
|
|
api: () => Promise.resolve({ messages: [] }),
|
|
CLIENT_TTL: {},
|
|
ChannelDecrypt: undefined,
|
|
truncate: (s) => s,
|
|
formatHashHex: (h) => String(h),
|
|
channelDisplayName: (c) => c && c.name,
|
|
escapeHtml,
|
|
getSenderColor: () => '#123456',
|
|
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
|
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
|
};
|
|
vm.createContext(ctx);
|
|
try {
|
|
vm.runInContext(fs.readFileSync('public/channels.js', 'utf8'), ctx);
|
|
} catch (e) {
|
|
// Tolerant: only the render path under test needs to have been
|
|
// exported before any unrelated init code throws.
|
|
}
|
|
return { ctx, chMessagesEl };
|
|
}
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(' ✅ ' + name); }
|
|
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
console.log('\n=== channels.js: pingBotReply (shared trigger/format logic) ===');
|
|
// This is the client-side twin of pingBotReply in cmd/server/db.go, used
|
|
// by the WebSocket live-push path and the client-side PSK-channel decrypt
|
|
// path (neither of which round-trips through GetChannelMessages, so
|
|
// neither gets the server-computed botReply without this).
|
|
|
|
test('exact "ping" (any case) triggers a reply', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
assert.ok(fn('ping', 1, 5, 'Obs') !== null);
|
|
assert.ok(fn('PING', 1, 5, 'Obs') !== null);
|
|
assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed');
|
|
});
|
|
|
|
test('"/ping" (the slash-command form) also triggers, alongside bare "ping"', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
assert.ok(fn('/ping', 1, 5, 'Obs') !== null);
|
|
assert.ok(fn('/PING', 1, 5, 'Obs') !== null, 'case-insensitive like the bare form');
|
|
assert.strictEqual(fn('/pingx', 1, 5, 'Obs'), null, 'still an exact match, not a prefix match');
|
|
});
|
|
|
|
test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
assert.ok(fn('@CoreScopeBot ping', 0, null, null) !== null);
|
|
});
|
|
|
|
test('"pinging" or other substrings do not match (exact trigger only)', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
assert.strictEqual(fn('pinging around', 1, 5, 'Obs'), null);
|
|
assert.strictEqual(fn('not ping', 1, 5, 'Obs'), null);
|
|
assert.strictEqual(fn('', 1, 5, 'Obs'), null);
|
|
});
|
|
|
|
test('reply text includes hops, SNR, and observer when present', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
const r = fn('ping', 3, 8.25, 'Observer One');
|
|
assert.strictEqual(r.sender, 'CoreScopeBot');
|
|
assert.ok(r.text.includes('3 hops'), r.text);
|
|
assert.ok(r.text.includes('SNR 8.3dB') || r.text.includes('SNR 8.2dB'), r.text);
|
|
assert.ok(r.text.includes('heard by Observer One'), r.text);
|
|
});
|
|
|
|
test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
const r = fn('ping', 0, null, null);
|
|
assert.ok(r.text.includes('0 hops (direct)'), r.text);
|
|
assert.ok(!r.text.includes('SNR'), r.text);
|
|
assert.ok(!r.text.includes('heard by'), r.text);
|
|
});
|
|
|
|
test('reply never includes scope or area -- both are already on the triggering message\'s own meta line', () => {
|
|
const { ctx } = makeSandbox();
|
|
const fn = ctx.window._channelsPingBotReplyForTest;
|
|
const r = fn('ping', 1, 5, 'Obs');
|
|
assert.ok(!r.text.includes('scope'), r.text);
|
|
assert.ok(!r.text.includes('area'), r.text);
|
|
});
|
|
|
|
console.log('\n=== channels.js: ping-bot reply rendering ===');
|
|
|
|
test('a message without botReply renders no bot bubble', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{ sender: 'Alice', text: 'just chatting', timestamp: '2026-01-15T10:00:00Z' },
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
assert.ok(!chMessagesEl.innerHTML.includes('ch-bot-message'), 'no botReply field should mean no bot bubble');
|
|
});
|
|
|
|
test('a message with botReply renders a distinct bot bubble with the reply text', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{
|
|
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
|
|
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 },
|
|
},
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
const html = chMessagesEl.innerHTML;
|
|
assert.ok(html.includes('ch-bot-message'), 'should render the distinct bot-message class');
|
|
assert.ok(html.includes('CoreScopeBot'), 'should show the bot sender name');
|
|
assert.ok(html.includes('2 hops'), 'should include the hop count from the reply text');
|
|
assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text');
|
|
});
|
|
|
|
test('"View path" link appears when a packetHash is available', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{
|
|
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123',
|
|
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null },
|
|
},
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
const html = chMessagesEl.innerHTML;
|
|
assert.ok(html.includes('View path'), 'should show the View path link');
|
|
assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up');
|
|
});
|
|
|
|
test('"View path" link still appears for a direct (0-hop) reply -- the map plots every station that heard it, not just relay hops', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{
|
|
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123',
|
|
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 0 hops (direct)', hops: 0, snr: null },
|
|
},
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
assert.ok(chMessagesEl.innerHTML.includes('View path'), 'a direct reply still has observer positions worth visualizing');
|
|
});
|
|
|
|
test('"View path" link is absent when there is no packetHash to look it up by', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{
|
|
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
|
|
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null },
|
|
},
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'without a packetHash there is nothing to fetch the path for');
|
|
});
|
|
|
|
test('the "not sent to the mesh" caveat is always present on a bot bubble', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{ sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } },
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
assert.ok(chMessagesEl.innerHTML.includes('Not sent to the mesh'), 'the caveat must be visible so this is never mistaken for a real mesh reply');
|
|
});
|
|
|
|
test('botReply.text and .sender are HTML-escaped (observer names are operator-controlled)', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{
|
|
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
|
|
botReply: { sender: '<img src=x onerror=alert(1)>', text: 'heard by <script>alert(2)</script>', hops: 0 },
|
|
},
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
const html = chMessagesEl.innerHTML;
|
|
assert.ok(!html.includes('<img src=x'), 'botReply.sender must be escaped');
|
|
assert.ok(!html.includes('<script>alert(2)'), 'botReply.text must be escaped');
|
|
});
|
|
|
|
test('the bot bubble renders immediately after its triggering message, not before other messages', () => {
|
|
const { ctx, chMessagesEl } = makeSandbox();
|
|
ctx.window._channelsSetStateForTest({ messages: [
|
|
{ sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } },
|
|
{ sender: 'Carol', text: 'after', timestamp: '2026-01-15T10:02:00Z' },
|
|
] });
|
|
ctx.window._channelsRenderMessagesForTest();
|
|
const html = chMessagesEl.innerHTML;
|
|
const pingIdx = html.indexOf('>ping<');
|
|
const botIdx = html.indexOf('ch-bot-message');
|
|
const afterIdx = html.indexOf('>after<');
|
|
assert.ok(pingIdx > -1 && botIdx > -1 && afterIdx > -1, 'all three pieces should be present');
|
|
assert.ok(pingIdx < botIdx && botIdx < afterIdx, 'order should be: ping message, bot reply, next message');
|
|
});
|
|
|
|
console.log('\n════════════════════════════════════════');
|
|
console.log(` Channels ping-bot reply: ${passed} passed, ${failed} failed`);
|
|
console.log('════════════════════════════════════════');
|
|
if (failed > 0) process.exit(1);
|