diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbcea9f..50c6cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest diff --git a/lib/node.js b/lib/node.js index 73677f5..8d9e03a 100644 --- a/lib/node.js +++ b/lib/node.js @@ -383,13 +383,21 @@ class SymNode extends EventEmitter { try { this._dnssdBrowse.kill(); } catch {} this._dnssdBrowse = null; } + if (this._browser) { + try { this._browser.stop(); } catch {} + this._browser = null; + } if (this._bonjour) { try { this._bonjour.destroy(); } catch {} this._bonjour = null; } if (this._server) { - this._server.close(); + await new Promise((resolve) => { + this._server.close(() => resolve()); + // Timeout in case server.close() hangs + setTimeout(resolve, 1000); + }); this._server = null; } @@ -724,13 +732,23 @@ class SymNode extends EventEmitter { ], { stdio: 'ignore' }); this._dnssdRegister.on('error', (err) => { - // Fallback to JavaScript mDNS if dns-sd not available (Linux) + // dns-sd not available (Linux, containers) — fallback to JavaScript mDNS this._log(`dns-sd not available, falling back to bonjour-service: ${err.message}`); + this._dnssdRegister = null; + // Kill browse process too — it won't work without dns-sd + if (this._dnssdBrowse) { + try { this._dnssdBrowse.kill(); } catch {} + this._dnssdBrowse = null; + } this._startBonjourFallback(); }); - // Browse for peers via dns-sd + // Browse for peers via dns-sd (only useful if dns-sd is available) this._dnssdBrowse = spawn('dns-sd', ['-B', '_sym._tcp'], { stdio: ['ignore', 'pipe', 'ignore'] }); + this._dnssdBrowse.on('error', () => { + // Handled by register error above — just prevent unhandled error crash + this._dnssdBrowse = null; + }); let browseBuffer = ''; this._dnssdBrowse.stdout.on('data', (data) => { browseBuffer += data.toString(); diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..0eaf8cf --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,162 @@ +'use strict'; + +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { + SYM_DIR, NODES_DIR, ensureDir, nodeDir, + uuidv7, validateName, generateSigningKeyPair, loadOrCreateIdentity, log, +} = require('../lib/config'); + +describe('uuidv7', () => { + it('should return lowercase 8-4-4-4-12 hex format', () => { + const id = uuidv7(); + assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('should have version nibble 7', () => { + const id = uuidv7(); + assert.strictEqual(id[14], '7'); + }); + + it('should have variant bits 10xx', () => { + const id = uuidv7(); + const variantChar = id[19]; + assert.ok('89ab'.includes(variantChar), `variant char should be 8/9/a/b, got '${variantChar}'`); + }); + + it('should produce unique IDs', () => { + const ids = new Set(Array.from({ length: 100 }, () => uuidv7())); + assert.strictEqual(ids.size, 100); + }); + + it('should be time-ordered (monotonic timestamps)', () => { + const a = uuidv7(); + const b = uuidv7(); + // Extract timestamp hex (first 12 chars without hyphen) + const tsA = a.replace(/-/g, '').slice(0, 12); + const tsB = b.replace(/-/g, '').slice(0, 12); + assert.ok(tsB >= tsA, `${tsB} should be >= ${tsA}`); + }); +}); + +describe('validateName', () => { + it('should accept valid names', () => { + assert.doesNotThrow(() => validateName('claude-code')); + assert.doesNotThrow(() => validateName('a')); + assert.doesNotThrow(() => validateName('my-node-123')); + }); + + it('should accept unicode names within 64 bytes', () => { + assert.doesNotThrow(() => validateName('日本語')); + }); + + it('should reject empty string', () => { + assert.throws(() => validateName(''), /non-empty/); + }); + + it('should reject names > 64 bytes', () => { + assert.throws(() => validateName('a'.repeat(65)), /1-64 bytes/); + }); + + it('should reject control characters', () => { + assert.throws(() => validateName('test\x00node'), /control/); + assert.throws(() => validateName('test\nnewline'), /control/); + assert.throws(() => validateName('test\ttab'), /control/); + }); + + it('should reject non-string input', () => { + assert.throws(() => validateName(null), /non-empty/); + assert.throws(() => validateName(undefined), /non-empty/); + }); +}); + +describe('generateSigningKeyPair', () => { + it('should return 32-byte raw Buffer keys', () => { + const kp = generateSigningKeyPair(); + assert.ok(Buffer.isBuffer(kp.publicKey), 'publicKey should be Buffer'); + assert.ok(Buffer.isBuffer(kp.privateKey), 'privateKey should be Buffer'); + assert.strictEqual(kp.publicKey.length, 32); + assert.strictEqual(kp.privateKey.length, 32); + }); + + it('should produce different keys each call', () => { + const a = generateSigningKeyPair(); + const b = generateSigningKeyPair(); + assert.ok(!a.publicKey.equals(b.publicKey), 'different calls should produce different keys'); + }); + + it('should produce base64url-safe strings when encoded', () => { + const kp = generateSigningKeyPair(); + const encoded = kp.publicKey.toString('base64url'); + assert.ok(!encoded.includes('+'), 'base64url should not contain +'); + assert.ok(!encoded.includes('='), 'base64url should not contain ='); + assert.ok(!encoded.includes('/'), 'base64url should not contain /'); + }); +}); + +describe('loadOrCreateIdentity', () => { + const testName = `test-identity-${Date.now()}`; + + after(() => { + fs.rmSync(nodeDir(testName), { recursive: true, force: true }); + }); + + it('should create new identity with UUID v7 and keypair', () => { + const id = loadOrCreateIdentity(testName); + assert.ok(id.nodeId, 'should have nodeId'); + assert.strictEqual(id.nodeId[14], '7', 'new node should use UUID v7'); + assert.strictEqual(id.name, testName); + assert.ok(id.hostname, 'should have hostname'); + assert.ok(id.createdAt, 'should have createdAt'); + assert.ok(id.publicKey, 'should have Ed25519 publicKey'); + assert.ok(id.privateKey, 'should have Ed25519 privateKey'); + }); + + it('should return same identity on second call', () => { + const a = loadOrCreateIdentity(testName); + const b = loadOrCreateIdentity(testName); + assert.strictEqual(a.nodeId, b.nodeId); + assert.strictEqual(a.publicKey, b.publicKey); + }); + + it('should migrate legacy identity without keypair', () => { + const legacyName = `test-legacy-${Date.now()}`; + const dir = nodeDir(legacyName); + ensureDir(dir); + const legacy = { nodeId: 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee', name: legacyName, hostname: 'test', createdAt: Date.now() }; + fs.writeFileSync(path.join(dir, 'identity.json'), JSON.stringify(legacy)); + + const id = loadOrCreateIdentity(legacyName); + assert.strictEqual(id.nodeId, legacy.nodeId, 'should preserve v4 nodeId'); + assert.ok(id.publicKey, 'should add publicKey during migration'); + assert.ok(id.privateKey, 'should add privateKey during migration'); + + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('ensureDir', () => { + it('should create nested directories', () => { + const dir = path.join(os.tmpdir(), `sym-test-${Date.now()}`, 'a', 'b'); + ensureDir(dir); + assert.ok(fs.existsSync(dir)); + fs.rmSync(path.join(os.tmpdir(), `sym-test-${Date.now()}`), { recursive: true, force: true }); + }); +}); + +describe('nodeDir', () => { + it('should return path under NODES_DIR', () => { + const dir = nodeDir('test-node'); + assert.ok(dir.startsWith(NODES_DIR)); + assert.ok(dir.endsWith('test-node')); + }); +}); + +describe('log', () => { + it('should not throw', () => { + assert.doesNotThrow(() => log('test', 'hello')); + }); +}); diff --git a/tests/frame-parser.test.js b/tests/frame-parser.test.js new file mode 100644 index 0000000..19941dc --- /dev/null +++ b/tests/frame-parser.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { EventEmitter } = require('events'); +const { FrameParser, sendFrame } = require('../lib/frame-parser'); + +function encodeFrame(msg) { + const payload = Buffer.from(JSON.stringify(msg), 'utf8'); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +describe('FrameParser', () => { + it('should parse a single frame', () => { + const parser = new FrameParser(); + const messages = []; + parser.on('message', (m) => messages.push(m)); + + parser.feed(encodeFrame({ type: 'ping' })); + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].type, 'ping'); + }); + + it('should parse multiple frames in one chunk', () => { + const parser = new FrameParser(); + const messages = []; + parser.on('message', (m) => messages.push(m)); + + const buf = Buffer.concat([ + encodeFrame({ type: 'ping' }), + encodeFrame({ type: 'pong' }), + encodeFrame({ type: 'handshake', nodeId: 'abc' }), + ]); + parser.feed(buf); + assert.strictEqual(messages.length, 3); + assert.strictEqual(messages[0].type, 'ping'); + assert.strictEqual(messages[1].type, 'pong'); + assert.strictEqual(messages[2].nodeId, 'abc'); + }); + + it('should handle partial frames across chunks', () => { + const parser = new FrameParser(); + const messages = []; + parser.on('message', (m) => messages.push(m)); + + const full = encodeFrame({ type: 'state-sync', h1: [1, 2, 3] }); + // Split in the middle + const mid = Math.floor(full.length / 2); + parser.feed(full.subarray(0, mid)); + assert.strictEqual(messages.length, 0, 'should not emit on partial data'); + parser.feed(full.subarray(mid)); + assert.strictEqual(messages.length, 1); + assert.deepStrictEqual(messages[0].h1, [1, 2, 3]); + }); + + it('should emit error for zero-length frame', () => { + const parser = new FrameParser(); + const errors = []; + parser.on('error', (e) => errors.push(e)); + + const header = Buffer.alloc(4); + header.writeUInt32BE(0, 0); + parser.feed(header); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Invalid frame length/); + }); + + it('should emit error for oversized frame', () => { + const parser = new FrameParser(); + const errors = []; + parser.on('error', (e) => errors.push(e)); + + const header = Buffer.alloc(4); + header.writeUInt32BE(2 * 1024 * 1024, 0); // 2 MiB > 1 MiB limit + parser.feed(header); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Invalid frame length/); + }); + + it('should emit error for invalid JSON', () => { + const parser = new FrameParser(); + const errors = []; + parser.on('error', (e) => errors.push(e)); + + const payload = Buffer.from('not json{{{', 'utf8'); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + parser.feed(Buffer.concat([header, payload])); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Invalid JSON/); + }); +}); + +describe('sendFrame', () => { + it('should write length-prefixed frame to socket', () => { + const chunks = []; + const mockSocket = { write: (data) => chunks.push(data) }; + const result = sendFrame(mockSocket, { type: 'ping' }); + assert.strictEqual(result, true); + assert.strictEqual(chunks.length, 1); + + // Parse it back + const parser = new FrameParser(); + const messages = []; + parser.on('message', (m) => messages.push(m)); + parser.feed(chunks[0]); + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].type, 'ping'); + }); + + it('should reject oversized payloads', () => { + const mockSocket = { write: () => {} }; + const bigContent = 'x'.repeat(1024 * 1024 + 1); + const result = sendFrame(mockSocket, { data: bigContent }); + assert.strictEqual(result, false); + }); + + it('should return false if socket.write throws', () => { + const mockSocket = { write: () => { throw new Error('broken pipe'); } }; + const result = sendFrame(mockSocket, { type: 'ping' }); + assert.strictEqual(result, false); + }); +}); diff --git a/tests/memory-store.test.js b/tests/memory-store.test.js index f8cb4b2..d70ec7a 100644 --- a/tests/memory-store.test.js +++ b/tests/memory-store.test.js @@ -95,6 +95,77 @@ describe('MemoryStore', () => { assert.ok(count >= 2, `should have at least 2 entries, got ${count}`); }); + it('should get entry by key', () => { + const entry = store.write('retrievable entry', { tags: ['gettest'] }); + const loaded = store.get(entry.key); + assert.ok(loaded, 'should load by key'); + assert.strictEqual(loaded.content, 'retrievable entry'); + }); + + it('should return null for missing key', () => { + const result = store.get('nonexistent-key'); + assert.strictEqual(result, null); + }); + + it('should return ancestors and parents', () => { + // Write a parent first + const parent = store.write('parent observation', { tags: ['lineage'] }); + // Write a child with parent reference + const { createCMB } = require('@sym-bot/core'); + const childCmb = createCMB({ + fields: { + focus: 'child of parent', + issue: 'none', intent: 'test lineage', motivation: 'coverage', + commitment: 'test', perspective: 'test', + mood: { text: 'neutral', valence: 0, arousal: 0 }, + }, + createdBy: 'test-agent', + parents: [parent.key], + }); + const child = store.write('child observation', { cmb: childCmb }); + + const parents = store.parents(child.key); + // Parents may or may not be populated depending on CMB lineage propagation + assert.ok(Array.isArray(parents), 'parents should be array'); + + const ancestors = store.ancestors(child.key); + assert.ok(Array.isArray(ancestors), 'ancestors should be array'); + }); + + it('should return descendants', () => { + const entry = store.write('root entry for descendants test'); + const descs = store.descendants(entry.key); + assert.ok(Array.isArray(descs), 'descendants should be array'); + }); + + it('should return stats', () => { + const s = store.stats(); + assert.ok(s.total >= 1, 'should have entries'); + assert.ok(typeof s.local === 'number'); + assert.ok(typeof s.peer === 'number'); + assert.ok(typeof s.hot === 'number'); + assert.ok(typeof s.cold === 'number'); + assert.strictEqual(s.total, s.local + s.peer); + }); + + it('should recall all when no query', () => { + const results = store.recall(''); + assert.ok(results.length >= 1, 'empty query should return all'); + const results2 = store.recall(); + assert.ok(results2.length >= 1, 'undefined query should return all'); + }); + + it('should compact old entries', () => { + // Compact with zero freshness = everything is old + const moved = store.compact(0); + assert.ok(typeof moved === 'number', 'compact should return count'); + }); + + it('should purge cold entries without descendants', () => { + const removed = store.purge(); + assert.ok(typeof removed === 'number', 'purge should return count'); + }); + it('should receive from peer', () => { const peerEntry = { key: 'peer-mem-1', diff --git a/tests/node.test.js b/tests/node.test.js new file mode 100644 index 0000000..a5170b2 --- /dev/null +++ b/tests/node.test.js @@ -0,0 +1,99 @@ +'use strict'; + +const { describe, it, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { nodeDir } = require('../lib/config'); + +// Use unique names to avoid state conflicts +const nodeName = `test-node-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + +describe('SymNode', () => { + let SymNode; + + // Dynamic require to avoid top-level import issues + it('should load SymNode', () => { + SymNode = require('../lib/node').SymNode; + assert.ok(SymNode, 'SymNode should be exported'); + }); + + it('should require a name', () => { + assert.throws(() => new SymNode({}), /requires a name/); + }); + + it('should set name and nodeId in constructor', () => { + const node = new SymNode({ name: nodeName, silent: true }); + assert.strictEqual(node.name, nodeName); + assert.ok(node.nodeId, 'nodeId should be set'); + assert.strictEqual(node.nodeId.length, 36, 'nodeId should be full UUID'); + }); + + // Note: lifecycle tests use relayOnly to avoid bonjour-service UDP socket leak + // (known issue: bonjour-service .destroy() doesn't close its multicast socket, + // preventing clean Node.js process exit). Bonjour path is tested in local dev (macOS). + + it('should return full nodeId in status()', async () => { + const node = new SymNode({ name: nodeName, silent: true, relayOnly: true }); + await node.start(); + const s = node.status(); + assert.strictEqual(s.nodeId, node.nodeId); + assert.strictEqual(s.nodeId.length, 36); + assert.strictEqual(s.name, nodeName); + assert.strictEqual(s.running, true); + assert.strictEqual(s.peerCount, 0); + await node.stop(); + }); + + it('should start and stop without error', async () => { + const name = `test-lifecycle-${Date.now()}`; + const node = new SymNode({ name, silent: true, relayOnly: true }); + await node.start(); + assert.strictEqual(node.status().running, true); + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + }); + + it('should return empty peers when no connections', () => { + const node = new SymNode({ name: nodeName, silent: true }); + const peers = node.peers(); + assert.ok(Array.isArray(peers)); + assert.strictEqual(peers.length, 0); + }); + + it('should return null coherence when no peers', () => { + const node = new SymNode({ name: nodeName, silent: true }); + const c = node.coherence(); + assert.strictEqual(c, null); + }); + + it('should remember and recall', async () => { + const name = `test-memory-${Date.now()}`; + const node = new SymNode({ name, silent: true, relayOnly: true }); + await node.start(); + + const entry = node.remember({ + focus: 'testing memory', + issue: 'none', + intent: 'verify remember/recall', + motivation: 'test coverage', + commitment: 'test suite', + perspective: 'developer', + mood: { text: 'focused', valence: 0.5, arousal: 0.3 }, + }); + assert.ok(entry, 'remember should return entry'); + assert.ok(entry.key, 'entry should have key'); + + const results = node.recall('testing memory'); + assert.ok(results.length >= 1, 'should find the memory'); + + const count = node.memories(); + assert.ok(count >= 1, 'should have at least 1 memory'); + + await node.stop(); + fs.rmSync(nodeDir(name), { recursive: true, force: true }); + }); + + after(() => { + fs.rmSync(nodeDir(nodeName), { recursive: true, force: true }); + }); +}); diff --git a/tests/transport.test.js b/tests/transport.test.js new file mode 100644 index 0000000..d60ae42 --- /dev/null +++ b/tests/transport.test.js @@ -0,0 +1,140 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { EventEmitter } = require('events'); +const { TcpTransport, RelayPeerTransport } = require('../lib/transport'); + +class MockSocket extends EventEmitter { + constructor() { super(); this.destroyed = false; this.chunks = []; } + write(data) { this.chunks.push(data); } + destroy() { this.destroyed = true; } +} + +describe('TcpTransport', () => { + it('should emit message events from parsed frames', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + const messages = []; + transport.on('message', (m) => messages.push(m)); + + // Send a valid frame through the socket + const payload = Buffer.from(JSON.stringify({ type: 'ping' }), 'utf8'); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length, 0); + socket.emit('data', Buffer.concat([header, payload])); + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].type, 'ping'); + }); + + it('should send frames via sendFrame', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + transport.send({ type: 'pong' }); + assert.strictEqual(socket.chunks.length, 1); + assert.ok(socket.chunks[0].length > 4, 'should have length prefix + payload'); + }); + + it('should not send after close', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + transport.close(); + transport.send({ type: 'ping' }); + assert.strictEqual(socket.chunks.length, 0); + }); + + it('should destroy socket on close', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + transport.close(); + assert.ok(socket.destroyed); + }); + + it('should be idempotent on double close', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + transport.close(); + transport.close(); // should not throw + assert.ok(socket.destroyed); + }); + + it('should emit close when socket closes', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + let closed = false; + transport.on('close', () => { closed = true; }); + socket.emit('close'); + assert.ok(closed); + }); + + it('should propagate socket errors', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + const errors = []; + transport.on('error', (e) => errors.push(e)); + socket.emit('error', new Error('connection reset')); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /connection reset/); + }); + + it('should return pending buffer', () => { + const socket = new MockSocket(); + const transport = new TcpTransport(socket); + const buf = transport.pendingBuffer; + assert.ok(Buffer.isBuffer(buf)); + }); +}); + +describe('RelayPeerTransport', () => { + it('should send envelope with to field when ws ready', () => { + const sent = []; + const mockWs = { readyState: 1, send: (data) => sent.push(data) }; + const transport = new RelayPeerTransport(mockWs, 'peer-123'); + transport.send({ type: 'ping' }); + assert.strictEqual(sent.length, 1); + const envelope = JSON.parse(sent[0]); + assert.strictEqual(envelope.to, 'peer-123'); + assert.strictEqual(envelope.payload.type, 'ping'); + }); + + it('should not send when ws is not ready', () => { + const sent = []; + const mockWs = { readyState: 0, send: (data) => sent.push(data) }; + const transport = new RelayPeerTransport(mockWs, 'peer-123'); + transport.send({ type: 'ping' }); + assert.strictEqual(sent.length, 0); + }); + + it('should not send after close', () => { + const sent = []; + const mockWs = { readyState: 1, send: (data) => sent.push(data) }; + const transport = new RelayPeerTransport(mockWs, 'peer-123'); + transport.close(); + transport.send({ type: 'ping' }); + assert.strictEqual(sent.length, 0); + }); + + it('should emit close on close()', () => { + const mockWs = { readyState: 1, send: () => {} }; + const transport = new RelayPeerTransport(mockWs, 'peer-123'); + let closed = false; + transport.on('close', () => { closed = true; }); + transport.close(); + assert.ok(closed); + }); + + it('should emit close on destroy()', () => { + const mockWs = { readyState: 1, send: () => {} }; + const transport = new RelayPeerTransport(mockWs, 'peer-123'); + let closed = false; + transport.on('close', () => { closed = true; }); + transport.destroy(); + assert.ok(closed); + }); + + it('should not send when ws is null', () => { + const transport = new RelayPeerTransport(null, 'peer-123'); + assert.doesNotThrow(() => transport.send({ type: 'ping' })); + }); +});