713 lines
29 KiB
TypeScript
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)}`;
|
|
}
|