chore: dev-stack switches, shared e2e harness, and desktop shell rules

- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+112
View File
@@ -0,0 +1,112 @@
import { expect, type Page } from '@playwright/test';
export interface PeerRoleEdge {
/** Remote peer id, in the identity space of the signal server routing that peer. */
peerId: string;
/** Our own actor id in that same identity space. */
localActorId: string | null;
isInitiator: boolean;
connectionState: string;
}
/**
* Read who elected themselves initiator for every active peer, together with the local
* actor id in that peer's identity space. Both halves of a pair must describe the same
* two ids, which is what makes the election comparable in the first place.
*/
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface PeerDataShape {
connection?: { connectionState?: string };
isInitiator?: boolean;
}
interface RealtimeShape {
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
signalingTransportHandler?: {
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
};
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return [];
}
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
const peers = realtime?.peerManager?.activePeerConnections;
if (!peers) {
return [];
}
const edges: PeerRoleEdge[] = [];
peers.forEach((peerData, peerId) => {
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
edges.push({
connectionState: peerData.connection?.connectionState ?? 'unknown',
isInitiator: peerData.isInitiator === true,
localActorId: credentials?.oderId ?? null,
peerId
});
});
return edges;
});
}
/**
* How many RTCPeerConnections this page has created since load. A clean session creates
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
* waiting for an offer that was never elected to be sent - adds another.
*/
export async function countCreatedPeerConnections(page: Page): Promise<number> {
return await page.evaluate(() =>
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
);
}
/**
* Every connected pair must have exactly one initiator. Comparing ids from two different
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
* neither does until a takeover timer fires.
*/
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
const directed = new Map<string, boolean>();
const pairs = new Set<string>();
for (const [clientName, edges] of Object.entries(edgesByClient)) {
for (const edge of edges) {
expect(
edge.localActorId,
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
).toBeTruthy();
const localActorId = edge.localActorId as string;
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
pairs.add([localActorId, edge.peerId].sort().join('<->'));
}
}
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
for (const pair of pairs) {
const [first, second] = pair.split('<->');
const forward = directed.get(`${first}->${second}`);
const backward = directed.get(`${second}->${first}`);
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
expect(
[forward, backward].filter(Boolean),
`expected exactly one initiator for pair ${pair}`
).toHaveLength(1);
}
}
+9 -1
View File
@@ -22,7 +22,11 @@ const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
// The Playwright helper supplies a durable directory when a test needs to
// restart the signaling process on the same port without losing its database.
const suppliedTmpDir = process.env.TEST_SERVER_DATA_DIR;
const ownsTmpDir = !suppliedTmpDir;
const tmpDir = suppliedTmpDir || mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true });
@@ -81,6 +85,10 @@ child.on('exit', (code) => {
// ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() {
if (!ownsTmpDir) {
return;
}
try {
rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
+85 -18
View File
@@ -1,11 +1,18 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
restart: () => Promise<void>;
/** Kill the process but keep the port and data dir, so `start()` can bring it back. */
kill: () => Promise<void>;
/** Start the server again on the same port and data dir after `kill()`. */
start: () => Promise<void>;
stop: () => Promise<void>;
}
@@ -15,27 +22,15 @@ 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'
});
const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
let child: ChildProcess | null = null;
let stopped = false;
try {
await waitForServerReady(port, child);
child = await spawnTestServer(port, dataDir);
} catch (error) {
await stopServer(child);
await rm(dataDir, { recursive: true, force: true });
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
@@ -48,8 +43,51 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
return {
port,
url: `http://localhost:${port}`,
restart: async () => {
if (stopped) {
throw new Error('Cannot restart a stopped test server');
}
if (child) {
await stopServer(child);
}
child = await spawnTestServer(port, dataDir);
},
kill: async () => {
if (stopped) {
throw new Error('Cannot kill a stopped test server');
}
if (child) {
await stopServer(child);
child = null;
}
},
start: async () => {
if (stopped) {
throw new Error('Cannot start a stopped test server');
}
if (child) {
return;
}
child = await spawnTestServer(port, dataDir);
},
stop: async () => {
await stopServer(child);
if (stopped) {
return;
}
stopped = true;
if (child) {
await stopServer(child);
child = null;
}
await rm(dataDir, { recursive: true, force: true });
}
};
}
@@ -57,6 +95,35 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
throw new Error('startTestServer: unreachable');
}
async function spawnTestServer(port: number, dataDir: string): Promise<ChildProcess> {
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_DATA_DIR: dataDir,
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);
throw error;
}
return child;
}
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const probe = createServer();
+188
View File
@@ -0,0 +1,188 @@
import { type BrowserContext, type Page } from '@playwright/test';
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
/** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */
interface StoredIceServerEntry {
id: string;
type: 'stun' | 'turn';
urls: string;
username?: string;
credential?: string;
}
export interface TurnCredentials {
urls: string;
username: string;
credential: string;
}
const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers';
/**
* Configure the app with a single TURN server, the way a user would in
* Settings -> ICE servers. Nothing test-specific reads this back: the app loads
* it through `IceServerSettingsService`, so the call really is configured the
* product way.
*
* Call BEFORE any `goto()`.
*/
export async function seedTurnOnlyIceServers(
target: BrowserContext | Page,
turn: TurnCredentials
): Promise<void> {
const entries: StoredIceServerEntry[] = [
{
credential: turn.credential,
id: 'e2e-turn',
type: 'turn',
urls: turn.urls,
username: turn.username
}
];
await target.addInitScript(
([key, value]) => {
localStorage.setItem(key, value);
},
[ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const
);
}
/**
* Take away the direct path. Every `RTCPeerConnection` is built with
* `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are
* discarded and the call can only succeed by relaying through the configured
* TURN server - which is what a user behind symmetric NAT is forced to do.
*
* Install AFTER `installWebRTCTracking` (it wraps whatever constructor is
* current) and BEFORE any `goto()`.
*/
export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise<void> {
await target.addInitScript(() => {
const harness = window as unknown as WebRtcTestHarnessWindow & {
__relayIceConfigs?: RTCConfiguration[];
};
const Wrapped = harness.RTCPeerConnection;
harness.__relayIceConfigs = [];
const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) {
const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' };
harness.__relayIceConfigs?.push(relayConfig);
return new Wrapped(relayConfig);
} as unknown as typeof RTCPeerConnection;
RelayOnly.prototype = Wrapped.prototype;
Object.setPrototypeOf(RelayOnly, Wrapped);
harness.RTCPeerConnection = RelayOnly;
});
}
/**
* The configuration each peer connection was actually built with. A relay-only
* run that connects nothing usually means the app handed over no TURN server at
* all, which looks identical to a broken relay from the outside.
*/
export async function getRelayIceConfigs(page: Page): Promise<RTCConfiguration[]> {
return await page.evaluate(() =>
(window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? []
);
}
export interface SelectedCandidatePair {
localCandidateType: string;
remoteCandidateType: string;
}
/**
* The candidate pair each connection actually settled on. `relay` on the local
* side means our packets left through the TURN server rather than going direct.
*/
export async function getSelectedCandidatePairs(page: Page): Promise<SelectedCandidatePair[]> {
return await page.evaluate(async () => {
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
const pairs: SelectedCandidatePair[] = [];
for (const pc of connections) {
let stats: RTCStatsReport;
try {
stats = await pc.getStats();
} catch {
continue;
}
const candidates = new Map<string, string>();
let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null;
stats.forEach((report) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown');
}
});
stats.forEach((report) => {
if (report.type !== 'candidate-pair') {
return;
}
const pair = report as unknown as {
state?: string;
nominated?: boolean;
selected?: boolean;
localCandidateId?: string;
remoteCandidateId?: string;
};
if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) {
selected = pair;
}
});
if (!selected) {
continue;
}
const pair = selected as { localCandidateId?: string; remoteCandidateId?: string };
pairs.push({
localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown',
remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown'
});
}
return pairs;
});
}
/**
* Wait until `expectedPairs` connections report a settled candidate pair whose
* local candidate is a TURN relay.
*/
export async function waitForRelayedCandidatePairs(
page: Page,
expectedPairs: number,
timeoutMs = 60_000
): Promise<SelectedCandidatePair[]> {
const deadline = Date.now() + timeoutMs;
let latest: SelectedCandidatePair[] = [];
while (Date.now() < deadline) {
latest = await getSelectedCandidatePairs(page);
const relayed = latest.filter((pair) => pair.localCandidateType === 'relay');
if (relayed.length >= expectedPairs) {
return latest;
}
await page.waitForTimeout(1_000);
}
throw new Error(
`Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}`
);
}
+137
View File
@@ -0,0 +1,137 @@
import { execFile } from 'node:child_process';
import { createServer } from 'node:net';
import { promisify } from 'node:util';
const run = promisify(execFile);
export interface TurnServerHandle {
urls: string;
username: string;
credential: string;
stop: () => Promise<void>;
}
const IMAGE = 'coturn/coturn:latest';
const CONTAINER_NAME = 'metoyou-e2e-turn';
const USERNAME = 'e2e';
const CREDENTIAL = 'e2epass';
const RELAY_MIN_PORT = 49_160;
const RELAY_MAX_PORT = 49_200;
/** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */
export async function isDockerAvailable(): Promise<boolean> {
try {
await run('docker', ['info'], { timeout: 15_000 });
return true;
} catch {
return false;
}
}
/**
* Run a throwaway coturn on the loopback interface. Relay-only tests need a real
* TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so
* without one there is no path at all and the test would prove nothing.
*/
export async function startTurnServer(): Promise<TurnServerHandle> {
await removeContainer();
const port = await allocatePort();
await run('docker', [
'run',
'--detach',
'--name',
CONTAINER_NAME,
'--network',
'host',
IMAGE,
'-n',
`--listening-port=${port}`,
'--listening-ip=127.0.0.1',
'--relay-ip=127.0.0.1',
`--min-port=${RELAY_MIN_PORT}`,
`--max-port=${RELAY_MAX_PORT}`,
'--lt-cred-mech',
`--user=${USERNAME}:${CREDENTIAL}`,
// Both browsers are on this machine. Without this coturn still hands out a
// relay candidate but refuses to forward to a 127.x peer, so ICE fails in a
// way that looks like a broken app rather than a blocked relay.
'--allow-loopback-peers',
'--realm=metoyou.test',
'--fingerprint',
'--no-tls',
'--no-dtls',
// Readiness is read back off `docker logs`: coturn logs to a file inside the
// container unless pointed at stdout, and the per-listener lines only appear
// at verbose level.
'--log-file=stdout',
'--verbose'
], { timeout: 120_000 });
await waitForTurnPort(port);
return {
credential: CREDENTIAL,
stop: removeContainer,
urls: `turn:127.0.0.1:${port}?transport=udp`,
username: USERNAME
};
}
async function removeContainer(): Promise<void> {
try {
await run('docker', [
'rm',
'--force',
CONTAINER_NAME
], { timeout: 30_000 });
} catch {
// No such container - nothing to clean up.
}
}
async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 })
.catch(() => ({ stderr: '', stdout: '' }));
if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) {
return;
}
await delay(250);
}
throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`);
}
/** coturn binds this itself, so only probe for a free port and hand it over. */
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 TURN port'));
return;
}
const { port } = address;
probe.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function delay(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, type Page } from '@playwright/test';
import type { Client } from '../fixtures/multi-client';
import { ChatRoomPage } from '../pages/chat-room.page';
import { RegisterPage } from '../pages/register.page';
import { ServerSearchPage } from '../pages/server-search.page';
import {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from './webrtc-helpers';
const PAIR_PASSWORD = 'TestPass123!';
export interface VoicePairClient extends Client {
displayName: string;
username: string;
}
/**
* Register two fresh users, put them in a new server, and connect both to one voice
* channel with the WebRTC tracking harness installed. Returns once both sides report a
* connected peer, an open data channel, and live audio stats.
*/
export async function createVoicePairInNewServer(
createClient: () => Promise<Client>,
serverName: string,
options: { channelName?: string; namePrefix?: string } = {}
): Promise<VoicePairClient[]> {
const channelName = options.channelName ?? 'General';
const namePrefix = options.namePrefix ?? 'Voice Pair';
const uniqueSuffix = Date.now();
const clients: VoicePairClient[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName: `${namePrefix} ${index + 1}`,
username: `voice_pair_${uniqueSuffix}_${index + 1}`
});
}
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(channelName);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
return clients;
}
/** Pin voice settings so audio levels and codecs do not vary between runs. */
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
export async function joinVoiceChannelUntilConnected(
page: Page,
channelName: string,
attempts = 3
): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const lastErrorMessage = lastError instanceof Error
? `Last error: ${lastError.message}`
: 'Last error: unavailable';
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}
export async function waitForLocalVoiceChannelConnection(
page: Page,
channelName: string,
timeout = 20_000
): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
interface UserShape { voiceState?: VoiceStateShape }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { id: string; channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}