import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { resolve } from 'path'; import { createServer } from 'net'; import type { GatewayServerConfig } from '../gateway/server.js'; import { GatewayServer } from '../gateway/server.js'; import { CompanionRuntimeClient } from './runtimeClient.js'; import { AndroidCompanionClient, IOSCompanionClient, MacOSCompanionClient, } from './platformClients.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)); }); }); } 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), 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 }, })), }; const mockToolRegistry = { register: vi.fn(), get: vi.fn(), list: vi.fn(() => []), filteredList: vi.fn(() => []), toAnthropicFormat: vi.fn(() => []), toOpenAIFormat: vi.fn(() => []), filteredToAnthropicFormat: vi.fn(() => []), filteredToOpenAIFormat: vi.fn(() => []), }; const mockToolExecutor = { execute: vi.fn(async () => ({ success: true, output: 'ok' })), }; const TEST_PORT = 18912; const TEST_TOKEN = 'platform-clients-token'; let LISTEN_ALLOWED = true; let server: GatewayServer; beforeAll(async () => { LISTEN_ALLOWED = await canListenOnLocalhost(); 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, '../gateway/ui'), auth: { token: TEST_TOKEN }, nodes: { enabled: true, allowedRoles: ['companion'], featureGates: { 'ui.canvas': true }, locationEnabled: true, pushEnabled: true, }, }); await server.start(); }); afterAll(async () => { if (!LISTEN_ALLOWED) { return; } await server.stop(); }); function createRuntime(): CompanionRuntimeClient { return new CompanionRuntimeClient({ url: `ws://127.0.0.1:${TEST_PORT}`, token: TEST_TOKEN, }); } describe('platform clients integration', () => { it('macOS companion wrapper registers and writes status with platform pinning', async () => { if (!LISTEN_ALLOWED) { return; } const runtime = createRuntime(); const client = new MacOSCompanionClient({ runtime, nodeId: 'macos-e2e' }); await client.connect(); try { const boot = await client.bootstrap(); expect(boot.register.registered).toBe(true); expect(boot.capabilities.node.id).toBe('macos-e2e'); const status = await client.publishHeartbeat({ appVersion: '1.0.0', statusText: 'menu-bar-active', powerSource: 'ac', }); expect(status.updated).toBe(true); expect(status.status.platform).toBe('macos'); const nodes = await client.listNodes(); const entry = nodes.nodes.find((n) => n.nodeId === 'macos-e2e'); expect(entry?.status?.platform).toBe('macos'); } finally { client.disconnect(); } }); it('iOS companion wrapper uses APNs push flow', async () => { if (!LISTEN_ALLOWED) { return; } const runtime = createRuntime(); const client = new IOSCompanionClient({ runtime, nodeId: 'ios-e2e' }); await client.connect(); try { await client.register(); const push = await client.registerPushToken({ token: 'd'.repeat(64), topic: 'dev.flynn.ios', environment: 'sandbox', }); expect(push.updated).toBe(true); expect(push.push.provider).toBe('apns'); const nodes = await client.listNodes(); const entry = nodes.nodes.find((n) => n.nodeId === 'ios-e2e'); expect(entry?.push?.provider).toBe('apns'); } finally { client.disconnect(); } }); it('Android companion wrapper uses FCM push flow', async () => { if (!LISTEN_ALLOWED) { return; } const runtime = createRuntime(); const client = new AndroidCompanionClient({ runtime, nodeId: 'android-e2e' }); await client.connect(); try { await client.register(); const push = await client.registerPushToken('e'.repeat(64)); expect(push.updated).toBe(true); expect(push.push.provider).toBe('fcm'); const nodes = await client.listNodes(); const entry = nodes.nodes.find((n) => n.nodeId === 'android-e2e'); expect(entry?.push?.provider).toBe('fcm'); } finally { client.disconnect(); } }); it('macOS companion wrapper supports canvas artifact lifecycle', async () => { if (!LISTEN_ALLOWED) { return; } const runtime = createRuntime(); const client = new MacOSCompanionClient({ runtime, nodeId: 'macos-canvas-e2e', defaultSessionId: 'ws:platform-canvas-e2e', }); await client.connect(); try { await client.register(); const put = await client.putCanvasArtifact({ artifactId: 'mac-art-1', type: 'note', content: { text: 'hello from platform wrapper' }, }); expect(put.upserted).toBe(true); expect(put.artifact.id).toBe('mac-art-1'); const list = await client.listCanvasArtifacts(); expect(list.artifacts.some((artifact) => artifact.id === 'mac-art-1')).toBe(true); const get = await client.getCanvasArtifact({ artifactId: 'mac-art-1' }); expect(get.artifact.id).toBe('mac-art-1'); const del = await client.deleteCanvasArtifact({ artifactId: 'mac-art-1' }); expect(del.deleted).toBe(true); const clear = await client.clearCanvasArtifacts(); expect(clear.cleared).toBe(0); } finally { client.disconnect(); } }); });