import { describe, expect, it, vi } from 'vitest'; import { AndroidCompanionClient, IOSCompanionClient, MacOSCompanionClient, } from './platformClients.js'; import type { CompanionRuntimeClient } from './runtimeClient.js'; function createRuntimeMock(): { runtime: CompanionRuntimeClient; connect: ReturnType; disconnect: ReturnType; dispose: ReturnType; registerNode: ReturnType; bootstrapNode: ReturnType; getNodeCapabilities: ReturnType; setNodeStatus: ReturnType; setNodeLocation: ReturnType; getNodeLocation: ReturnType; setNodePushToken: ReturnType; getSystemCapabilities: ReturnType; listSystemNodes: ReturnType; putCanvasArtifact: ReturnType; getCanvasArtifact: ReturnType; listCanvasArtifacts: ReturnType; deleteCanvasArtifact: ReturnType; clearCanvasArtifacts: ReturnType; subscribeAgentStream: ReturnType; subscribeAgentTyping: ReturnType; subscribeContextWarning: ReturnType; subscribeEvents: ReturnType; subscribeEvent: ReturnType; clearEventSubscriptions: ReturnType; listKnownEventNames: ReturnType; waitForEvent: ReturnType; waitForAgentStream: ReturnType; waitForAgentTyping: ReturnType; waitForContextWarning: ReturnType; waitForAnyEvent: ReturnType; eventSubscriptionCount: number; } { const connect = vi.fn(async () => undefined); const disconnect = vi.fn(() => undefined); const dispose = vi.fn(() => undefined); const registerNode = vi.fn(async () => ({ registered: true })); const bootstrapNode = vi.fn(async () => ({ register: { registered: true }, capabilities: { node: { id: 'n1', role: 'companion', registeredAt: Date.now() }, protocol: { serverVersion: 1, nodeVersion: 1, negotiatedVersion: 1 }, capabilities: { declared: [], enabled: [], featureGates: {} }, }, })); const getNodeCapabilities = vi.fn(async () => ({ node: { id: 'n1', role: 'companion', registeredAt: Date.now() }, protocol: { serverVersion: 1, nodeVersion: 1, negotiatedVersion: 1 }, capabilities: { declared: [], enabled: [], featureGates: {} } })); const setNodeStatus = vi.fn(async () => ({ updated: true })); const setNodeLocation = vi.fn(async () => ({ updated: true })); const getNodeLocation = vi.fn(async () => ({ node: { id: 'n1', role: 'companion' }, location: null })); const setNodePushToken = vi.fn(async () => ({ updated: true })); const getSystemCapabilities = vi.fn(async () => ({ protocol: { version: 1 }, nodes: { enabled: true, locationEnabled: true, pushEnabled: true, allowedRoles: ['companion'], registered: true }, featureGates: {} })); const listSystemNodes = vi.fn(async () => ({ nodes: [], summary: { total: 0 } })); const putCanvasArtifact = vi.fn(async () => ({ upserted: true, artifact: { id: 'a1' } })); const getCanvasArtifact = vi.fn(async () => ({ artifact: { id: 'a1' } })); const listCanvasArtifacts = vi.fn(async () => ({ artifacts: [{ id: 'a1' }] })); const deleteCanvasArtifact = vi.fn(async () => ({ deleted: true })); const clearCanvasArtifacts = vi.fn(async () => ({ cleared: 1 })); const subscribeAgentStream = vi.fn(() => () => undefined); const subscribeAgentTyping = vi.fn(() => () => undefined); const subscribeContextWarning = vi.fn(() => () => undefined); const subscribeEvents = vi.fn(() => () => undefined); const subscribeEvent = vi.fn(() => () => undefined); const clearEventSubscriptions = vi.fn(() => undefined); const listKnownEventNames = vi.fn(() => ['agent.stream', 'agent.typing', 'context_warning']); const waitForEvent = vi.fn(async () => ({ token: 'evented' })); const waitForAgentStream = vi.fn(async () => ({ token: 'streamed' })); const waitForAgentTyping = vi.fn(async () => ({ active: true })); const waitForContextWarning = vi.fn(async () => ({ thresholdPct: 75, estimatedPct: 90 })); const waitForAnyEvent = vi.fn(async () => ({ event: 'agent.stream', data: { token: 'any' } })); const eventSubscriptionCount = 3; const runtime = { connect, disconnect, dispose, registerNode, bootstrapNode, getNodeCapabilities, setNodeStatus, setNodeLocation, getNodeLocation, setNodePushToken, getSystemCapabilities, listSystemNodes, putCanvasArtifact, getCanvasArtifact, listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, subscribeAgentStream, subscribeAgentTyping, subscribeContextWarning, subscribeEvents, subscribeEvent, clearEventSubscriptions, listKnownEventNames, waitForEvent, waitForAgentStream, waitForAgentTyping, waitForContextWarning, waitForAnyEvent, get eventSubscriptionCount() { return eventSubscriptionCount; }, } as unknown as CompanionRuntimeClient; return { runtime, connect, disconnect, dispose, registerNode, bootstrapNode, getNodeCapabilities, setNodeStatus, setNodeLocation, getNodeLocation, setNodePushToken, getSystemCapabilities, listSystemNodes, putCanvasArtifact, getCanvasArtifact, listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, subscribeAgentStream, subscribeAgentTyping, subscribeContextWarning, subscribeEvents, subscribeEvent, clearEventSubscriptions, listKnownEventNames, waitForEvent, waitForAgentStream, waitForAgentTyping, waitForContextWarning, waitForAnyEvent, eventSubscriptionCount, }; } describe('platform companion clients', () => { it('macOS client uses macos platform status and APNs push', async () => { const mock = createRuntimeMock(); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); await client.connect(); await client.register(); await client.setStatus({ appVersion: '1.0.0', powerSource: 'ac' }); await client.setLocation({ latitude: 10, longitude: 20, source: 'manual' }); await client.registerPushToken({ token: 'a'.repeat(64), topic: 'dev.flynn.macos', environment: 'production' }); await client.listNodes(); client.disconnect(); expect(mock.connect).toHaveBeenCalledOnce(); expect(mock.registerNode).toHaveBeenCalledWith(expect.objectContaining({ nodeId: 'mac-node', role: 'companion' })); expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'macos' })); expect(mock.setNodePushToken).toHaveBeenCalledWith(expect.objectContaining({ provider: 'apns', topic: 'dev.flynn.macos' })); expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'macos', role: 'companion' }); expect(mock.disconnect).toHaveBeenCalledOnce(); }); it('iOS client uses ios platform status and APNs push', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); await client.register(); await client.setStatus({ statusText: 'foreground', batteryPct: 52, powerSource: 'battery' }); await client.registerPushToken({ token: 'b'.repeat(64), topic: 'dev.flynn.ios', environment: 'sandbox' }); await client.listNodes(); expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'ios' })); expect(mock.setNodePushToken).toHaveBeenCalledWith(expect.objectContaining({ provider: 'apns', environment: 'sandbox' })); expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'ios', role: 'companion' }); }); it('android client uses android platform status and FCM push', async () => { const mock = createRuntimeMock(); const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); await client.register(); await client.setStatus({ appVersion: '2.0.0', powerSource: 'battery' }); await client.registerPushToken('c'.repeat(64)); await client.listNodes(); expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'android' })); expect(mock.setNodePushToken).toHaveBeenCalledWith({ provider: 'fcm', token: 'c'.repeat(64) }); expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'android', role: 'companion' }); }); it('platform dispose forwards to runtime dispose', async () => { const mock = createRuntimeMock(); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); client.dispose(); expect(mock.dispose).toHaveBeenCalledOnce(); }); it('platform stream helper methods forward to runtime client', async () => { const mock = createRuntimeMock(); const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); const streamHandler = vi.fn(); const typingHandler = vi.fn(); const warningHandler = vi.fn(); const eventHandler = vi.fn(); const streamEventHandler = vi.fn(); const unsubscribeStream = client.subscribeAgentStream(streamHandler); const unsubscribeTyping = client.subscribeAgentTyping(typingHandler); const unsubscribeWarning = client.subscribeContextWarning(warningHandler); const unsubscribeEvents = client.subscribeEvents(eventHandler); const unsubscribeEvent = client.subscribeEvent('agent.stream', streamEventHandler); const waitedEvent = await client.waitForEvent<{ token: string }>('agent.stream', { timeoutMs: 500 }); const awaited = await client.waitForAgentStream<{ token: string }>({ timeoutMs: 500 }); const waitedTyping = await client.waitForAgentTyping<{ active: boolean }>({ timeoutMs: 500 }); const waitedWarning = await client.waitForContextWarning<{ thresholdPct: number; estimatedPct: number }>({ timeoutMs: 500 }); const waitedAny = await client.waitForAnyEvent<{ token: string }>(['agent.stream'], { timeoutMs: 500 }); expect(mock.subscribeAgentStream).toHaveBeenCalledWith(streamHandler); expect(mock.subscribeAgentTyping).toHaveBeenCalledWith(typingHandler); expect(mock.subscribeContextWarning).toHaveBeenCalledWith(warningHandler); expect(mock.subscribeEvents).toHaveBeenCalledWith(eventHandler); expect(mock.subscribeEvent).toHaveBeenCalledWith('agent.stream', streamEventHandler); expect(mock.waitForEvent).toHaveBeenCalledWith('agent.stream', { timeoutMs: 500 }); expect(mock.waitForAgentStream).toHaveBeenCalledWith({ timeoutMs: 500 }); expect(mock.waitForAgentTyping).toHaveBeenCalledWith({ timeoutMs: 500 }); expect(mock.waitForContextWarning).toHaveBeenCalledWith({ timeoutMs: 500 }); expect(mock.waitForAnyEvent).toHaveBeenCalledWith(['agent.stream'], { timeoutMs: 500 }); expect(waitedEvent).toEqual({ token: 'evented' }); expect(awaited).toEqual({ token: 'streamed' }); expect(waitedTyping).toEqual({ active: true }); expect(waitedWarning).toEqual({ thresholdPct: 75, estimatedPct: 90 }); expect(waitedAny).toEqual({ event: 'agent.stream', data: { token: 'any' } }); unsubscribeStream(); unsubscribeTyping(); unsubscribeWarning(); unsubscribeEvents(); unsubscribeEvent(); }); it('platform clearEventSubscriptions forwards to runtime client', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); client.clearEventSubscriptions(); expect(mock.clearEventSubscriptions).toHaveBeenCalledOnce(); }); it('platform listKnownEventNames forwards to runtime client', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); const events = client.listKnownEventNames(); expect(mock.listKnownEventNames).toHaveBeenCalledOnce(); expect(events).toEqual(['agent.stream', 'agent.typing', 'context_warning']); }); it('platform eventSubscriptionCount forwards runtime getter value', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); expect(client.eventSubscriptionCount).toBe(mock.eventSubscriptionCount); }); it('macOS client forwards canvas methods to runtime client', async () => { const mock = createRuntimeMock(); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); await client.putCanvasArtifact({ sessionId: 'ws:test-canvas', type: 'markdown', content: { body: 'hello' }, }); await client.listCanvasArtifacts('ws:test-canvas'); await client.getCanvasArtifact({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); await client.deleteCanvasArtifact({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); await client.clearCanvasArtifacts('ws:test-canvas'); expect(mock.putCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:test-canvas', type: 'markdown', content: { body: 'hello' }, }); expect(mock.listCanvasArtifacts).toHaveBeenCalledWith('ws:test-canvas'); expect(mock.getCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); expect(mock.deleteCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:test-canvas', artifactId: 'a1' }); expect(mock.clearCanvasArtifacts).toHaveBeenCalledWith('ws:test-canvas'); }); it('bootstrap registers node and then fetches capabilities', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); const result = await client.bootstrap(); expect(mock.bootstrapNode).toHaveBeenCalledOnce(); expect(result).toEqual({ register: { registered: true }, capabilities: expect.any(Object), systemCapabilities: undefined, }); }); it('publishHeartbeat uses safe defaults for status payload', async () => { const mock = createRuntimeMock(); const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); await client.publishHeartbeat(); expect(mock.setNodeStatus).toHaveBeenCalledWith( expect.objectContaining({ platform: 'android', statusText: 'heartbeat', powerSource: 'unknown', }), ); }); it('uses defaultSessionId for canvas operations when sessionId is omitted', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node', defaultSessionId: 'ws:default-canvas', }); await client.putCanvasArtifact({ type: 'markdown', content: { body: 'default session' }, }); await client.listCanvasArtifacts(); await client.getCanvasArtifact({ artifactId: 'a1' }); await client.deleteCanvasArtifact({ artifactId: 'a1' }); await client.clearCanvasArtifacts(); expect(mock.putCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:default-canvas', type: 'markdown', content: { body: 'default session' }, artifactId: undefined, title: undefined, metadata: undefined, }); expect(mock.listCanvasArtifacts).toHaveBeenCalledWith('ws:default-canvas'); expect(mock.getCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:default-canvas', artifactId: 'a1' }); expect(mock.deleteCanvasArtifact).toHaveBeenCalledWith({ sessionId: 'ws:default-canvas', artifactId: 'a1' }); expect(mock.clearCanvasArtifacts).toHaveBeenCalledWith('ws:default-canvas'); }); it('throws when canvas sessionId is missing and no defaultSessionId is configured', async () => { const mock = createRuntimeMock(); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); expect(() => client.listCanvasArtifacts()).toThrow( 'sessionId is required (provide one or configure defaultSessionId)', ); }); it('creates a bound heartbeat loop helper from platform clients', async () => { const mock = createRuntimeMock(); const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); const loop = client.createHeartbeatLoop(); await loop.tickNow(); expect(mock.setNodeStatus).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios', statusText: 'heartbeat', }), ); }); it('bootstrap can request system capabilities snapshot', async () => { const mock = createRuntimeMock(); mock.bootstrapNode.mockResolvedValueOnce({ register: { registered: true }, capabilities: { node: { id: 'n1', role: 'companion', registeredAt: Date.now() }, protocol: { serverVersion: 1, nodeVersion: 1, negotiatedVersion: 1 }, capabilities: { declared: [], enabled: [], featureGates: {} }, }, systemCapabilities: { protocol: { version: 1 }, nodes: { enabled: true, locationEnabled: true, pushEnabled: true, allowedRoles: ['companion'], registered: true, }, featureGates: {}, }, }); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); const result = await client.bootstrap({ includeSystemCapabilities: true }); expect(mock.bootstrapNode).toHaveBeenCalledWith( expect.objectContaining({ nodeId: 'mac-node' }), { includeSystemCapabilities: true }, ); expect(result.systemCapabilities?.nodes.enabled).toBe(true); }); });