Files
Toju/e2e/tests/chat/cross-signal-dm-identity.spec.ts
myxelium 3266581d3c 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.
2026-08-14 03:19:29 +02:00

208 lines
7.9 KiB
TypeScript

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)}`;
}