Files
Toju/e2e/tests/chat/server-icon-sync.spec.ts

504 lines
16 KiB
TypeScript

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<PersistentClient> {
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<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
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<void> {
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<void> {
await new ServerSearchPage(page).joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown>) => {
if (server['name'] !== serverName) {
return server;
}
const { icon: _icon, ...serverWithoutIcon } = server;
return serverWithoutIcon;
})
}
});
});
}
async function waitForRoomReady(page: Page): Promise<void> {
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<void> {
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<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean>((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)}`;
}