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
228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|