Files
flynn/src/gateway/server.test.ts
T

1035 lines
32 KiB
TypeScript

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<boolean> {
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<WebSocket> {
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<GatewayResponse | GatewayError | GatewayEvent> {
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<Array<GatewayResponse | GatewayError | GatewayEvent>> {
return new Promise((resolve) => {
const messages: Array<GatewayResponse | GatewayError | GatewayEvent> = [];
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<string, unknown>;
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<GatewayError>((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<string, unknown>;
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-cache');
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');
});
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<WebSocket> {
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<WebSocket>((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<WebSocket>((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<WebSocket>((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<WebSocket>((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<WebSocket>((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<WebSocket>((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();
}
}
});
});