diff --git a/e2e-tests/playwright/playwright.config.ts b/e2e-tests/playwright/playwright.config.ts index eb012ae879..9f2cf74e6c 100644 --- a/e2e-tests/playwright/playwright.config.ts +++ b/e2e-tests/playwright/playwright.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ use: { browserName: 'chromium', ...devices['iPad Pro 11'], + permissions: ['notifications'], }, }, { diff --git a/e2e-tests/playwright/support/mock_browser_api.ts b/e2e-tests/playwright/support/mock_browser_api.ts new file mode 100644 index 0000000000..494cec3796 --- /dev/null +++ b/e2e-tests/playwright/support/mock_browser_api.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Page} from '@playwright/test'; + +type NotificationData = {title: string} & NotificationOptions; + +// Extend the Window interface to add custom properties +declare global { + interface Window { + _originalNotification: typeof Notification; + _notifications: NotificationData[]; + getNotifications: () => NotificationData[]; + } +} + +/** + * `stubNotification` intercepts the Notification API to capture notifications. + * + * Note: + * - Works across browsers and devices, except in headless mode, where stubbing the Notification API is supported only in Firefox and WebKit. + * - An `Error: page.evaluate: window.getNotifications is not a function` may occur if the `stubNotification` function is called before the page has fully loaded. + * + * @param page Page object + * @param permission Permission setting for notifications, with possible values: "default" | "granted" | "denied". Note: A notification sound may still occur even when set to "denied", as the browser might attempt to trigger system notifications. + */ +export async function stubNotification(page: Page, permission: NotificationPermission) { + await page.evaluate((notificationPermission: NotificationPermission) => { + // Override the Notification.requestPermission method + window.Notification.requestPermission = () => Promise.resolve(permission); + + // Copy the original Notification + if (!window._originalNotification) { + window._originalNotification = window.Notification; + } + + // Initialize a list where to capture the notifications + window._notifications = []; + + // Override the Notification constructor + class CustomNotification extends window._originalNotification { + constructor(title: string, options?: NotificationOptions) { + super(title, options); + const notification = {title, ...options}; + window._notifications.push(notification); + } + } + + // Set static properties and permission status + Object.defineProperties(CustomNotification, { + permission: { + get: () => notificationPermission, + }, + requestPermission: { + value: () => Promise.resolve(notificationPermission), + }, + }); + + // Replace the global Notification with the custom one + window.Notification = CustomNotification as unknown as typeof Notification; + + // Method to get all notifications + window.getNotifications = () => window._notifications; + }, permission); +} + +/** + * `waitForNotification` waits for a specified number of notifications to be received on the page within a given timeout. + * @param page Page object + * @param expectedCount Number of notifications to wait for before returning. (default: 1) + * @param timeout Wait time in milliseconds. (default: 5000ms) + * @returns An array of notifications received + */ +export async function waitForNotification( + page: Page, + expectedCount = 1, + timeout: number = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const notifications = await page.evaluate(() => window.getNotifications()); + if (notifications.length >= expectedCount) { + return notifications; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + // eslint-disable-next-line no-console + console.error(`Notification not received within the timeout period of ${timeout}ms`); + return []; +} diff --git a/e2e-tests/playwright/support/test_fixture.ts b/e2e-tests/playwright/support/test_fixture.ts index 2ad0ff6317..b623e4a815 100644 --- a/e2e-tests/playwright/support/test_fixture.ts +++ b/e2e-tests/playwright/support/test_fixture.ts @@ -8,6 +8,7 @@ import {initSetup, getAdminClient} from './server'; import {hideDynamicChannelsContent, waitForAnimationEnd, waitUntil} from './test_action'; import {pages} from './ui/pages'; import {matchSnapshot} from './visual'; +import {stubNotification, waitForNotification} from './mock_browser_api'; export {expect} from '@playwright/test'; @@ -41,7 +42,7 @@ export const test = base.extend({ class PlaywrightExtended { // ./browser_context - readonly testBrowser: TestBrowser; + readonly testBrowser; // ./flag readonly shouldHaveCallsEnabled; @@ -65,6 +66,10 @@ class PlaywrightExtended { // ./visual readonly matchSnapshot; + // ./mock_browser_api + readonly stubNotification; + readonly waitForNotification; + constructor(browser: Browser) { // ./browser_context this.testBrowser = new TestBrowser(browser); @@ -90,6 +95,10 @@ class PlaywrightExtended { // ./visual this.matchSnapshot = matchSnapshot; + + // ./mock_browser_api + this.stubNotification = stubNotification; + this.waitForNotification = waitForNotification; } } diff --git a/e2e-tests/playwright/support/ui/components/channels/post.ts b/e2e-tests/playwright/support/ui/components/channels/post.ts index 94d0b87fbb..3aea5e44e8 100644 --- a/e2e-tests/playwright/support/ui/components/channels/post.ts +++ b/e2e-tests/playwright/support/ui/components/channels/post.ts @@ -63,6 +63,14 @@ export default class ChannelsPost { await this.removePostButton.waitFor(); await this.removePostButton.click(); } + + /** + * `toContainText` verifies if the post contains the specified text. + * @param text Text to be verified in the post + */ + async toContainText(text: string) { + await expect(this.container).toContainText(text); + } } export {ChannelsPost}; diff --git a/e2e-tests/playwright/support/ui/pages/channels.ts b/e2e-tests/playwright/support/ui/pages/channels.ts index f1330ac97a..1731d60e4f 100644 --- a/e2e-tests/playwright/support/ui/pages/channels.ts +++ b/e2e-tests/playwright/support/ui/pages/channels.ts @@ -69,12 +69,20 @@ export default class ChannelsPage { if (teamName) { channelsUrl += `${teamName}`; if (channelName) { - const prefix = channelName.startsWith('@') ? '/messages' : ''; + const prefix = channelName.startsWith('@') ? '/messages' : '/channels'; channelsUrl += `${prefix}/${channelName}`; } } await this.page.goto(channelsUrl); } + + /** + * `postMessage` posts a message in the current channel + * @param message Message to post + */ + async postMessage(message: string) { + await this.centerView.postCreate.postMessage(message); + } } export {ChannelsPage}; diff --git a/e2e-tests/playwright/tests/functional/channels/notifications/notification.spec.ts b/e2e-tests/playwright/tests/functional/channels/notifications/notification.spec.ts new file mode 100644 index 0000000000..56ea4b5ca3 --- /dev/null +++ b/e2e-tests/playwright/tests/functional/channels/notifications/notification.spec.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@e2e-support/test_fixture'; + +test('MM-T483 Channel-wide mentions with uppercase letters', async ({pw, pages, headless, browserName}) => { + test.skip( + headless && browserName !== 'firefox', + 'Works across browsers and devices, except in headless mode, where stubbing the Notification API is supported only in Firefox and WebKit.', + ); + + // Initialize setup and get the required users and team + const {team, adminUser, user} = await pw.initSetup(); + + // Log in as the admin in one browser session and navigate to the "town-square" channel + const {page: adminPage} = await pw.testBrowser.login(adminUser); + const adminChannelPage = new pages.ChannelsPage(adminPage); + await adminChannelPage.goto(team.name, 'town-square'); + await adminChannelPage.toBeVisible(); + + // Stub the Notification in the admin's browser to capture notifications + await pw.stubNotification(adminPage, 'granted'); + + // Log in as the regular user in a separate browser and navigate to the "off-topic" channel + const {page: otherPage} = await pw.testBrowser.login(user); + const otherChannelPage = new pages.ChannelsPage(otherPage); + await otherChannelPage.goto(team.name, 'off-topic'); + await otherChannelPage.toBeVisible(); + + // Post a channel-wide mention message "@ALL" in uppercase from the user's browser + const message = `@ALL good morning, ${team.name}!`; + await otherChannelPage.postMessage(message); + + // Wait for a notification to be received in the admin's browser and verify its content + const notifications = await pw.waitForNotification(adminPage); + expect(notifications.length).toBe(1); + + const notification = notifications[0]; + expect(notification.title).toBe('Off-Topic'); + expect(notification.body).toBe(`@${user.username}: ${message}`); + expect(notification.tag).toBe(`@${user.username}: ${message}`); + expect(notification.icon).toContain('.png'); + expect(notification.requireInteraction).toBe(false); + expect(notification.silent).toBe(false); + + // Verify the last post as viewed by the regular user in the "off-topic" channel contains the message and is highlighted + const otherLastPost = await otherChannelPage.centerView.getLastPost(); + await otherLastPost.toContainText(message); + await expect(otherLastPost.container.locator('.mention--highlight')).toBeVisible(); + await expect(otherLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); + + // Admin navigates to the "off-topic" channel and verifies the message is posted and highlighted correctly + await adminChannelPage.goto(team.name, 'off-topic'); + const adminLastPost = await adminChannelPage.centerView.getLastPost(); + await adminLastPost.toContainText(message); + await expect(adminLastPost.container.locator('.mention--highlight')).toBeVisible(); + await expect(adminLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); +});