import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { WebSocket } from 'ws'; import { resolve } from 'path'; import { createServer } from 'net'; import { GatewayServer } from './server.js'; import type { GatewayServerConfig } from './server.js'; import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js'; import { ErrorCode } from './protocol.js'; async function canListenOnLocalhost(): Promise { return new Promise((resolvePromise) => { const s = createServer(); s.once('error', () => resolvePromise(false)); s.listen(0, '127.0.0.1', () => { s.close(() => resolvePromise(true)); }); }); } let LISTEN_ALLOWED = true; // Minimal mocks for dependencies const mockSession = { id: 'test', addMessage: vi.fn(), getHistory: vi.fn(() => []), clear: vi.fn(), setHistory: vi.fn(), replaceHistory: vi.fn(), }; const mockSessionManager = { getSession: vi.fn(() => mockSession), getSessionConfig: vi.fn(() => undefined), listSessions: vi.fn(() => ['ws:test']), transferSession: vi.fn(), closeSession: vi.fn(), }; const mockModelClient = { chat: vi.fn(async () => ({ content: 'Hello from Flynn!', stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 5 }, })), setTier: vi.fn(() => true), getTier: vi.fn(() => 'default' as const), getLabel: vi.fn((tier: string) => tier), setTierStrict: vi.fn(), addTierChangeListener: vi.fn(), removeTierChangeListener: vi.fn(), }; const mockToolRegistry = { register: vi.fn(), get: vi.fn((name: string) => (name === 'shell.exec' ? { name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } } : undefined)), list: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]), filteredList: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]), toAnthropicFormat: vi.fn(() => []), toOpenAIFormat: vi.fn(() => []), filteredToAnthropicFormat: vi.fn(() => []), filteredToOpenAIFormat: vi.fn(() => []), }; const mockToolExecutor = { execute: vi.fn(async () => ({ success: true, output: 'executed' })), }; const TEST_PORT = 18899; let server: GatewayServer; function createClient(): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}`); ws.on('open', () => resolve(ws)); ws.on('error', reject); }); } function sendAndReceive(ws: WebSocket, msg: object): Promise { return new Promise((resolve) => { ws.once('message', (data) => { resolve(JSON.parse(data.toString())); }); ws.send(JSON.stringify(msg)); }); } function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise> { return new Promise((resolve) => { const messages: Array = []; const handler = (data: Buffer) => { messages.push(JSON.parse(data.toString())); if (messages.length >= count) { ws.off('message', handler); resolve(messages); } }; ws.on('message', handler); ws.send(JSON.stringify(msg)); }); } beforeAll(async () => { LISTEN_ALLOWED = await canListenOnLocalhost(); }); describe('GatewayServer integration', () => { beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } server = new GatewayServer({ port: TEST_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], version: '0.1.0-test', uiDir: resolve(import.meta.dirname, 'ui'), }); await server.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await server.stop(); }); it('responds to system.health', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 1, method: 'system.health' }); const response = result as GatewayResponse; expect(response.id).toBe(1); const r = response.result as Record; expect(r.status).toBe('ok'); expect(r.version).toBe('0.1.0-test'); expect(typeof r.uptime).toBe('number'); } finally { ws.close(); } }); it('returns MethodNotFound for unknown method', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 2, method: 'unknown.method' }); const error = result as GatewayError; expect(error.error.code).toBe(ErrorCode.MethodNotFound); } finally { ws.close(); } }); it('returns ParseError for invalid JSON', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { const result = await new Promise((resolve) => { ws.once('message', (data) => resolve(JSON.parse(data.toString()))); ws.send('not valid json'); }); expect(result.error.code).toBe(ErrorCode.ParseError); } finally { ws.close(); } }); it('lists tools via tools.list', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 3, method: 'tools.list' }); const response = result as GatewayResponse; const r = response.result as { tools: Array<{ name: string }> }; expect(r.tools.length).toBeGreaterThan(0); expect(r.tools[0].name).toBe('shell.exec'); } finally { ws.close(); } }); it('sends agent message and receives done event', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { // agent.send streams events — we expect a 'done' event const messages = await sendAndReceiveAll(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }, 1); const doneEvent = messages[0] as GatewayEvent; expect(doneEvent.id).toBe(4); expect(doneEvent.event).toBe('done'); expect((doneEvent.data as { content?: string }).content).toBe('Hello from Flynn!'); } finally { ws.close(); } }); it('tracks connections correctly', async () => { if (!LISTEN_ALLOWED) { return; } const ws1 = await createClient(); const ws2 = await createClient(); try { const result = await sendAndReceive(ws1, { id: 5, method: 'system.health' }); const r = (result as GatewayResponse).result as Record; expect(r.connections).toBe(2); } finally { ws1.close(); ws2.close(); } }); it('lists registered methods', () => { if (!LISTEN_ALLOWED) { return; } const methods = server.getMethods(); expect(methods).toContain('system.health'); expect(methods).toContain('agent.send'); expect(methods).toContain('agent.cancel'); expect(methods).toContain('sessions.list'); expect(methods).toContain('sessions.history'); expect(methods).toContain('sessions.create'); expect(methods).toContain('tools.list'); expect(methods).toContain('tools.invoke'); expect(methods).toContain('canvas.put'); expect(methods).toContain('canvas.list'); expect(methods).toContain('system.nodes'); expect(methods).toContain('node.status.set'); expect(methods).toContain('node.push_token.set'); }); it('supports canvas artifact lifecycle via gateway RPC', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createClient(); try { const put = await sendAndReceive(ws, { id: 31, method: 'canvas.put', params: { sessionId: 'ws:test-canvas', artifactId: 'a1', type: 'note', content: { text: 'draft' }, }, }); expect((put as GatewayResponse).id).toBe(31); const list = await sendAndReceive(ws, { id: 32, method: 'canvas.list', params: { sessionId: 'ws:test-canvas' }, }); const artifacts = ((list as GatewayResponse).result as { artifacts: Array<{ id: string }> }).artifacts; expect(artifacts).toHaveLength(1); expect(artifacts[0]?.id).toBe('a1'); const clear = await sendAndReceive(ws, { id: 33, method: 'canvas.clear', params: { sessionId: 'ws:test-canvas' }, }); expect(((clear as GatewayResponse).result as { cleared: number }).cleared).toBe(1); } finally { ws.close(); } }); // ── HTTP static file serving tests ──────────────────────────── it('serves index.html on HTTP GET /', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/html'); expect(res.headers.get('cache-control')).toBe('no-store'); const body = await res.text(); expect(body).toContain('Flynn'); }); it('serves style.css on HTTP GET /style.css', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/css'); expect(res.headers.get('cache-control')).toBe('no-store'); }); it('serves sw.js with no-store cache policy', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/sw.js`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('application/javascript'); expect(res.headers.get('cache-control')).toBe('no-store'); }); it('returns 404 for unknown HTTP path', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`); expect(res.status).toBe(404); }); it('returns 404 for path traversal attempt', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`); expect(res.status).toBe(404); }); }); describe('GatewayServer lock mode', () => { const LOCK_PORT = 18897; let lockServer: GatewayServer; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } lockServer = new GatewayServer({ port: LOCK_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], lock: true, uiDir: resolve(import.meta.dirname, 'ui'), }); await lockServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await lockServer.stop(); }); function createLockClient(): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`); ws.on('open', () => resolve(ws)); ws.on('error', reject); }); } it('allows the first client to connect', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createLockClient(); try { const result = await sendAndReceive(ws, { id: 1, method: 'system.health' }); const response = result as GatewayResponse; expect((response.result as { status?: string }).status).toBe('ok'); } finally { ws.close(); // Wait for the close to propagate so connectionMap is empty await new Promise(r => setTimeout(r, 100)); } }); it('rejects second client with code 4003 when locked', async () => { if (!LISTEN_ALLOWED) { return; } const ws1 = await createLockClient(); try { // Second client should be rejected const closePromise = new Promise<{ code: number; reason: string }>((resolve) => { const ws2 = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`); ws2.on('close', (code, reason) => { resolve({ code, reason: reason.toString() }); }); }); const { code, reason } = await closePromise; expect(code).toBe(4003); expect(reason).toContain('locked'); } finally { ws1.close(); await new Promise(r => setTimeout(r, 100)); } }); it('allows a new client after the previous one disconnects', async () => { if (!LISTEN_ALLOWED) { return; } const ws1 = await createLockClient(); ws1.close(); // Wait for the close to propagate await new Promise(r => setTimeout(r, 100)); const ws2 = await createLockClient(); try { const result = await sendAndReceive(ws2, { id: 2, method: 'system.health' }); const response = result as GatewayResponse; expect((response.result as { status?: string }).status).toBe('ok'); } finally { ws2.close(); await new Promise(r => setTimeout(r, 100)); } }); it('system.lock handler returns lock status', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await createLockClient(); try { const result = await sendAndReceive(ws, { id: 3, method: 'system.lock' }); const response = result as GatewayResponse; const r = response.result as { locked: boolean; activeClients: number; maxClients: number | null }; expect(r.locked).toBe(true); expect(r.activeClients).toBe(1); expect(r.maxClients).toBe(1); } finally { ws.close(); await new Promise(r => setTimeout(r, 100)); } }); }); describe('GatewayServer HTTP auth', () => { const AUTH_PORT = 18898; let authServer: GatewayServer; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } authServer = new GatewayServer({ port: AUTH_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], auth: { token: 'test-secret' }, authHttp: true, uiDir: resolve(import.meta.dirname, 'ui'), }); await authServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await authServer.stop(); }); it('returns 401 for HTTP request without token', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`); expect(res.status).toBe(401); expect(res.headers.get('www-authenticate')).toBe('Bearer'); }); it('serves content with valid Bearer token', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`, { headers: { Authorization: 'Bearer test-secret' }, }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/html'); }); }); describe('GatewayServer request body limits', () => { const BODY_PORT = 18896; let bodyLimitServer: GatewayServer; const gmailHandler = { handlePushNotification: vi.fn(async () => {}), }; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } bodyLimitServer = new GatewayServer({ port: BODY_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], gmailHandler: gmailHandler as unknown as GatewayServerConfig['gmailHandler'], maxRequestBodyBytes: 64, uiDir: resolve(import.meta.dirname, 'ui'), }); await bodyLimitServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await bodyLimitServer.stop(); }); it('accepts gmail push body under limit', async () => { if (!LISTEN_ALLOWED) { return; } gmailHandler.handlePushNotification.mockClear(); const body = JSON.stringify({ message: { data: 'abc' } }); const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(200); expect(gmailHandler.handlePushNotification).toHaveBeenCalledWith('abc'); }); it('rejects gmail push body over limit with 413', async () => { if (!LISTEN_ALLOWED) { return; } gmailHandler.handlePushNotification.mockClear(); const body = JSON.stringify({ message: { data: 'x'.repeat(2048) } }); const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(413); expect(gmailHandler.handlePushNotification).not.toHaveBeenCalled(); }); }); describe('GatewayServer WebChat push endpoints', () => { const PUSH_PORT = 18894; let pushServer: GatewayServer; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } pushServer = new GatewayServer({ port: PUSH_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], auth: { token: 'push-secret' }, authHttp: true, uiDir: resolve(import.meta.dirname, 'ui'), webchatPush: { enabled: true, vapidPublicKey: 'BO_test_public_key', maxSubscriptions: 2, }, }); await pushServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await pushServer.stop(); }); it('returns push public key metadata when authenticated', async () => { if (!LISTEN_ALLOWED) { return; } const res = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/public-key`, { headers: { Authorization: 'Bearer push-secret' }, }); expect(res.status).toBe(200); const body = await res.json() as { enabled: boolean; vapidPublicKey: string | null }; expect(body.enabled).toBe(true); expect(body.vapidPublicKey).toBe('BO_test_public_key'); }); it('stores and deletes webchat push subscriptions', async () => { if (!LISTEN_ALLOWED) { return; } const headers = { Authorization: 'Bearer push-secret', 'Content-Type': 'application/json' }; const payload = { endpoint: 'https://example.invalid/sub/1', keys: { p256dh: 'p256dh-sample', auth: 'auth-sample', }, userAgent: 'vitest', }; const putRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { method: 'POST', headers, body: JSON.stringify(payload), }); expect(putRes.status).toBe(200); const putBody = await putRes.json() as { stored: boolean; count: number }; expect(putBody.stored).toBe(true); expect(putBody.count).toBe(1); const listRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { headers: { Authorization: 'Bearer push-secret' }, }); expect(listRes.status).toBe(200); const listBody = await listRes.json() as { count: number; maxSubscriptions: number }; expect(listBody.count).toBe(1); expect(listBody.maxSubscriptions).toBe(2); const delRes = await fetch(`http://127.0.0.1:${PUSH_PORT}/webchat/push/subscriptions`, { method: 'DELETE', headers, body: JSON.stringify({ endpoint: payload.endpoint }), }); expect(delRes.status).toBe(200); const delBody = await delRes.json() as { removed: boolean; count: number }; expect(delBody.removed).toBe(true); expect(delBody.count).toBe(0); }); }); describe('GatewayServer WebSocket ingress rate limiting', () => { const RATE_PORT = 18895; let rateServer: GatewayServer; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } rateServer = new GatewayServer({ port: RATE_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], uiDir: resolve(import.meta.dirname, 'ui'), wsRateLimit: { enabled: true, capacity: 2, refillPerSec: 1, maxViolations: 3, violationWindowMs: 10_000, }, }); await rateServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await rateServer.stop(); }); it('throttles bursts and closes repeated offenders', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${RATE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const first = await sendAndReceive(ws, { id: 1, method: 'system.health' }); expect((first as GatewayResponse).id).toBe(1); const second = await sendAndReceive(ws, { id: 2, method: 'system.health' }); expect((second as GatewayResponse).id).toBe(2); const third = await sendAndReceive(ws, { id: 3, method: 'system.health' }); const rateErr = third as GatewayError; expect(rateErr.error.code).toBe(ErrorCode.InternalError); expect(rateErr.error.message).toContain('Rate limit exceeded'); // Trigger additional violations; server should close on max violation threshold. ws.send(JSON.stringify({ id: 4, method: 'system.health' })); ws.send(JSON.stringify({ id: 5, method: 'system.health' })); const close = await new Promise<{ code: number; reason: string }>((resolve) => { ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() })); }); expect(close.code).toBe(4008); expect(close.reason).toContain('Rate limit exceeded'); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); }); describe('GatewayServer node registration and capability negotiation', () => { const NODE_PORT = 18894; let nodeServer: GatewayServer; beforeAll(async () => { if (!LISTEN_ALLOWED) { return; } nodeServer = new GatewayServer({ port: NODE_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], modelClient: mockModelClient, systemPrompt: 'Test prompt', toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], uiDir: resolve(import.meta.dirname, 'ui'), nodes: { enabled: true, allowedRoles: ['companion'], featureGates: { 'ui.canvas': true }, locationEnabled: true, pushEnabled: true, }, }); await nodeServer.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await nodeServer.stop(); }); it('enforces role allow/deny and node registration lifecycle', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const beforeRegister = await sendAndReceive(ws, { id: 1, method: 'node.capabilities.get', params: {} }); expect((beforeRegister as GatewayError).error.code).toBe(ErrorCode.AuthFailed); const badRole = await sendAndReceive(ws, { id: 2, method: 'node.register', params: { nodeId: 'node-bad', role: 'observer', protocolVersion: 1, capabilities: ['ui.canvas'], }, }); expect((badRole as GatewayError).error.code).toBe(ErrorCode.AuthFailed); const registered = await sendAndReceive(ws, { id: 3, method: 'node.register', params: { nodeId: 'node-good', role: 'companion', protocolVersion: 1, capabilities: ['ui.canvas'], }, }); expect((registered as GatewayResponse).id).toBe(3); expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); const capabilities = await sendAndReceive(ws, { id: 4, method: 'node.capabilities.get', params: {} }); expect((capabilities as GatewayResponse).id).toBe(4); expect(((capabilities as GatewayResponse).result as { node: { role: string } }).node.role).toBe('companion'); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); it('supports node location set/get after registration', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const registered = await sendAndReceive(ws, { id: 10, method: 'node.register', params: { nodeId: 'node-loc', role: 'companion', protocolVersion: 1, capabilities: ['location'], }, }); expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); const setResult = await sendAndReceive(ws, { id: 11, method: 'node.location.set', params: { latitude: 51.5074, longitude: -0.1278, source: 'gps', }, }); expect(((setResult as GatewayResponse).result as { updated: boolean }).updated).toBe(true); const getResult = await sendAndReceive(ws, { id: 12, method: 'node.location.get', params: {}, }); const location = ((getResult as GatewayResponse).result as { location: { latitude: number; longitude: number }; }).location; expect(location.latitude).toBe(51.5074); expect(location.longitude).toBe(-0.1278); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); it('supports node.status.set and exposes registered nodes via system.nodes', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const registered = await sendAndReceive(ws, { id: 20, method: 'node.register', params: { nodeId: 'node-mac', role: 'companion', protocolVersion: 1, capabilities: ['ui.canvas'], }, }); expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); const status = await sendAndReceive(ws, { id: 21, method: 'node.status.set', params: { platform: 'macos', appVersion: '0.3.1', deviceName: 'MacBook Pro', batteryPct: 64, powerSource: 'battery', }, }); expect(((status as GatewayResponse).result as { updated: boolean }).updated).toBe(true); const nodes = await sendAndReceive(ws, { id: 22, method: 'system.nodes', params: { role: 'companion', platform: 'macos', limit: 10 }, }); const list = ((nodes as GatewayResponse).result as { nodes: Array<{ nodeId: string; status?: { platform: string; appVersion?: string } }>; }).nodes; expect(list.length).toBeGreaterThanOrEqual(1); expect(list.some((entry) => entry.nodeId === 'node-mac')).toBe(true); expect(list.find((entry) => entry.nodeId === 'node-mac')?.status?.platform).toBe('macos'); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); it('supports node.push_token.set and exposes masked push summary via system.nodes', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const registered = await sendAndReceive(ws, { id: 30, method: 'node.register', params: { nodeId: 'node-ios', role: 'companion', protocolVersion: 1, capabilities: ['notifications'], }, }); expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); const push = await sendAndReceive(ws, { id: 31, method: 'node.push_token.set', params: { provider: 'apns', token: 'abcd1234abcd1234abcd1234abcd1234', topic: 'com.example.flynn', environment: 'sandbox', }, }); const preview = ((push as GatewayResponse).result as { push: { tokenPreview: string }; }).push.tokenPreview; expect(preview).toContain('abcd1234'); const nodes = await sendAndReceive(ws, { id: 32, method: 'system.nodes', params: { role: 'companion', limit: 10 }, }); const list = ((nodes as GatewayResponse).result as { nodes: Array<{ nodeId: string; push?: { tokenPreview: string } }>; }).nodes; const iosNode = list.find((entry) => entry.nodeId === 'node-ios'); expect(iosNode?.push?.tokenPreview).toContain('abcd1234'); expect(iosNode?.push?.tokenPreview).not.toContain('abcd1234abcd1234abcd1234'); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); it('supports android fcm push token registration', async () => { if (!LISTEN_ALLOWED) { return; } const ws = await new Promise((resolve, reject) => { const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); c.on('open', () => resolve(c)); c.on('error', reject); }); try { const registered = await sendAndReceive(ws, { id: 40, method: 'node.register', params: { nodeId: 'node-android', role: 'companion', protocolVersion: 1, capabilities: ['notifications'], }, }); expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); const push = await sendAndReceive(ws, { id: 41, method: 'node.push_token.set', params: { provider: 'fcm', token: 'fcm_abcdefghijklmnopqrstuvwxyz123456', }, }); expect(((push as GatewayResponse).result as { updated: boolean }).updated).toBe(true); const nodes = await sendAndReceive(ws, { id: 42, method: 'system.nodes', params: { role: 'companion', limit: 20 }, }); const list = ((nodes as GatewayResponse).result as { nodes: Array<{ nodeId: string; push?: { provider: string; environment?: string } }>; }).nodes; const androidNode = list.find((entry) => entry.nodeId === 'node-android'); expect(androidNode?.push?.provider).toBe('fcm'); expect(androidNode?.push?.environment).toBeUndefined(); } finally { if (ws.readyState === WebSocket.OPEN) { ws.close(); } } }); });