mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 04:59:56 +00:00
- server.js disambiguateHops() now delegates to server-helpers.js (was a full copy of the same algorithm, ~70 lines removed) - live.js resolveHopPositions() now delegates to shared HopResolver (was a standalone reimplementation, ~50 lines removed) - HopResolver.init() called when live page loads/updates node data - Net -106 lines, same behavior, single source of truth All unit tests pass (241). E2E 13/16 (3 pre-existing Chromium crashes).
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const helpers = require('./server-helpers');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
let passed = 0, failed = 0;
|
|
function assert(cond, msg) {
|
|
if (cond) { passed++; console.log(` ✅ ${msg}`); }
|
|
else { failed++; console.error(` ❌ ${msg}`); }
|
|
}
|
|
|
|
console.log('── server-helpers tests ──\n');
|
|
|
|
// --- loadConfigFile ---
|
|
console.log('loadConfigFile:');
|
|
{
|
|
// Returns {} when no files exist
|
|
const result = helpers.loadConfigFile(['/nonexistent/path.json']);
|
|
assert(typeof result === 'object' && Object.keys(result).length === 0, 'returns {} for missing files');
|
|
|
|
// Loads valid JSON
|
|
const tmp = path.join(os.tmpdir(), `test-config-${Date.now()}.json`);
|
|
fs.writeFileSync(tmp, JSON.stringify({ hello: 'world' }));
|
|
const result2 = helpers.loadConfigFile([tmp]);
|
|
assert(result2.hello === 'world', 'loads valid JSON file');
|
|
fs.unlinkSync(tmp);
|
|
|
|
// Falls back to second path
|
|
const tmp2 = path.join(os.tmpdir(), `test-config2-${Date.now()}.json`);
|
|
fs.writeFileSync(tmp2, JSON.stringify({ fallback: true }));
|
|
const result3 = helpers.loadConfigFile(['/nonexistent.json', tmp2]);
|
|
assert(result3.fallback === true, 'falls back to second path');
|
|
fs.unlinkSync(tmp2);
|
|
|
|
// Handles malformed JSON
|
|
const tmp3 = path.join(os.tmpdir(), `test-config3-${Date.now()}.json`);
|
|
fs.writeFileSync(tmp3, 'not json{{{');
|
|
const result4 = helpers.loadConfigFile([tmp3]);
|
|
assert(Object.keys(result4).length === 0, 'returns {} for malformed JSON');
|
|
fs.unlinkSync(tmp3);
|
|
}
|
|
|
|
// --- loadThemeFile ---
|
|
console.log('\nloadThemeFile:');
|
|
{
|
|
const result = helpers.loadThemeFile(['/nonexistent/theme.json']);
|
|
assert(typeof result === 'object' && Object.keys(result).length === 0, 'returns {} for missing files');
|
|
|
|
const tmp = path.join(os.tmpdir(), `test-theme-${Date.now()}.json`);
|
|
fs.writeFileSync(tmp, JSON.stringify({ theme: { accent: '#ff0000' } }));
|
|
const result2 = helpers.loadThemeFile([tmp]);
|
|
assert(result2.theme.accent === '#ff0000', 'loads theme file');
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
|
|
// --- buildHealthConfig ---
|
|
console.log('\nbuildHealthConfig:');
|
|
{
|
|
const h = helpers.buildHealthConfig({});
|
|
assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
|
|
assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
|
|
assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
|
|
assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
|
|
|
|
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
|
|
assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
|
|
assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
|
|
|
|
const h3 = helpers.buildHealthConfig(null);
|
|
assert(h3.infraDegradedMs === 86400000, 'handles null config');
|
|
}
|
|
|
|
// --- getHealthMs ---
|
|
console.log('\ngetHealthMs:');
|
|
{
|
|
const HEALTH = helpers.buildHealthConfig({});
|
|
|
|
const rep = helpers.getHealthMs('repeater', HEALTH);
|
|
assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
|
|
assert(rep.silentMs === 259200000, 'repeater uses infra silent');
|
|
|
|
const room = helpers.getHealthMs('room', HEALTH);
|
|
assert(room.degradedMs === 86400000, 'room uses infra degraded');
|
|
|
|
const comp = helpers.getHealthMs('companion', HEALTH);
|
|
assert(comp.degradedMs === 3600000, 'companion uses node degraded');
|
|
assert(comp.silentMs === 86400000, 'companion uses node silent');
|
|
|
|
const sensor = helpers.getHealthMs('sensor', HEALTH);
|
|
assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
|
|
|
|
const undef = helpers.getHealthMs(undefined, HEALTH);
|
|
assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
|
|
}
|
|
|
|
// --- isHashSizeFlipFlop ---
|
|
console.log('\nisHashSizeFlipFlop:');
|
|
{
|
|
assert(helpers.isHashSizeFlipFlop(null, null) === false, 'null seq returns false');
|
|
assert(helpers.isHashSizeFlipFlop([1, 2], new Set([1, 2])) === false, 'too few samples');
|
|
assert(helpers.isHashSizeFlipFlop([1, 1, 1], new Set([1])) === false, 'single size');
|
|
assert(helpers.isHashSizeFlipFlop([1, 1, 1, 2, 2, 2], new Set([1, 2])) === false, 'clean upgrade (1 transition)');
|
|
assert(helpers.isHashSizeFlipFlop([1, 2, 1], new Set([1, 2])) === true, 'flip-flop detected');
|
|
assert(helpers.isHashSizeFlipFlop([1, 2, 1, 2], new Set([1, 2])) === true, 'repeated flip-flop');
|
|
assert(helpers.isHashSizeFlipFlop([2, 1, 2], new Set([1, 2])) === true, 'reverse flip-flop');
|
|
assert(helpers.isHashSizeFlipFlop([1, 2, 3], new Set([1, 2, 3])) === true, 'three sizes, 2 transitions');
|
|
}
|
|
|
|
// --- computeContentHash ---
|
|
console.log('\ncomputeContentHash:');
|
|
{
|
|
// Minimal packet: header + path byte + payload
|
|
// header=0x04, path_byte=0x00 (hash_size=1, 0 hops), payload=0xABCD
|
|
const hex1 = '0400abcd';
|
|
const h1 = helpers.computeContentHash(hex1);
|
|
assert(typeof h1 === 'string' && h1.length === 16, 'returns 16-char hash');
|
|
|
|
// Same payload, different path should give same hash
|
|
// header=0x04, path_byte=0x41 (hash_size=2, 1 hop), path=0x1234, payload=0xABCD
|
|
const hex2 = '04411234abcd';
|
|
const h2 = helpers.computeContentHash(hex2);
|
|
assert(h1 === h2, 'same content different path = same hash');
|
|
|
|
// Different payload = different hash
|
|
const hex3 = '0400ffff';
|
|
const h3 = helpers.computeContentHash(hex3);
|
|
assert(h3 !== h1, 'different payload = different hash');
|
|
|
|
// Very short hex
|
|
const h4 = helpers.computeContentHash('04');
|
|
assert(h4 === '04', 'short hex returns prefix');
|
|
|
|
// Invalid hex
|
|
const h5 = helpers.computeContentHash('xyz');
|
|
assert(typeof h5 === 'string', 'handles invalid hex gracefully');
|
|
}
|
|
|
|
// --- geoDist ---
|
|
console.log('\ngeoDist:');
|
|
{
|
|
assert(helpers.geoDist(0, 0, 0, 0) === 0, 'same point = 0');
|
|
assert(helpers.geoDist(0, 0, 3, 4) === 5, 'pythagorean triple');
|
|
assert(helpers.geoDist(37.7749, -122.4194, 37.7749, -122.4194) === 0, 'SF to SF = 0');
|
|
const d = helpers.geoDist(37.0, -122.0, 38.0, -122.0);
|
|
assert(Math.abs(d - 1.0) < 0.001, '1 degree latitude diff');
|
|
}
|
|
|
|
// --- deriveHashtagChannelKey ---
|
|
console.log('\nderiveHashtagChannelKey:');
|
|
{
|
|
const k1 = helpers.deriveHashtagChannelKey('test');
|
|
assert(typeof k1 === 'string' && k1.length === 32, 'returns 32-char key');
|
|
const k2 = helpers.deriveHashtagChannelKey('test');
|
|
assert(k1 === k2, 'deterministic');
|
|
const k3 = helpers.deriveHashtagChannelKey('other');
|
|
assert(k3 !== k1, 'different input = different key');
|
|
}
|
|
|
|
// --- buildBreakdown ---
|
|
console.log('\nbuildBreakdown:');
|
|
{
|
|
const r1 = helpers.buildBreakdown(null, null, null, null);
|
|
assert(JSON.stringify(r1) === '{}', 'null rawHex returns empty');
|
|
|
|
const r2 = helpers.buildBreakdown('04', null, null, null);
|
|
assert(r2.ranges.length === 1, 'single-byte returns header only');
|
|
assert(r2.ranges[0].label === 'Header', 'header range');
|
|
|
|
// 2 bytes: header + path byte, no payload
|
|
const r3 = helpers.buildBreakdown('0400', null, null, null);
|
|
assert(r3.ranges.length === 2, 'two bytes: header + path length');
|
|
assert(r3.ranges[1].label === 'Path Length', 'path length range');
|
|
|
|
// With payload: header=04, path_byte=00, payload=abcd
|
|
const r4 = helpers.buildBreakdown('0400abcd', null, null, null);
|
|
assert(r4.ranges.some(r => r.label === 'Payload'), 'has payload range');
|
|
|
|
// With path hops: header=04, path_byte=0x41 (size=2, count=1), path=1234, payload=ff
|
|
const r5 = helpers.buildBreakdown('04411234ff', null, null, null);
|
|
assert(r5.ranges.some(r => r.label === 'Path'), 'has path range');
|
|
|
|
// ADVERT with enough payload
|
|
// flags=0x90 (0x10=GPS + 0x80=Name)
|
|
const advertHex = '0400' + 'aa'.repeat(32) + 'bb'.repeat(4) + 'cc'.repeat(64) + '90' + 'dddddddddddddddd' + '48656c6c6f';
|
|
const r6 = helpers.buildBreakdown(advertHex, { type: 'ADVERT' }, null, null);
|
|
assert(r6.ranges.some(r => r.label === 'PubKey'), 'ADVERT has PubKey sub-range');
|
|
assert(r6.ranges.some(r => r.label === 'Flags'), 'ADVERT has Flags sub-range');
|
|
assert(r6.ranges.some(r => r.label === 'Latitude'), 'ADVERT with GPS flag has Latitude');
|
|
assert(r6.ranges.some(r => r.label === 'Name'), 'ADVERT with name flag has Name');
|
|
}
|
|
|
|
// --- disambiguateHops ---
|
|
console.log('\ndisambiguateHops:');
|
|
{
|
|
const nodes = [
|
|
{ public_key: 'aabb11223344', name: 'Node-A', lat: 37.0, lon: -122.0 },
|
|
{ public_key: 'ccdd55667788', name: 'Node-C', lat: 37.1, lon: -122.1 },
|
|
];
|
|
// Single unique match
|
|
const r1 = helpers.disambiguateHops(['aabb'], nodes);
|
|
assert(r1.length === 1, 'resolves single hop');
|
|
assert(r1[0].name === 'Node-A', 'resolves to correct node');
|
|
assert(r1[0].pubkey === 'aabb11223344', 'includes pubkey');
|
|
|
|
// Unknown hop
|
|
delete nodes._prefixIdx; delete nodes._prefixIdxName;
|
|
const r2 = helpers.disambiguateHops(['ffff'], nodes);
|
|
assert(r2[0].name === 'ffff', 'unknown hop uses hex as name');
|
|
|
|
// Multiple hops
|
|
delete nodes._prefixIdx; delete nodes._prefixIdxName;
|
|
const r3 = helpers.disambiguateHops(['aabb', 'ccdd'], nodes);
|
|
assert(r3.length === 2, 'resolves multiple hops');
|
|
assert(r3[0].name === 'Node-A' && r3[1].name === 'Node-C', 'both resolved');
|
|
}
|
|
|
|
// --- updateHashSizeForPacket ---
|
|
console.log('\nupdateHashSizeForPacket:');
|
|
{
|
|
const map = new Map(), allMap = new Map(), seqMap = new Map();
|
|
|
|
// ADVERT packet (payload_type=4)
|
|
// path byte 0x40 = hash_size 2 (bits 7-6 = 01)
|
|
const p1 = {
|
|
payload_type: 4,
|
|
raw_hex: '0440' + 'aa'.repeat(100),
|
|
decoded_json: JSON.stringify({ pubKey: 'abc123' }),
|
|
path_json: null
|
|
};
|
|
helpers.updateHashSizeForPacket(p1, map, allMap, seqMap);
|
|
assert(map.get('abc123') === 2, 'ADVERT sets hash_size=2');
|
|
assert(allMap.get('abc123').has(2), 'all map has size 2');
|
|
assert(seqMap.get('abc123')[0] === 2, 'seq map records size');
|
|
|
|
// Non-ADVERT with path_json fallback
|
|
const map2 = new Map(), allMap2 = new Map(), seqMap2 = new Map();
|
|
const p2 = {
|
|
payload_type: 1,
|
|
raw_hex: '0140ff', // path byte 0x40 = hash_size 2
|
|
decoded_json: JSON.stringify({ pubKey: 'def456' }),
|
|
path_json: JSON.stringify(['aabb'])
|
|
};
|
|
helpers.updateHashSizeForPacket(p2, map2, allMap2, seqMap2);
|
|
assert(map2.get('def456') === 2, 'non-ADVERT falls back to path byte');
|
|
|
|
// Already-parsed decoded_json (object, not string)
|
|
const map3 = new Map(), allMap3 = new Map(), seqMap3 = new Map();
|
|
const p3 = {
|
|
payload_type: 4,
|
|
raw_hex: '04c0' + 'aa'.repeat(100), // 0xC0 = bits 7-6 = 11 = hash_size 4
|
|
decoded_json: { pubKey: 'ghi789' },
|
|
path_json: null
|
|
};
|
|
helpers.updateHashSizeForPacket(p3, map3, allMap3, seqMap3);
|
|
assert(map3.get('ghi789') === 4, 'handles object decoded_json');
|
|
}
|
|
|
|
// --- rebuildHashSizeMap ---
|
|
console.log('\nrebuildHashSizeMap:');
|
|
{
|
|
const map = new Map(), allMap = new Map(), seqMap = new Map();
|
|
const packets = [
|
|
// Newest first (as packet store provides)
|
|
{ payload_type: 4, raw_hex: '0480' + 'bb'.repeat(50), decoded_json: JSON.stringify({ pubKey: 'node1' }), path_json: null },
|
|
{ payload_type: 4, raw_hex: '0440' + 'aa'.repeat(50), decoded_json: JSON.stringify({ pubKey: 'node1' }), path_json: null },
|
|
];
|
|
helpers.rebuildHashSizeMap(packets, map, allMap, seqMap);
|
|
assert(map.get('node1') === 3, 'first seen (newest) wins for map');
|
|
assert(allMap.get('node1').size === 2, 'all map has both sizes');
|
|
// Seq should be reversed to chronological: [2, 3]
|
|
const seq = seqMap.get('node1');
|
|
assert(seq[0] === 2 && seq[1] === 3, 'sequence is chronological (reversed)');
|
|
|
|
// Pass 2 fallback: node without advert
|
|
const map2 = new Map(), allMap2 = new Map(), seqMap2 = new Map();
|
|
const packets2 = [
|
|
{ payload_type: 1, raw_hex: '0140ff', decoded_json: JSON.stringify({ pubKey: 'node2' }), path_json: JSON.stringify(['aabb']) },
|
|
];
|
|
helpers.rebuildHashSizeMap(packets2, map2, allMap2, seqMap2);
|
|
assert(map2.get('node2') === 2, 'pass 2 fallback from path');
|
|
}
|
|
|
|
// --- requireApiKey ---
|
|
console.log('\nrequireApiKey:');
|
|
{
|
|
// No API key configured
|
|
const mw1 = helpers.requireApiKey(null);
|
|
let nextCalled = false;
|
|
mw1({headers: {}, query: {}}, {}, () => { nextCalled = true; });
|
|
assert(nextCalled, 'no key configured = passes through');
|
|
|
|
// Valid key
|
|
const mw2 = helpers.requireApiKey('secret123');
|
|
nextCalled = false;
|
|
mw2({headers: {'x-api-key': 'secret123'}, query: {}}, {}, () => { nextCalled = true; });
|
|
assert(nextCalled, 'valid header key passes');
|
|
|
|
// Valid key via query
|
|
nextCalled = false;
|
|
mw2({headers: {}, query: {apiKey: 'secret123'}}, {}, () => { nextCalled = true; });
|
|
assert(nextCalled, 'valid query key passes');
|
|
|
|
// Invalid key
|
|
let statusCode = null, jsonBody = null;
|
|
const mockRes = {
|
|
status(code) { statusCode = code; return { json(body) { jsonBody = body; } }; }
|
|
};
|
|
nextCalled = false;
|
|
mw2({headers: {'x-api-key': 'wrong'}, query: {}}, mockRes, () => { nextCalled = true; });
|
|
assert(!nextCalled && statusCode === 401, 'invalid key returns 401');
|
|
}
|
|
|
|
console.log(`\n═══════════════════════════════════════`);
|
|
console.log(` PASSED: ${passed}`);
|
|
console.log(` FAILED: ${failed}`);
|
|
console.log(`═══════════════════════════════════════`);
|
|
if (failed > 0) process.exit(1);
|