import { expect, type Page } from '@playwright/test'; import { type Client } from '../fixtures/multi-client'; import { LoginPage } from '../pages/login.page'; import { RegisterPage } from '../pages/register.page'; import { ServerSearchPage } from '../pages/server-search.page'; import { ChatRoomPage } from '../pages/chat-room.page'; import { ChatMessagesPage } from '../pages/chat-messages.page'; export const MULTI_DEVICE_PASSWORD = 'TestPass123!'; export const MULTI_DEVICE_VOICE_CHANNEL = 'General'; export interface MultiDeviceCredentials { username: string; displayName: string; password: string; } export interface MultiDeviceScenario { clientA: Client; clientB: Client; credentials: MultiDeviceCredentials; serverName: string; messagesA: ChatMessagesPage; messagesB: ChatMessagesPage; roomA: ChatRoomPage; roomB: ChatRoomPage; } export function uniqueMultiDeviceName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`; } export async function createMultiDeviceScenario( createClient: () => Promise, options: { suffix?: string; serverDescription?: string } = {} ): Promise { const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device'); const credentials: MultiDeviceCredentials = { username: `multi_${suffix}`, displayName: 'Multi Device User', password: MULTI_DEVICE_PASSWORD }; const serverName = `Multi Device Server ${suffix}`; const clientA = await createClient(); const clientB = await createClient(); await warmClientPage(clientA.page); await warmClientPage(clientB.page); const registerPage = new RegisterPage(clientA.page); await registerPage.goto(); await registerPage.register(credentials.username, credentials.displayName, credentials.password); await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); const searchA = new ServerSearchPage(clientA.page); await searchA.createServer(serverName, { description: options.serverDescription ?? 'Multi-device session coverage' }); await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await waitForCurrentRoomName(clientA.page, serverName); const roomA = new ChatRoomPage(clientA.page); await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL); await loginSecondDeviceIntoServer(clientB.page, credentials, serverName); await waitForCurrentRoomName(clientB.page, serverName); const messagesA = new ChatMessagesPage(clientA.page); const messagesB = new ChatMessagesPage(clientB.page); const roomB = new ChatRoomPage(clientB.page); await messagesA.waitForReady(); await messagesB.waitForReady(); return { clientA, clientB, credentials, serverName, messagesA, messagesB, roomA, roomB }; } export async function loginSecondDeviceIntoServer( page: Page, credentials: MultiDeviceCredentials, serverName: string ): Promise { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login(credentials.username, credentials.password); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); const search = new ServerSearchPage(page); await search.joinServerFromSearch(serverName); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); } export async function expectCrossDeviceMessage( sender: ChatMessagesPage, receiver: ChatMessagesPage, message: string, timeout = 60_000 ): Promise { await sender.sendMessage(message); await expectSyncedMessage(receiver, message, timeout); } /** Waits until a message sent elsewhere appears in the local chat history. */ export async function expectSyncedMessage( receiver: ChatMessagesPage, message: string, timeout = 90_000 ): Promise { await receiver.waitForReady(); await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout }); } export async function expectSyncedMessageWithResync( page: Page, receiver: ChatMessagesPage, message: string, timeout = 60_000 ): Promise { await receiver.waitForReady(); const alreadyVisible = await receiver.getMessageItemByText(message) .isVisible() .catch(() => false); if (!alreadyVisible) { await resyncChannelMessages(page); } await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout }); } export async function resyncChannelMessages(page: Page, channelName = 'general'): Promise { const channel = page.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first(); await expect(channel).toBeVisible({ timeout: 10_000 }); await channel.click({ button: 'right' }); await page.getByRole('button', { name: 'Resync Messages' }).click(); } export async function closeClient(client: Client): Promise { await client.context.close(); } export async function registerGuestAndJoinServer( page: Page, credentials: MultiDeviceCredentials, serverName: string ): Promise { const registerPage = new RegisterPage(page); await registerPage.goto(); await registerPage.register(credentials.username, credentials.displayName, credentials.password); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); const search = new ServerSearchPage(page); await search.joinServerFromSearch(serverName); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); } export async function reopenClientInServer( createClient: () => Promise, credentials: MultiDeviceCredentials, serverName: string ): Promise<{ client: Client; messages: ChatMessagesPage }> { const client = await createClient(); await warmClientPage(client.page); await loginSecondDeviceIntoServer(client.page, credentials, serverName); const messages = new ChatMessagesPage(client.page); await messages.waitForReady(); return { client, messages }; } async function warmClientPage(page: Page): Promise { await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => undefined); } async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise { await page.waitForFunction( (expectedRoomName) => { interface RoomShape { name?: string } interface AngularDebugApi { getComponent: (element: Element) => Record; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return false; } const component = debugApi.getComponent(host); const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; return currentRoom?.name === expectedRoomName; }, roomName, { timeout } ); } export async function readClientInstanceId(page: Page): Promise { return page.evaluate(() => { const sessionId = sessionStorage.getItem('metoyou.clientInstanceId')?.trim(); if (sessionId) { return sessionId; } return localStorage.getItem('metoyou.clientInstanceId')?.trim() ?? null; }); } export async function logoutFromMenu(page: Page): Promise { const menuButton = page.getByRole('button', { name: 'Menu' }); const logoutButton = page.getByRole('button', { name: 'Logout' }); await expect(menuButton).toBeVisible({ timeout: 10_000 }); await menuButton.click(); await expect(logoutButton).toBeVisible({ timeout: 10_000 }); await logoutButton.click(); await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); } export function channelsSidePanel(page: Page) { return page.locator('app-rooms-side-panel').first(); } export function membersSidePanel(page: Page) { return page.locator('app-rooms-side-panel').last(); } export function serverMemberRow(page: Page, displayName: string) { return membersSidePanel(page) .locator('[role="button"], button') .filter({ has: page.getByText(displayName, { exact: true }) }) .first(); } /** * Gates cross-user assertions on real presence: the peer must show up in the * members panel before chat delivery between the two users can be expected. */ export async function expectServerPeerVisible( page: Page, displayName: string, timeout = 45_000 ): Promise { await expect(serverMemberRow(page, displayName)).toBeVisible({ timeout }); } export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) { return page .locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`) .getByText('Join', { exact: true }); } export async function expectPassiveVoiceOnDevice( page: Page, options: { timeout?: number; displayName?: string; channelName?: string } = {} ): Promise { const timeout = options.timeout ?? 45_000; const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL; const displayName = options.displayName; await expect.poll(async () => { const membersLabel = await membersSidePanel(page) .getByText('In voice on another device', { exact: false }) .isVisible() .catch(() => false); const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible() .catch(() => false); const grayedVoiceUser = displayName ? await channelsSidePanel(page).locator('.opacity-50') .filter({ hasText: displayName }) .first() .isVisible() .catch(() => false) : false; return membersLabel || joinBadge || grayedVoiceUser; }, { timeout }).toBe(true); } export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise { await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout }); }