feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
This commit is contained in:
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user