snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {duration, expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify disabling "Ignore mentions" counts @channel, @here, and @all as mentions.
*/
test('MM-T568 Channel Notifications turn off Ignore mentions for @channel, @here and @all', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [sender] = await adminClient.createUsers(team.id, 1, 'channel-mentions');
const channelA = await adminClient.createPublicChannel(team.id, 'Mention Source');
const channelB = await adminClient.createPublicChannel(team.id, 'Mention Away');
await adminClient.addToChannel(user.id, channelA.id);
await adminClient.addToChannel(user.id, channelB.id);
await adminClient.addToChannel(sender.id, channelA.id);
// # Open channel notification preferences and turn off ignore mentions
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelA.name);
await channelsPage.toBeVisible();
const notificationPreferences = await channelsPage.openChannelNotificationPreferences();
if (await notificationPreferences.ignoreMentionsCheckbox.isChecked()) {
await notificationPreferences.ignoreMentionsCheckbox.uncheck();
}
await notificationPreferences.save();
// # Move away and post each channel-wide mention from another user. @here only mentions users who
// are currently online, so force the receiving user's status online first to avoid a race against
// their websocket connection setting it asynchronously. Space the posts apart since firing them
// back-to-back races the per-post async mention-count update and can under-count the total.
await adminClient.updateStatus({user_id: user.id, status: 'online'});
await channelsPage.goto(team.name, channelB.name);
await adminClient.createPost({channel_id: channelA.id, user_id: sender.id, message: '@all test'});
await pw.wait(duration.one_sec);
await adminClient.createPost({channel_id: channelA.id, user_id: sender.id, message: '@channel test'});
await pw.wait(duration.one_sec);
await adminClient.createPost({channel_id: channelA.id, user_id: sender.id, message: '@here test'});
// * Verify the channel is unread and counts all 3 channel-wide mentions exactly
await channelsPage.sidebarLeft.assertItemUnread(channelA.name);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(channelA.name)).toHaveText('3');
});

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
// The notify-all confirmation modal appears once a channel has more than this many members.
const NOTIFY_ALL_THRESHOLD = 5;
const NOTIFY_ALL_TITLE = 'Confirm sending notifications to entire channel';
/**
* @objective Verify that channel-wide mentions with uppercase letters trigger notifications and are properly highlighted.
*
* @precondition
* - Two users are members of the same team
* - Notification permissions are granted in the browser
*/
test(
'MM-T483 triggers notification with uppercase channel-wide mention and highlights message for all users',
{tag: '@notifications'},
async ({pw}) => {
// # 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, channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, 'town-square');
await adminChannelsPage.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 {channelsPage: otherChannelsPage} = await pw.testBrowser.login(user);
await otherChannelsPage.goto(team.name, 'off-topic');
await otherChannelsPage.toBeVisible();
// # Post a channel-wide mention message "@ALL" in uppercase from the user's browser
const message = `@ALL good morning, ${team.name}!`;
await otherChannelsPage.postMessage(message);
// * Verify notification is received in the admin's browser with correct 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}`);
// The Notifications API tag must not leak the message body; the app uses the
// opaque post ID as the tag so notifications coalesce per conversation.
const otherLastPost = await otherChannelsPage.getLastPost();
expect(notification.tag).toBe(await otherLastPost.getId());
expect(notification.tag).not.toContain(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
await otherLastPost.toContainText(message);
await expect(otherLastPost.container.locator('.mention--highlight')).toBeVisible();
await expect(otherLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible();
// # Navigate admin to the "off-topic" channel
await adminChannelsPage.goto(team.name, 'off-topic');
// * Verify the message is posted and highlighted correctly for the admin user
const adminLastPost = await adminChannelsPage.getLastPost();
await adminLastPost.toContainText(message);
await expect(adminLastPost.container.locator('.mention--highlight')).toBeVisible();
await expect(adminLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible();
},
);
/**
* @objective Verify a channel-wide mention typed in mixed case (@channEL) is recognized as a mention,
* prompts the notify-all confirmation, and is posted with its original casing preserved.
*/
test(
'MM-T484 recognizes a mixed-case @channEL channel-wide mention and preserves its casing',
{tag: '@notifications'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
// # Add enough members so the channel exceeds the notify-all threshold
for (let i = 0; i < NOTIFY_ALL_THRESHOLD; i++) {
const extraUser = await pw.createNewUserProfile(adminClient, {prefix: 'channel-wide'});
await adminClient.addToTeam(team.id, extraUser.id);
}
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Type a mixed-case channel-wide mention and send it
const message = `@channEL mixed case mention ${pw.random.id()}`;
await channelsPage.centerView.postCreate.writeMessage(message);
await channelsPage.centerView.postCreate.sendMessage();
// * Verify the notify-all confirmation modal appears (mention was recognized despite casing)
const confirmModal = channelsPage.getConfirmModal(NOTIFY_ALL_TITLE);
await confirmModal.toBeVisible();
// # Confirm sending the notification
await confirmModal.confirm();
// * Verify the posted message preserves the mixed-case mention
const lastPost = await channelsPage.getLastPost();
await lastPost.toContainText(message);
},
);
/**
* @objective Verify an @HERE channel-wide mention typed in uppercase is recognized as a mention,
* prompts the notify-all confirmation, and keeps its uppercase casing after switching channels.
*/
test(
'MM-T485 recognizes an uppercase @HERE channel-wide mention and preserves its casing',
{tag: '@notifications'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
// # Add enough members so the channel exceeds the notify-all threshold
for (let i = 0; i < NOTIFY_ALL_THRESHOLD; i++) {
const extraUser = await pw.createNewUserProfile(adminClient, {prefix: 'channel-wide'});
await adminClient.addToTeam(team.id, extraUser.id);
}
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
// * Verify the "Channel-wide mentions" keyword is enabled by default in notification settings
const settingsModal = await channelsPage.openSettings();
const notificationSettings = await settingsModal.openNotificationsTab();
await notificationSettings.keywordsTriggerNotificationsEditButton.click();
await expect(notificationSettings.channelWideMentionsCheckbox).toBeChecked();
await settingsModal.close();
// # Type an uppercase channel-wide mention and send it
const message = `@HERE channel-wide mention ${pw.random.id()}`;
await channelsPage.centerView.postCreate.writeMessage(message);
await channelsPage.centerView.postCreate.sendMessage();
// * Verify the notify-all confirmation modal appears (mention was recognized despite uppercase)
const confirmModal = channelsPage.getConfirmModal(NOTIFY_ALL_TITLE);
await confirmModal.toBeVisible();
// # Confirm sending the notification
await confirmModal.confirm();
// * Verify the posted message preserves the uppercase mention
const lastPost = await channelsPage.getLastPost();
await lastPost.toContainText(message);
// # Switch to another channel and back
await channelsPage.sidebarLeft.goToItem('town-square');
await channelsPage.sidebarLeft.goToItem('off-topic');
// * Verify the uppercase mention is still shown after returning to the channel
const postAfterSwitch = await channelsPage.getLastPost();
await postAfterSwitch.toContainText(message);
},
);

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify clicking a desktop notification for a message in another team's channel navigates
* the user to that team and channel.
*
* @precondition
* - Desktop notifications are set to "All new messages" so a non-mention post triggers a notification
* - Notification permissions are granted in the browser
*/
test(
'MM-T492 navigates to the originating team and channel when a desktop notification is clicked',
{tag: '@notifications'},
async ({pw}) => {
const {adminClient, adminUser, team, user} = await pw.initSetup();
// # Add the user to a second team that hosts the channel the notification will come from
const secondTeam = await pw.createNewTeam(adminClient, {
name: 'team',
displayName: 'Second Team',
type: 'O',
unique: true,
});
await adminClient.addToTeam(secondTeam.id, user.id);
await adminClient.addToTeam(secondTeam.id, adminUser.id);
const otherChannel = await adminClient.getChannelByName(secondTeam.id, 'off-topic');
// # Set the user's desktop notifications to fire for all new messages
await adminClient.patchUser({
id: user.id,
notify_props: {
...user.notify_props,
desktop: 'all',
desktop_sound: 'false',
},
});
// # Log in and view the first team's Town Square, then stub notifications
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
// # Post a message from the admin in the second team's channel
const message = `Click notification from other team ${pw.random.id()}`;
await adminClient.createPost({channel_id: otherChannel.id, user_id: adminUser.id, message});
// * Verify a desktop notification is received for the second team's channel
const notifications = await pw.waitForNotification(page);
expect(notifications.length).toBe(1);
expect(notifications[0].title).toBe('Off-Topic');
expect(notifications[0].body).toContain(message);
// # Click the desktop notification
await pw.clickNotification(page);
// * Verify the app navigates to the second team's channel where the message was posted
await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${secondTeam.name}/`);
await channelsPage.centerView.header.toHaveTitle('Off-Topic');
await channelsPage.centerView.waitUntilLastPostContains(message);
},
);
/**
* @objective Verify clicking a desktop notification for a direct message navigates the user to that DM.
*
* @precondition
* - Notification permissions are granted in the browser
*/
test(
'MM-T493 navigates to the direct message when its desktop notification is clicked',
{tag: '@notifications'},
async ({pw}) => {
const {adminClient, adminUser, team, user} = await pw.initSetup();
// # Create a direct message channel between the admin and the user
const dmChannel = await adminClient.createDirectChannel([adminUser.id, user.id]);
// # Log in and view Town Square, then stub notifications
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
// # Send a direct message from the admin to the user
const message = `Click notification from DM ${pw.random.id()}`;
await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message});
// * Verify a desktop notification is received for the direct message
const notifications = await pw.waitForNotification(page);
expect(notifications.length).toBe(1);
expect(notifications[0].body).toContain(message);
// # Click the desktop notification
await pw.clickNotification(page);
// * Verify the app navigates to the direct message conversation with the admin
await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain('/messages/');
await channelsPage.centerView.header.toHaveTitle(adminUser.username);
await channelsPage.centerView.waitUntilLastPostContains(message);
},
);
/**
* @objective Verify a user in "Do Not Disturb" does not receive desktop notifications, and receives them
* again once the status is cleared.
*
* @precondition
* - Notification permissions are granted in the browser
*/
test(
'MM-T495 suppresses desktop notifications while Do Not Disturb is enabled',
{tag: '@notifications'},
async ({pw}) => {
const {adminClient, adminUser, team, user} = await pw.initSetup();
// # Create a direct message channel between the admin and the user
const dmChannel = await adminClient.createDirectChannel([adminUser.id, user.id]);
// # Log in and view Town Square (not the DM), then stub notifications
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
// # Send a direct message from the admin while the user is available
const firstMessage = `DND baseline ${pw.random.id()}`;
await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: firstMessage});
// * Verify the direct message triggers a desktop notification
const notifications = await pw.waitForNotification(page);
expect(notifications.length).toBe(1);
expect(notifications[0].body).toContain(firstMessage);
// # Enable Do Not Disturb via the slash command
await channelsPage.postMessage('/dnd');
// * Verify the Do Not Disturb system message confirms it is enabled
await channelsPage.centerView.waitUntilLastPostContains('Do Not Disturb is enabled');
// # Send another direct message from the admin while the user is in Do Not Disturb
const secondMessage = `DND suppressed ${pw.random.id()}`;
await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: secondMessage});
// * Verify no second desktop notification is received while in Do Not Disturb
const suppressed = await pw.waitForNotification(page, 2, pw.duration.two_sec);
expect(suppressed).toHaveLength(0);
// # Clear the status back to online via the slash command
await channelsPage.postMessage('/online');
// * Verify the account menu reflects the online status
const accountMenu = await channelsPage.openUserAccountMenu();
await expect(accountMenu.online).toHaveAttribute('aria-checked', 'true');
},
);

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, testConfig} from '@mattermost/playwright-lib';
async function postToWebhook(webhookId: string, payload: Record<string, unknown>) {
const hookUrl = `${testConfig.baseURL}/hooks/${webhookId}`;
const resp = await fetch(hookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) {
throw new Error(`Webhook POST failed: ${resp.status} ${await resp.text()}`);
}
}
function notificationText(n: {title?: string; body?: string}) {
return `${n.title ?? ''}\n${n.body ?? ''}`;
}
function channelHasMessage(posts: {posts: Record<string, {message?: string}>}, message: string) {
return Object.values(posts.posts).some((p) => p.message?.includes(message));
}
test.describe('Silent notification webhook delivery', () => {
/**
* @objective Verify integration posts with silent=true are delivered in-channel without unread or desktop notify side effects.
*
* @precondition
* - User is a member of a dedicated channel with an incoming webhook
* - User is viewing a different channel so webhook posts are not auto-marked read
* - Desktop notify level is mention-based (default); messages include @username
*/
test(
'incoming webhook silent post is visible without unread or desktop notification',
{tag: ['@notifications']},
async ({pw, headless, browserName}) => {
test.skip(
headless && browserName !== 'firefox' && browserName !== 'webkit',
'In headless mode, Notification stubbing is only reliable in Firefox (and WebKit when configured). Use npm run playwright-ui (headed), --project=firefox, or PW_HEADLESS=false.',
);
const {team, user, adminClient} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'silent-hook',
displayName: 'Silent Hook Test',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
const webhook = await adminClient.createIncomingWebhook({
channel_id: channel.id,
display_name: 'Silent E2E Hook',
});
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Mark the webhook channel as read, then view another channel
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
const channelLink = channelsPage.sidebarLeft.container.locator(`#sidebarItem_${channel.name}`);
// # Post a normal webhook message with @mention to verify unread + notification baseline
const normalMessage = `normal webhook @${user.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: normalMessage});
await expect(channelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect
.poll(async () => (await page.evaluate(() => window.getNotifications())).length)
.toBeGreaterThan(0);
await channelsPage.sidebarLeft.goToItem(channel.name);
const normalPost = await channelsPage.getLastPost();
await normalPost.toContainText(normalMessage);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await expect(channelLink).not.toHaveClass(/unread-title/);
const syncChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'sync-pulse',
displayName: 'Sync Pulse',
unique: true,
}),
);
await adminClient.addToChannel(user.id, syncChannel.id);
const syncChannelLink = channelsPage.sidebarLeft.container.locator(`#sidebarItem_${syncChannel.name}`);
await pw.clearCapturedNotifications(page);
// # Post silent webhook, then a normal post on another channel to flush the WS pipeline (FIFO)
const silentMessage = `silent webhook @${user.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: silentMessage, silent: true});
await expect
.poll(async () => channelHasMessage(await adminClient.getPosts(channel.id, 0, 30), silentMessage))
.toBe(true);
const pulseMessage = `pulse ${Date.now()}`;
await adminClient.createPost({channel_id: syncChannel.id, message: pulseMessage});
await expect(syncChannelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect(channelLink).not.toHaveClass(/unread-title/);
const notificationsAfterSilent = await page.evaluate(() => window.getNotifications());
expect(notificationsAfterSilent.some((n) => notificationText(n).includes(silentMessage))).toBe(false);
// * Silent post is still visible; no New Messages separator (final assertions — goToItem marks viewed)
await channelsPage.sidebarLeft.goToItem(channel.name);
const silentPost = await channelsPage.getLastPost();
await silentPost.toContainText(silentMessage);
await expect(page.locator('[data-testid="NotificationSeparator"]')).not.toBeVisible();
},
);
/**
* @objective Verify a user @mentioned in a silent webhook post does not receive a desktop notification.
*/
test(
'silent webhook @mention does not desktop-notify the mentioned user',
{tag: ['@notifications']},
async ({pw, headless, browserName}) => {
test.skip(
headless && browserName !== 'firefox' && browserName !== 'webkit',
'Desktop notification stubbing is only reliable in headless Firefox and WebKit.',
);
const {team, user, adminClient} = await pw.initSetup();
const mentionedUser = await pw.createNewUserProfile(adminClient);
await adminClient.addToTeam(team.id, mentionedUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'silent-mention',
displayName: 'Silent Mention Test',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.addToChannel(mentionedUser.id, channel.id);
const webhook = await adminClient.createIncomingWebhook({
channel_id: channel.id,
display_name: 'Silent Mention Hook',
});
const {channelsPage: posterChannelsPage} = await pw.testBrowser.login(user);
await posterChannelsPage.goto(team.name, channel.name);
await posterChannelsPage.toBeVisible();
await posterChannelsPage.goto(team.name, 'off-topic');
await posterChannelsPage.toBeVisible();
const {page: mentioneePage, channelsPage: mentioneeChannelsPage} =
await pw.testBrowser.login(mentionedUser);
await mentioneeChannelsPage.goto(team.name, 'off-topic');
await mentioneeChannelsPage.toBeVisible();
await pw.stubNotification(mentioneePage, 'granted');
const channelLink = mentioneeChannelsPage.sidebarLeft.container.locator(`#sidebarItem_${channel.name}`);
// # Baseline: normal @mention webhook should notify the mentioned user and mark channel unread
const normalMessage = `normal mention @${mentionedUser.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: normalMessage});
await expect(channelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect
.poll(async () => (await mentioneePage.evaluate(() => window.getNotifications())).length)
.toBeGreaterThan(0);
// # Mark the channel read by opening it, then return to off-topic for a clean baseline
await mentioneeChannelsPage.sidebarLeft.goToItem(channel.name);
await mentioneeChannelsPage.toBeVisible();
await mentioneeChannelsPage.goto(team.name, 'off-topic');
await mentioneeChannelsPage.toBeVisible();
await expect(channelLink).not.toHaveClass(/unread-title/);
await pw.clearCapturedNotifications(mentioneePage);
// # Silent @mention should not notify the mentioned user nor mark the channel unread
const silentMessage = `silent mention @${mentionedUser.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: silentMessage, silent: true});
await expect
.poll(async () => channelHasMessage(await adminClient.getPosts(channel.id, 0, 30), silentMessage))
.toBe(true);
await expect
.poll(async () => (await mentioneePage.evaluate(() => window.getNotifications())).length)
.toBe(0);
await expect(channelLink).not.toHaveClass(/unread-title/);
const notificationsAfterSilent = await mentioneePage.evaluate(() => window.getNotifications());
expect(notificationsAfterSilent.some((n) => notificationText(n).includes(silentMessage))).toBe(false);
},
);
});

