import { expect, type Page } from '@playwright/test'; import { test } from '../../fixtures/multi-client'; import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { readSignalServerCredentialFromPage } from '../../helpers/auth-api'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; /** * Regression coverage for "User receiving direct call doesn't get notified": * starting a call from the DM chat header (steps: open DM of a user, click * call) must ring the recipient - incoming-call modal, ring audio, and a * server-rail call entry. Includes the cross-signal topology where the callee * is addressed by a provisioned actor id instead of their home identity. */ const USER_PASSWORD = 'TestPass123!'; const PRIMARY_SIGNAL_ID = 'e2e-dm-ring-primary'; const SECONDARY_SIGNAL_ID = 'e2e-dm-ring-secondary'; test.describe('DM header call ring', () => { test.describe.configure({ timeout: 240_000 }); test('callee is notified when the caller starts the call from the DM chat header', async ({ createClient }) => { const suffix = uniqueName('dm-ring'); const serverName = `DM Ring Server ${suffix}`; const alice = await createClient(); const bob = await createClient(); await installRingInstrumentation(bob.page); await test.step('Alice and Bob register and meet in a server', async () => { await registerUser(alice.page, `alice_${suffix}`, 'Alice'); await registerUser(bob.page, `bob_${suffix}`, 'Bob'); const aliceSearch = new ServerSearchPage(alice.page); await aliceSearch.createServer(serverName, { description: 'DM header call ring regression coverage' }); 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(); }); await test.step('Both users open the DM view; live DM delivery confirms the transport works', async () => { const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); await expect(bobUserCard).toBeVisible({ timeout: 20_000 }); await bobUserCard.getByRole('button', { name: 'Message Bob' }).click(); await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 }); const aliceUserCard = bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' }).first(); await expect(aliceUserCard).toBeVisible({ timeout: 20_000 }); await aliceUserCard.getByRole('button', { name: 'Message Alice' }).click(); await expect(bob.page).toHaveURL(/\/dm\//, { timeout: 15_000 }); // Mirrors the bug report: the users are in the DM view (not a server // room) when the call starts. The message must arrive live so a broken // ring cannot be blamed on a dead transport. await alice.page.getByTestId('dm-input').fill(`hello before call ${suffix}`); await alice.page.getByTestId('dm-input').press('Enter'); await expect(bob.page.locator('app-dm-chat').getByText(`hello before call ${suffix}`)).toBeVisible({ timeout: 20_000 }); }); await test.step('Alice starts the call from the DM chat header', async () => { const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' }); await expect(callButton).toBeVisible({ timeout: 20_000 }); await expect(callButton).toBeEnabled({ timeout: 20_000 }); await callButton.click(); await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 }); }); await test.step('Bob gets the incoming-call modal, ring audio, and rail entry', async () => { await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 }); await expect(bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect .poll(async () => await getCallAudioPlayCount(bob.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(0); }); }); test('callee homed on another signal server is notified when called via their provisioned actor id', async ({ createClient, testServer }) => { const secondaryServer = await startTestServer(); try { const suffix = uniqueName('xsig-ring'); const serverName = `Cross Signal Ring ${suffix}`; const alice = await createClient(); const bob = await createClient(); const endpoints = [ { id: PRIMARY_SIGNAL_ID, name: 'E2E Ring Signal A', url: testServer.url, isActive: true, status: 'online' }, { id: SECONDARY_SIGNAL_ID, name: 'E2E Ring Signal B', url: secondaryServer.url, isActive: true, status: 'online' } ]; await installTestServerEndpoints(alice.context, endpoints); await installTestServerEndpoints(bob.context, endpoints); await installRingInstrumentation(bob.page); await test.step('Alice registers on the primary signal, Bob on the secondary', async () => { const aliceRegister = new RegisterPage(alice.page); await aliceRegister.goto(); await aliceRegister.serverSelect.selectOption(PRIMARY_SIGNAL_ID); await aliceRegister.register(`alice_${suffix}`, 'Alice', USER_PASSWORD); await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); const bobRegister = new RegisterPage(bob.page); await bobRegister.goto(); await bobRegister.serverSelect.selectOption(SECONDARY_SIGNAL_ID); await bobRegister.register(`bob_${suffix}`, 'Bob', USER_PASSWORD); await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); }); await test.step('They meet in a room on the primary signal; Bob gets a provisioned actor identity', async () => { const aliceSearch = new ServerSearchPage(alice.page); await aliceSearch.createServer(serverName, { description: 'Cross-signal DM call ring coverage', sourceId: PRIMARY_SIGNAL_ID }); 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(); await expect.poll(async () => await readSignalServerCredentialFromPage(bob.page, testServer.url), { timeout: 30_000 } ).not.toBeNull(); }); await test.step('Alice opens the DM with Bob and calls from the DM chat header', async () => { const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); await expect(bobUserCard).toBeVisible({ timeout: 20_000 }); await bobUserCard.getByRole('button', { name: 'Message Bob' }).click(); await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 }); const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' }); await expect(callButton).toBeVisible({ timeout: 20_000 }); await expect(callButton).toBeEnabled({ timeout: 20_000 }); await callButton.click(); await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 }); }); await test.step('Bob gets the incoming-call modal and ring audio', async () => { await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 }); await expect .poll(async () => await getCallAudioPlayCount(bob.page), { timeout: 20_000, intervals: [500, 1_000] }) .toBeGreaterThan(0); }); } finally { await secondaryServer.stop(); } }); }); async function registerUser(page: Page, username: string, displayName: string): Promise { const registerPage = new RegisterPage(page); await registerPage.goto(); await registerPage.register(username, displayName, USER_PASSWORD); await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); } async function installRingInstrumentation(page: Page): Promise { await page.addInitScript(() => { const OriginalAudio = window.Audio; const callAudioState = { playCount: 0 }; (window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState; 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); audio.play = () => { if (isCallAudio(audio)) { callAudioState.playCount += 1; } return originalPlay(); }; return audio; } as typeof Audio; window.Audio.prototype = OriginalAudio.prototype; Object.setPrototypeOf(window.Audio, OriginalAudio); }); } async function getCallAudioPlayCount(page: Page): Promise { return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0); } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; }