File size: 5,954 Bytes
4360e71 dfd5fc7 4360e71 dfd5fc7 4360e71 dfd5fc7 4360e71 fce80d1 4360e71 dfd5fc7 7323d61 4360e71 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | // Control-channel tests: reconnect backoff, queue flush, terminal close codes.
// Run: node --test test/control.test.mjs
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { WebSocketServer } from 'ws';
const server = new WebSocketServer({ port: 0 });
const port = server.address().port;
const url = `ws://127.0.0.1:${port}/ws?role=device`;
// ControlChannel reads CLOUD.wsUrl at import time — import after env is set.
process.env.MONA_CLOUD_WS = url;
const { ControlChannel } = await import('../src/control.js');
let connections = 0;
let closeCode = null;
let lastHello = null;
server.on('connection', (ws) => {
connections++;
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'hello') {
lastHello = msg;
ws.send(JSON.stringify({ type: 'pong', data: {} }));
}
});
if (closeCode !== null) {
// 1006 is reserved and can never be sent as a close frame — terminate()
// is what produces a real 1006 (abnormal closure) on the client side.
if (closeCode === 1006) ws.terminate();
else ws.close(closeCode);
}
});
const reset = () => { connections = 0; lastHello = null; };
// terminate() produces an ECONNRESET on the client — expected in the 1006 test.
const quiet = (ch) => ch.on('error', () => {});
describe('control channel', () => {
before(() => { server.on('connection', () => {}); });
after(() => server.close());
it('emits auth-failed and stops on terminal close code 4001', async () => {
reset();
closeCode = 4001;
const ch = new ControlChannel('test-key', 'agent-1');
const events = [];
quiet(ch);
ch.on('auth-failed', (code) => events.push(['auth-failed', code]));
await new Promise((resolve) => {
ch.on('disconnected', () => setTimeout(resolve, 100));
ch.connect();
});
assert.equal(events.length, 1);
assert.equal(events[0][1], 4001);
assert.equal(ch.stopped, true);
// No reconnect loop: allow one backoff cycle, assert no new connection.
await new Promise((r) => setTimeout(r, 1500));
assert.equal(connections, 1);
ch.close();
});
it('emits auth-failed and stops on terminal close code 4003 (revoked)', async () => {
reset();
closeCode = 4003;
const ch = new ControlChannel('test-key', 'agent-1');
let failed = false;
quiet(ch);
ch.on('auth-failed', () => { failed = true; });
await new Promise((resolve) => {
ch.on('disconnected', () => setTimeout(resolve, 100));
ch.connect();
});
assert.equal(failed, true);
assert.equal(ch.stopped, true);
await new Promise((r) => setTimeout(r, 1500));
assert.equal(connections, 1);
ch.close();
});
it('reconnects with backoff after a transient close (1006)', async () => {
reset();
closeCode = 1006;
const ch = new ControlChannel('test-key', 'agent-1');
quiet(ch);
// A second disconnect can only happen after a reconnect — wait for it.
let disconnects = 0;
const reconnected = new Promise((resolve, reject) => {
ch.on('disconnected', () => {
if (++disconnects >= 2) resolve(true);
});
ch.connect();
setTimeout(() => reject(new Error('no reconnect within 5s')), 5000);
});
assert.equal(await reconnected, true);
assert.ok(connections >= 2, `expected a reconnect, saw ${connections} connection(s)`);
closeCode = null;
ch.close();
});
it('queues messages until open, then flushes', async () => {
reset();
closeCode = null;
const ch = new ControlChannel('test-key', 'agent-1');
let flushed = false;
quiet(ch);
ch.connect();
ch.step('test', { n: 1 }); // sent before open queued
await new Promise((resolve) => {
const timer = setInterval(() => {
if (connections === 1) { flushed = true; clearInterval(timer); resolve(); }
}, 50);
});
assert.equal(flushed, true);
ch.close();
});
it('sends v:1 envelope and announces capabilities in hello', async () => {
reset();
closeCode = null;
const caps = {
tools: [{ name: 'sysinfo', description: 'System information' }],
shell: { allowlist: ['df', 'uptime'], unsafe: false, platform: 'darwin' },
};
const ch = new ControlChannel('test-key', 'agent-1', caps);
quiet(ch);
ch.connect();
await new Promise((resolve) => {
const timer = setInterval(() => {
if (lastHello) { clearInterval(timer); resolve(); }
}, 25);
});
assert.equal(lastHello.v, 1);
assert.equal(lastHello.agentId, 'agent-1');
assert.ok(Array.isArray(lastHello.data.capabilities.tools));
assert.equal(lastHello.data.capabilities.tools[0].name, 'sysinfo');
assert.deepEqual(lastHello.data.capabilities.shell.allowlist, ['df', 'uptime']);
assert.ok(lastHello.data.host);
assert.ok(lastHello.data.version);
ch.close();
});
it('streams extended metrics (cpu/mem/disk percent)', async () => {
reset();
closeCode = null;
const ch = new ControlChannel('test-key', 'agent-1', null, { metricsIntervalMs: 120 });
quiet(ch);
const got = new Promise((resolve) => ch.on('metrics', resolve));
ch.connect();
const m = await Promise.race([
got,
new Promise((_, reject) => setTimeout(() => reject(new Error('no metrics within 3s')), 3000)),
]);
assert.equal(typeof m.cpuPercent, 'number');
assert.ok(m.cpuPercent >= 0 && m.cpuPercent <= 100);
assert.ok(m.mem.percent >= 0 && m.mem.percent <= 100);
assert.equal(typeof m.mem.used, 'number');
assert.equal(typeof m.diskPercent, 'number');
assert.equal(typeof m.uptimeSeconds, 'number');
assert.ok(m.cpuModel);
// Backward-compatible fields the TUI depends on:
assert.equal(typeof m.mem.total, 'number');
assert.equal(typeof m.mem.free, 'number');
assert.ok(Array.isArray(m.cpuLoad));
ch.close();
});
});
|