import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { dumpRtcDiagnostics, installAutoResumeAudioContext, installWebRTCTracking, waitForAllPeerAudioFlow, waitForAudioFlow, waitForAudioStatsPresent, waitForConnectedPeerCount, waitForInboundVideoFlow, waitForOutboundVideoFlow, waitForPeerConnected } from '../../helpers/webrtc-helpers'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint'; interface DirectCallScenario { alice: Client; bob: Client; charlie?: Client; aliceUserId: string; bobUserId: string; charlieUserId?: string; } interface AudioFlowDelta { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number; } interface VideoFlowDelta { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number; } const USER_PASSWORD = 'TestPass123!'; test.describe('Direct private calls', () => { test.describe.configure({ timeout: 240_000 }); test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => { const scenario = await createDirectCallScenario(createClient, { includeCharlie: true }); const callMessage = `Call chat ${uniqueName('msg')}`; const privateOnlyMessage = `Private before group ${uniqueName('msg')}`; const groupMessage = `Group call chat ${uniqueName('msg')}`; await test.step('Alice starts a call from the search people card', async () => { await disableLastViewedChatResume(scenario.alice.page); await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' }); await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 }); const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first(); await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 }); await bobPeopleCard.hover(); await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click(); await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 }); await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); }); await test.step('Alice starts sharing before Bob joins', async () => { await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click(); await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 }); }); await test.step('Bob receives a ringing call and the ring stops when he answers', async () => { await expect .poll(async () => await getCallAudioPlayCount(scenario.bob.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(0); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect .poll(async () => await getCallNotificationCount(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBeGreaterThan(0); await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 }); await scenario.bob.page.getByRole('button', { name: 'Join call' }).click(); await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(0); await expect .poll(async () => await getCallAudioPauseCount(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBeGreaterThan(0); }); await test.step('WebRTC connects and late-join screen share is visible', async () => { await waitForPeerConnected(scenario.alice.page, 45_000); await waitForPeerConnected(scenario.bob.page, 45_000); await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId); await expectParticipantConnected(scenario.alice.page, scenario.bobUserId); await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId); await expectParticipantConnected(scenario.bob.page, scenario.bobUserId); const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000); const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000); if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) { console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page))); console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page))); } expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share'); expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share'); await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 }); }); await test.step('Audio flows in both directions', async () => { await waitForAudioStatsPresent(scenario.alice.page, 30_000); await waitForAudioStatsPresent(scenario.bob.page, 30_000); const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000); const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000); if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) { console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page))); console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page))); } expectAudioFlow(aliceDelta, 'Alice direct call'); expectAudioFlow(bobDelta, 'Bob direct call'); }); await test.step('Adding a third participant converts the call chat to an empty group chat', async () => { if (!scenario.charlie || !scenario.charlieUserId) { throw new Error('Expected direct-call scenario to include Charlie.'); } const charlie = scenario.charlie; await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage); await scenario.alice.page.getByTestId('dm-input').press('Enter'); await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 }); await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId); await scenario.alice.page.getByRole('button', { name: 'Add user' }).click(); await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, { timeout: 20_000 }); await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 }); await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect .poll(async () => await getCallAudioPlayCount(charlie.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(0); await charlie.page.getByRole('button', { name: 'Open private call' }).click(); await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 }); await charlie.page.getByRole('button', { name: 'Join call' }).click(); await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 }); await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0); await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000); await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000); await waitForConnectedPeerCount(charlie.page, 2, 45_000); await Promise.all([ waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000), waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000), waitForAllPeerAudioFlow(charlie.page, 2, 45_000) ]); await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId); await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId); await expectParticipantConnected(charlie.page, scenario.aliceUserId); await expectParticipantConnected(charlie.page, scenario.bobUserId); await expectParticipantConnected(charlie.page, scenario.charlieUserId); await scenario.alice.page.getByTestId('dm-input').fill(groupMessage); await scenario.alice.page.getByTestId('dm-input').press('Enter'); await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 }); await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 }); }); await test.step('Private call streams can switch between all-stream and focused viewing', async () => { await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click(); await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 }); await expect .poll(async () => await privateCallGridStreamCount(scenario.bob.page), { timeout: 30_000, intervals: [500, 1_000] }) .toBeGreaterThanOrEqual(2); await scenario.bob.page .getByTestId('private-call-stream-grid') .locator('app-voice-workspace-stream-tile') .first() .click(); await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 }); await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera'); await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick(); await expect .poll(async () => await hasFullscreenElement(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(true); await exitFullscreen(scenario.bob.page); await expect .poll(async () => await hasFullscreenElement(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(false); await scenario.bob.page.getByTestId('private-call-show-all-streams').click(); await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 }); }); await test.step('Both clients show their own speaking indicator', async () => { await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, { timeout: 20_000 }); await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, { timeout: 20_000 }); }); await test.step('Private call layout does not require vertical scrolling', async () => { await expect .poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(true); await expect .poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(true); await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0); await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0); await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0); await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0); const originalWidth = await privateCallChatWidth(scenario.alice.page); const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer'); const box = await resizer.boundingBox(); expect(box, 'private call chat resizer should be measurable').not.toBeNull(); if (box) { await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20); await scenario.alice.page.mouse.down(); await scenario.alice.page.mouse.move(box.x - 96, box.y + 20); await scenario.alice.page.mouse.up(); } await expect .poll(async () => await privateCallChatWidth(scenario.alice.page), { timeout: 10_000, intervals: [250, 500] }) .toBeGreaterThan(originalWidth + 40); }); await test.step('Embedded call chat syncs and does not expose another call button', async () => { await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0); await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0); await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob'); await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 }); await scenario.alice.page.getByTestId('dm-input').fill(callMessage); await scenario.alice.page.getByTestId('dm-input').press('Enter'); await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 }); }); await test.step('Group chat call button rings every other participant', async () => { if (!scenario.charlie) { throw new Error('Expected direct-call scenario to include Charlie.'); } await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click(); await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click(); await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click(); await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page); const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page); await scenario.alice.page .locator('app-dm-chat header') .getByRole('button', { name: /Call/i }) .click(); await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 }); await expect .poll(async () => await getCallAudioPlayCount(scenario.bob.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(bobPlayCountBeforeGroupCall); await expect .poll(async () => await getCallAudioPlayCount(scenario.charlie.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(charliePlayCountBeforeGroupCall); await scenario.bob.page .getByRole('button', { name: 'Open private call' }) .last() .click(); await scenario.bob.page.getByRole('button', { name: 'Join call' }).click(); await expect .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(0); await scenario.charlie.page .getByRole('button', { name: 'Open private call' }) .last() .click(); await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click(); await expect .poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(0); }); }); test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => { const scenario = await createDirectCallScenario(createClient); await test.step('Unknown call routes render an inert empty state', async () => { await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' }); await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0); }); await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => { await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 }); await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click(); await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0); await expect .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(0); }); await test.step('Leaving an answered call clears local ringing and returns to DM', async () => { await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await scenario.bob.page.getByRole('button', { name: 'Join call' }).click(); await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 }); await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click(); await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { timeout: 10_000, intervals: [250, 500] }) .toBe(0); }); }); }); async function createDirectCallScenario( createClient: () => Promise, options: { includeCharlie?: boolean } = {} ): Promise { const suffix = uniqueName('direct-call'); const serverName = `Direct Call Server ${suffix}`; const alice = await createClient(); const bob = await createClient(); const charlie = options.includeCharlie ? await createClient() : undefined; await installDirectCallInstrumentation(alice.page); await installDirectCallInstrumentation(bob.page); if (charlie) { await installDirectCallInstrumentation(charlie.page); } await registerUser(alice.page, `alice_${suffix}`, 'Alice'); await registerUser(bob.page, `bob_${suffix}`, 'Bob'); if (charlie) { await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie'); } const aliceUserId = await getCurrentUserId(alice.page); const aliceSearch = new ServerSearchPage(alice.page); await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatMessagesPage(alice.page).waitForReady(); const bobSearch = new ServerSearchPage(bob.page); await bobSearch.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatMessagesPage(bob.page).waitForReady(); if (charlie) { const charlieSearch = new ServerSearchPage(charlie.page); await charlieSearch.joinServerFromSearch(serverName); await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatMessagesPage(charlie.page).waitForReady(); } const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null; await expect(bobRoomCard).toBeVisible({ timeout: 20_000 }); if (charlieRoomCard) { await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 }); } const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid'); const bobUserId = bobUserCardTestId?.replace('room-user-card-', ''); const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null; const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', ''); if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) { throw new Error('Expected direct-call scenario users to expose stable ids.'); } return { alice, bob, charlie, aliceUserId, bobUserId, charlieUserId }; } async function installDirectCallInstrumentation(page: Page): Promise { await installWebRTCTracking(page); await installAutoResumeAudioContext(page); await page.addInitScript(() => { localStorage.setItem( 'metoyou_voice_settings', JSON.stringify({ inputVolume: 100, outputVolume: 100, audioBitrate: 96, latencyProfile: 'balanced', includeSystemAudio: false, noiseReduction: false, screenShareQuality: 'balanced', askScreenShareQuality: false }) ); const OriginalAudio = window.Audio; const callAudioState = { activeLoops: 0, pauseCount: 0, playCount: 0 }; const callNotificationState = { count: 0, bodies: [] as string[], titles: [] as string[] }; (window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState; (window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState; function isCallAudio(audio: HTMLAudioElement): boolean { return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav'); } (window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) { const audio = new OriginalAudio(src); const originalPlay = audio.play.bind(audio); const originalPause = audio.pause.bind(audio); audio.play = () => { if (isCallAudio(audio)) { callAudioState.playCount += 1; if (audio.loop) { callAudioState.activeLoops += 1; } } return originalPlay(); }; audio.pause = () => { if (isCallAudio(audio)) { callAudioState.pauseCount += 1; if (audio.loop && callAudioState.activeLoops > 0) { callAudioState.activeLoops -= 1; } } return originalPause(); }; return audio; } as typeof Audio; window.Audio.prototype = OriginalAudio.prototype; Object.setPrototypeOf(window.Audio, OriginalAudio); class MockNotification { static permission: NotificationPermission = 'granted'; onclick: ((this: Notification, ev: Event) => unknown) | null = null; constructor(title: string, options?: NotificationOptions) { callNotificationState.count += 1; callNotificationState.titles.push(title); callNotificationState.bodies.push(options?.body ?? ''); } static async requestPermission(): Promise { return 'granted'; } close(): void {} } (window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification; }); } async function registerUser(page: Page, username: string, displayName: string): Promise { const registerPage = new RegisterPage(page); await registerPage.goto(); await registerPage.register(username, displayName, USER_PASSWORD); await expect(page).toHaveURL(/\/search/, { timeout: 20_000 }); } async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise { await disableLastViewedChatResume(page); await page.goto('/search', { waitUntil: 'domcontentloaded' }); const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first(); await expect(peopleCard).toBeVisible({ timeout: 20_000 }); await peopleCard.hover(); await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click(); await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 }); } async function getCurrentUserId(page: Page): Promise { return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? ''); } async function getCallAudioPlayCount(page: Page): Promise { return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0); } async function getCallAudioPauseCount(page: Page): Promise { return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0); } async function getCallNotificationCount(page: Page): Promise { return await page.evaluate(() => ( window as Window & { __callNotificationState?: { count: number } } ).__callNotificationState?.count ?? 0); } async function getActiveCallAudioLoops(page: Page): Promise { return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0); } async function expectParticipantConnected(page: Page, userId: string | undefined): Promise { if (!userId) { throw new Error('Expected a stable participant id.'); } await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 }); } async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise { for (let sample = 0; sample < 3; sample++) { const [ senderAudio, receiverAudio, outboundVideo, inboundVideo ] = await Promise.all([ waitForAudioFlow(senderPage, 30_000), waitForAudioFlow(receiverPage, 30_000), waitForOutboundVideoFlow(senderPage, 30_000), waitForInboundVideoFlow(receiverPage, 30_000) ]); expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`); expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`); expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`); expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`); } } async function privateCallMainHasNoVerticalOverflow(page: Page): Promise { return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1); } async function privateCallGridStreamCount(page: Page): Promise { return await page .getByTestId('private-call-stream-grid') .locator('app-voice-workspace-stream-tile') .count(); } async function privateCallChatWidth(page: Page): Promise { return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width); } async function hasFullscreenElement(page: Page): Promise { return await page.evaluate(() => document.fullscreenElement !== null); } async function exitFullscreen(page: Page): Promise { await page.evaluate(async () => { if (document.fullscreenElement) { await document.exitFullscreen(); } }); } function expectAudioFlow(delta: AudioFlowDelta, label: string): void { expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true); expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true); } function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void { expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true); } function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void { expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true); } function isAudioFlowing(delta: AudioFlowDelta): boolean { return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) && (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0); } function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean { return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0; } function isInboundVideoFlowing(delta: VideoFlowDelta): boolean { return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0; } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; }