import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PersonaWebRTCProtocol } from './webrtc'; // Core manda i server ICE (con le credenziali TURN) nel messaggio OPEN: la // RTCPeerConnection va creata solo dopo, e il datachannel prima dell'offerta. class FakeWebSocket extends EventTarget { static OPEN = 1; static instances: FakeWebSocket[] = []; readyState = 0; sent: Record[] = []; onmessage: ((event: { data: string }) => void) | null = null; onclose: ((event: { code: number }) => void) | null = null; constructor(public url: string) { super(); FakeWebSocket.instances.push(this); } open() { this.readyState = FakeWebSocket.OPEN; this.dispatchEvent(new Event('open')); } receive(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } send(data: string) { this.sent.push(JSON.parse(data)); } close() { this.readyState = 3; } } class FakePeerConnection { static instances: FakePeerConnection[] = []; calls: string[] = []; ontrack = null; onicecandidate = null; ondatachannel = null; constructor(public config: RTCConfiguration) { FakePeerConnection.instances.push(this); } addTrack() {} createDataChannel(label: string) { this.calls.push(`datachannel:${label}`); return {}; } async createOffer() { this.calls.push('offer'); return { sdp: 'sdp', type: 'offer' }; } async setLocalDescription() {} close() {} } const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const CLOUDFLARE_ICE_SERVERS = [{ urls: ['turn:turn.cloudflare.com:3478?transport=udp'], username: 'u', credential: 'c' }]; describe('PersonaWebRTCProtocol ICE servers', () => { beforeEach(() => { FakeWebSocket.instances = []; FakePeerConnection.instances = []; vi.stubGlobal('WebSocket', FakeWebSocket); vi.stubGlobal('RTCPeerConnection', FakePeerConnection); vi.stubGlobal('MediaStream', class {}); vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: async () => ({ getTracks: () => [] }) }, }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); const start = async () => { const protocol = new PersonaWebRTCProtocol({ apiKey: 'key', agentId: 'agent', webrtcUrl: 'wss://test' }); const connected = protocol.connect('session'); await flush(); return { connected, ws: FakeWebSocket.instances[0] }; }; it('uses the ICE servers announced by core', async () => { const { connected, ws } = await start(); ws.open(); expect(FakePeerConnection.instances).toHaveLength(0); ws.receive({ type: 'OPEN', payload: { iceServers: CLOUDFLARE_ICE_SERVERS } }); await connected; const [pc] = FakePeerConnection.instances; expect(pc.config.iceServers).toEqual(CLOUDFLARE_ICE_SERVERS); expect(pc.calls).toEqual(['datachannel:messages', 'offer']); expect(ws.sent.map((m) => m.type)).toEqual(['OFFER']); }); it('falls back to STUN when core does not send OPEN', async () => { const { connected, ws } = await start(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); ws.open(); await vi.advanceTimersByTimeAsync(3000); await connected; const [pc] = FakePeerConnection.instances; expect(pc.config.iceServers).toEqual([{ urls: 'stun:stun.cloudflare.com:3478' }]); expect(ws.sent.map((m) => m.type)).toEqual(['OFFER']); }); it('does not time out while the socket is still connecting', async () => { const { connected, ws } = await start(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); await vi.advanceTimersByTimeAsync(10000); expect(FakePeerConnection.instances).toHaveLength(0); ws.open(); ws.receive({ type: 'OPEN', payload: { iceServers: CLOUDFLARE_ICE_SERVERS } }); await connected; expect(FakePeerConnection.instances[0].config.iceServers).toEqual(CLOUDFLARE_ICE_SERVERS); }); });