- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine suspend; the reload client otherwise destroys the session under test. - `dev-peer.sh` plus a separate userdata dir runs a second local peer. - `tools/voice-probe.js` samples peer state and RTP counters from a live window, persisting to localStorage so a renderer reload cannot erase it. - e2e helpers for voice pairs, peer-role election, and a TURN relay. - Electron single-instance and dev-client-load decisions move into rules files with colocated specs.
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
import { expect, type Page } from '@playwright/test';
|
|
import type { Client } from '../fixtures/multi-client';
|
|
import { ChatRoomPage } from '../pages/chat-room.page';
|
|
import { RegisterPage } from '../pages/register.page';
|
|
import { ServerSearchPage } from '../pages/server-search.page';
|
|
import {
|
|
installAutoResumeAudioContext,
|
|
installWebRTCTracking,
|
|
waitForAudioStatsPresent,
|
|
waitForConnectedPeerCount,
|
|
waitForOpenDataChannelCount
|
|
} from './webrtc-helpers';
|
|
|
|
const PAIR_PASSWORD = 'TestPass123!';
|
|
|
|
export interface VoicePairClient extends Client {
|
|
displayName: string;
|
|
username: string;
|
|
}
|
|
|
|
/**
|
|
* Register two fresh users, put them in a new server, and connect both to one voice
|
|
* channel with the WebRTC tracking harness installed. Returns once both sides report a
|
|
* connected peer, an open data channel, and live audio stats.
|
|
*/
|
|
export async function createVoicePairInNewServer(
|
|
createClient: () => Promise<Client>,
|
|
serverName: string,
|
|
options: { channelName?: string; namePrefix?: string } = {}
|
|
): Promise<VoicePairClient[]> {
|
|
const channelName = options.channelName ?? 'General';
|
|
const namePrefix = options.namePrefix ?? 'Voice Pair';
|
|
const uniqueSuffix = Date.now();
|
|
const clients: VoicePairClient[] = [];
|
|
|
|
for (let index = 0; index < 2; index++) {
|
|
const client = await createClient();
|
|
|
|
await installDeterministicVoiceSettings(client.page);
|
|
await installWebRTCTracking(client.page);
|
|
await installAutoResumeAudioContext(client.page);
|
|
|
|
clients.push({
|
|
...client,
|
|
displayName: `${namePrefix} ${index + 1}`,
|
|
username: `voice_pair_${uniqueSuffix}_${index + 1}`
|
|
});
|
|
}
|
|
|
|
for (const client of clients) {
|
|
const registerPage = new RegisterPage(client.page);
|
|
|
|
await registerPage.goto();
|
|
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
|
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
|
}
|
|
|
|
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
|
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
|
|
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
|
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
|
|
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
|
|
|
|
for (const client of clients) {
|
|
const room = new ChatRoomPage(client.page);
|
|
|
|
await room.joinVoiceChannel(channelName);
|
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
|
}
|
|
|
|
for (const client of clients) {
|
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
|
await waitForAudioStatsPresent(client.page, 30_000);
|
|
}
|
|
|
|
return clients;
|
|
}
|
|
|
|
/** Pin voice settings so audio levels and codecs do not vary between runs. */
|
|
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
|
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
|
|
}));
|
|
});
|
|
}
|
|
|
|
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
|
const searchInput = page.getByPlaceholder('Search servers...');
|
|
|
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
|
await searchInput.fill(roomName);
|
|
|
|
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
|
|
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
|
await roomCard.dblclick();
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
|
await waitForCurrentRoomName(page, roomName);
|
|
}
|
|
|
|
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
|
const roomButton = page.locator(`button[title="${roomName}"]`);
|
|
|
|
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
|
await roomButton.click();
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
|
await waitForCurrentRoomName(page, roomName);
|
|
}
|
|
|
|
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
|
await page.waitForFunction(
|
|
(expectedRoomName) => {
|
|
interface RoomShape { name?: string }
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
return false;
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
|
|
return currentRoom?.name === expectedRoomName;
|
|
},
|
|
roomName,
|
|
{ timeout }
|
|
);
|
|
}
|
|
|
|
export async function joinVoiceChannelUntilConnected(
|
|
page: Page,
|
|
channelName: string,
|
|
attempts = 3
|
|
): Promise<void> {
|
|
const room = new ChatRoomPage(page);
|
|
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
await room.joinVoiceChannel(channelName);
|
|
|
|
try {
|
|
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
|
|
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
}
|
|
|
|
const lastErrorMessage = lastError instanceof Error
|
|
? `Last error: ${lastError.message}`
|
|
: 'Last error: unavailable';
|
|
|
|
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
|
|
}
|
|
|
|
export async function waitForLocalVoiceChannelConnection(
|
|
page: Page,
|
|
channelName: string,
|
|
timeout = 20_000
|
|
): Promise<void> {
|
|
await page.waitForFunction(
|
|
(name) => {
|
|
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
|
|
interface UserShape { voiceState?: VoiceStateShape }
|
|
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
|
interface RoomShape { id: string; channels?: ChannelShape[] }
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
return false;
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
|
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
|
|
const voiceState = currentUser?.voiceState;
|
|
|
|
return !!voiceChannel
|
|
&& voiceState?.isConnected === true
|
|
&& voiceState.roomId === voiceChannel.id
|
|
&& voiceState.serverId === currentRoom.id;
|
|
},
|
|
channelName,
|
|
{ timeout }
|
|
);
|
|
}
|