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 expect.poll(async () => { return await receiver.getMessageItemByText(message).isVisible() .catch(() => false); }, { timeout }).toBe(true); } 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 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 }); }