import { describe, expect, it } from 'bun:test'; import { MediaStreamHandler } from '../media-stream-handler'; import { MissingMediaTrackError, type MediaTrackKind, type TwinTransport } from '../types'; /** * Unit tests for MediaStreamOptions.kinds — the per-kind transceiver negotiation * on the receiving (initiator, no local stream) path. * * - Omitted kinds must reproduce the legacy behaviour exactly: one video * transceiver, and a connection that RESOLVES even when no track ever arrives * (existing camera consumers rely on this tolerance). * - Explicit kinds add one transceiver per kind, and every requested kind must * deliver a track within connectionTimeout or the connection rejects with * MissingMediaTrackError naming the missing kind — the silent-hang fix. * * We drive a fake RTCPeerConnection that flips itself to 'connected' right after * the initiator sets its local offer, and deliver remote tracks by invoking the * pc.ontrack handler directly. Dependency-injected transport, no module mocks — * matching the sibling one-shot-answer / sendonly-liveness tests. */ interface FakeTrack { id: string; kind: MediaTrackKind; readyState: 'live' | 'ended'; onended: (() => void) | null; stop(): void; } function makeFakeTrack(kind: MediaTrackKind, id: string): FakeTrack { return { id, kind, readyState: 'live', onended: null, stop(): void { this.readyState = 'ended'; }, }; } /** Minimal MediaStream stand-in for the handler's internal remoteStream. */ class FakeMediaStream { private tracks: FakeTrack[] = []; getTracks(): FakeTrack[] { return [...this.tracks]; } addTrack(track: FakeTrack): void { this.tracks.push(track); } removeTrack(track: FakeTrack): void { const trackIndex = this.tracks.findIndex((existingTrack) => existingTrack.id === track.id); if (trackIndex !== -1) { this.tracks.splice(trackIndex, 1); } } getTrackById(trackId: string): FakeTrack | null { return this.tracks.find((existingTrack) => existingTrack.id === trackId) ?? null; } } class FakeRTCPeerConnection { connectionState = 'new'; signalingState = 'stable'; iceGatheringState: RTCIceGatheringState = 'new'; onicecandidate: ((event: { candidate: unknown }) => void) | null = null; onicegatheringstatechange: (() => void) | null = null; oniceconnectionstatechange: (() => void) | null = null; onconnectionstatechange: (() => void) | null = null; ontrack: ((event: { track: FakeTrack }) => void) | null = null; remoteDescription: { type: string; sdp: string } | null = null; localDescription: { type: string; sdp: string } | null = null; transceiverKinds: string[] = []; addEventListener(): void {} removeEventListener(): void {} addTransceiver(kind: string): void { this.transceiverKinds.push(kind); } addTrack(): { replaceTrack: () => void } { return { replaceTrack: () => {} }; } getSenders(): unknown[] { return []; } async createOffer(): Promise<{ type: string; sdp: string }> { return { type: 'offer', sdp: 'v=0\r\no=- 111 2 IN IP4 0.0.0.0\r\ns=-\r\n' }; } async setLocalDescription(desc: { type: string; sdp: string }): Promise { this.localDescription = { type: desc.type, sdp: desc.sdp }; if (desc.type === 'offer') { // The remote "answers" instantly: flip to connected on the next macrotask // so the initiator's connect() resolves and setupMediaStream takes over. setTimeout(() => { this.connectionState = 'connected'; this.onconnectionstatechange?.(); }, 0); } } async setRemoteDescription(desc: { type: string; sdp: string }): Promise { this.remoteDescription = desc; } async getStats(): Promise> { return new Map(); } close(): void { this.connectionState = 'closed'; } } /** Install the fakes as globals; returns created pc instances + a restore(). */ function installFakeWebRTC(): { instances: FakeRTCPeerConnection[]; restore: () => void } { const instances: FakeRTCPeerConnection[] = []; class TrackingFakePeerConnection extends FakeRTCPeerConnection { constructor() { super(); instances.push(this); } } const globals = globalThis as { RTCPeerConnection?: unknown; MediaStream?: unknown }; const originalPeerConnection = globals.RTCPeerConnection; const originalMediaStream = globals.MediaStream; globals.RTCPeerConnection = TrackingFakePeerConnection; globals.MediaStream = FakeMediaStream; return { instances, restore: () => { globals.RTCPeerConnection = originalPeerConnection; globals.MediaStream = originalMediaStream; }, }; } function makeSilentTransport(): TwinTransport { return { twinId: 'consumer-twin', sendMessage: async () => {}, subscribe: async () => {}, onMessage: () => {}, offMessage: () => {}, }; } function makeReceivingHandler(kinds: MediaTrackKind[] | undefined, connectionTimeout: number): MediaStreamHandler { return new MediaStreamHandler( 'mic-twin', true, // initiator (receiving consumer, no local stream) makeSilentTransport(), { mediaOptions: { direction: 'recvonly', kinds }, connectionTimeout, }, 'default', ); } const flush = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('MediaStreamOptions.kinds', () => { it('defaults to a single video transceiver when kinds is omitted (legacy behaviour)', async () => { const fake = installFakeWebRTC(); const handler = makeReceivingHandler(undefined, 5000); try { const connectPromise = handler.connect(); await flush(20); const peerConnection = fake.instances[0]; expect(peerConnection.transceiverKinds).toEqual(['video']); peerConnection.ontrack?.({ track: makeFakeTrack('video', 'video-track-1') }); const stream = await connectPromise; expect(stream.getTracks().map((track) => track.kind)).toEqual(['video']); } finally { handler.close(); fake.restore(); } }); it('still resolves without any remote track when kinds is omitted (legacy tolerance)', async () => { const fake = installFakeWebRTC(); const handler = makeReceivingHandler(undefined, 5000); try { // No track is ever delivered; the legacy path logs and proceeds after ~1s. const stream = await handler.connect(); expect(stream.getTracks()).toEqual([]); } finally { handler.close(); fake.restore(); } }); it('negotiates an audio-only stream with kinds: ["audio"]', async () => { const fake = installFakeWebRTC(); const handler = makeReceivingHandler(['audio'], 5000); try { const connectPromise = handler.connect(); await flush(20); const peerConnection = fake.instances[0]; expect(peerConnection.transceiverKinds).toEqual(['audio']); peerConnection.ontrack?.({ track: makeFakeTrack('audio', 'audio-track-1') }); const stream = await connectPromise; expect(stream.getTracks().map((track) => track.kind)).toEqual(['audio']); } finally { handler.close(); fake.restore(); } }); it('negotiates both kinds simultaneously with kinds: ["audio", "video"]', async () => { const fake = installFakeWebRTC(); const handler = makeReceivingHandler(['audio', 'video'], 5000); try { const connectPromise = handler.connect(); await flush(20); const peerConnection = fake.instances[0]; expect(peerConnection.transceiverKinds).toEqual(['audio', 'video']); peerConnection.ontrack?.({ track: makeFakeTrack('video', 'video-track-1') }); peerConnection.ontrack?.({ track: makeFakeTrack('audio', 'audio-track-1') }); const stream = await connectPromise; expect( stream .getTracks() .map((track) => track.kind) .sort(), ).toEqual(['audio', 'video']); } finally { handler.close(); fake.restore(); } }); it('rejects with MissingMediaTrackError naming the kind the publisher never delivered', async () => { const fake = installFakeWebRTC(); // Short connectionTimeout so the enforcement window elapses quickly. const handler = makeReceivingHandler(['audio', 'video'], 400); try { const connectPromise = handler.connect(); await flush(20); const peerConnection = fake.instances[0]; // The publisher only has video (e.g. a camera asked for audio too). peerConnection.ontrack?.({ track: makeFakeTrack('video', 'video-track-1') }); let caught: unknown; try { await connectPromise; } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MissingMediaTrackError); expect((caught as MissingMediaTrackError).missingKinds).toEqual(['audio']); expect((caught as MissingMediaTrackError).message).toContain('audio'); // The failed handler must not linger half-connected. expect(handler.isHandlerClosed()).toBe(true); } finally { handler.close(); fake.restore(); } }); it('rejects with every missing kind when nothing arrives at all', async () => { const fake = installFakeWebRTC(); const handler = makeReceivingHandler(['audio', 'video'], 400); try { let caught: unknown; try { await handler.connect(); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MissingMediaTrackError); expect((caught as MissingMediaTrackError).missingKinds).toEqual(['audio', 'video']); } finally { handler.close(); fake.restore(); } }); });