test: Add 8 people voice tests
This commit is contained in:
@@ -5,23 +5,15 @@ import {
|
||||
type BrowserContext,
|
||||
type Browser
|
||||
} from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
||||
import { startTestServer, type TestServerHandle } from '../helpers/test-server';
|
||||
|
||||
export interface Client {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
}
|
||||
|
||||
interface TestServerHandle {
|
||||
port: number;
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface MultiClientFixture {
|
||||
createClient: () => Promise<Client>;
|
||||
testServer: TestServerHandle;
|
||||
@@ -31,10 +23,9 @@ const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
|
||||
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
|
||||
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`,
|
||||
'--autoplay-policy=no-user-gesture-required'
|
||||
];
|
||||
const E2E_DIR = join(__dirname, '..');
|
||||
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
||||
|
||||
export const test = base.extend<MultiClientFixture>({
|
||||
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
||||
@@ -81,122 +72,3 @@ export const test = base.extend<MultiClientFixture>({
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
const port = await allocatePort();
|
||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
||||
cwd: E2E_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
TEST_SERVER_PORT: String(port)
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
process.stdout.write(chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServerReady(port, child);
|
||||
} catch (error) {
|
||||
await stopServer(child);
|
||||
|
||||
if (attempt < retries) {
|
||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
stop: async () => {
|
||||
await stopServer(child);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('startTestServer: unreachable');
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
probe.close();
|
||||
reject(new Error('Failed to resolve an ephemeral test server port'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { port } = address;
|
||||
|
||||
probe.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
||||
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(readyUrl);
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server still starting.
|
||||
}
|
||||
|
||||
await wait(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for test server on port ${port}`);
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
|
||||
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
||||
|
||||
if (!exited && child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await once(child, 'exit');
|
||||
}
|
||||
}
|
||||
|
||||
function wait(durationMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@ import { type BrowserContext, type Page } from '@playwright/test';
|
||||
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||
|
||||
export interface SeededEndpointInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface SeededEndpointStorageState {
|
||||
key: string;
|
||||
removedKey: string;
|
||||
@@ -17,21 +26,32 @@ interface SeededEndpointStorageState {
|
||||
}
|
||||
|
||||
function buildSeededEndpointStorageState(
|
||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
endpointsOrPort: ReadonlyArray<SeededEndpointInput> | number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
): SeededEndpointStorageState {
|
||||
const endpoint = {
|
||||
const endpoints = Array.isArray(endpointsOrPort)
|
||||
? endpointsOrPort.map((endpoint) => ({
|
||||
id: endpoint.id,
|
||||
name: endpoint.name,
|
||||
url: endpoint.url,
|
||||
isActive: endpoint.isActive ?? true,
|
||||
isDefault: endpoint.isDefault ?? false,
|
||||
status: endpoint.status ?? 'unknown'
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 'e2e-test-server',
|
||||
name: 'E2E Test Server',
|
||||
url: `http://localhost:${port}`,
|
||||
url: `http://localhost:${endpointsOrPort}`,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
||||
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
||||
endpoints: [endpoint]
|
||||
endpoints
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +79,15 @@ export async function installTestServerEndpoint(
|
||||
await context.addInitScript(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
|
||||
export async function installTestServerEndpoints(
|
||||
context: BrowserContext,
|
||||
endpoints: ReadonlyArray<SeededEndpointInput>
|
||||
): Promise<void> {
|
||||
const storageState = buildSeededEndpointStorageState(endpoints);
|
||||
|
||||
await context.addInitScript(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed localStorage with a single signal endpoint pointing at the test server.
|
||||
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
|
||||
@@ -79,3 +108,12 @@ export async function seedTestServerEndpoint(
|
||||
|
||||
await page.evaluate(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
|
||||
export async function seedTestServerEndpoints(
|
||||
page: Page,
|
||||
endpoints: ReadonlyArray<SeededEndpointInput>
|
||||
): Promise<void> {
|
||||
const storageState = buildSeededEndpointStorageState(endpoints);
|
||||
|
||||
await page.evaluate(applySeededEndpointStorageState, storageState);
|
||||
}
|
||||
|
||||
132
e2e/helpers/test-server.ts
Normal file
132
e2e/helpers/test-server.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface TestServerHandle {
|
||||
port: number;
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const E2E_DIR = join(__dirname, '..');
|
||||
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
||||
|
||||
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
const port = await allocatePort();
|
||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
||||
cwd: E2E_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
TEST_SERVER_PORT: String(port)
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
process.stdout.write(chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServerReady(port, child);
|
||||
} catch (error) {
|
||||
await stopServer(child);
|
||||
|
||||
if (attempt < retries) {
|
||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
stop: async () => {
|
||||
await stopServer(child);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('startTestServer: unreachable');
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
probe.close();
|
||||
reject(new Error('Failed to resolve an ephemeral test server port'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { port } = address;
|
||||
|
||||
probe.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
||||
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(readyUrl);
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server still starting.
|
||||
}
|
||||
|
||||
await wait(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for test server on port ${port}`);
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
|
||||
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
||||
|
||||
if (!exited && child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await once(child, 'exit');
|
||||
}
|
||||
}
|
||||
|
||||
function wait(durationMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
@@ -46,75 +46,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||
|
||||
// Patch getUserMedia to use an AudioContext oscillator for audio
|
||||
// instead of the hardware capture device. Chromium's fake audio
|
||||
// device intermittently fails to produce frames after renegotiation.
|
||||
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
||||
const wantsAudio = !!constraints?.audio;
|
||||
|
||||
if (!wantsAudio) {
|
||||
return origGetUserMedia(constraints);
|
||||
}
|
||||
|
||||
// Get the original stream (may include video)
|
||||
const originalStream = await origGetUserMedia(constraints);
|
||||
const audioCtx = new AudioContext();
|
||||
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
|
||||
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
|
||||
}
|
||||
|
||||
const source = audioCtx.createBufferSource();
|
||||
const gain = audioCtx.createGain();
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
source.loop = true;
|
||||
gain.gain.value = 0.12;
|
||||
|
||||
const dest = audioCtx.createMediaStreamDestination();
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(dest);
|
||||
source.start();
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
try {
|
||||
await audioCtx.resume();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
||||
const resultStream = new MediaStream();
|
||||
|
||||
syntheticMediaResources.push({ audioCtx, source });
|
||||
|
||||
resultStream.addTrack(synthAudioTrack);
|
||||
|
||||
// Keep any video tracks from the original stream
|
||||
for (const videoTrack of originalStream.getVideoTracks()) {
|
||||
resultStream.addTrack(videoTrack);
|
||||
}
|
||||
|
||||
// Stop original audio tracks since we're not using them
|
||||
for (const track of originalStream.getAudioTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
|
||||
synthAudioTrack.addEventListener('ended', () => {
|
||||
try {
|
||||
source.stop();
|
||||
} catch {}
|
||||
|
||||
void audioCtx.close().catch(() => {});
|
||||
}, { once: true });
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
|
||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||
// picker dialog is never shown.
|
||||
@@ -218,6 +149,177 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the number of tracked peer connections in `connected` state. */
|
||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait until the expected number of peer connections are `connected`. */
|
||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume all suspended AudioContext instances created by the synthetic
|
||||
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
|
||||
* Chrome treats the call as a user-gesture — this satisfies the autoplay
|
||||
* policy that otherwise blocks `AudioContext.resume()`.
|
||||
*/
|
||||
export async function resumeSyntheticAudioContexts(page: Page): Promise<number> {
|
||||
const cdpSession = await page.context().newCDPSession(page);
|
||||
|
||||
try {
|
||||
const result = await cdpSession.send('Runtime.evaluate', {
|
||||
expression: `(async () => {
|
||||
const resources = window.__rtcSyntheticMediaResources;
|
||||
if (!resources) return 0;
|
||||
let resumed = 0;
|
||||
for (const r of resources) {
|
||||
if (r.audioCtx.state === 'suspended') {
|
||||
await r.audioCtx.resume();
|
||||
resumed++;
|
||||
}
|
||||
}
|
||||
return resumed;
|
||||
})()`,
|
||||
awaitPromise: true,
|
||||
userGesture: true
|
||||
});
|
||||
|
||||
return result.result.value ?? 0;
|
||||
} finally {
|
||||
await cdpSession.detach();
|
||||
}
|
||||
}
|
||||
|
||||
interface PerPeerAudioStat {
|
||||
connectionState: string;
|
||||
inboundBytes: number;
|
||||
inboundPackets: number;
|
||||
outboundBytes: number;
|
||||
outboundPackets: number;
|
||||
}
|
||||
|
||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const snapshots: PerPeerAudioStat[] = [];
|
||||
|
||||
for (const pc of connections) {
|
||||
let inboundBytes = 0;
|
||||
let inboundPackets = 0;
|
||||
let outboundBytes = 0;
|
||||
let outboundPackets = 0;
|
||||
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||
outboundBytes += report.bytesSent ?? 0;
|
||||
outboundPackets += report.packetsSent ?? 0;
|
||||
}
|
||||
|
||||
if (report.type === 'inbound-rtp' && kind === 'audio') {
|
||||
inboundBytes += report.bytesReceived ?? 0;
|
||||
inboundPackets += report.packetsReceived ?? 0;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Closed connection.
|
||||
}
|
||||
|
||||
snapshots.push({
|
||||
connectionState: pc.connectionState,
|
||||
inboundBytes,
|
||||
inboundPackets,
|
||||
outboundBytes,
|
||||
outboundPackets
|
||||
});
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until every connected peer connection shows inbound and outbound audio flow. */
|
||||
export async function waitForAllPeerAudioFlow(
|
||||
page: Page,
|
||||
expectedConnectedPeers: number,
|
||||
timeoutMs = 45_000,
|
||||
pollIntervalMs = 1_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// Track which peer indices have been confirmed flowing at least once.
|
||||
// This prevents a peer from being missed just because it briefly paused
|
||||
// during one specific poll interval.
|
||||
const confirmedFlowing = new Set<number>();
|
||||
|
||||
let previous = await getPerPeerAudioStats(page);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
const current = await getPerPeerAudioStats(page);
|
||||
const connectedPeers = current.filter((stat) => stat.connectionState === 'connected');
|
||||
|
||||
if (connectedPeers.length >= expectedConnectedPeers) {
|
||||
for (let index = 0; index < current.length; index++) {
|
||||
const curr = current[index];
|
||||
|
||||
if (!curr || curr.connectionState !== 'connected') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = previous[index] ?? {
|
||||
connectionState: 'new',
|
||||
inboundBytes: 0,
|
||||
inboundPackets: 0,
|
||||
outboundBytes: 0,
|
||||
outboundPackets: 0
|
||||
};
|
||||
const inboundFlowing = curr.inboundBytes > prev.inboundBytes || curr.inboundPackets > prev.inboundPackets;
|
||||
const outboundFlowing = curr.outboundBytes > prev.outboundBytes || curr.outboundPackets > prev.outboundPackets;
|
||||
|
||||
if (inboundFlowing && outboundFlowing) {
|
||||
confirmedFlowing.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if enough peers have been confirmed across all samples
|
||||
const connectedIndices = current
|
||||
.map((stat, idx) => stat.connectionState === 'connected' ? idx : -1)
|
||||
.filter((idx) => idx >= 0);
|
||||
const confirmedCount = connectedIndices.filter((idx) => confirmedFlowing.has(idx)).length;
|
||||
|
||||
if (confirmedCount >= expectedConnectedPeers) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
previous = current;
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${expectedConnectedPeers} peers with bidirectional audio flow`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbound and inbound audio RTP stats aggregated across all peer
|
||||
* connections. Uses a per-connection high water mark stored on `window` so
|
||||
|
||||
@@ -19,13 +19,65 @@ export class ChatRoomPage {
|
||||
|
||||
/** Click a voice channel by name in the channels sidebar to join voice. */
|
||||
async joinVoiceChannel(channelName: string) {
|
||||
const channelButton = this.page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: channelName, exact: true });
|
||||
const channelButton = this.getVoiceChannelButton(channelName);
|
||||
|
||||
if (await channelButton.count() === 0) {
|
||||
await this.refreshRoomMetadata();
|
||||
}
|
||||
|
||||
if (await channelButton.count() === 0) {
|
||||
// Second attempt - metadata might still be syncing
|
||||
await this.page.waitForTimeout(2_000);
|
||||
await this.refreshRoomMetadata();
|
||||
}
|
||||
|
||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||
await channelButton.click();
|
||||
}
|
||||
|
||||
/** Creates a voice channel if it is not already present in the current room. */
|
||||
async ensureVoiceChannelExists(channelName: string) {
|
||||
const channelButton = this.getVoiceChannelButton(channelName);
|
||||
|
||||
if (await channelButton.count() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshRoomMetadata();
|
||||
|
||||
// Wait a bit longer for Angular to render the channel list after refresh
|
||||
try {
|
||||
await expect(channelButton).toBeVisible({ timeout: 5_000 });
|
||||
return;
|
||||
} catch {
|
||||
// Channel genuinely doesn't exist - create it
|
||||
}
|
||||
|
||||
await this.openCreateVoiceChannelDialog();
|
||||
|
||||
try {
|
||||
await this.createChannel(channelName);
|
||||
} catch {
|
||||
// If the dialog didn't close (e.g. duplicate name validation), dismiss it
|
||||
const dialog = this.page.locator('app-confirm-dialog');
|
||||
|
||||
if (await dialog.isVisible()) {
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' });
|
||||
const closeButton = dialog.getByRole('button', { name: 'Close dialog' });
|
||||
|
||||
if (await cancelButton.isVisible()) {
|
||||
await cancelButton.click();
|
||||
} else if (await closeButton.isVisible()) {
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Click a text channel by name in the channels sidebar to switch chat rooms. */
|
||||
async joinTextChannel(channelName: string) {
|
||||
const channelButton = this.getTextChannelButton(channelName);
|
||||
@@ -100,6 +152,11 @@ export class ChatRoomPage {
|
||||
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
|
||||
}
|
||||
|
||||
/** Get the deafen toggle button inside voice controls. */
|
||||
get deafenButton() {
|
||||
return this.voiceControls.locator('button:has(ng-icon[name="lucideHeadphones"])').first();
|
||||
}
|
||||
|
||||
/** Get the disconnect/hang-up button (destructive styled). */
|
||||
get disconnectButton() {
|
||||
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
|
||||
@@ -112,10 +169,9 @@ export class ChatRoomPage {
|
||||
|
||||
/** Get the count of voice users listed under a voice channel. */
|
||||
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
|
||||
const channelSection = this.page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: channelName })
|
||||
.locator('..');
|
||||
const userAvatars = channelSection.locator('app-user-avatar');
|
||||
// The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper
|
||||
const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]');
|
||||
const userAvatars = channelWrapper.locator('app-user-avatar');
|
||||
|
||||
return userAvatars.count();
|
||||
}
|
||||
@@ -154,9 +210,11 @@ export class ChatRoomPage {
|
||||
}
|
||||
|
||||
private getTextChannelButton(channelName: string): Locator {
|
||||
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i');
|
||||
return this.channelsSidePanel.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
|
||||
}
|
||||
|
||||
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first();
|
||||
private getVoiceChannelButton(channelName: string): Locator {
|
||||
return this.channelsSidePanel.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`).first();
|
||||
}
|
||||
|
||||
private async createTextChannelThroughComponent(channelName: string): Promise<void> {
|
||||
@@ -384,7 +442,3 @@ export class ChatRoomPage {
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
export class ServerSearchPage {
|
||||
readonly searchInput: Locator;
|
||||
readonly createServerButton: Locator;
|
||||
readonly railCreateServerButton: Locator;
|
||||
readonly searchCreateServerButton: Locator;
|
||||
readonly settingsButton: Locator;
|
||||
|
||||
// Create server dialog
|
||||
@@ -21,7 +23,9 @@ export class ServerSearchPage {
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
||||
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.createServerButton = this.searchCreateServerButton;
|
||||
this.settingsButton = page.locator('button[title="Settings"]');
|
||||
|
||||
// Create dialog elements
|
||||
@@ -39,8 +43,20 @@ export class ServerSearchPage {
|
||||
await this.page.goto('/search');
|
||||
}
|
||||
|
||||
async createServer(name: string, options?: { description?: string; topic?: string }) {
|
||||
await this.createServerButton.click();
|
||||
async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
|
||||
if (!await this.serverNameInput.isVisible()) {
|
||||
if (await this.searchCreateServerButton.isVisible()) {
|
||||
await this.searchCreateServerButton.click();
|
||||
} else {
|
||||
await this.railCreateServerButton.click();
|
||||
|
||||
if (!await this.serverNameInput.isVisible()) {
|
||||
await expect(this.searchCreateServerButton).toBeVisible({ timeout: 10_000 });
|
||||
await this.searchCreateServerButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(this.serverNameInput).toBeVisible();
|
||||
await this.serverNameInput.fill(name);
|
||||
|
||||
@@ -52,6 +68,10 @@ export class ServerSearchPage {
|
||||
await this.serverTopicInput.fill(options.topic);
|
||||
}
|
||||
|
||||
if (options?.sourceId) {
|
||||
await this.signalEndpointSelect.selectOption(options.sourceId);
|
||||
}
|
||||
|
||||
await this.dialogCreateButton.click();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ['microphone', 'camera'],
|
||||
launchOptions: {
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
781
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal file
781
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
installTestServerEndpoints,
|
||||
type SeededEndpointInput
|
||||
} from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b';
|
||||
|
||||
// ── Room / channel names ─────────────────────────────────────────────
|
||||
const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`;
|
||||
const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`;
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
// ── User constants ───────────────────────────────────────────────────
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const USER_COUNT = 8;
|
||||
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
||||
const STABILITY_WINDOW_MS = 20_000;
|
||||
|
||||
// ── User signal configuration groups ─────────────────────────────────
|
||||
//
|
||||
// Group A (users 0-1): Both signal servers in network config (normal)
|
||||
// Group B (users 2-3): Only primary signal — secondary NOT in config.
|
||||
// They join the secondary room via invite link,
|
||||
// which auto-adds the endpoint.
|
||||
// Group C (users 4-5): Both signals initially, but secondary is removed
|
||||
// after registration. They still see the room from
|
||||
// search because the primary signal can discover it
|
||||
// via findServerAcrossActiveEndpoints fallback.
|
||||
// Group D (users 6-7): Only secondary signal in config. They join the
|
||||
// primary room via invite link.
|
||||
|
||||
type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
group: SignalGroup;
|
||||
}
|
||||
|
||||
type TestClient = Client & { user: TestUser };
|
||||
|
||||
function endpointsForGroup(
|
||||
group: SignalGroup,
|
||||
primaryUrl: string,
|
||||
secondaryUrl: string
|
||||
): SeededEndpointInput[] {
|
||||
switch (group) {
|
||||
case 'both':
|
||||
return [
|
||||
{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' },
|
||||
{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }
|
||||
];
|
||||
case 'primary-only':
|
||||
return [
|
||||
{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }
|
||||
];
|
||||
case 'both-then-remove-secondary':
|
||||
// Seed both initially; test will remove secondary after registration.
|
||||
return [
|
||||
{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' },
|
||||
{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }
|
||||
];
|
||||
case 'secondary-only':
|
||||
return [
|
||||
{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Mixed signal-config voice', () => {
|
||||
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
|
||||
createClient,
|
||||
testServer
|
||||
}) => {
|
||||
test.setTimeout(720_000);
|
||||
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const allEndpoints: SeededEndpointInput[] = [
|
||||
{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, status: 'online' },
|
||||
{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryServer.url, isActive: true, status: 'online' }
|
||||
];
|
||||
|
||||
const users = buildUsers();
|
||||
const clients: TestClient[] = [];
|
||||
|
||||
// ── Create clients with per-group endpoint configs ───────────
|
||||
for (const user of users) {
|
||||
const client = await createClient();
|
||||
const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url);
|
||||
|
||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
|
||||
clients.push({ ...client, user });
|
||||
}
|
||||
|
||||
// ── Register ─────────────────────────────────────────────────
|
||||
await test.step('Register each user on their configured signal endpoint', async () => {
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
const registrationEndpointId =
|
||||
client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID;
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.serverSelect.selectOption(registrationEndpointId);
|
||||
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Create rooms ────────────────────────────────────────────
|
||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||
// Use a "both" user (client 0) to create both rooms
|
||||
const searchPage = new ServerSearchPage(clients[0].page);
|
||||
|
||||
await searchPage.createServer(VOICE_ROOM_NAME, {
|
||||
description: 'Voice room on primary signal',
|
||||
sourceId: PRIMARY_SIGNAL_ID
|
||||
});
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await searchPage.createServer(SECONDARY_ROOM_NAME, {
|
||||
description: 'Chat room on secondary signal',
|
||||
sourceId: SECONDARY_SIGNAL_ID
|
||||
});
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
// ── Create invite links ─────────────────────────────────────
|
||||
//
|
||||
// Group B (primary-only) needs invite to secondary room.
|
||||
// Group D (secondary-only) needs invite to primary room.
|
||||
let primaryRoomInviteUrl: string;
|
||||
let secondaryRoomInviteUrl: string;
|
||||
|
||||
await test.step('Create invite links for cross-signal rooms', async () => {
|
||||
// Navigate to voice room to get its ID
|
||||
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
||||
const primaryRoomId = await getCurrentRoomId(clients[0].page);
|
||||
const userId = await getCurrentUserId(clients[0].page);
|
||||
|
||||
// Navigate to secondary room to get its ID
|
||||
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
|
||||
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
|
||||
|
||||
// Create invite for primary room (voice) via API
|
||||
const primaryInvite = await createInviteViaApi(
|
||||
testServer.url,
|
||||
primaryRoomId,
|
||||
userId,
|
||||
clients[0].user.displayName
|
||||
);
|
||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||
|
||||
// Create invite for secondary room (chat) via API
|
||||
const secondaryInvite = await createInviteViaApi(
|
||||
secondaryServer.url,
|
||||
secondaryRoomId,
|
||||
userId,
|
||||
clients[0].user.displayName
|
||||
);
|
||||
secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`;
|
||||
});
|
||||
|
||||
// ── Remove secondary endpoint for group C ───────────────────
|
||||
await test.step('Remove secondary signal from group C users', async () => {
|
||||
for (const client of clients.filter((c) => c.user.group === 'both-then-remove-secondary')) {
|
||||
await client.page.evaluate((primaryEndpoint) => {
|
||||
localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint]));
|
||||
}, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Join rooms ──────────────────────────────────────────────
|
||||
await test.step('All users join the voice room (some via search, some via invite)', async () => {
|
||||
for (const client of clients.slice(1)) {
|
||||
if (client.user.group === 'secondary-only') {
|
||||
// Group D: no primary signal → join voice room via invite
|
||||
await client.page.goto(primaryRoomInviteUrl);
|
||||
await waitForInviteJoin(client.page);
|
||||
} else {
|
||||
// Groups A, B, C: have primary signal → join via search
|
||||
await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate client 0 back to voice room
|
||||
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
|
||||
});
|
||||
|
||||
await test.step('All users also join the secondary chat room', async () => {
|
||||
for (const client of clients.slice(1)) {
|
||||
if (client.user.group === 'primary-only') {
|
||||
// Group B: no secondary signal → join chat room via invite
|
||||
await client.page.goto(secondaryRoomInviteUrl);
|
||||
await waitForInviteJoin(client.page);
|
||||
} else if (client.user.group === 'secondary-only') {
|
||||
// Group D: has secondary → join via search
|
||||
await openSearchView(client.page);
|
||||
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
||||
} else {
|
||||
// Groups A, C: can search
|
||||
await openSearchView(client.page);
|
||||
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure everyone navigates back to voice room
|
||||
for (const client of clients) {
|
||||
await openSavedRoomByName(client.page, VOICE_ROOM_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Voice channel ───────────────────────────────────────────
|
||||
await test.step('Create voice channel and join all 8 users', async () => {
|
||||
const hostRoom = new ChatRoomPage(clients[0].page);
|
||||
|
||||
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Audio mesh ──────────────────────────────────────────────
|
||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAudioStatsPresent(client.page, 30_000)
|
||||
));
|
||||
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
));
|
||||
});
|
||||
|
||||
// ── Voice workspace roster ──────────────────────────────────
|
||||
await test.step('Voice workspace shows all 8 users on every client', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Stability + concurrent chat ─────────────────────────────
|
||||
await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => {
|
||||
// Pick 2 users from different groups to navigate away and chat
|
||||
const chatters = [clients[2], clients[6]]; // group C + group D
|
||||
const stayers = clients.filter((c) => !chatters.includes(c));
|
||||
|
||||
// Chatters navigate to secondary room and send messages
|
||||
for (const chatter of chatters) {
|
||||
await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME);
|
||||
await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
const chatPage0 = new ChatMessagesPage(chatters[0].page);
|
||||
const chatPage1 = new ChatMessagesPage(chatters[1].page);
|
||||
|
||||
await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`);
|
||||
await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`);
|
||||
|
||||
// Verify messages arrive
|
||||
await expect(
|
||||
chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Meanwhile stability loop on all clients (including chatters — voice still active)
|
||||
const deadline = Date.now() + STABILITY_WINDOW_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of stayers) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}
|
||||
|
||||
// Check chatters still have voice peers even while viewing another room
|
||||
for (const chatter of chatters) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}
|
||||
|
||||
if (Date.now() < deadline) {
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate chatters back to voice room
|
||||
for (const chatter of chatters) {
|
||||
await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME);
|
||||
}
|
||||
|
||||
// Verify audio still flowing after stability window
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mute ────────────────────────────────────────────────────
|
||||
await test.step('Mute state propagates for every user across all clients', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
});
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Audio still flows on all peers after mute cycling', async () => {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Deafen ──────────────────────────────────────────────────
|
||||
await test.step('Deafen state propagates for every user across all clients', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: true
|
||||
});
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
// Un-deafen does NOT restore mute – user stays muted
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── User builders ────────────────────────────────────────────────────
|
||||
|
||||
function buildUsers(): TestUser[] {
|
||||
const groups: SignalGroup[] = [
|
||||
'both', 'both', // 0-1
|
||||
'primary-only', 'primary-only', // 2-3
|
||||
'both-then-remove-secondary', 'both-then-remove-secondary', // 4-5
|
||||
'secondary-only', 'secondary-only' // 6-7
|
||||
];
|
||||
|
||||
return groups.map((group, index) => ({
|
||||
username: `mixed_sig_${Date.now()}_${index + 1}`,
|
||||
displayName: `Mixed User ${index + 1}`,
|
||||
password: USER_PASSWORD,
|
||||
group
|
||||
}));
|
||||
}
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function createInviteViaApi(
|
||||
serverBaseUrl: string,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
displayName: string
|
||||
): Promise<{ id: string }> {
|
||||
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
requesterUserId: userId,
|
||||
requesterDisplayName: displayName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return await response.json() as { id: string };
|
||||
}
|
||||
|
||||
async function getCurrentRoomId(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
interface RoomShape { id: 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) {
|
||||
throw new Error('Angular debug API unavailable');
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.();
|
||||
|
||||
if (!currentRoom?.id) {
|
||||
throw new Error('No current room');
|
||||
}
|
||||
|
||||
return currentRoom.id;
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentUserId(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UserShape {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
throw new Error('Angular debug API unavailable');
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('Current user not found');
|
||||
}
|
||||
|
||||
return user.id;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation helpers ───────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function openSearchView(page: Page): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.locator('button[title="Create Server"]').click();
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||
|
||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||
await roomCard.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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function waitForInviteJoin(page: Page): Promise<void> {
|
||||
// Invite page loads → auto-joins → redirects to room
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first();
|
||||
|
||||
await expect(viewButton).toBeVisible({ timeout: 10_000 });
|
||||
await viewButton.click();
|
||||
}
|
||||
|
||||
// ── Voice helpers ────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error([
|
||||
`Failed to connect ${page.url()} to voice channel ${channelName}.`,
|
||||
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable'
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Roster / state helpers ───────────────────────────────────────────
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => Array<unknown>) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||
interface RoomShape { 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 channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array<unknown>) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: ReadonlyArray<TestClient>,
|
||||
displayName: string,
|
||||
expectedState: { isMuted: boolean; isDeafened: boolean }
|
||||
): Promise<void> {
|
||||
for (const client of clients) {
|
||||
await client.page.waitForFunction(
|
||||
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
|
||||
interface VoiceStateShape { isMuted?: boolean; isDeafened?: boolean }
|
||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
|
||||
interface RoomShape { 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 voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice');
|
||||
|
||||
if (!voiceChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||
const entry = roster.find((u) => u.displayName === expectedDisplayName);
|
||||
|
||||
return entry?.voiceState?.isMuted === expectedMuted
|
||||
&& entry?.voiceState?.isDeafened === expectedDeafened;
|
||||
},
|
||||
{
|
||||
expectedDisplayName: displayName,
|
||||
expectedMuted: expectedState.isMuted,
|
||||
expectedDeafened: expectedState.isDeafened
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
761
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal file
761
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
installTestServerEndpoints,
|
||||
type SeededEndpointInput
|
||||
} from '../../helpers/seed-test-endpoint';
|
||||
import { startTestServer } from '../../helpers/test-server';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||
const PRIMARY_ROOM_NAME = `Dual Signal Voice A ${Date.now()}`;
|
||||
const SECONDARY_ROOM_NAME = `Dual Signal Voice B ${Date.now()}`;
|
||||
const VOICE_CHANNEL = 'General';
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const USER_COUNT = 8;
|
||||
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
||||
const STABILITY_WINDOW_MS = 20_000;
|
||||
|
||||
type TestUser = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type TestClient = Client & {
|
||||
user: TestUser;
|
||||
};
|
||||
|
||||
test.describe('Dual-signal multi-user voice', () => {
|
||||
test('keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds', async ({
|
||||
createClient,
|
||||
testServer
|
||||
}) => {
|
||||
test.setTimeout(720_000);
|
||||
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const endpoints: SeededEndpointInput[] = [
|
||||
{
|
||||
id: PRIMARY_SIGNAL_ID,
|
||||
name: 'E2E Signal A',
|
||||
url: testServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
},
|
||||
{
|
||||
id: SECONDARY_SIGNAL_ID,
|
||||
name: 'E2E Signal B',
|
||||
url: secondaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
const users = buildUsers();
|
||||
const clients = await createTrackedClients(createClient, users, endpoints);
|
||||
|
||||
await test.step('Register every user with both active endpoints available', async () => {
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||||
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create primary and secondary rooms on different signal endpoints', async () => {
|
||||
const searchPage = new ServerSearchPage(clients[0].page);
|
||||
|
||||
await searchPage.createServer(PRIMARY_ROOM_NAME, {
|
||||
description: 'Primary signal room for 8-user voice mesh',
|
||||
sourceId: PRIMARY_SIGNAL_ID
|
||||
});
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await searchPage.createServer(SECONDARY_ROOM_NAME, {
|
||||
description: 'Secondary signal room for dual-socket coverage',
|
||||
sourceId: SECONDARY_SIGNAL_ID
|
||||
});
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Every user joins both rooms to keep 2 signal sockets open', async () => {
|
||||
for (const client of clients.slice(1)) {
|
||||
await joinRoomFromSearch(client.page, PRIMARY_ROOM_NAME);
|
||||
}
|
||||
|
||||
for (const client of clients.slice(1)) {
|
||||
await openSearchView(client.page);
|
||||
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await openSavedRoomByName(client.page, PRIMARY_ROOM_NAME);
|
||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create voice channel and join all 8 users', async () => {
|
||||
const hostRoom = new ChatRoomPage(clients[0].page);
|
||||
|
||||
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||
// Wait for all clients to have at least one connected peer (fast)
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
));
|
||||
|
||||
// Wait for all clients to have all 7 peers connected
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
));
|
||||
|
||||
// Wait for audio stats to appear on all clients
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAudioStatsPresent(client.page, 30_000)
|
||||
));
|
||||
|
||||
// Allow the mesh to settle — voice routing, allowed-peer-id
|
||||
// propagation and renegotiation all need time after the last
|
||||
// user joins.
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
|
||||
// Check bidirectional audio flow on each client
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
));
|
||||
});
|
||||
|
||||
await test.step('Voice workspace and side panel show all 8 users on every client', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Voice stays stable for more than 20 seconds across both signals', async () => {
|
||||
const deadline = Date.now() + STABILITY_WINDOW_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of clients) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(2);
|
||||
}
|
||||
|
||||
if (Date.now() < deadline) {
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Mute state propagates for every user across all clients', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
});
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Audio still flows on all peers after mute cycling', async () => {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Deafen state propagates for every user across all clients', async () => {
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: true
|
||||
});
|
||||
|
||||
await room.deafenButton.click();
|
||||
await client.page.waitForTimeout(500);
|
||||
// Un-deafen does NOT restore mute – the user stays muted
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: true,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
|
||||
// Every user is left muted after deafen cycling — unmute them all
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.muteButton.click();
|
||||
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
});
|
||||
}
|
||||
|
||||
// Final audio flow check on every peer — confirms the full
|
||||
// send/receive pipeline still works after mute+deafen cycling
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function buildUsers(): TestUser[] {
|
||||
return Array.from({ length: USER_COUNT }, (_value, index) => ({
|
||||
username: `voice8_user_${Date.now()}_${index + 1}`,
|
||||
displayName: `Voice User ${index + 1}`,
|
||||
password: USER_PASSWORD
|
||||
}));
|
||||
}
|
||||
|
||||
async function createTrackedClients(
|
||||
createClient: () => Promise<Client>,
|
||||
users: TestUser[],
|
||||
endpoints: ReadonlyArray<SeededEndpointInput>
|
||||
): Promise<TestClient[]> {
|
||||
const clients: TestClient[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
const client = await createClient();
|
||||
|
||||
await installTestServerEndpoints(client.context, endpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
user
|
||||
});
|
||||
}
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function openSearchView(page: Page): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.locator('button[title="Create Server"]').click();
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
||||
|
||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||
await roomCard.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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first();
|
||||
|
||||
if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(viewButton).toBeVisible({ timeout: 10_000 });
|
||||
await viewButton.click();
|
||||
}
|
||||
|
||||
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 diagnostics = await getVoiceJoinDiagnostics(page, channelName);
|
||||
const displayName = diagnostics.currentUser?.displayName ?? 'Unknown user';
|
||||
|
||||
throw new Error([
|
||||
`Failed to connect ${displayName} to voice channel ${channelName}.`,
|
||||
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable',
|
||||
`Current room: ${diagnostics.currentRoom?.name ?? 'none'} (${diagnostics.currentRoom?.id ?? 'n/a'})`,
|
||||
`Current user id: ${diagnostics.currentUser?.id ?? 'none'} / ${diagnostics.currentUser?.oderId ?? 'none'}`,
|
||||
`Current user voice state: ${JSON.stringify(diagnostics.currentUser?.voiceState ?? null)}`,
|
||||
`Voice channel id: ${diagnostics.voiceChannel?.id ?? 'missing'}`,
|
||||
`Visible voice roster: ${diagnostics.voiceUsers.join(', ') || 'none'}`,
|
||||
`Connected signaling managers: ${diagnostics.connectedSignalCount}`,
|
||||
`Local voice facade state: ${JSON.stringify(diagnostics.localVoiceState)}`,
|
||||
`Voice connection error: ${diagnostics.connectionErrorMessage ?? 'none'}`
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
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((channel) => channel.type === 'voice' && channel.name === name);
|
||||
const voiceState = currentUser?.voiceState;
|
||||
|
||||
return !!voiceChannel
|
||||
&& voiceState?.isConnected === true
|
||||
&& voiceState.roomId === voiceChannel.id
|
||||
&& voiceState.serverId === currentRoom.id;
|
||||
},
|
||||
channelName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise<{
|
||||
connectedSignalCount: number;
|
||||
connectionErrorMessage: string | null;
|
||||
currentRoom: { id?: string; name?: string } | null;
|
||||
currentUser: { id?: string; oderId?: string; displayName?: string; voiceState?: Record<string, unknown> } | null;
|
||||
localVoiceState: {
|
||||
isVoiceConnected: boolean;
|
||||
localStreamTracks: number;
|
||||
rawMicTracks: number;
|
||||
};
|
||||
voiceChannel: { id?: string; name?: string } | null;
|
||||
voiceUsers: string[];
|
||||
}> {
|
||||
return await page.evaluate((name) => {
|
||||
interface VoiceStateShape {
|
||||
isConnected?: boolean;
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
roomId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface UserShape {
|
||||
id?: string;
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
voiceState?: VoiceStateShape;
|
||||
}
|
||||
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
id?: string;
|
||||
name?: 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 {
|
||||
connectedSignalCount: 0,
|
||||
connectionErrorMessage: 'Angular debug API unavailable',
|
||||
currentRoom: null,
|
||||
currentUser: null,
|
||||
localVoiceState: {
|
||||
isVoiceConnected: false,
|
||||
localStreamTracks: 0,
|
||||
rawMicTracks: 0
|
||||
},
|
||||
voiceChannel: null,
|
||||
voiceUsers: []
|
||||
};
|
||||
}
|
||||
|
||||
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((channel) => channel.type === 'voice' && channel.name === name) ?? null;
|
||||
const voiceUsers = voiceChannel
|
||||
? ((component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [])
|
||||
.map((user) => user.displayName ?? 'Unknown user')
|
||||
: [];
|
||||
const voiceConnection = component['voiceConnection'] as {
|
||||
getLocalStream?: () => MediaStream | null;
|
||||
getRawMicStream?: () => MediaStream | null;
|
||||
isVoiceConnected?: () => boolean;
|
||||
} | undefined;
|
||||
const realtime = component['realtime'] as {
|
||||
connectionErrorMessage?: () => string | null;
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return {
|
||||
connectedSignalCount: realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0,
|
||||
connectionErrorMessage: realtime?.connectionErrorMessage?.() ?? null,
|
||||
currentRoom,
|
||||
currentUser,
|
||||
localVoiceState: {
|
||||
isVoiceConnected: voiceConnection?.isVoiceConnected?.() ?? false,
|
||||
localStreamTracks: voiceConnection?.getLocalStream?.()?.getTracks().length ?? 0,
|
||||
rawMicTracks: voiceConnection?.getRawMicStream?.()?.getTracks().length ?? 0
|
||||
},
|
||||
voiceChannel,
|
||||
voiceUsers
|
||||
};
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
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 realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
};
|
||||
} | undefined;
|
||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
|
||||
return countValue === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
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 0;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>;
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => Array<unknown>) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
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 channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array<unknown>) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: ReadonlyArray<TestClient>,
|
||||
displayName: string,
|
||||
expectedState: { isMuted: boolean; isDeafened: boolean }
|
||||
): Promise<void> {
|
||||
for (const client of clients) {
|
||||
await client.page.waitForFunction(
|
||||
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
|
||||
interface VoiceStateShape {
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
}
|
||||
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
}
|
||||
|
||||
interface UserShape {
|
||||
displayName: string;
|
||||
voiceState?: VoiceStateShape;
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
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 voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
|
||||
|
||||
if (!voiceChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||
const entry = roster.find((user) => user.displayName === expectedDisplayName);
|
||||
|
||||
return entry?.voiceState?.isMuted === expectedMuted
|
||||
&& entry?.voiceState?.isDeafened === expectedDeafened;
|
||||
},
|
||||
{
|
||||
expectedDisplayName: displayName,
|
||||
expectedMuted: expectedState.isMuted,
|
||||
expectedDeafened: expectedState.isDeafened
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ export class VoiceConnectionFacade {
|
||||
return this.realtime.getRawMicStream();
|
||||
}
|
||||
|
||||
reportConnectionError(message: string): void {
|
||||
this.realtime.reportConnectionError(message);
|
||||
}
|
||||
|
||||
clearConnectionError(): void {
|
||||
this.realtime.clearConnectionError();
|
||||
}
|
||||
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
return await this.realtime.enableVoice();
|
||||
}
|
||||
|
||||
@@ -235,8 +235,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.voicePlayback.playPendingStreams(this.playbackOptions());
|
||||
|
||||
// Persist settings after successful connection
|
||||
this.webrtcService.clearConnectionError();
|
||||
this.saveSettings();
|
||||
} catch (_error) {}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect voice session.';
|
||||
|
||||
this.webrtcService.reportConnectionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Retry connection when there's a connection error
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
|
||||
(click)="selectTextChannel(ch.id)"
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
data-channel-type="text"
|
||||
[attr.data-channel-name]="ch.name"
|
||||
>
|
||||
<span class="text-muted-foreground text-base">#</span>
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
@@ -129,6 +131,8 @@
|
||||
[class.bg-secondary]="isCurrentRoom(ch.id)"
|
||||
[disabled]="!voiceEnabled()"
|
||||
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
|
||||
data-channel-type="voice"
|
||||
[attr.data-channel-name]="ch.name"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<ng-icon
|
||||
|
||||
@@ -559,23 +559,32 @@ export class RoomsSidePanelComponent {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) {
|
||||
this.voiceConnection.clearConnectionError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room || !this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
|
||||
if (!room) {
|
||||
this.voiceConnection.reportConnectionError('No active room selected for voice join.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
|
||||
this.voiceConnection.reportConnectionError('You do not have permission to join this voice channel.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
|
||||
this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch(() => undefined);
|
||||
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.voiceConnection.clearConnectionError();
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.trackCurrentUserMic();
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
@@ -583,6 +592,14 @@ export class RoomsSidePanelComponent {
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private handleVoiceJoinFailure(error: unknown): void {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to join voice channel.';
|
||||
|
||||
this.voiceConnection.reportConnectionError(message);
|
||||
}
|
||||
|
||||
private trackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
const micStream = this.voiceConnection.getRawMicStream();
|
||||
|
||||
@@ -492,6 +492,14 @@ export class WebRTCService implements OnDestroy {
|
||||
return this.peerMediaFacade.getRawMicStream();
|
||||
}
|
||||
|
||||
reportConnectionError(message: string): void {
|
||||
this.state.setConnectionError(message);
|
||||
}
|
||||
|
||||
clearConnectionError(): void {
|
||||
this.state.clearConnectionError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request microphone access and start sending audio to all peers.
|
||||
*
|
||||
|
||||
@@ -120,6 +120,16 @@ export class WebRtcStateController {
|
||||
this._isNoiseReductionEnabled.set(enabled);
|
||||
}
|
||||
|
||||
setConnectionError(message: string | null): void {
|
||||
this._hasConnectionError.set(!!message);
|
||||
this._connectionErrorMessage.set(message);
|
||||
}
|
||||
|
||||
clearConnectionError(): void {
|
||||
this._hasConnectionError.set(false);
|
||||
this._connectionErrorMessage.set(null);
|
||||
}
|
||||
|
||||
setConnectedPeers(peers: string[]): void {
|
||||
this._connectedPeers.set(peers);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||
|
||||
[value]="displayNameDraft()"
|
||||
(input)="onDisplayNameInput($event)"
|
||||
(blur)="finishEdit('displayName')"
|
||||
@@ -67,7 +66,6 @@
|
||||
<textarea
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||
|
||||
[value]="descriptionDraft()"
|
||||
placeholder="Add a description"
|
||||
(input)="onDescriptionInput($event)"
|
||||
|
||||
Reference in New Issue
Block a user