import { test, expect, type Page } from '@playwright/test'; import { test as multiClientTest } from '../../fixtures/multi-client'; 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 { username: string; displayName: string; password: string; } /** * Regression coverage for: "Emojis should be user bound not client bound". * * A custom emoji belongs to the user who saved it, not to the client. A second * account signing in on the same client must NOT inherit the first user's emoji * library/picker. * * The whole scenario runs in a SINGLE page load (only the very first navigation * reloads). All user switching is client-side via the router, because the leak * lived in the long-lived singleton CustomEmojiService that used to keep the * previous user's library after a logout + login without a reload. To avoid the * (separate) in-session "create a second server" limitation, the second user * joins the first user's server rather than creating their own. */ // Minimal valid 1x1 transparent GIF; the emoji pipeline validates mime + size only. const TINY_GIF = Buffer.from( '47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b', 'hex' ); multiClientTest.describe('Custom emoji are user bound, not client bound', () => { multiClientTest.describe.configure({ timeout: 180_000 }); multiClientTest('a second user on the same client does not inherit the first user library', async ({ createClient }) => { const { page } = await createClient(); const suffix = uniqueName('emoji-bound'); const alice: TestUser = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' }; const bob: TestUser = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' }; const serverName = `Shared Emoji Server ${suffix}`; const libraryEmoji = page.locator('app-custom-emoji-picker [data-custom-emoji-library]'); await test.step('Alice registers, creates a server and uploads a custom emoji', async () => { await new RegisterPage(page).goto(); await submitRegistration(page, alice); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); await createServer(page, serverName); await openComposerEmojiModal(page); await page.locator('app-custom-emoji-picker input[type="file"]').setInputFiles({ name: `partyblob_${suffix}.gif`, mimeType: 'image/gif', buffer: TINY_GIF }); }); await test.step('Alice sees her own uploaded emoji in her library', async () => { await openComposerEmojiModal(page); await expect(libraryEmoji).toHaveCount(1, { timeout: 15_000 }); await page.keyboard.press('Escape'); }); await test.step('Bob signs in on the same client (no reload) and joins the same server', async () => { await logoutClientSide(page); await registerClientSide(page, bob); await joinServerClientSide(page, serverName); }); await test.step('Bob does not inherit Alice custom emoji library', async () => { await openComposerEmojiModal(page); // The modal is open (the file input is asserted inside the helper), so an // empty grid is a genuine assertion rather than a timing artifact. await expect(libraryEmoji).toHaveCount(0); }); }); }); async function createServer(page: Page, serverName: string): Promise { const searchPage = new ServerSearchPage(page); await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 }); await searchPage.createServerButton.click(); await expect(searchPage.serverNameInput).toBeVisible({ timeout: 15_000 }); // Client-side nav can render the form before its `(ngModelChange)` handler is // wired, so an early fill never reaches the backing signal. Clear + refill // until the submit button actually enables. await expect.poll(async () => { await searchPage.serverNameInput.fill(''); await searchPage.serverNameInput.fill(serverName); return searchPage.createSubmitButton.isEnabled(); }, { timeout: 15_000 }).toBe(true); await searchPage.createSubmitButton.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatMessagesPage(page).waitForReady(); } async function joinServerClientSide(page: Page, serverName: string): Promise { const searchPage = new ServerSearchPage(page); await page.locator('a[href="/servers"]').first() .click(); await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 }); await searchPage.searchInput.fill(serverName); const serverCard = page.locator('div[title]', { hasText: serverName }).first(); await expect(serverCard).toBeVisible({ timeout: 20_000 }); await serverCard.dblclick(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await new ChatMessagesPage(page).waitForReady(); } async function openComposerEmojiModal(page: Page): Promise { const picker = page.locator('app-custom-emoji-picker'); const fileInput = picker.locator('input[type="file"]'); // Reset to a known state: dismiss any open picker, then open it fresh. await page.keyboard.press('Escape').catch(() => {}); await expect(picker).toHaveCount(0, { timeout: 5_000 }) .catch(() => {}); await page.locator('app-chat-message-composer') .getByRole('button', { name: 'Open emoji selector' }) .first() .click(); await expect(picker).toBeVisible({ timeout: 10_000 }); // The compact picker exposes a button that opens the full panel (with the // upload field and the custom-emoji grid). await picker.getByRole('button', { name: 'Open emoji selector' }).click(); await expect(fileInput).toBeAttached({ timeout: 10_000 }); } async function registerClientSide(page: Page, user: TestUser): Promise { const loginPage = new LoginPage(page); const registerPage = new RegisterPage(page); await expect(loginPage.registerLink).toBeVisible({ timeout: 15_000 }); await loginPage.registerLink.click(); await expect(registerPage.usernameInput).toBeVisible({ timeout: 15_000 }); await submitRegistration(page, user); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); } /** * Fills the registration form resiliently. On client-side navigation the * template-driven `ngModel` can attach a tick after the input is visible, so an * early `fill` is overwritten back to empty. Re-fill until every value sticks. */ async function submitRegistration(page: Page, user: TestUser): Promise { const username = page.locator('#register-username'); const displayName = page.locator('#register-display-name'); const password = page.locator('#register-password'); await expect.poll(async () => { await username.fill(user.username); await displayName.fill(user.displayName); await password.fill(user.password); return [ await username.inputValue(), await displayName.inputValue(), await password.inputValue() ].join('|'); }, { timeout: 15_000 }).toBe([ user.username, user.displayName, user.password ].join('|')); await page.getByRole('button', { name: 'Create Account' }).click(); } async function logoutClientSide(page: Page): Promise { const menuButton = page.getByRole('button', { name: 'Menu' }); const logoutButton = page.getByRole('button', { name: 'Logout' }); await expect(menuButton).toBeVisible({ timeout: 10_000 }); await menuButton.click(); await expect(logoutButton).toBeVisible({ timeout: 10_000 }); await logoutButton.click(); await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout: 10_000 }); } function uniqueName(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36) .slice(2, 8)}`; }