View File

@@ -0,0 +1,326 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AdminConfig} from '@mattermost/types/config';
import type {DeepPartial} from '@mattermost/types/utilities';
import type {TextInputSetting} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
/**
* Patch the Notifications page required fields to known valid values so tests
* that load the page always start with a saveable form state, regardless of
* what other parallel tests may have left in the server config.
*/
async function resetNotificationsConfig(adminClient: {
patchConfig: (config: DeepPartial<AdminConfig>) => Promise<unknown>;
}) {
await adminClient.patchConfig({
EmailSettings: {
FeedbackName: 'Mattermost Notification',
FeedbackEmail: 'notification@mattertest.com',
},
SupportSettings: {
SupportEmail: 'support@mattertest.com',
},
});
}
/** Wait until API reflects required notification fields (guards against concurrent initSetup). */
async function waitForNotificationsServerPreconditions(adminClient: {getConfig: () => Promise<unknown>}) {
await expect
.poll(
async () => {
const c = (await adminClient.getConfig()) as AdminConfig;
const support = c.SupportSettings?.SupportEmail?.trim();
const feedbackEmail = c.EmailSettings?.FeedbackEmail?.trim();
const feedbackName = c.EmailSettings?.FeedbackName?.trim();
return Boolean(support && feedbackEmail && feedbackName);
},
{timeout: 90_000, intervals: [300, 800, 1500, 3000]},
)
.toBe(true);
}
/**
* Populate the required notification text fields so the form is valid before saving. This does not
* assert that Save becomes enabled: the admin console only enables it once the form is dirty, and
* these values may already be what the server holds (e.g. a reused server where this spec ran before).
* Assert on Save at the call sites that actually changed something.
*/
async function fillRequiredNotificationFields(notifications: {
notificationDisplayName: TextInputSetting;
notificationFromAddress: TextInputSetting;
supportEmailAddress: TextInputSetting;
notificationReplyToAddress: TextInputSetting;
}) {
await notifications.notificationDisplayName.container.scrollIntoViewIfNeeded();
await notifications.notificationDisplayName.fill('Mattermost Notification');
await notifications.notificationFromAddress.container.scrollIntoViewIfNeeded();
await notifications.notificationFromAddress.fill('notification@mattertest.com');
await notifications.supportEmailAddress.container.scrollIntoViewIfNeeded();
await notifications.supportEmailAddress.fill('support@mattertest.com');
await notifications.notificationReplyToAddress.container.scrollIntoViewIfNeeded();
await notifications.notificationReplyToAddress.fill('notification@mattertest.com');
}
test.describe('System Console Notifications', () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify that the Push Notification Contents setting is properly displayed and can be changed to all available options
*/
test('Push Notification Contents setting displays correctly and saves all options', async ({pw}) => {
// Multiple reload/save/retry rounds — default 60 s CI timeout is too tight when shards contend on config.
test.setTimeout(240000);
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// Ensure required Notifications fields are populated so the Save button
// starts enabled — prevents state pollution from concurrent initSetup() calls
// that reset FeedbackName and SupportEmail to '' via updateConfig(defaultConfig).
await resetNotificationsConfig(adminClient);
await waitForNotificationsServerPreconditions(adminClient);
// # Update to default config with required notification fields populated
await adminClient.patchConfig({
EmailSettings: {
FeedbackName: 'Mattermost Notification',
FeedbackEmail: 'notification@mattertest.com',
PushNotificationContents: 'full',
},
SupportSettings: {
SupportEmail: 'support@mattertest.com',
},
});
await waitForNotificationsServerPreconditions(adminClient);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.gotoNotificationsSettings();
// # Wait for Notifications section to load
const notifications = systemConsolePage.notifications;
await notifications.toBeVisible();
// Re-apply guard: a concurrent initSetup() may have cleared SupportEmail (a required
// field) between the initial resetNotificationsConfig call and the page rendering here,
// leaving the Save button disabled. Re-apply the config and reload so the form
// renders with all required fields populated.
await resetNotificationsConfig(adminClient);
await waitForNotificationsServerPreconditions(adminClient);
await systemConsolePage.page.reload();
await systemConsolePage.gotoNotificationsSettings();
await notifications.toBeVisible();
await fillRequiredNotificationFields(notifications);
// * Verify that setting is visible and matches text content
await notifications.pushNotificationContents.container.scrollIntoViewIfNeeded();
await notifications.pushNotificationContents.toBeVisible();
// * Verify that the help text is visible and matches text content
const helpText = notifications.pushNotificationContents.helpText;
await expect(helpText).toBeVisible();
const contents = [
'Generic description with only sender name',
' - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. ',
'Generic description with sender and channel names',
' - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. ',
'Full message content sent in the notification payload',
" - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is ",
'highly recommended',
' this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.',
'Full message content fetched from the server on receipt',
' - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed.',
];
await expect(helpText).toHaveText(contents.join(''));
const strongElements = helpText.locator('strong');
await expect(strongElements.nth(0)).toHaveText(contents[0]);
await expect(strongElements.nth(1)).toHaveText(contents[2]);
await expect(strongElements.nth(2)).toHaveText(contents[4]);
await expect(strongElements.nth(3)).toHaveText(contents[6]);
await expect(strongElements.nth(4)).toHaveText(contents[8]);
// * Verify that the option/dropdown is visible and has default value
const dropdown = notifications.pushNotificationContents.dropdown;
await expect(dropdown).toBeVisible();
await expect(dropdown).toHaveValue('full');
const options = [
{label: 'Generic description with only sender name', value: 'generic_no_channel'},
{label: 'Generic description with sender and channel names', value: 'generic'},
{label: 'Full message content sent in the notification payload', value: 'full'},
{label: 'Full message content fetched from the server on receipt', value: 'id_loaded'},
];
// # Select each value and save
// * Verify that the config is correctly saved in the server
for (const option of options) {
let saved = false;
for (let attempt = 0; attempt < 8 && !saved; attempt++) {
await resetNotificationsConfig(adminClient);
await waitForNotificationsServerPreconditions(adminClient);
await systemConsolePage.page.reload();
await systemConsolePage.gotoNotificationsSettings();
await notifications.toBeVisible();
await notifications.pushNotificationContents.container.scrollIntoViewIfNeeded();
await notifications.pushNotificationContents.toBeVisible();
const loopDropdown = notifications.pushNotificationContents.dropdown;
await expect(loopDropdown).toBeVisible();
await loopDropdown.selectOption({label: option.label});
await expect(loopDropdown).toHaveValue(option.value);
await fillRequiredNotificationFields(notifications);
// The dropdown change above dirties the form, so Save must become enabled here.
await expect(notifications.saveButton).not.toBeDisabled({timeout: 25000});
await notifications.save();
const {adminClient: pollClient} = await pw.getAdminClient();
try {
await expect
.poll(
async () => {
const config = await pollClient.getConfig();
return config.EmailSettings?.PushNotificationContents;
},
{timeout: 45000, intervals: [500, 1000, 2000, 3000]},
)
.toBe(option.value);
saved = true;
} catch {
// Concurrent full-config resets can drop the save — reload and retry.
}
}
if (!saved) {
throw new Error(`Failed to save PushNotificationContents=${option.value} after retries`);
}
}
});
/**
* @objective Verify that the Support Email setting can be changed and saved
*/
test('MM-T1210 Can change Support Email setting', async ({pw}) => {
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// Ensure required Notifications fields are populated so the Save button
// starts enabled — prevents state pollution from other parallel tests.
await resetNotificationsConfig(adminClient);
await waitForNotificationsServerPreconditions(adminClient);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.gotoNotificationsSettings();
// # Wait for Notifications section to load
const notifications = systemConsolePage.notifications;
await notifications.toBeVisible();
// # Scroll Support Email section into view and verify that it's visible
await notifications.supportEmailAddress.container.scrollIntoViewIfNeeded();
await notifications.supportEmailAddress.toBeVisible();
// * Verify that the help text is visible and matches text content
await expect(notifications.supportEmailAddress.helpText).toBeVisible();
await expect(notifications.supportEmailAddress.helpText).toHaveText(
'Email address displayed on support emails.',
);
// # Clear and type new email
const newEmail = 'changed_for_test_support@example.com';
await notifications.supportEmailAddress.clear();
await notifications.supportEmailAddress.fill(newEmail);
// * Verify that set value is visible and matches text
await expect(notifications.supportEmailAddress.input).toHaveValue(newEmail);
// # Wait for Save button to be enabled (React processes fill() events asynchronously)
await expect(notifications.saveButton).not.toBeDisabled();
// # Save setting
await notifications.save();
// * Verify that the config is correctly saved in the server
await expect
.poll(async () => {
const config = await adminClient.getConfig();
return config.SupportSettings?.SupportEmail;
})
.toBe(newEmail);
});
/**
* @objective Verify that the save button is disabled when mandatory fields are empty
*/
test('MM-41671 cannot save the notifications page if mandatory fields are missing', async ({pw}) => {
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// Ensure all required fields are populated before the test starts so that
// clearing one field at a time reliably disables the save button, and
// restoring it reliably re-enables it (no other empty field blocking save).
await resetNotificationsConfig(adminClient);
await waitForNotificationsServerPreconditions(adminClient);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit Notifications admin console page (direct URL — sidebar link can be off-screen in CI)
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.gotoNotificationsSettings();
// # Wait for Notifications section to load
const notifications = systemConsolePage.notifications;
await notifications.toBeVisible();
const tests = [
{name: 'Support Email Address', field: notifications.supportEmailAddress},
{name: 'Notification Display Name', field: notifications.notificationDisplayName},
{name: 'Notification From Address', field: notifications.notificationFromAddress},
];
for (const testCase of tests) {
// # Clear the field
await testCase.field.toBeVisible();
await testCase.field.clear();
// Scope error check to this field's container to avoid strict-mode failure
// when other fields on the page also have validation errors simultaneously.
const fieldError = testCase.field.container.locator('.has-error');
// * Error message is shown and save button is disabled
await expect(fieldError).toHaveText(`"${testCase.name}" is required`);
await expect(notifications.saveButton).toBeDisabled();
// # Restore the field with a valid value so format-validation errors from
// this field don't interfere with the next iteration.
await testCase.field.fill('test@example.com');
// * Ensure error for this field is gone and save button is enabled
await expect(fieldError).toHaveCount(0);
await expect(notifications.saveButton).not.toBeDisabled();
}
});
});