import { test, expect } from '../../fixtures/multi-client'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { installAutoResumeAudioContext, installWebRTCTracking, waitForConnectedPeerCount } from '../../helpers/webrtc-helpers'; const VOICE_SETTINGS = JSON.stringify({ inputVolume: 100, outputVolume: 100, audioBitrate: 96, latencyProfile: 'balanced', includeSystemAudio: false, noiseReduction: false, screenShareQuality: 'balanced', askScreenShareQuality: false }); /** * Seed deterministic voice settings on a page so noise reduction and * input gating don't interfere with the fake audio tone. */ async function seedVoiceSettings(page: import('@playwright/test').Page): Promise { await page.addInitScript((settings: string) => { localStorage.setItem('metoyou_voice_settings', settings); }, VOICE_SETTINGS); } /** * Close all of a client's RTCPeerConnections and prevent any * reconnection by sabotaging the SDP negotiation methods on the * prototype - new connections get created but can never complete ICE. * * Chromium doesn't fire `connectionstatechange` on programmatic * `close()`, so we dispatch the event manually so the app's recovery * code runs and updates the connected-peers signal. */ async function killAndBlockPeerConnections(page: import('@playwright/test').Page): Promise { await page.evaluate(() => { // Sabotage SDP methods so no NEW connections can negotiate. const proto = RTCPeerConnection.prototype; proto.createOffer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); proto.createAnswer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); proto.setLocalDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); proto.setRemoteDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); // Close every existing connection and manually fire the event // Chromium omits when close() is called from JS. const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []; for (const pc of connections) { try { pc.close(); pc.dispatchEvent(new Event('connectionstatechange')); } catch { /* already closed */ } } }); } test.describe('Connectivity warning', () => { test.describe.configure({ timeout: 180_000 }); test('shows warning icon when a peer loses all connections', async ({ createClient }) => { const suffix = `connwarn_${Date.now()}`; const serverName = `ConnWarn ${suffix}`; const alice = await createClient(); const bob = await createClient(); const charlie = await createClient(); // ── Install WebRTC tracking & AudioContext auto-resume ── for (const client of [ alice, bob, charlie ]) { await installWebRTCTracking(client.page); await installAutoResumeAudioContext(client.page); await seedVoiceSettings(client.page); } // ── Register all three users ── await test.step('Register Alice', async () => { const register = new RegisterPage(alice.page); await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Bob', async () => { const register = new RegisterPage(bob.page); await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Charlie', async () => { const register = new RegisterPage(charlie.page); await register.goto(); await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); }); // ── Create server and have everyone join ── await test.step('Alice creates a server', async () => { const search = new ServerSearchPage(alice.page); await search.createServer(serverName); }); await test.step('Bob joins the server', async () => { const search = new ServerSearchPage(bob.page); await search.searchInput.fill(serverName); const card = bob.page.locator('button', { hasText: serverName }).first(); await expect(card).toBeVisible({ timeout: 15_000 }); await card.click(); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); await test.step('Charlie joins the server', async () => { const search = new ServerSearchPage(charlie.page); await search.searchInput.fill(serverName); const card = charlie.page.locator('button', { hasText: serverName }).first(); await expect(card).toBeVisible({ timeout: 15_000 }); await card.click(); await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); const aliceRoom = new ChatRoomPage(alice.page); const bobRoom = new ChatRoomPage(bob.page); const charlieRoom = new ChatRoomPage(charlie.page); // ── Everyone joins voice ── await test.step('All three join voice', async () => { await aliceRoom.joinVoiceChannel('General'); await bobRoom.joinVoiceChannel('General'); await charlieRoom.joinVoiceChannel('General'); }); await test.step('All users see each other in voice', async () => { // Each user should see the other two in the voice channel list. await expect( aliceRoom.channelsSidePanel.getByText('Bob') ).toBeVisible({ timeout: 20_000 }); await expect( aliceRoom.channelsSidePanel.getByText('Charlie') ).toBeVisible({ timeout: 20_000 }); await expect( bobRoom.channelsSidePanel.getByText('Alice') ).toBeVisible({ timeout: 20_000 }); await expect( bobRoom.channelsSidePanel.getByText('Charlie') ).toBeVisible({ timeout: 20_000 }); await expect( charlieRoom.channelsSidePanel.getByText('Alice') ).toBeVisible({ timeout: 20_000 }); await expect( charlieRoom.channelsSidePanel.getByText('Bob') ).toBeVisible({ timeout: 20_000 }); }); // ── Wait for full mesh to establish ── await test.step('All peer connections establish', async () => { // Each client should have 2 connected peers (full mesh of 3). await waitForConnectedPeerCount(alice.page, 2, 30_000); await waitForConnectedPeerCount(bob.page, 2, 30_000); await waitForConnectedPeerCount(charlie.page, 2, 30_000); }); // ── Break Charlie's connections ── await test.step('Kill Charlie peer connections and block reconnection', async () => { await killAndBlockPeerConnections(charlie.page); // Give the health service time to detect the desync. // Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates. await alice.page.waitForTimeout(15_000); }); // ── Assert connectivity warnings ── // // The warning icon (lucideAlertTriangle) is a direct sibling of the // user-name span inside the same voice-row div. Using the CSS // general-sibling combinator (~) avoids accidentally matching a // parent container that holds multiple rows. await test.step('Alice sees warning icon next to Charlie', async () => { const charlieWarning = aliceRoom.channelsSidePanel .locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]'); await expect(charlieWarning).toBeVisible({ timeout: 30_000 }); }); await test.step('Bob sees warning icon next to Charlie', async () => { const charlieWarning = bobRoom.channelsSidePanel .locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]'); await expect(charlieWarning).toBeVisible({ timeout: 30_000 }); }); await test.step('Alice does NOT see warning icon next to Bob', async () => { const bobWarning = aliceRoom.channelsSidePanel .locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]'); await expect(bobWarning).not.toBeVisible(); }); await test.step('Charlie sees local desync banner', async () => { const desyncBanner = charlie.page.locator('text=You may have connectivity issues'); await expect(desyncBanner).toBeVisible({ timeout: 30_000 }); }); }); });