341 lines
9.6 KiB
TypeScript
341 lines
9.6 KiB
TypeScript
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<boolean> {
|
|
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('platform event subscription helpers track subscription count lifecycle', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const runtime = createRuntime();
|
|
const client = new MacOSCompanionClient({ runtime, nodeId: 'macos-events-e2e' });
|
|
|
|
expect(client.eventSubscriptionCount).toBe(0);
|
|
const unsubscribeA = client.subscribeEvents(() => undefined);
|
|
const unsubscribeB = client.subscribeEvent('agent.stream', () => undefined);
|
|
expect(client.eventSubscriptionCount).toBe(2);
|
|
|
|
unsubscribeA();
|
|
expect(client.eventSubscriptionCount).toBe(1);
|
|
|
|
client.clearEventSubscriptions();
|
|
expect(client.eventSubscriptionCount).toBe(0);
|
|
|
|
unsubscribeB();
|
|
expect(client.eventSubscriptionCount).toBe(0);
|
|
});
|
|
|
|
it('platform pendingEventWaitCount tracks waitForAnyEvent lifecycle', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const runtime = createRuntime();
|
|
const client = new IOSCompanionClient({ runtime, nodeId: 'ios-wait-count-e2e' });
|
|
|
|
expect(client.pendingEventWaitCount).toBe(0);
|
|
const awaited = expect(
|
|
client.waitForAnyEvent(['agent.stream'], { timeoutMs: 10_000 }),
|
|
).rejects.toThrow('Event subscriptions cleared');
|
|
expect(client.pendingEventWaitCount).toBe(1);
|
|
|
|
client.clearEventSubscriptions();
|
|
await awaited;
|
|
expect(client.pendingEventWaitCount).toBe(0);
|
|
});
|
|
|
|
it('platform waitForIdle resolves after pending waiters are cleared', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const runtime = createRuntime();
|
|
const client = new IOSCompanionClient({ runtime, nodeId: 'ios-wait-idle-e2e' });
|
|
|
|
const pending = client.waitForAnyEvent(['agent.stream'], { timeoutMs: 10_000 }).catch(() => undefined);
|
|
expect(client.hasPendingWork).toBe(true);
|
|
|
|
const idle = client.waitForIdle({ timeoutMs: 1_000, pollIntervalMs: 5 });
|
|
setTimeout(() => {
|
|
client.clearEventSubscriptions();
|
|
}, 20);
|
|
|
|
await expect(idle).resolves.toBeUndefined();
|
|
await pending;
|
|
expect(client.hasPendingWork).toBe(false);
|
|
});
|
|
|
|
it('platform getPendingWorkSnapshot reflects waiter lifecycle', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const runtime = createRuntime();
|
|
const client = new IOSCompanionClient({ runtime, nodeId: 'ios-snapshot-e2e' });
|
|
|
|
expect(client.getPendingWorkSnapshot()).toEqual({
|
|
pendingRequestCount: 0,
|
|
pendingEventWaitCount: 0,
|
|
hasPendingWork: false,
|
|
});
|
|
|
|
const pending = client.waitForAnyEvent(['agent.stream'], { timeoutMs: 10_000 }).catch(() => undefined);
|
|
expect(client.getPendingWorkSnapshot()).toEqual({
|
|
pendingRequestCount: 0,
|
|
pendingEventWaitCount: 1,
|
|
hasPendingWork: true,
|
|
});
|
|
|
|
client.clearEventSubscriptions();
|
|
await pending;
|
|
expect(client.getPendingWorkSnapshot()).toEqual({
|
|
pendingRequestCount: 0,
|
|
pendingEventWaitCount: 0,
|
|
hasPendingWork: false,
|
|
});
|
|
});
|
|
|
|
it('platform connected reflects runtime connection lifecycle', async () => {
|
|
if (!LISTEN_ALLOWED) {
|
|
return;
|
|
}
|
|
|
|
const runtime = createRuntime();
|
|
const client = new IOSCompanionClient({ runtime, nodeId: 'ios-connected-e2e' });
|
|
|
|
expect(client.connected).toBe(false);
|
|
await client.connect();
|
|
expect(client.connected).toBe(true);
|
|
client.disconnect();
|
|
expect(client.connected).toBe(false);
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|