import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { MediaStreamHandler } from '../media-stream-handler'; import type { TwinTransport } from '../types'; /** * Unit tests for the sendonly media liveness backstop. * * A sendonly responder peer (one fan-out viewer) receives no media, so the frame * monitor never runs, and @roamhq/wrtc may never flip pc.connectionState when the * viewer silently vanishes (page reload/crash) — leaking its slot. The backstop * watches inbound transport activity via getStats and tears the peer down once a * proven-live connection goes silent past the timeout. * * We stub setInterval (to capture the monitor callback and fire it on demand) and * Date.now (to advance the clock), and feed a controllable fake getStats. */ const noopTransport: TwinTransport = { twinId: 'cam', sendMessage: async () => {}, subscribe: async () => {}, onMessage: () => {}, offMessage: () => {}, }; const candidatePair = (bytesReceived: number) => new Map([['cp', { type: 'candidate-pair', bytesReceived, packetsReceived: 0, responsesReceived: 0 }]]); describe('MediaStreamHandler sendonly liveness backstop', () => { let realSetInterval: typeof setInterval; let realClearInterval: typeof clearInterval; let realDateNow: typeof Date.now; let captured: Array<() => void>; let now: number; beforeEach(() => { captured = []; realSetInterval = global.setInterval; realClearInterval = global.clearInterval; realDateNow = Date.now; (global as any).setInterval = (fn: () => void) => { captured.push(fn); return 123 as any; }; (global as any).clearInterval = () => {}; now = 1_000_000; Date.now = () => now; }); afterEach(() => { global.setInterval = realSetInterval; global.clearInterval = realClearInterval; Date.now = realDateNow; }); /** Arm a sendonly fan-out handler whose pc serves stats from getStatsFn. */ function arm(getStatsFn: () => Map, onDisconnected: () => void) { const handler = new MediaStreamHandler( 'viewer-twin', false, // responder noopTransport, { mediaOptions: { direction: 'sendonly', localStream: { getTracks: () => [] } as any }, peerId: 'P1', selfManagedSignaling: true, }, 'default', ); handler.setCallbacks({ onDisconnected }); (handler as any).pcManager = { getPeerConnection: () => ({ connectionState: 'connected', getStats: async () => getStatsFn() }), }; (handler as any).handlePeerConnected(); // arms the monitor → captured return handler; } const fireMonitor = async () => { for (const fn of captured) await fn(); }; it('tears down a fan-out peer once a proven-live connection goes silent past the timeout', async () => { let bytes = 0; let disconnected = 0; arm( () => candidatePair(bytes), () => disconnected++, ); // Healthy: inbound grows on each check → clock keeps resetting, no teardown. for (let i = 0; i < 3; i++) { bytes += 1000; now += 5000; await fireMonitor(); } expect(disconnected).toBe(0); // Viewer vanishes: inbound frozen, advance past the 20s stale timeout. now += 25000; await fireMonitor(); expect(disconnected).toBe(1); }); it('never tears down a peer whose inbound keeps advancing', async () => { let bytes = 0; let disconnected = 0; arm( () => candidatePair(bytes), () => disconnected++, ); for (let i = 0; i < 12; i++) { bytes += 500; now += 5000; await fireMonitor(); } expect(disconnected).toBe(0); }); it('does not act when getStats exposes no inbound counters (no false positives)', async () => { let disconnected = 0; // Only an outbound counter (which always advances on a blind sender) — must be ignored. arm( () => new Map([['out', { type: 'outbound-rtp', bytesSent: 99999 }]]), () => disconnected++, ); for (let i = 0; i < 12; i++) { now += 5000; await fireMonitor(); } expect(disconnected).toBe(0); }); });