fix: Bug - User receiving direct call doesn't get notified (identity aliases)
Match incoming direct-call events against every local identity alias - home id, entity id, peer id, and each provisioned signal-server actor id - instead of only oderId||id. A caller who met the callee through a room on the caller's signal server addresses the ring by the callee's provisioned actor id, so the old admission check silently dropped it: the caller went "In Voice" while the callee saw no modal, no ring audio, and no rail entry. Incoming self aliases are normalized onto the canonical local id (normalizeDirectCallPayloadSelfAliases) so they never appear as a phantom third participant, and remoteParticipantIds / the DM-header peer lookup skip all self aliases. Adds a DM-header call ring e2e including the cross-signal topology (callee homed on a secondary signal server) that fails on the old code. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user