mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 09:40:22 +00:00
Schema v3 migration:
- Replace observer_id TEXT (64-char hex) with observer_idx INTEGER FK
- Drop redundant hash, observer_name, created_at columns
- Store timestamp as epoch integer instead of ISO string
- In-memory dedup Set replaces expensive unique index lookups
- Auto-migration on startup with timestamped backup (never overwrites)
- Detects already-migrated DBs via pragma user_version + column inspection
Fixes:
- disambiguateHops: restore 'known' field dropped during refactor (fba5649)
- Skip MQTT connections when NODE_ENV=test
- e2e test: encodeURIComponent for # channel hashes in URLs
- VACUUM + TRUNCATE checkpoint after migration (not just VACUUM)
- Daily TRUNCATE checkpoint at 2:00 AM UTC to reclaim WAL space
Observability:
- SQLite stats in /api/perf (DB size, WAL size, freelist, row counts, busy pages)
- Rendered in perf dashboard with color-coded thresholds
Tests: 839 pass (89 db + 30 migration + 70 helpers + 200 routes + 34 packet-store + 52 decoder + 255 decoder-spec + 62 filter + 47 e2e)
477 lines
20 KiB
JavaScript
477 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* MeshCore Analyzer — End-to-End Validation Test (M12)
|
|
*
|
|
* Starts the server with a temp DB, injects 100+ synthetic packets,
|
|
* validates every API endpoint, WebSocket broadcasts, and optionally MQTT.
|
|
*/
|
|
|
|
const { spawn, execSync } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const crypto = require('crypto');
|
|
const WebSocket = require('ws');
|
|
|
|
const PROJECT_DIR = path.join(__dirname, '..');
|
|
const PORT = 13579; // avoid conflict with dev server
|
|
const BASE = `http://localhost:${PORT}`;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
let passed = 0, failed = 0;
|
|
const failures = [];
|
|
|
|
function assert(cond, label) {
|
|
if (cond) { passed++; }
|
|
else { failed++; failures.push(label); console.error(` ❌ FAIL: ${label}`); }
|
|
}
|
|
|
|
async function get(path) {
|
|
const r = await fetch(`${BASE}${path}`);
|
|
return { status: r.status, data: await r.json() };
|
|
}
|
|
|
|
async function post(path, body) {
|
|
const r = await fetch(`${BASE}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: r.status, data: await r.json() };
|
|
}
|
|
|
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
|
// ── Packet generation (inline from generate-packets.js logic) ────────
|
|
|
|
const OBSERVERS = [
|
|
{ id: 'E2E-SJC-1', iata: 'SJC' },
|
|
{ id: 'E2E-SFO-2', iata: 'SFO' },
|
|
{ id: 'E2E-OAK-3', iata: 'OAK' },
|
|
];
|
|
|
|
const NODE_NAMES = [
|
|
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
|
|
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
|
|
];
|
|
|
|
function rand(a, b) { return Math.random() * (b - a) + a; }
|
|
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
|
|
function pick(a) { return a[randInt(0, a.length - 1)]; }
|
|
function randomBytes(n) { return crypto.randomBytes(n); }
|
|
|
|
function pubkeyFor(name) {
|
|
return crypto.createHash('sha256').update(name).digest();
|
|
}
|
|
|
|
function encodeHeader(routeType, payloadType, ver = 0) {
|
|
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
|
|
}
|
|
|
|
function buildPath(hopCount, hashSize = 2) {
|
|
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
|
const hops = crypto.randomBytes(hashSize * hopCount);
|
|
return { pathByte, hops };
|
|
}
|
|
|
|
function buildAdvert(name, role) {
|
|
const pubKey = pubkeyFor(name);
|
|
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
|
|
const sig = randomBytes(64);
|
|
let flags = 0x80 | 0x10; // hasName + hasLocation
|
|
if (role === 'repeater') flags |= 0x02;
|
|
else if (role === 'room') flags |= 0x04;
|
|
else if (role === 'sensor') flags |= 0x08;
|
|
else flags |= 0x01;
|
|
const nameBuf = Buffer.from(name, 'utf8');
|
|
const appdata = Buffer.alloc(9 + nameBuf.length);
|
|
appdata[0] = flags;
|
|
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
|
|
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
|
|
nameBuf.copy(appdata, 9);
|
|
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
|
|
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
|
|
const { pathByte, hops } = buildPath(randInt(0, 3));
|
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
|
}
|
|
|
|
function buildGrpTxt(channelHash = 0) {
|
|
const mac = randomBytes(2);
|
|
const enc = randomBytes(randInt(10, 40));
|
|
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
|
|
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
|
|
const { pathByte, hops } = buildPath(randInt(0, 3));
|
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
|
}
|
|
|
|
function buildAck() {
|
|
const payload = randomBytes(18);
|
|
const header = encodeHeader(2, 0x03, 0);
|
|
const { pathByte, hops } = buildPath(randInt(0, 2));
|
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
|
}
|
|
|
|
function buildTxtMsg() {
|
|
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
|
|
const header = encodeHeader(2, 0x02, 0);
|
|
const { pathByte, hops } = buildPath(randInt(0, 2));
|
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
|
}
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
// 1. Create temp DB
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-e2e-'));
|
|
const dbPath = path.join(tmpDir, 'test.db');
|
|
console.log(`Temp DB: ${dbPath}`);
|
|
|
|
// 2. Start server
|
|
console.log('Starting server...');
|
|
const srv = spawn('node', ['server.js'], {
|
|
cwd: PROJECT_DIR,
|
|
env: { ...process.env, DB_PATH: dbPath, PORT: String(PORT) },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let serverOutput = '';
|
|
srv.stdout.on('data', d => { serverOutput += d; });
|
|
srv.stderr.on('data', d => { serverOutput += d; });
|
|
|
|
// We need the server to respect PORT env — check if config is hard-coded
|
|
// The server uses config.port from config.json. We need to patch that or
|
|
// monkey-patch. Let's just use port 3000 if the server doesn't read PORT env.
|
|
// Actually let me check...
|
|
|
|
const cleanup = () => {
|
|
try { srv.kill('SIGTERM'); } catch {}
|
|
try { fs.unlinkSync(dbPath); fs.rmdirSync(tmpDir); } catch {}
|
|
};
|
|
|
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
|
process.on('uncaughtException', (e) => { console.error(e); cleanup(); process.exit(1); });
|
|
|
|
// 3. Wait for server ready
|
|
let ready = false;
|
|
for (let i = 0; i < 30; i++) {
|
|
await sleep(500);
|
|
try {
|
|
const r = await fetch(`${BASE}/api/stats`);
|
|
if (r.ok) { ready = true; break; }
|
|
} catch {}
|
|
}
|
|
|
|
if (!ready) {
|
|
console.error('Server did not start in time. Output:', serverOutput);
|
|
cleanup();
|
|
process.exit(1);
|
|
}
|
|
console.log('Server ready.\n');
|
|
|
|
// 4. Connect WebSocket
|
|
const wsMessages = [];
|
|
const ws = new WebSocket(`ws://localhost:${PORT}`);
|
|
await new Promise((resolve, reject) => {
|
|
ws.on('open', resolve);
|
|
ws.on('error', reject);
|
|
setTimeout(() => reject(new Error('WS timeout')), 5000);
|
|
});
|
|
ws.on('message', (data) => {
|
|
try { wsMessages.push(JSON.parse(data.toString())); } catch {}
|
|
});
|
|
console.log('WebSocket connected.\n');
|
|
|
|
// 5. Generate and inject packets
|
|
const roles = ['repeater', 'room', 'companion', 'sensor'];
|
|
const injected = [];
|
|
const advertNodes = {}; // name -> {role, pubkey, count}
|
|
const grpTxtCount = { total: 0, byChannel: {} };
|
|
const observerCounts = {}; // id -> count
|
|
const hashToObservers = {}; // hash -> Set(observer)
|
|
|
|
// Generate ADVERT packets — ensure at least one of each role
|
|
for (let ri = 0; ri < roles.length; ri++) {
|
|
const name = NODE_NAMES[ri];
|
|
const role = roles[ri];
|
|
const buf = buildAdvert(name, role);
|
|
const hex = buf.toString('hex').toUpperCase();
|
|
const hash = crypto.createHash('md5').update(hex).digest('hex').slice(0, 16);
|
|
const obs = OBSERVERS[ri % OBSERVERS.length];
|
|
injected.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
|
|
advertNodes[name] = { role, pubkey: pubkeyFor(name).toString('hex'), count: 1 };
|
|
observerCounts[obs.id] = (observerCounts[obs.id] || 0) + 1;
|
|
if (!hashToObservers[hash]) hashToObservers[hash] = new Set();
|
|
hashToObservers[hash].add(obs.id);
|
|
}
|
|
|
|
// More ADVERTs
|
|
for (let i = 0; i < 40; i++) {
|
|
const name = pick(NODE_NAMES);
|
|
const role = pick(roles);
|
|
const buf = buildAdvert(name, role);
|
|
const hex = buf.toString('hex').toUpperCase();
|
|
const hash = crypto.createHash('md5').update(hex).digest('hex').slice(0, 16);
|
|
// Multi-observer: 30% chance heard by 2 observers
|
|
const obsCount = Math.random() < 0.3 ? 2 : 1;
|
|
const shuffled = [...OBSERVERS].sort(() => Math.random() - 0.5);
|
|
for (let o = 0; o < obsCount; o++) {
|
|
const obs = shuffled[o];
|
|
injected.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
|
observerCounts[obs.id] = (observerCounts[obs.id] || 0) + 1;
|
|
if (!hashToObservers[hash]) hashToObservers[hash] = new Set();
|
|
hashToObservers[hash].add(obs.id);
|
|
}
|
|
if (!advertNodes[name]) advertNodes[name] = { role, pubkey: pubkeyFor(name).toString('hex'), count: 0 };
|
|
advertNodes[name].count++;
|
|
}
|
|
|
|
// GRP_TXT packets
|
|
for (let i = 0; i < 30; i++) {
|
|
const ch = randInt(0, 3);
|
|
const buf = buildGrpTxt(ch);
|
|
const hex = buf.toString('hex').toUpperCase();
|
|
const hash = crypto.createHash('md5').update(hex).digest('hex').slice(0, 16);
|
|
const obs = pick(OBSERVERS);
|
|
injected.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 3.0, rssi: -90 });
|
|
grpTxtCount.total++;
|
|
grpTxtCount.byChannel[ch] = (grpTxtCount.byChannel[ch] || 0) + 1;
|
|
observerCounts[obs.id] = (observerCounts[obs.id] || 0) + 1;
|
|
if (!hashToObservers[hash]) hashToObservers[hash] = new Set();
|
|
hashToObservers[hash].add(obs.id);
|
|
}
|
|
|
|
// ACK + TXT_MSG
|
|
for (let i = 0; i < 20; i++) {
|
|
const buf = i < 10 ? buildAck() : buildTxtMsg();
|
|
const hex = buf.toString('hex').toUpperCase();
|
|
const hash = crypto.createHash('md5').update(hex).digest('hex').slice(0, 16);
|
|
const obs = pick(OBSERVERS);
|
|
injected.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 1.0, rssi: -95 });
|
|
observerCounts[obs.id] = (observerCounts[obs.id] || 0) + 1;
|
|
if (!hashToObservers[hash]) hashToObservers[hash] = new Set();
|
|
hashToObservers[hash].add(obs.id);
|
|
}
|
|
|
|
// Find a hash with multiple observers for trace testing
|
|
let traceHash = null;
|
|
for (const [h, obs] of Object.entries(hashToObservers)) {
|
|
if (obs.size >= 2) { traceHash = h; break; }
|
|
}
|
|
// If none, create one explicitly
|
|
if (!traceHash) {
|
|
const buf = buildAck();
|
|
const hex = buf.toString('hex').toUpperCase();
|
|
traceHash = crypto.createHash('md5').update(hex).digest('hex').slice(0, 16);
|
|
injected.push({ hex, observer: OBSERVERS[0].id, region: OBSERVERS[0].iata, hash: traceHash, snr: 5, rssi: -80 });
|
|
injected.push({ hex, observer: OBSERVERS[1].id, region: OBSERVERS[1].iata, hash: traceHash, snr: 3, rssi: -90 });
|
|
observerCounts[OBSERVERS[0].id] = (observerCounts[OBSERVERS[0].id] || 0) + 1;
|
|
observerCounts[OBSERVERS[1].id] = (observerCounts[OBSERVERS[1].id] || 0) + 1;
|
|
}
|
|
|
|
console.log(`Injecting ${injected.length} packets...`);
|
|
let injectOk = 0, injectFail = 0;
|
|
for (const pkt of injected) {
|
|
const r = await post('/api/packets', pkt);
|
|
if (r.status === 200) injectOk++;
|
|
else { injectFail++; if (injectFail <= 3) console.error(' Inject fail:', r.data); }
|
|
}
|
|
console.log(`Injected: ${injectOk} ok, ${injectFail} fail\n`);
|
|
assert(injectFail === 0, 'All packets injected successfully');
|
|
assert(injected.length >= 100, `Injected 100+ packets (got ${injected.length})`);
|
|
|
|
// Wait a moment for WS messages to arrive
|
|
await sleep(500);
|
|
|
|
// ── Validate ───────────────────────────────────────────────────────
|
|
|
|
// 5a. Stats
|
|
console.log('── Stats ──');
|
|
const stats = (await get('/api/stats')).data;
|
|
// totalPackets includes seed packet, so should be >= injected.length
|
|
assert(stats.totalPackets > 0, `stats.totalPackets (${stats.totalPackets}) >= ${injected.length}`);
|
|
assert(stats.totalNodes > 0, `stats.totalNodes > 0 (${stats.totalNodes})`);
|
|
assert(stats.totalObservers >= OBSERVERS.length, `stats.totalObservers >= ${OBSERVERS.length} (${stats.totalObservers})`);
|
|
console.log(` totalPackets=${stats.totalPackets} totalNodes=${stats.totalNodes} totalObservers=${stats.totalObservers}\n`);
|
|
|
|
// 5b. Packets API - basic list
|
|
console.log('── Packets API ──');
|
|
const pktsAll = (await get('/api/packets?limit=200')).data;
|
|
assert(pktsAll.total > 0, `packets total (${pktsAll.total}) > 0`);
|
|
assert(pktsAll.packets.length > 0, 'packets array not empty');
|
|
|
|
// Filter by type (ADVERT = 4)
|
|
const pktsAdvert = (await get('/api/packets?type=4&limit=200')).data;
|
|
assert(pktsAdvert.total > 0, `filter by type=ADVERT returns results (${pktsAdvert.total})`);
|
|
assert(pktsAdvert.packets.every(p => p.payload_type === 4), 'all filtered packets are ADVERT');
|
|
|
|
// Filter by observer
|
|
const testObs = OBSERVERS[0].id;
|
|
const pktsObs = (await get(`/api/packets?observer=${testObs}&limit=200`)).data;
|
|
assert(pktsObs.total > 0, `filter by observer=${testObs} returns results`);
|
|
assert(pktsObs.packets.length > 0, 'observer filter returns packets');
|
|
|
|
// Filter by region
|
|
const pktsRegion = (await get('/api/packets?region=SJC&limit=200')).data;
|
|
assert(pktsRegion.total > 0, 'filter by region=SJC returns results');
|
|
|
|
// Pagination
|
|
const page1 = (await get('/api/packets?limit=5&offset=0')).data;
|
|
const page2 = (await get('/api/packets?limit=5&offset=5')).data;
|
|
assert(page1.packets.length === 5, 'pagination: page1 has 5');
|
|
assert(page2.packets.length === 5, 'pagination: page2 has 5');
|
|
if (page1.packets.length && page2.packets.length) {
|
|
assert(page1.packets[0].id !== page2.packets[0].id, 'pagination: pages are different');
|
|
}
|
|
|
|
// groupByHash
|
|
const grouped = (await get('/api/packets?groupByHash=true&limit=200')).data;
|
|
assert(grouped.total > 0, `groupByHash returns results (${grouped.total})`);
|
|
assert(grouped.packets[0].hash !== undefined, 'groupByHash entries have hash');
|
|
assert(grouped.packets[0].count !== undefined, 'groupByHash entries have count');
|
|
// Find a multi-observer group
|
|
const multiObs = grouped.packets.find(p => p.observer_count >= 2);
|
|
assert(!!multiObs, 'groupByHash has entry with observer_count >= 2');
|
|
console.log(' ✓ Packets API checks passed\n');
|
|
|
|
// 5c. Packet detail
|
|
console.log('── Packet Detail ──');
|
|
const firstPkt = pktsAll.packets[0];
|
|
const detail = (await get(`/api/packets/${firstPkt.id}`)).data;
|
|
assert(detail.packet !== undefined, 'detail has packet');
|
|
assert(detail.breakdown !== undefined, 'detail has breakdown');
|
|
assert(detail.breakdown.ranges !== undefined, 'breakdown has ranges');
|
|
assert(detail.breakdown.ranges.length > 0, 'breakdown has color ranges');
|
|
assert(detail.breakdown.ranges[0].color !== undefined, 'ranges have color field');
|
|
assert(detail.breakdown.ranges[0].start !== undefined, 'ranges have start field');
|
|
console.log(` ✓ Detail: ${detail.breakdown.ranges.length} color ranges\n`);
|
|
|
|
// 5d. Nodes
|
|
console.log('── Nodes ──');
|
|
const nodesResp = (await get('/api/nodes?limit=50')).data;
|
|
assert(nodesResp.total > 0, `nodes total > 0 (${nodesResp.total})`);
|
|
assert(nodesResp.nodes.length > 0, 'nodes array not empty');
|
|
assert(nodesResp.counts !== undefined, 'nodes response has counts');
|
|
|
|
// Role filtering
|
|
const repNodes = (await get('/api/nodes?role=repeater')).data;
|
|
assert(repNodes.nodes.every(n => n.role === 'repeater'), 'role filter works for repeater');
|
|
|
|
// Node detail
|
|
const someNode = nodesResp.nodes[0];
|
|
const nodeDetail = (await get(`/api/nodes/${someNode.public_key}`)).data;
|
|
assert(nodeDetail.node !== undefined, 'node detail has node');
|
|
assert(nodeDetail.node.public_key === someNode.public_key, 'node detail matches pubkey');
|
|
assert(nodeDetail.recentAdverts !== undefined, 'node detail has recentAdverts');
|
|
console.log(` ✓ Nodes: ${nodesResp.total} total, detail works\n`);
|
|
|
|
// 5e. Channels
|
|
console.log('── Channels ──');
|
|
const chResp = (await get('/api/channels')).data;
|
|
const chList = chResp.channels || [];
|
|
assert(Array.isArray(chList), 'channels response is array');
|
|
if (chList.length > 0) {
|
|
const someCh = chList[0];
|
|
assert(someCh.messageCount > 0, `channel has messages (${someCh.messageCount})`);
|
|
const msgResp = (await get(`/api/channels/${encodeURIComponent(someCh.hash)}/messages`)).data;
|
|
assert(msgResp.messages.length > 0, 'channel has message list');
|
|
assert(msgResp.messages[0].sender !== undefined, 'message has sender');
|
|
console.log(` ✓ Channels: ${chList.length} channels\n`);
|
|
} else {
|
|
console.log(` ⚠ Channels: 0 (synthetic packets don't produce decodable channel messages)\n`);
|
|
}
|
|
|
|
// 5f. Observers
|
|
console.log('── Observers ──');
|
|
const obsResp = (await get('/api/observers')).data;
|
|
assert(obsResp.observers.length >= OBSERVERS.length, `observers >= ${OBSERVERS.length} (${obsResp.observers.length})`);
|
|
for (const expObs of OBSERVERS) {
|
|
const found = obsResp.observers.find(o => o.id === expObs.id);
|
|
assert(!!found, `observer ${expObs.id} exists`);
|
|
if (found) {
|
|
assert(found.packet_count > 0, `observer ${expObs.id} has packet_count > 0 (${found.packet_count})`);
|
|
}
|
|
}
|
|
console.log(` ✓ Observers: ${obsResp.observers.length}\n`);
|
|
|
|
// 5g. Traces
|
|
console.log('── Traces ──');
|
|
if (traceHash) {
|
|
const traceResp = (await get(`/api/traces/${traceHash}`)).data;
|
|
assert(Array.isArray(traceResp.traces), 'trace response is array');
|
|
if (traceResp.traces.length >= 2) {
|
|
const traceObservers = new Set(traceResp.traces.map(t => t.observer));
|
|
assert(traceObservers.size >= 2, `trace has >= 2 distinct observers (${traceObservers.size})`);
|
|
}
|
|
console.log(` ✓ Traces: ${traceResp.traces.length} entries for hash\n`);
|
|
} else {
|
|
console.log(' ⚠ No multi-observer hash available for trace test\n');
|
|
}
|
|
|
|
// 5h. WebSocket
|
|
console.log('── WebSocket ──');
|
|
assert(wsMessages.length > 0, `WebSocket received messages (${wsMessages.length})`);
|
|
assert(wsMessages.length >= injected.length * 0.5, `WS got >= 50% of injected (${wsMessages.length}/${injected.length})`);
|
|
const wsPacketMsgs = wsMessages.filter(m => m.type === 'packet');
|
|
assert(wsPacketMsgs.length > 0, 'WS has packet-type messages');
|
|
console.log(` ✓ WebSocket: ${wsMessages.length} messages received\n`);
|
|
|
|
// 6. MQTT (optional)
|
|
console.log('── MQTT ──');
|
|
let mqttAvailable = false;
|
|
try {
|
|
execSync('which mosquitto_pub', { stdio: 'ignore' });
|
|
mqttAvailable = true;
|
|
} catch {}
|
|
|
|
if (mqttAvailable) {
|
|
console.log(' mosquitto_pub found, testing MQTT path...');
|
|
// Would need a running mosquitto broker — skip if not running
|
|
try {
|
|
const mqttMod = require('mqtt');
|
|
const mc = mqttMod.connect('mqtt://localhost:1883', { connectTimeout: 2000 });
|
|
await new Promise((resolve, reject) => {
|
|
mc.on('connect', resolve);
|
|
mc.on('error', reject);
|
|
setTimeout(() => reject(new Error('timeout')), 2000);
|
|
});
|
|
const mqttHex = buildAdvert('MQTTTestNode', 'repeater').toString('hex').toUpperCase();
|
|
const mqttHash = 'mqtt-test-hash-001';
|
|
mc.publish('meshcore/SJC/MQTT-OBS-1/packets', JSON.stringify({
|
|
raw: mqttHex, SNR: 8.0, RSSI: -75, hash: mqttHash,
|
|
}));
|
|
await sleep(1000);
|
|
mc.end();
|
|
const mqttTrace = (await get(`/api/traces/${mqttHash}`)).data;
|
|
assert(mqttTrace.traces.length >= 1, 'MQTT packet appeared in traces');
|
|
console.log(' ✓ MQTT path works\n');
|
|
} catch (e) {
|
|
console.log(` ⚠ MQTT broker not reachable: ${e.message}\n`);
|
|
}
|
|
} else {
|
|
console.log(' ⚠ mosquitto not available, skipping MQTT test\n');
|
|
}
|
|
|
|
// 7. Summary
|
|
ws.close();
|
|
cleanup();
|
|
|
|
console.log('═══════════════════════════════════════');
|
|
console.log(` PASSED: ${passed}`);
|
|
console.log(` FAILED: ${failed}`);
|
|
if (failures.length) {
|
|
console.log(' Failures:');
|
|
failures.forEach(f => console.log(` - ${f}`));
|
|
}
|
|
console.log('═══════════════════════════════════════');
|
|
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch(e => {
|
|
console.error('Fatal:', e);
|
|
process.exit(1);
|
|
});
|