feat(presence): live stream badges, honest ring delivery, cross-signal DMs
- Camera and screen-share badges are decided by `shouldShowStreamIndicator`, so a live stream is visible from outside the voice channel and clicking the badge joins the streamer's channel before focusing the stream. - `ringParticipants` reports whether a `direct-call` ring reached anyone; a call that reached nobody shows `call.errors.ringUndelivered` instead of sitting in "calling" as if it were live. - Direct messages resolve every local identity alias, so a conversation opened from a foreign roster entry lands in the same thread.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* P4 coverage: one human must own one DM thread, and a call that reached
|
||||
* nobody must not look live.
|
||||
*
|
||||
* The fork this guards against: a peer on another signal server addresses the
|
||||
* local user by their provisioned actor id, so the inbound conversation id does
|
||||
* not match the id the local user builds from their home identity. Before the
|
||||
* canonicalization the recipient ended up with two threads for the same human -
|
||||
* one holding the peer's messages, one empty - and clicking the peer opened the
|
||||
* empty one.
|
||||
*/
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-dm-identity-primary';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-dm-identity-secondary';
|
||||
|
||||
test.describe('Cross-signal direct message identity', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('keeps one DM thread when the peer addresses the local user by a provisioned actor id', async ({
|
||||
createClient,
|
||||
testServer
|
||||
}) => {
|
||||
const secondaryServer = await startTestServer();
|
||||
|
||||
try {
|
||||
const suffix = uniqueName('xsig-dm');
|
||||
const serverName = `Cross Signal DM ${suffix}`;
|
||||
const message = `cross signal hello ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const endpoints = [
|
||||
{
|
||||
id: PRIMARY_SIGNAL_ID,
|
||||
name: 'E2E DM Signal A',
|
||||
url: testServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
},
|
||||
{
|
||||
id: SECONDARY_SIGNAL_ID,
|
||||
name: 'E2E DM Signal B',
|
||||
url: secondaryServer.url,
|
||||
isActive: true,
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
await installTestServerEndpoints(alice.context, endpoints);
|
||||
await installTestServerEndpoints(bob.context, endpoints);
|
||||
|
||||
await test.step('Alice is home on signal A, Bob on signal B', async () => {
|
||||
await registerOn(alice.page, PRIMARY_SIGNAL_ID, `alice_${suffix}`, 'Alice');
|
||||
await registerOn(bob.page, SECONDARY_SIGNAL_ID, `bob_${suffix}`, 'Bob');
|
||||
});
|
||||
|
||||
await test.step('They meet in a room on signal A, so Bob acts through a provisioned identity', async () => {
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Cross-signal DM identity coverage',
|
||||
sourceId: PRIMARY_SIGNAL_ID
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
await new ServerSearchPage(bob.page).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 sends Bob a DM addressed to his provisioned actor id', async () => {
|
||||
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||
await alice.page.getByTestId('dm-input').fill(message);
|
||||
await alice.page.getByTestId('dm-input').press('Enter');
|
||||
|
||||
// Bob stores the thread before he ever opens the DM view, so the
|
||||
// inbound conversation id is the only id his device knows.
|
||||
await expect
|
||||
.poll(async () => await countStoredConversations(bob.page), { timeout: 30_000 })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await test.step('Bob opens Alice and finds one thread holding her message', async () => {
|
||||
await openDmFromRoomUserCard(bob.page, 'Alice');
|
||||
|
||||
await expect(bob.page.locator('app-dm-chat').getByText(message)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(bob.page.locator('app-dm-conversation-item')).toHaveCount(1, { timeout: 20_000 });
|
||||
expect(await countStoredConversations(bob.page)).toBe(1);
|
||||
});
|
||||
} finally {
|
||||
await secondaryServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('surfaces an undelivered ring instead of a call that looks live', async ({ createClient }) => {
|
||||
const suffix = uniqueName('undelivered-ring');
|
||||
const serverName = `Undelivered Ring ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await test.step('Alice and Bob meet in a room', async () => {
|
||||
await registerOn(alice.page, null, `alice_${suffix}`, 'Alice');
|
||||
await registerOn(bob.page, null, `bob_${suffix}`, 'Bob');
|
||||
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Undelivered call ring coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(bob.page).waitForReady();
|
||||
});
|
||||
|
||||
await test.step('Alice calls Bob with no transport that can carry the ring', async () => {
|
||||
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||
await alice.page.evaluate(() => window.simulateOffline?.());
|
||||
|
||||
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||
|
||||
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||
await callButton.click();
|
||||
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The call view says the ring reached nobody', async () => {
|
||||
await expect(alice.page.getByTestId('private-call-error')).toContainText('Could not reach anyone', {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function registerOn(
|
||||
page: Page,
|
||||
signalServerId: string | null,
|
||||
username: string,
|
||||
displayName: string
|
||||
): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
|
||||
if (signalServerId) {
|
||||
await registerPage.serverSelect.selectOption(signalServerId);
|
||||
}
|
||||
|
||||
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
|
||||
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
|
||||
|
||||
await expect(userCard).toBeVisible({ timeout: 20_000 });
|
||||
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
|
||||
await expect(page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** Count stored DM threads for whichever user owns this browser profile. */
|
||||
async function countStoredConversations(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const prefix = 'metoyou_direct_message_conversations:';
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (let index = 0; index < localStorage.length; index++) {
|
||||
const key = localStorage.key(index);
|
||||
|
||||
if (!key?.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[];
|
||||
|
||||
total += Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
// A half-written entry is not a thread.
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForInboundVideoFlow,
|
||||
waitForOutboundVideoFlow
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* Screen share is pull-based: the sharer only attaches tracks to peers that asked
|
||||
* for them. This covers the case the request model has to get right - a viewer who
|
||||
* arrives after the share already started.
|
||||
*/
|
||||
test.describe('Late joiner screen share', () => {
|
||||
test('a user who joins voice mid-share still receives the screen', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const serverName = `Late Share ${Date.now()}`;
|
||||
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||
const viewer = await createVoiceClient(createClient, 'viewer');
|
||||
|
||||
await test.step('Both users register and join the server', async () => {
|
||||
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||
description: 'Late joiner screen share test'
|
||||
});
|
||||
|
||||
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(viewer.page).joinServerFromSearch(serverName);
|
||||
await expect(viewer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The sharer starts sharing while alone in voice', async () => {
|
||||
const room = new ChatRoomPage(sharer.page);
|
||||
|
||||
await room.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await openVoiceWorkspace(sharer.page);
|
||||
await room.startScreenShare();
|
||||
await expect(room.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('The viewer joins voice after the share is already running', async () => {
|
||||
const room = new ChatRoomPage(viewer.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForConnectedPeerCount(viewer.page, 1, 90_000);
|
||||
await waitForConnectedPeerCount(sharer.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(viewer.page, 30_000);
|
||||
|
||||
await openVoiceWorkspace(viewer.page);
|
||||
});
|
||||
|
||||
await test.step('The in-progress screen reaches the late joiner', async () => {
|
||||
try {
|
||||
const outbound = await waitForOutboundVideoFlow(sharer.page, 60_000);
|
||||
const inbound = await waitForInboundVideoFlow(viewer.page, 60_000);
|
||||
|
||||
expect(
|
||||
outbound.outboundBytesDelta > 0 || outbound.outboundPacketsDelta > 0,
|
||||
'The sharer never sent screen video to the late joiner'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
inbound.inboundBytesDelta > 0 || inbound.inboundPacketsDelta > 0,
|
||||
'The late joiner never received the in-progress screen share'
|
||||
).toBe(true);
|
||||
} catch (error) {
|
||||
console.log(`[sharer RTC]\n${await dumpRtcDiagnostics(sharer.page)}`);
|
||||
console.log(`[viewer RTC]\n${await dumpRtcDiagnostics(viewer.page)}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('The late joiner renders a remote screen tile', async () => {
|
||||
await expect(viewer.page.locator('app-voice-workspace-stream-tile').first())
|
||||
.toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Expand the voice workspace, which is what turns on remote screen-share requests. */
|
||||
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||
const viewButton = page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: /view/i })
|
||||
.first();
|
||||
|
||||
await expect(viewButton).toBeVisible({ timeout: 20_000 });
|
||||
await viewButton.click();
|
||||
await expect(page.locator('app-voice-workspace')).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function createVoiceClient(
|
||||
createClient: () => Promise<Client>,
|
||||
role: string
|
||||
): Promise<Client> {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
`late_share_${role}_${Date.now()}`,
|
||||
`Late Share ${role}`,
|
||||
USER_PASSWORD
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import { installAutoResumeAudioContext, installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* A user sharing alone in a voice channel used to look idle to everyone else,
|
||||
* because the LIVE badge was gated on the observer's own voice connection. The
|
||||
* observer here never joins voice, so the badge can only appear if sharing
|
||||
* presence reaches a non-participant.
|
||||
*/
|
||||
test.describe('Screen share visibility from outside the channel', () => {
|
||||
test('a user who is not in voice sees the LIVE badge of someone sharing alone', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const serverName = `Outside Share ${Date.now()}`;
|
||||
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||
const observer = await createVoiceClient(createClient, 'observer');
|
||||
const sharerRoom = new ChatRoomPage(sharer.page);
|
||||
const observerRoom = new ChatRoomPage(observer.page);
|
||||
|
||||
await test.step('Both users register and join the server', async () => {
|
||||
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||
description: 'Live badge visibility test'
|
||||
});
|
||||
|
||||
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(observer.page).joinServerFromSearch(serverName);
|
||||
await expect(observer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('The sharer shares while alone in the voice channel', async () => {
|
||||
await sharerRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
await sharerRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(sharerRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await sharerRoom.startScreenShare();
|
||||
await expect(sharerRoom.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('The observer stays out of voice and still sees the badge', async () => {
|
||||
await expect(observerRoom.channelsSidePanel).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Proves the observer never joined: the disconnect control only shows in voice.
|
||||
await expect(observerRoom.disconnectButton).toBeHidden();
|
||||
|
||||
await expect(liveBadge(observerRoom))
|
||||
.toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
await test.step('The badge disappears when the share stops', async () => {
|
||||
await sharerRoom.stopScreenShare();
|
||||
|
||||
await expect(liveBadge(observerRoom))
|
||||
.toBeHidden({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function liveBadge(room: ChatRoomPage) {
|
||||
return room.channelsSidePanel
|
||||
.locator('[data-testid="voice-user-live"]')
|
||||
.first();
|
||||
}
|
||||
|
||||
async function createVoiceClient(
|
||||
createClient: () => Promise<Client>,
|
||||
role: string
|
||||
): Promise<Client> {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
`outside_share_${role}_${Date.now()}`,
|
||||
`Outside Share ${role}`,
|
||||
USER_PASSWORD
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
|
||||
return client;
|
||||
}
|
||||
Reference in New Issue
Block a user