import { test, expect } from '../../fixtures/multi-client'; import { installWebRTCTracking, waitForPeerConnected, isPeerStillConnected, waitForAudioFlow, waitForAudioStatsPresent, waitForVideoFlow, waitForOutboundVideoFlow, waitForInboundVideoFlow, dumpRtcDiagnostics } from '../../helpers/webrtc-helpers'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; /** * Screen sharing E2E tests: verify video, screen-share audio, and voice audio * flow correctly between users during screen sharing. * * Uses the same dedicated-browser-per-client infrastructure as voice tests. * getDisplayMedia is monkey-patched to return a synthetic canvas video stream * + 880 Hz oscillator audio, bypassing the browser picker dialog. */ const ALICE = { username: `alice_ss_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' }; const BOB = { username: `bob_ss_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' }; const SERVER_NAME = `SS Test ${Date.now()}`; const VOICE_CHANNEL = 'General'; /** Register a user and navigate to /search. */ async function registerUser(page: import('@playwright/test').Page, user: typeof ALICE) { const registerPage = new RegisterPage(page); await registerPage.goto(); await expect(registerPage.submitButton).toBeVisible(); await registerPage.register(user.username, user.displayName, user.password); await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); } /** Both users register → Alice creates server → Bob joins. */ async function setupServerWithBothUsers( alice: { page: import('@playwright/test').Page }, bob: { page: import('@playwright/test').Page } ) { await registerUser(alice.page, ALICE); await registerUser(bob.page, BOB); // Alice creates server const aliceSearch = new ServerSearchPage(alice.page); await aliceSearch.createServer(SERVER_NAME, { description: 'Screen share E2E' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); // Bob joins server const bobSearch = new ServerSearchPage(bob.page); await bobSearch.searchInput.fill(SERVER_NAME); const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first(); await expect(serverCard).toBeVisible({ timeout: 10_000 }); await serverCard.click(); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); } /** Ensure voice channel exists and both users join it. */ async function joinVoiceTogether( alice: { page: import('@playwright/test').Page }, bob: { page: import('@playwright/test').Page } ) { const aliceRoom = new ChatRoomPage(alice.page); const existingChannel = alice.page .locator('app-rooms-side-panel') .getByRole('button', { name: VOICE_CHANNEL, exact: true }); if (await existingChannel.count() === 0) { await aliceRoom.openCreateVoiceChannelDialog(); await aliceRoom.createChannel(VOICE_CHANNEL); await expect(existingChannel).toBeVisible({ timeout: 10_000 }); } await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); const bobRoom = new ChatRoomPage(bob.page); await bobRoom.joinVoiceChannel(VOICE_CHANNEL); await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); // Wait for WebRTC + audio pipeline await waitForPeerConnected(alice.page, 30_000); await waitForPeerConnected(bob.page, 30_000); await waitForAudioStatsPresent(alice.page, 20_000); await waitForAudioStatsPresent(bob.page, 20_000); // Expand voice workspace on both clients so the demand-driven screen // share request flow can fire (requires connectRemoteShares = true). // Click the "VIEW" badge that appears next to the active voice channel. const aliceView = alice.page.locator('app-rooms-side-panel') .getByRole('button', { name: /view/i }) .first(); const bobView = bob.page.locator('app-rooms-side-panel') .getByRole('button', { name: /view/i }) .first(); await expect(aliceView).toBeVisible({ timeout: 10_000 }); await aliceView.click(); await expect(alice.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 }); await expect(bobView).toBeVisible({ timeout: 10_000 }); await bobView.click(); await expect(bob.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 }); // Re-verify audio stats are present after workspace expansion (the VIEW // click can trigger renegotiation which briefly disrupts audio). await waitForAudioStatsPresent(alice.page, 20_000); await waitForAudioStatsPresent(bob.page, 20_000); } function expectFlowing( delta: { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number }, label: string ) { expect( delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should be sending` ).toBe(true); expect( delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should be receiving` ).toBe(true); } test.describe('Screen sharing', () => { test('single user screen share: video and audio flow to receiver, voice audio continues', async ({ createClient }) => { test.setTimeout(180_000); const alice = await createClient(); const bob = await createClient(); await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); // ── Setup: register, server, voice ──────────────────────────── await test.step('Setup server and voice channel', async () => { await setupServerWithBothUsers(alice, bob); await joinVoiceTogether(alice, bob); }); // ── Verify voice audio before screen share ──────────────────── await test.step('Voice audio flows before screen share', async () => { const aliceDelta = await waitForAudioFlow(alice.page, 30_000); const bobDelta = await waitForAudioFlow(bob.page, 30_000); expectFlowing(aliceDelta, 'Alice voice'); expectFlowing(bobDelta, 'Bob voice'); }); // ── Alice starts screen sharing ─────────────────────────────── await test.step('Alice starts screen sharing', async () => { const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.startScreenShare(); // Screen share button should show active state (MonitorOff icon) await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 }); }); // ── Verify screen share video flows ─────────────────────────── await test.step('Screen share video flows from Alice to Bob', async () => { // Screen share is unidirectional: Alice sends video, Bob receives it. const aliceVideo = await waitForOutboundVideoFlow(alice.page, 30_000); const bobVideo = await waitForInboundVideoFlow(bob.page, 30_000); if (aliceVideo.outboundBytesDelta === 0 || bobVideo.inboundBytesDelta === 0) { console.log('[Alice RTC]\n' + await dumpRtcDiagnostics(alice.page)); console.log('[Bob RTC]\n' + await dumpRtcDiagnostics(bob.page)); } expect( aliceVideo.outboundBytesDelta > 0 || aliceVideo.outboundPacketsDelta > 0, 'Alice should be sending screen share video' ).toBe(true); expect( bobVideo.inboundBytesDelta > 0 || bobVideo.inboundPacketsDelta > 0, 'Bob should be receiving screen share video' ).toBe(true); }); // ── Verify voice audio continues during screen share ────────── await test.step('Voice audio continues during screen share', async () => { const aliceAudio = await waitForAudioFlow(alice.page, 20_000); const bobAudio = await waitForAudioFlow(bob.page, 20_000); expectFlowing(aliceAudio, 'Alice voice during screen share'); expectFlowing(bobAudio, 'Bob voice during screen share'); }); // ── Bob can hear Alice talk while she screen shares ─────────── await test.step('Bob receives audio from Alice during screen share', async () => { // Specifically check Bob is receiving audio (from Alice's voice) const bobAudio = await waitForAudioFlow(bob.page, 15_000); expect( bobAudio.inboundBytesDelta > 0, 'Bob should receive voice audio while Alice screen shares' ).toBe(true); }); // ── Alice stops screen sharing ──────────────────────────────── await test.step('Alice stops screen sharing', async () => { const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.stopScreenShare(); // Active icon should disappear - regular Monitor icon shown instead await expect( aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first() ).toBeVisible({ timeout: 10_000 }); }); // ── Voice audio still works after screen share ends ─────────── await test.step('Voice audio resumes normally after screen share stops', async () => { const aliceAudio = await waitForAudioFlow(alice.page, 20_000); const bobAudio = await waitForAudioFlow(bob.page, 20_000); expectFlowing(aliceAudio, 'Alice voice after screen share'); expectFlowing(bobAudio, 'Bob voice after screen share'); }); }); test('multiple users screen share simultaneously', async ({ createClient }) => { test.setTimeout(180_000); const alice = await createClient(); const bob = await createClient(); await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); await test.step('Setup server and voice channel', async () => { await setupServerWithBothUsers(alice, bob); await joinVoiceTogether(alice, bob); }); // ── Both users start screen sharing ─────────────────────────── await test.step('Alice starts screen sharing', async () => { const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.startScreenShare(); await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 }); }); await test.step('Bob starts screen sharing', async () => { const bobRoom = new ChatRoomPage(bob.page); await bobRoom.startScreenShare(); await expect(bobRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 }); }); // ── Verify video flows in both directions ───────────────────── await test.step('Video flows bidirectionally with both screen shares active', async () => { // Both sharing: each page sends and receives video const aliceVideo = await waitForVideoFlow(alice.page, 30_000); const bobVideo = await waitForVideoFlow(bob.page, 30_000); expectFlowing(aliceVideo, 'Alice screen share video'); expectFlowing(bobVideo, 'Bob screen share video'); }); // ── Voice audio continues with dual screen shares ───────────── await test.step('Voice audio continues with both users screen sharing', async () => { const aliceAudio = await waitForAudioFlow(alice.page, 20_000); const bobAudio = await waitForAudioFlow(bob.page, 20_000); expectFlowing(aliceAudio, 'Alice voice during dual screen share'); expectFlowing(bobAudio, 'Bob voice during dual screen share'); }); // ── Both stop screen sharing ────────────────────────────────── await test.step('Both users stop screen sharing', async () => { const aliceRoom = new ChatRoomPage(alice.page); const bobRoom = new ChatRoomPage(bob.page); await aliceRoom.stopScreenShare(); await expect( aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first() ).toBeVisible({ timeout: 10_000 }); await bobRoom.stopScreenShare(); await expect( bobRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first() ).toBeVisible({ timeout: 10_000 }); }); }); test('screen share connection stays stable for 10+ seconds', async ({ createClient }) => { test.setTimeout(180_000); const alice = await createClient(); const bob = await createClient(); await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); await test.step('Setup server and voice channel', async () => { await setupServerWithBothUsers(alice, bob); await joinVoiceTogether(alice, bob); }); await test.step('Alice starts screen sharing', async () => { const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.startScreenShare(); await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 }); // Wait for video pipeline to fully establish await waitForOutboundVideoFlow(alice.page, 30_000); await waitForInboundVideoFlow(bob.page, 30_000); }); // ── Stability checkpoints at 0s, 5s, 10s ───────────────────── await test.step('Connection stays stable for 10+ seconds during screen share', async () => { for (const checkpoint of [ 0, 5_000, 5_000 ]) { if (checkpoint > 0) { await alice.page.waitForTimeout(checkpoint); } const aliceConnected = await isPeerStillConnected(alice.page); const bobConnected = await isPeerStillConnected(bob.page); expect(aliceConnected, 'Alice should still be connected').toBe(true); expect(bobConnected, 'Bob should still be connected').toBe(true); } // After 10s - verify both video and audio still flowing const aliceVideo = await waitForOutboundVideoFlow(alice.page, 15_000); const bobVideo = await waitForInboundVideoFlow(bob.page, 15_000); expect( aliceVideo.outboundBytesDelta > 0, 'Alice still sending screen share video after 10s' ).toBe(true); expect( bobVideo.inboundBytesDelta > 0, 'Bob still receiving screen share video after 10s' ).toBe(true); const aliceAudio = await waitForAudioFlow(alice.page, 15_000); const bobAudio = await waitForAudioFlow(bob.page, 15_000); expectFlowing(aliceAudio, 'Alice voice after 10s screen share'); expectFlowing(bobAudio, 'Bob voice after 10s screen share'); }); // ── Clean disconnect ────────────────────────────────────────── await test.step('Alice stops screen share and disconnects', async () => { const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.stopScreenShare(); await aliceRoom.disconnectButton.click(); await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 }); }); }); });