import { expect, type Locator, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page'; interface DesktopNotificationRecord { title: string; body: string; } interface NotificationScenario { alice: Client; bob: Client; aliceRoom: ChatRoomPage; bobRoom: ChatRoomPage; bobMessages: ChatMessagesPage; serverName: string; channelName: string; } test.describe('Chat notifications', () => { test.describe.configure({ timeout: 180_000 }); test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => { const scenario = await createNotificationScenario(createClient); const message = `Background notification ${uniqueName('msg')}`; await test.step('Bob sends a message to Alice\'s inactive channel', async () => { await clearDesktopNotifications(scenario.alice.page); await scenario.bobRoom.joinTextChannel(scenario.channelName); await scenario.bobMessages.sendMessage(message); }); await test.step('Alice receives a desktop notification with the channel preview', async () => { const notification = await waitForDesktopNotification(scenario.alice.page); expect(notification).toEqual({ title: `${scenario.serverName} ยท #${scenario.channelName}`, body: `Bob: ${message}` }); }); await test.step('Alice sees unread badges for the room and the inactive channel', async () => { await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 }); }); }); test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => { const scenario = await createNotificationScenario(createClient); const message = `Muted notification ${uniqueName('msg')}`; await test.step('Alice mutes the inactive text channel', async () => { await muteTextChannel(scenario.alice.page, scenario.channelName); await clearDesktopNotifications(scenario.alice.page); }); await test.step('Bob sends a message into the muted channel', async () => { await scenario.bobRoom.joinTextChannel(scenario.channelName); await scenario.bobMessages.sendMessage(message); }); await test.step('Alice still sees unread badges for the room and channel', async () => { await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 }); }); await test.step('Alice does not get a muted desktop popup', async () => { const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500); expect(notificationAppeared).toBe(false); }); }); }); async function createNotificationScenario(createClient: () => Promise): Promise { const suffix = uniqueName('notify'); const serverName = `Notifications Server ${suffix}`; const channelName = uniqueName('updates'); const aliceCredentials = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' }; const bobCredentials = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' }; const alice = await createClient(); const bob = await createClient(); await installDesktopNotificationSpy(alice.page); await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password); await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password); const aliceSearch = new ServerSearchPage(alice.page); await aliceSearch.createServer(serverName, { description: 'E2E notification coverage server' }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const bobSearch = new ServerSearchPage(bob.page); const serverCard = bob.page.locator('button', { hasText: serverName }).first(); await bobSearch.searchInput.fill(serverName); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.click(); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const aliceRoom = new ChatRoomPage(alice.page); const bobRoom = new ChatRoomPage(bob.page); const aliceMessages = new ChatMessagesPage(alice.page); const bobMessages = new ChatMessagesPage(bob.page); await aliceMessages.waitForReady(); await bobMessages.waitForReady(); await aliceRoom.ensureTextChannelExists(channelName); await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 }); return { alice, bob, aliceRoom, bobRoom, bobMessages, serverName, channelName }; } async function registerUser(page: Page, username: string, displayName: string, password: string): Promise { const registerPage = new RegisterPage(page); await registerPage.goto(); await registerPage.register(username, displayName, password); await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); } async function installDesktopNotificationSpy(page: Page): Promise { await page.addInitScript(() => { const notifications: DesktopNotificationRecord[] = []; class MockNotification { static permission = 'granted'; static async requestPermission(): Promise { return 'granted'; } onclick: (() => void) | null = null; constructor(title: string, options?: NotificationOptions) { notifications.push({ title, body: options?.body ?? '' }); } close(): void { return; } } Object.defineProperty(window, '__desktopNotifications', { value: notifications, configurable: true }); Object.defineProperty(window, 'Notification', { value: MockNotification, configurable: true, writable: true }); }); } async function clearDesktopNotifications(page: Page): Promise { await page.evaluate(() => { (window as WindowWithDesktopNotifications).__desktopNotifications.length = 0; }); } async function waitForDesktopNotification(page: Page): Promise { await expect.poll( async () => (await readDesktopNotifications(page)).length, { timeout: 20_000, message: 'Expected a desktop notification to be emitted' } ).toBeGreaterThan(0); const notifications = await readDesktopNotifications(page); return notifications[notifications.length - 1]; } async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise { try { await page.waitForFunction( () => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0, undefined, { timeout } ); return true; } catch (error) { if (error instanceof Error && error.name === 'TimeoutError') { return false; } throw error; } } async function readDesktopNotifications(page: Page): Promise { return page.evaluate(() => { return [...(window as WindowWithDesktopNotifications).__desktopNotifications]; }); } async function muteTextChannel(page: Page, channelName: string): Promise { const channelButton = getTextChannelButton(page, channelName); const contextMenu = page.locator('app-context-menu'); await expect(channelButton).toBeVisible({ timeout: 20_000 }); await channelButton.click({ button: 'right' }); await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 }); await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click(); await expect(contextMenu).toHaveCount(0); } function getSavedRoomButton(page: Page, roomName: string): Locator { return page.locator(`button[title="${roomName}"]`).first(); } function getTextChannelButton(page: Page, channelName: string): Locator { return page.locator('app-rooms-side-panel').first() .locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`) .first(); } function getUnreadBadge(container: Locator): Locator { return container.locator('span.rounded-full').first(); } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } interface WindowWithDesktopNotifications extends Window { __desktopNotifications: DesktopNotificationRecord[]; }