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