504 lines
16 KiB
TypeScript
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)}`;
|
|
}
|