feat: Add profile images
This commit is contained in:
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
],
|
||||
webServer: {
|
||||
command: 'cd ../toju-app && npx ng serve',
|
||||
port: 4200,
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000
|
||||
}
|
||||
|
||||
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
458
e2e/tests/chat/profile-avatar-sync.spec.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
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 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 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 });
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||
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<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();
|
||||
|
||||
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> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 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 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 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 });
|
||||
}
|
||||
|
||||
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<void> {
|
||||
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
||||
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<void> {
|
||||
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 expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user