mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 19:16:10 +00:00
367 lines of Playwright interactions covering nodes, packets, map, analytics, customizer, channels, live, home pages. Fixed e2e channels assertion (chList vs chResp.channels).
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/${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);
|
|
});
|