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; registerNode: 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; } { const connect = vi.fn(async () => undefined); const disconnect = vi.fn(() => undefined); const registerNode = vi.fn(async () => ({ registered: true })); 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 runtime = { connect, disconnect, registerNode, getNodeCapabilities, setNodeStatus, setNodeLocation, getNodeLocation, setNodePushToken, getSystemCapabilities, listSystemNodes, putCanvasArtifact, getCanvasArtifact, listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, } as unknown as CompanionRuntimeClient; return { runtime, connect, disconnect, registerNode, getNodeCapabilities, setNodeStatus, setNodeLocation, getNodeLocation, setNodePushToken, getSystemCapabilities, listSystemNodes, putCanvasArtifact, getCanvasArtifact, listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, }; } 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('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.registerNode).toHaveBeenCalledOnce(); expect(mock.getNodeCapabilities).toHaveBeenCalledOnce(); expect(mock.registerNode.mock.invocationCallOrder[0]).toBeLessThan( mock.getNodeCapabilities.mock.invocationCallOrder[0], ); expect(result).toEqual({ register: { registered: true }, capabilities: expect.any(Object), }); }); 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)', ); }); });