import { expect, type Page } from '@playwright/test'; import { test } from '../../fixtures/multi-client'; import { countCreatedPeerConnections } from '../../helpers/peer-role'; import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session'; import { dumpRtcDiagnostics, getOpenDataChannelCount, getPerPeerAudioStats } from '../../helpers/webrtc-helpers'; type PeerAudioStats = Awaited>; /** Override for a quick check or a longer leak hunt: `SOAK_MINUTES=2 npx playwright test ...`. */ const SOAK_MINUTES = Number(process.env['SOAK_MINUTES'] ?? 30); const SAMPLE_INTERVAL_MS = 30_000; interface ResourceSnapshot { audioElements: number; heapMb: number; remoteTracks: number; } /** * A long call must not accumulate anything. Structural counters are the honest leak * signal here - a churning recovery loop shows up as extra remote tracks or audio * elements long before heap bytes say anything conclusive. */ async function readResources(page: Page): Promise { return page.evaluate(() => { interface HeapCapablePerformance extends Performance { memory?: { usedJSHeapSize: number }; } const usedHeap = (performance as HeapCapablePerformance).memory?.usedJSHeapSize ?? 0; const remoteTracks = (window as unknown as { __rtcRemoteTracks?: unknown[] }).__rtcRemoteTracks ?? []; return { audioElements: document.querySelectorAll('audio').length, heapMb: Math.round(usedHeap / (1_024 * 1_024)), remoteTracks: remoteTracks.length }; }); } function describeStats(stats: PeerAudioStats): string { return stats .map((stat) => `${stat.connectionState} in=${stat.inboundPackets} out=${stat.outboundPackets}`) .join(' | ') || 'no peers'; } test.describe('Long voice session', () => { test(`carries audio for ${SOAK_MINUTES} minutes without stalling, rebuilding, or accumulating`, async ({ createClient }) => { const soakMs = SOAK_MINUTES * 60_000; test.setTimeout(soakMs + 300_000); const clients = await createVoicePairInNewServer(createClient, `Voice Soak ${Date.now()}`, { namePrefix: 'Soak Voice' }); const baselineConnections = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page))); expect(baselineConnections, 'each client should start with exactly one peer connection').toEqual([1, 1]); const baselineResources = await Promise.all(clients.map((client) => readResources(client.page))); const previousStats: PeerAudioStats[] = await Promise.all( clients.map((client) => getPerPeerAudioStats(client.page)) ); const deadline = Date.now() + soakMs; const startedAt = Date.now(); let sampleIndex = 0; while (Date.now() < deadline) { await clients[0].page.waitForTimeout(SAMPLE_INTERVAL_MS); sampleIndex++; const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000); for (let index = 0; index < clients.length; index++) { const client = clients[index]; await assertClientStillHealthy(client, previousStats[index], elapsedSeconds); previousStats[index] = await getPerPeerAudioStats(client.page); } const resources = await Promise.all(clients.map((current) => readResources(current.page))); console.log( `[soak] sample ${sampleIndex} at +${elapsedSeconds}s: ` + clients .map((client, index) => `${client.displayName} heap=${resources[index].heapMb}MB` + ` audio=${resources[index].audioElements} tracks=${resources[index].remoteTracks}`) .join(', ') ); } await test.step('Nothing accumulated over the session', async () => { const finalResources = await Promise.all(clients.map((client) => readResources(client.page))); for (let index = 0; index < clients.length; index++) { const baseline = baselineResources[index]; const final = finalResources[index]; const label = clients[index].displayName; // A stable call fires `track` once per remote track; repeats mean the media path // was torn down and rebuilt behind the assertions above. expect(final.remoteTracks, `${label} gained remote tracks during the soak`).toBe(baseline.remoteTracks); expect(final.audioElements, `${label} accumulated audio elements`).toBeLessThanOrEqual(baseline.audioElements + 1); expect( final.heapMb, `${label} heap grew from ${baseline.heapMb}MB to ${final.heapMb}MB` ).toBeLessThan(baseline.heapMb * 3 + 200); } }); }); }); async function assertClientStillHealthy( client: VoicePairClient, previous: PeerAudioStats, elapsedSeconds: number ): Promise { const label = `${client.displayName} at +${elapsedSeconds}s`; try { const current = await getPerPeerAudioStats(client.page); const connected = current.filter((stat) => stat.connectionState === 'connected'); expect(connected, `${label}: expected exactly one connected peer, saw ${describeStats(current)}`).toHaveLength(1); const before = previous[0]; const now = current[0]; expect(now.inboundPackets, `${label}: inbound audio stalled`).toBeGreaterThan(before.inboundPackets); expect(now.outboundPackets, `${label}: outbound audio stalled`).toBeGreaterThan(before.outboundPackets); // A rebuild would restore audio within a sample or two, so the flow assertions above // cannot see it. Only the creation count can. expect( await countCreatedPeerConnections(client.page), `${label}: the peer connection was rebuilt mid-call` ).toBe(1); expect(await getOpenDataChannelCount(client.page), `${label}: the control channel is not open`).toBe(1); } catch (error) { console.log(`[soak] ${label} diagnostics:\n${await dumpRtcDiagnostics(client.page)}`); throw error; } }