import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium, type BrowserContext, type Page } from '@playwright/test'; import { test, expect } from '../../fixtures/multi-client'; import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint'; import { installWebRTCTracking } from '../../helpers/webrtc-helpers'; import { LoginPage } from '../../pages/login.page'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; interface TestUser { displayName: string; password: string; username: string; } interface AvatarUploadPayload { buffer: Buffer; dataUrl: string; mimeType: string; name: string; } interface PersistentClient { context: BrowserContext; page: Page; user: TestUser; userDataDir: string; } interface ProfileMetadata { description?: string; displayName: string; } const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; const GIF_FRAME_MARKER = Buffer.from([ 0x21, 0xF9, 0x04 ]); const NETSCAPE_LOOP_EXTENSION = Buffer.from([ 0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00 ]); const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const VOICE_CHANNEL = 'General'; test.describe('Profile avatar sync', () => { test.describe.configure({ timeout: 240_000 }); test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => { const suffix = uniqueName('avatar'); const serverName = `Avatar Sync Server ${suffix}`; const messageText = `Avatar sync message ${suffix}`; const avatarA = buildAnimatedGifUpload('alpha'); const avatarB = buildAnimatedGifUpload('beta'); const aliceUser: TestUser = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' }; const bobUser: TestUser = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' }; const carolUser: TestUser = { username: `carol_${suffix}`, displayName: 'Carol', password: 'TestPass123!' }; const clients: PersistentClient[] = []; try { const alice = await createPersistentClient(aliceUser, testServer.port); const bob = await createPersistentClient(bobUser, testServer.port); clients.push(alice, bob); await test.step('Alice and Bob register, create a server, and join the same room', async () => { await registerUser(alice); await registerUser(bob); const aliceSearchPage = new ServerSearchPage(alice.page); await aliceSearchPage.createServer(serverName, { description: 'Avatar synchronization E2E coverage' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await joinServerFromSearch(bob.page, serverName); await waitForRoomReady(alice.page); await waitForRoomReady(bob.page); await waitForConnectedPeerCount(alice.page, 1); await waitForConnectedPeerCount(bob.page, 1); await expectUserRowVisible(bob.page, aliceUser.displayName); }); const roomUrl = alice.page.url(); await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => { await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA); await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl); await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl); }); await test.step('Alice sees the updated avatar in voice controls', async () => { await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL); await joinVoiceChannel(alice.page, VOICE_CHANNEL); await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl); }); const carol = await createPersistentClient(carolUser, testServer.port); clients.push(carol); await test.step('Carol joins after the first change and sees the updated avatar', async () => { await registerUser(carol); await joinServerFromSearch(carol.page, serverName); await waitForRoomReady(carol.page); await waitForConnectedPeerCount(alice.page, 2); await waitForConnectedPeerCount(carol.page, 1); await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl); }); await test.step('Alice avatar is used in chat messages for everyone in the room', async () => { const aliceMessagesPage = new ChatMessagesPage(alice.page); await aliceMessagesPage.sendMessage(messageText); await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl); await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl); await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl); }); await test.step('Alice changes the avatar again and all three users see the update in real time', async () => { await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB); await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl); await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl); await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl); await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl); await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl); await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl); await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl); }); await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => { await restartPersistentClient(bob, testServer.port); await openRoomAfterRestart(bob, roomUrl); await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl); await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl); await restartPersistentClient(carol, testServer.port); await openRoomAfterRestart(carol, roomUrl); await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl); await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl); await restartPersistentClient(alice, testServer.port); await openRoomAfterRestart(alice, roomUrl); await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl); await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl); }); } finally { await Promise.all(clients.map(async (client) => { await closePersistentClient(client); await rm(client.userDataDir, { recursive: true, force: true }); })); } }); }); test.describe('Profile metadata sync', () => { test.describe.configure({ timeout: 240_000 }); test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => { const suffix = uniqueName('profile'); const serverName = `Profile Sync Server ${suffix}`; const messageText = `Profile sync message ${suffix}`; const firstProfile: ProfileMetadata = { displayName: `Alice One ${suffix}`, description: `First synced profile description ${suffix}` }; const secondProfile: ProfileMetadata = { displayName: `Alice Two ${suffix}`, description: `Second synced profile description ${suffix}` }; const aliceUser: TestUser = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' }; const bobUser: TestUser = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' }; const carolUser: TestUser = { username: `carol_${suffix}`, displayName: 'Carol', password: 'TestPass123!' }; const clients: PersistentClient[] = []; try { const alice = await createPersistentClient(aliceUser, testServer.port); const bob = await createPersistentClient(bobUser, testServer.port); clients.push(alice, bob); await test.step('Alice and Bob register, create a server, and join the same room', async () => { await registerUser(alice); await registerUser(bob); const aliceSearchPage = new ServerSearchPage(alice.page); await aliceSearchPage.createServer(serverName, { description: 'Profile synchronization E2E coverage' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await joinServerFromSearch(bob.page, serverName); await waitForRoomReady(alice.page); await waitForRoomReady(bob.page); await waitForConnectedPeerCount(alice.page, 1); await waitForConnectedPeerCount(bob.page, 1); await expectUserRowVisible(bob.page, aliceUser.displayName); }); const roomUrl = alice.page.url(); await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => { await updateProfileFromRoomSidebar(alice.page, { displayName: aliceUser.displayName }, firstProfile); await expectUserRowVisible(alice.page, firstProfile.displayName); await expectUserRowVisible(bob.page, firstProfile.displayName); await expectProfileCardDetails(bob.page, firstProfile); }); const carol = await createPersistentClient(carolUser, testServer.port); clients.push(carol); await test.step('Carol joins after the first change and sees the updated profile', async () => { await registerUser(carol); await joinServerFromSearch(carol.page, serverName); await waitForRoomReady(carol.page); await waitForConnectedPeerCount(alice.page, 2); await waitForConnectedPeerCount(carol.page, 1); await expectUserRowVisible(carol.page, firstProfile.displayName); await expectProfileCardDetails(carol.page, firstProfile); }); await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => { await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile); await expectUserRowVisible(alice.page, secondProfile.displayName); await expectUserRowVisible(bob.page, secondProfile.displayName); await expectUserRowVisible(carol.page, secondProfile.displayName); await expectProfileCardDetails(bob.page, secondProfile); await expectProfileCardDetails(carol.page, secondProfile); const aliceMessagesPage = new ChatMessagesPage(alice.page); await aliceMessagesPage.sendMessage(messageText); await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName); await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName); await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName); }); await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => { await restartPersistentClient(bob, testServer.port); await openRoomAfterRestart(bob, roomUrl); await expectUserRowVisible(bob.page, secondProfile.displayName); await expectProfileCardDetails(bob.page, secondProfile); await restartPersistentClient(carol, testServer.port); await openRoomAfterRestart(carol, roomUrl); await expectUserRowVisible(carol.page, secondProfile.displayName); await expectProfileCardDetails(carol.page, secondProfile); await restartPersistentClient(alice, testServer.port); await openRoomAfterRestart(alice, roomUrl); await expectUserRowVisible(alice.page, secondProfile.displayName); await expectProfileCardDetails(alice.page, secondProfile); }); } finally { await Promise.all(clients.map(async (client) => { await closePersistentClient(client); await rm(client.userDataDir, { recursive: true, force: true }); })); } }); }); async function createPersistentClient(user: TestUser, testServerPort: number): Promise { const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-')); const session = await launchPersistentSession(userDataDir, testServerPort); return { context: session.context, page: session.page, user, userDataDir }; } async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise { await closePersistentClient(client); const session = await launchPersistentSession(client.userDataDir, testServerPort); client.context = session.context; client.page = session.page; } async function closePersistentClient(client: PersistentClient): Promise { try { await client.context.close(); } catch { // Ignore repeated cleanup attempts during finally. } } async function launchPersistentSession( userDataDir: string, testServerPort: number ): Promise<{ context: BrowserContext; page: Page }> { const context = await chromium.launchPersistentContext(userDataDir, { args: CLIENT_LAUNCH_ARGS, baseURL: 'http://localhost:4200', permissions: ['microphone', 'camera'] }); await installTestServerEndpoint(context, testServerPort); const page = context.pages()[0] ?? await context.newPage(); await installWebRTCTracking(page); return { context, page }; } async function registerUser(client: PersistentClient): Promise { const registerPage = new RegisterPage(client.page); await retryTransientNavigation(() => registerPage.goto()); await registerPage.register(client.user.username, client.user.displayName, client.user.password); await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 }); } async function joinServerFromSearch(page: Page, serverName: string): Promise { const searchPage = new ServerSearchPage(page); const serverCard = page.locator('button', { hasText: serverName }).first(); await searchPage.searchInput.fill(serverName); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); } async function ensureVoiceChannelExists(page: Page, channelName: string): Promise { const chatRoom = new ChatRoomPage(page); const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true }); if (await existingVoiceChannel.count() > 0) { return; } await chatRoom.openCreateVoiceChannelDialog(); await chatRoom.createChannel(channelName); await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 }); } async function joinVoiceChannel(page: Page, channelName: string): Promise { const chatRoom = new ChatRoomPage(page); await chatRoom.joinVoiceChannel(channelName); await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); } async function uploadAvatarFromRoomSidebar( page: Page, displayName: string, avatar: AvatarUploadPayload ): Promise { const currentUserRow = getUserRow(page, displayName); const profileFileInput = page.locator('app-profile-card input[type="file"]'); const applyButton = page.getByRole('button', { name: 'Apply picture' }); await expect(currentUserRow).toBeVisible({ timeout: 15_000 }); if (await profileFileInput.count() === 0) { await currentUserRow.click(); await expect(profileFileInput).toBeAttached({ timeout: 10_000 }); } await profileFileInput.setInputFiles({ name: avatar.name, mimeType: avatar.mimeType, buffer: avatar.buffer }); await expect(applyButton).toBeVisible({ timeout: 10_000 }); await applyButton.click(); await expect(applyButton).not.toBeVisible({ timeout: 10_000 }); } async function updateProfileFromRoomSidebar( page: Page, currentProfile: ProfileMetadata, nextProfile: ProfileMetadata ): Promise { const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName); const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true }); await expect(displayNameButton).toBeVisible({ timeout: 10_000 }); await displayNameButton.click(); const displayNameInput = profileCard.locator('input[type="text"]').first(); await expect(displayNameInput).toBeVisible({ timeout: 10_000 }); await displayNameInput.fill(nextProfile.displayName); await displayNameInput.blur(); await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 }); const currentDescriptionText = currentProfile.description || 'Add a description'; await profileCard.getByText(currentDescriptionText, { exact: true }).click(); const descriptionInput = profileCard.locator('textarea').first(); await expect(descriptionInput).toBeVisible({ timeout: 10_000 }); await descriptionInput.fill(nextProfile.description || ''); await descriptionInput.blur(); await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 }); await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 }); if (nextProfile.description) { await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 }); } } async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise { await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' })); if (client.page.url().includes('/login')) { const loginPage = new LoginPage(client.page); await loginPage.login(client.user.username, client.user.password); await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 }); await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }); } await waitForRoomReady(client.page); } async function retryTransientNavigation(navigate: () => Promise, attempts = 4): Promise { let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { try { return await navigate(); } catch (error) { lastError = error; const message = error instanceof Error ? error.message : String(error); const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET'); if (!isTransientNavigationError || attempt === attempts) { throw error; } } } throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`); } async function waitForRoomReady(page: Page): Promise { const messagesPage = new ChatMessagesPage(page); await messagesPage.waitForReady(); await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 }); } async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise { await page.waitForFunction((expectedCount) => { const connections = (window as { __rtcConnections?: RTCPeerConnection[]; }).__rtcConnections ?? []; return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount; }, count, { timeout }); } async function openProfileCardFromUserRow(page: Page, displayName: string) { await closeProfileCard(page); const row = getUserRow(page, displayName); await expect(row).toBeVisible({ timeout: 20_000 }); await row.click(); const profileCard = page.locator('app-profile-card'); await expect(profileCard).toBeVisible({ timeout: 10_000 }); return profileCard; } async function closeProfileCard(page: Page): Promise { const profileCard = page.locator('app-profile-card'); if (await profileCard.count() === 0) { return; } try { await expect(profileCard).toBeVisible({ timeout: 1_000 }); } catch { return; } await page.mouse.click(8, 8); await expect(profileCard).toHaveCount(0, { timeout: 10_000 }); } function getUserRow(page: Page, displayName: string) { const usersSidePanel = page.locator('app-rooms-side-panel').last(); return usersSidePanel.locator('[role="button"]').filter({ has: page.getByText(displayName, { exact: true }) }) .first(); } async function expectUserRowVisible(page: Page, displayName: string): Promise { await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 }); } async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise { const profileCard = await openProfileCardFromUserRow(page, profile.displayName); await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 }); if (profile.description) { await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 }); } await closeProfileCard(page); } async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise { const row = getUserRow(page, displayName); await expect(row).toBeVisible({ timeout: 20_000 }); await expect.poll(async () => { const image = row.locator('img').first(); if (await image.count() === 0) { return null; } return image.getAttribute('src'); }, { timeout: 20_000, message: `${displayName} avatar src should update` }).toBe(expectedDataUrl); await expect.poll(async () => { const image = row.locator('img').first(); if (await image.count() === 0) { return false; } return image.evaluate((element) => { const img = element as HTMLImageElement; return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0; }); }, { timeout: 20_000, message: `${displayName} avatar image should load` }).toBe(true); } async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise { const messagesPage = new ChatMessagesPage(page); const messageItem = messagesPage.getMessageItemByText(messageText); await expect(messageItem).toBeVisible({ timeout: 20_000 }); await expect.poll(async () => { const image = messageItem.locator('app-user-avatar img').first(); if (await image.count() === 0) { return null; } return image.getAttribute('src'); }, { timeout: 20_000, message: `Chat message avatar for "${messageText}" should update` }).toBe(expectedDataUrl); } async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise { const messagesPage = new ChatMessagesPage(page); const messageItem = messagesPage.getMessageItemByText(messageText); await expect(messageItem).toBeVisible({ timeout: 20_000 }); await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 }); } async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise { const voiceControls = page.locator('app-voice-controls'); await expect(voiceControls).toBeVisible({ timeout: 20_000 }); await expect.poll(async () => { const image = voiceControls.locator('app-user-avatar img').first(); if (await image.count() === 0) { return null; } return image.getAttribute('src'); }, { timeout: 20_000, message: 'Voice controls avatar should update' }).toBe(expectedDataUrl); } function buildAnimatedGifUpload(label: string): AvatarUploadPayload { const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64'); const frameStart = baseGif.indexOf(GIF_FRAME_MARKER); if (frameStart < 0) { throw new Error('Failed to locate GIF frame marker for animated avatar payload'); } const header = baseGif.subarray(0, frameStart); const frame = baseGif.subarray(frameStart, baseGif.length - 1); const commentData = Buffer.from(label, 'ascii'); const commentExtension = Buffer.concat([ Buffer.from([ 0x21, 0xFE, commentData.length ]), commentData, Buffer.from([0x00]) ]); const buffer = Buffer.concat([ header, NETSCAPE_LOOP_EXTENSION, commentExtension, frame, frame, Buffer.from([0x3B]) ]); const base64 = buffer.toString('base64'); return { buffer, dataUrl: `data:image/gif;base64,${base64}`, mimeType: 'image/gif', name: `animated-avatar-${label}.gif` }; } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; }