import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium, type BrowserContext, type Locator, type Page, type Route } 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'; interface TestUser { displayName: string; password: string; username: string; } interface ImageUploadPayload { buffer: Buffer; dataUrl: string; mimeType: string; name: string; } interface PersistentClient { context: BrowserContext; page: Page; user: TestUser; userDataDir: string; } const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; const GIF_FRAME_MARKER = Buffer.from([ 0x21, 0xf9, 0x04 ]); const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000; test.describe('Server icon sync', () => { test.describe.configure({ timeout: 240_000 }); test('loads the chat-server image for online, late-joining, restarted, and discovery users', async ({ testServer }) => { const suffix = uniqueName('server-icon'); const serverName = `Icon Sync Server ${suffix}`; const icon = buildGifUpload('server-icon'); 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 daveUser: TestUser = { username: `dave_${suffix}`, displayName: 'Dave', 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 creates a server and Bob joins before the icon changes', async () => { await registerUser(alice); await registerUser(bob); await new ServerSearchPage(alice.page).createServer(serverName, { description: 'Server icon 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); }); const roomUrl = alice.page.url(); await test.step('Alice uploads a server icon and sees it in every owner-facing place', async () => { await uploadServerIconFromSettings(alice.page, serverName, icon); await expectServerSettingsIcon(alice.page, serverName, icon.dataUrl); await closeSettingsModal(alice.page); await expectRoomHeaderIcon(alice.page, serverName, icon.dataUrl); await expectRailIcon(alice.page, serverName, icon.dataUrl); }); await test.step('Bob was online during the change and receives the icon live', async () => { await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl); await expectRailIcon(bob.page, serverName, icon.dataUrl); }); const carol = await createPersistentClient(carolUser, testServer.port); clients.push(carol); await test.step('Carol joins after the change and loads the existing server icon', async () => { await registerUser(carol); await joinServerFromSearch(carol.page, serverName); await waitForRoomReady(carol.page); await waitForConnectedPeerCount(alice.page, 2); await expectRoomHeaderIcon(carol.page, serverName, icon.dataUrl); await expectRailIcon(carol.page, serverName, icon.dataUrl); }); await test.step('Bob keeps the server icon after a full app restart', async () => { await restartPersistentClient(bob, testServer.port); await openRoomAfterRestart(bob, roomUrl); await expectRoomHeaderIcon(bob.page, serverName, icon.dataUrl); await expectRailIcon(bob.page, serverName, icon.dataUrl); }); const dave = await createPersistentClient(daveUser, testServer.port); clients.push(dave); await test.step('Dave has not joined, but discovery loads the icon through a temporary peer sync', async () => { await registerUser(dave); await stripServerIconFromDirectorySearch(dave.page, serverName); await dave.page.goto('/search', { waitUntil: 'domcontentloaded' }); await new ServerSearchPage(dave.page).searchInput.fill(serverName); await expectSearchResultIcon(dave.page, serverName, icon.dataUrl); await expect(dave.page).toHaveURL(/\/search/); }); } 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-server-icon-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 { await new ServerSearchPage(page).joinServerFromSearch(serverName); await expect(page).toHaveURL(/\/room\//, { timeout: 15_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 uploadServerIconFromSettings(page: Page, serverName: string, icon: ImageUploadPayload): Promise { await openServerSettings(page, serverName); const fileInput = page.locator('#server-icon-upload'); await expect(fileInput).toBeAttached({ timeout: 10_000 }); await fileInput.setInputFiles({ name: icon.name, mimeType: icon.mimeType, buffer: icon.buffer }); } async function openServerSettings(page: Page, serverName: string): Promise { await page.locator('app-title-bar button[title="Menu"]').click(); const titleBarMenu = page.locator('app-title-bar .absolute.right-0.top-full').first(); await expect(titleBarMenu).toBeVisible({ timeout: 5_000 }); await titleBarMenu.getByRole('button', { name: 'Settings' }).click(); const dialog = page.locator('app-settings-modal'); const serverSettingsTitle = dialog.getByRole('heading', { name: 'Server Settings' }); try { await expect(serverSettingsTitle).toBeVisible({ timeout: 2_000 }); } catch { await openSettingsModalThroughAngularDevMode(page); await expect(serverSettingsTitle).toBeVisible({ timeout: 10_000 }); } const serverSelect = dialog.locator('select').first(); if ((await serverSelect.count()) > 0) { await expect(serverSelect).toContainText(serverName, { timeout: 10_000 }); } await dialog.getByRole('button', { name: 'Server', exact: true }).click(); await expect(page.locator('app-server-settings')).toBeVisible({ timeout: 10_000 }); } async function openSettingsModalThroughAngularDevMode(page: Page): Promise { await page.evaluate(() => { interface SettingsModalComponentHandle { modal?: { open: (page: string) => void; }; } interface AngularDebugApi { getComponent: (element: Element) => SettingsModalComponentHandle; applyChanges?: (component: SettingsModalComponentHandle) => void; } const host = document.querySelector('app-settings-modal'); const debugApi = (window as Window & { ng?: AngularDebugApi }).ng; const component = host && debugApi?.getComponent(host); if (!component?.modal?.open) { throw new Error('Angular debug API could not open settings modal'); } component.modal.open('server'); debugApi.applyChanges?.(component); }); } async function closeSettingsModal(page: Page): Promise { await page.keyboard.press('Escape'); await expect(page.locator('app-settings-modal').getByRole('heading', { name: 'Settings', exact: true })).not.toBeVisible({ timeout: 10_000 }); } async function stripServerIconFromDirectorySearch(page: Page, serverName: string): Promise { await page.route('**/api/servers**', async (route: Route) => { const response = await route.fetch(); const contentType = response.headers()['content-type'] ?? ''; if (!contentType.includes('application/json')) { await route.fulfill({ response }); return; } const body = await response.json(); if (!body || !Array.isArray(body.servers)) { await route.fulfill({ response, json: body }); return; } await route.fulfill({ response, json: { ...body, servers: body.servers.map((server: Record) => { if (server['name'] !== serverName) { return server; } const { icon: _icon, ...serverWithoutIcon } = server; return serverWithoutIcon; }) } }); }); } 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 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 expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const settingsPanel = page.locator('app-server-settings'); const image = settingsPanel.locator('[style*="background-image"]').first(); await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon'); } async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const channelsPanel = page.locator('app-rooms-side-panel').first(); const image = channelsPanel.locator('[style*="background-image"]').first(); await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon'); } async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first(); await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon'); } async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first(); const image = serverCard.locator('[style*="background-image"]').first(); await expect(serverCard).toBeVisible({ timeout: 20_000 }); await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon'); } async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise { await expect .poll( async () => { if ((await image.count()) === 0) { return null; } return image.evaluate((element) => getComputedStyle(element).backgroundImage); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, message: `${label} background should update` } ) .toContain(expectedDataUrl); await expect .poll( async () => { if ((await image.count()) === 0) { return false; } return image.evaluate( (element) => new Promise((resolve) => { const backgroundImage = getComputedStyle(element).backgroundImage; const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage); const img = new Image(); if (!match?.[1]) { resolve(false); return; } img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0); img.onerror = () => resolve(false); img.src = match[1]; }) ); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, message: `${label} should load` } ) .toBe(true); } function buildGifUpload(label: string): ImageUploadPayload { 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 server icon 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, commentExtension, frame, frame, Buffer.from([0x3b]) ]); const base64 = buffer.toString('base64'); return { buffer, dataUrl: `data:image/gif;base64,${base64}`, mimeType: 'image/gif', name: `server-icon-${label}.gif` }; } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; }