feat: server image
This commit is contained in:
465
e2e/tests/chat/server-icon-sync.spec.ts
Normal file
465
e2e/tests/chat/server-icon-sync.spec.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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(() => {
|
||||
type SettingsModalComponentHandle = {
|
||||
modal?: {
|
||||
open: (page: string) => void;
|
||||
};
|
||||
};
|
||||
type 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(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(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(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon');
|
||||
}
|
||||
|
||||
async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(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(`img[alt="${serverName} icon"]`).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 20_000 });
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon');
|
||||
}
|
||||
|
||||
async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if ((await image.count()) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
},
|
||||
{
|
||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||
message: `${label} src should update`
|
||||
}
|
||||
)
|
||||
.toBe(expectedDataUrl);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
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: 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, 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user