Files
Toju/e2e/tests/voice/direct-call.spec.ts
2026-05-17 15:14:52 +02:00

713 lines
29 KiB
TypeScript

import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForInboundVideoFlow,
waitForOutboundVideoFlow,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
interface DirectCallScenario {
alice: Client;
bob: Client;
charlie?: Client;
aliceUserId: string;
bobUserId: string;
charlieUserId?: string;
}
interface AudioFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
interface VideoFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
const USER_PASSWORD = 'TestPass123!';
test.describe('Direct private calls', () => {
test.describe.configure({ timeout: 240_000 });
test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
const callMessage = `Call chat ${uniqueName('msg')}`;
const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
const groupMessage = `Group call chat ${uniqueName('msg')}`;
await test.step('Alice starts a call from the search people card', async () => {
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
await bobPeopleCard.hover();
await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
});
await test.step('Alice starts sharing before Bob joins', async () => {
await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallNotificationCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await expect
.poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
});
await test.step('WebRTC connects and late-join screen share is visible', async () => {
await waitForPeerConnected(scenario.alice.page, 45_000);
await waitForPeerConnected(scenario.bob.page, 45_000);
await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);
const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);
if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Audio flows in both directions', async () => {
await waitForAudioStatsPresent(scenario.alice.page, 30_000);
await waitForAudioStatsPresent(scenario.bob.page, 30_000);
const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);
if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectAudioFlow(aliceDelta, 'Alice direct call');
expectAudioFlow(bobDelta, 'Bob direct call');
});
await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
if (!scenario.charlie || !scenario.charlieUserId) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
const charlie = scenario.charlie;
await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();
await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
timeout: 20_000
});
await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await charlie.page.getByRole('button', { name: 'Open private call' }).click();
await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
await waitForConnectedPeerCount(charlie.page, 2, 45_000);
await Promise.all([
waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
]);
await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
await expectParticipantConnected(charlie.page, scenario.aliceUserId);
await expectParticipantConnected(charlie.page, scenario.bobUserId);
await expectParticipantConnected(charlie.page, scenario.charlieUserId);
await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });
await expect
.poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
timeout: 30_000,
intervals: [500, 1_000]
})
.toBeGreaterThanOrEqual(2);
await scenario.bob.page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.first()
.click();
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });
await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');
await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await exitFullscreen(scenario.bob.page);
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(false);
await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
});
await test.step('Both clients show their own speaking indicator', async () => {
await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
});
await test.step('Private call layout does not require vertical scrolling', async () => {
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);
const originalWidth = await privateCallChatWidth(scenario.alice.page);
const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
const box = await resizer.boundingBox();
expect(box, 'private call chat resizer should be measurable').not.toBeNull();
if (box) {
await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
await scenario.alice.page.mouse.down();
await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
await scenario.alice.page.mouse.up();
}
await expect
.poll(async () => await privateCallChatWidth(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(originalWidth + 40);
});
await test.step('Embedded call chat syncs and does not expose another call button', async () => {
await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });
await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Group chat call button rings every other participant', async () => {
if (!scenario.charlie) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);
await scenario.alice.page
.locator('app-dm-chat header')
.getByRole('button', { name: /Call/i })
.click();
await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(bobPlayCountBeforeGroupCall);
await expect
.poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
await scenario.bob.page
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await scenario.charlie.page
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Unknown call routes render an inert empty state', async () => {
await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
});
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
});
async function createDirectCallScenario(
createClient: () => Promise<Client>,
options: { includeCharlie?: boolean } = {}
): Promise<DirectCallScenario> {
const suffix = uniqueName('direct-call');
const serverName = `Direct Call Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = options.includeCharlie ? await createClient() : undefined;
await installDirectCallInstrumentation(alice.page);
await installDirectCallInstrumentation(bob.page);
if (charlie) {
await installDirectCallInstrumentation(charlie.page);
}
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
if (charlie) {
await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
}
const aliceUserId = await getCurrentUserId(alice.page);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(bob.page).waitForReady();
if (charlie) {
const charlieSearch = new ServerSearchPage(charlie.page);
await charlieSearch.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(charlie.page).waitForReady();
}
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
if (charlieRoomCard) {
await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
}
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');
if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
throw new Error('Expected direct-call scenario users to expose stable ids.');
}
return {
alice,
bob,
charlie,
aliceUserId,
bobUserId,
charlieUserId
};
}
async function installDirectCallInstrumentation(page: Page): Promise<void> {
await installWebRTCTracking(page);
await installAutoResumeAudioContext(page);
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
})
);
const OriginalAudio = window.Audio;
const callAudioState = {
activeLoops: 0,
pauseCount: 0,
playCount: 0
};
const callNotificationState = {
count: 0,
bodies: [] as string[],
titles: [] as string[]
};
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
(window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;
function isCallAudio(audio: HTMLAudioElement): boolean {
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
}
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
const audio = new OriginalAudio(src);
const originalPlay = audio.play.bind(audio);
const originalPause = audio.pause.bind(audio);
audio.play = () => {
if (isCallAudio(audio)) {
callAudioState.playCount += 1;
if (audio.loop) {
callAudioState.activeLoops += 1;
}
}
return originalPlay();
};
audio.pause = () => {
if (isCallAudio(audio)) {
callAudioState.pauseCount += 1;
if (audio.loop && callAudioState.activeLoops > 0) {
callAudioState.activeLoops -= 1;
}
}
return originalPause();
};
return audio;
} as typeof Audio;
window.Audio.prototype = OriginalAudio.prototype;
Object.setPrototypeOf(window.Audio, OriginalAudio);
class MockNotification {
static permission: NotificationPermission = 'granted';
onclick: ((this: Notification, ev: Event) => unknown) | null = null;
constructor(title: string, options?: NotificationOptions) {
callNotificationState.count += 1;
callNotificationState.titles.push(title);
callNotificationState.bodies.push(options?.body ?? '');
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {}
}
(window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
});
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
}
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
await disableLastViewedChatResume(page);
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
await expect(peopleCard).toBeVisible({ timeout: 20_000 });
await peopleCard.hover();
await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
}
async function getCallAudioPlayCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
}
async function getCallAudioPauseCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
}
async function getCallNotificationCount(page: Page): Promise<number> {
return await page.evaluate(() => (
window as Window & { __callNotificationState?: { count: number } }
).__callNotificationState?.count ?? 0);
}
async function getActiveCallAudioLoops(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
}
async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
if (!userId) {
throw new Error('Expected a stable participant id.');
}
await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
}
async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
for (let sample = 0; sample < 3; sample++) {
const [
senderAudio,
receiverAudio,
outboundVideo,
inboundVideo
] = await Promise.all([
waitForAudioFlow(senderPage, 30_000),
waitForAudioFlow(receiverPage, 30_000),
waitForOutboundVideoFlow(senderPage, 30_000),
waitForInboundVideoFlow(receiverPage, 30_000)
]);
expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
}
}
async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
}
async function privateCallGridStreamCount(page: Page): Promise<number> {
return await page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.count();
}
async function privateCallChatWidth(page: Page): Promise<number> {
return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
}
async function hasFullscreenElement(page: Page): Promise<boolean> {
return await page.evaluate(() => document.fullscreenElement !== null);
}
async function exitFullscreen(page: Page): Promise<void> {
await page.evaluate(async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
});
}
function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
}
function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
}
function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
}
function isAudioFlowing(delta: AudioFlowDelta): boolean {
return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
&& (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
}
function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
}
function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}