Bind custom emoji library membership to the signed-in user instead of the client. CustomEmojiService now tracks saved emoji ids per user id in localStorage (metoyou_custom_emoji_saved:<userId>) and the picker only shows the active user's set, seeded on first load from legacy savedByUser rows the user created. This stops a second account on the same client (or Electron's shared SQLite database) from inheriting another user's emoji picker, while keeping synced assets available for message rendering. Adds unit coverage for per-user scoping and a single-page-load Playwright e2e that switches users client-side (second user joins the first user's server) and asserts no library leak. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.9 KiB
TypeScript
205 lines
7.9 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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)}`;
|
|
}
|