SEC-10587 E2E/Playwright: Migrate RFQA browser tests (batch 1, 1-20) (#37352)

This commit is contained in:
sabril
2026-07-08 15:20:29 +08:00
committed by GitHub
parent 923881436f
commit 02d770d64b
40 changed files with 1215 additions and 1112 deletions
@@ -1,37 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @keyboard_shortcuts¨
describe('Keyboard Shortcuts', () => {
before(() => {
cy.apiInitSetup().then(({channelUrl}) => {
cy.visit(channelUrl);
});
});
it('MM-T1242 - CTRL/CMD+K - Typed characters are not lost after switching channels', () => {
const message = 'Hello World!';
// # Type CTRL/CMD+K to open 'Switch Channels'
cy.uiGetPostTextBox().cmdOrCtrlShortcut('K');
// * Verify that the suggestion list is visible
cy.get('#suggestionList').should('be.visible');
// # Type ENTER to switch to new channel
cy.get('#quickSwitchInput').type('{enter}');
// # Typing message
cy.get('body').type(message);
// * Textbox should have text equal to message
cy.uiGetPostTextBox().should('have.text', message);
});
});
@@ -38,106 +38,6 @@ describe('Keyboard Shortcuts', () => {
cy.visit(`/${testTeam.name}/channels/town-square`);
});
it('MM-T1239 - CTRL+/ and CMD+/ and /shortcuts', () => {
// # Type CTRL/CMD+/
cy.uiGetPostTextBox().cmdOrCtrlShortcut('/');
// # Verify that the 'Keyboard Shortcuts' modal is open
modalShouldOpen();
// # Verify that the 'Keyboard Shortcuts' modal displays the CTRL/CMD+U shortcut
cy.get('.section').eq(2).within(() => {
cy.findByText('Files').should('be.visible');
cy.get('.shortcut-line').should('be.visible').as('shortcutLine');
if (isMac()) {
cy.get('@shortcutLine').findByText('⌘').should('be.visible');
} else {
cy.get('@shortcutLine').findByText('Ctrl').should('be.visible');
}
cy.get('@shortcutLine').findByText('U').should('be.visible');
});
// # Type CTRL/CMD+/ to close the 'Keyboard Shortcuts' modal
cy.get('body').cmdOrCtrlShortcut('/');
cy.get('#shortcutsModalLabel').should('not.exist');
// # Type /shortcuts
cy.uiGetPostTextBox().clear().type('/shortcuts{enter}');
modalShouldOpen();
// # Close the 'Keyboard Shortcuts' modal using the x button
cy.get('.modal-header button.close').should('have.attr', 'aria-label', 'Close').click();
cy.get('#shortcutsModalLabel').should('not.exist');
// # Type /shortcuts
cy.uiGetPostTextBox().clear().type('/shortcuts{enter}');
// # Close the 'Keyboard Shortcuts' modal by pressing ESC key
cy.get('body').type('{esc}');
cy.get('#shortcutsModalLabel').should('not.exist');
});
it('MM-T1254 - CTRL/CMD+UP; CTRL/CMD+DOWN', () => {
const messagePrefix = 'hello from current user: ';
let message;
const count = 5;
// # Post messages to the center channel
for (let index = 0; index < count; index++) {
message = messagePrefix + index;
cy.postMessage(message);
}
for (let index = 0; index < count; index++) {
// # Type CTRL/CMD+UP
cy.uiGetPostTextBox().cmdOrCtrlShortcut('{uparrow}');
// # Verify that the previous message is displayed
message = messagePrefix + (4 - index);
cy.uiGetPostTextBox().contains(message);
}
// # One extra CTRL/CMD+UP does not change the displayed message
cy.uiGetPostTextBox().cmdOrCtrlShortcut('{uparrow}');
message = messagePrefix + '0';
cy.uiGetPostTextBox().contains(message);
for (let index = 1; index < count; index++) {
// # Type CTRL/CMD+DOWN
cy.uiGetPostTextBox().cmdOrCtrlShortcut('{downarrow}');
// # Verify that the next message is displayed
message = messagePrefix + index;
cy.uiGetPostTextBox().contains(message);
}
});
it('MM-T1260 - UP arrow', () => {
const message = 'Test';
const editMessage = 'Edit Test';
// # Post message text
cy.uiGetPostTextBox().clear().type(message).type('{enter}').wait(TIMEOUTS.HALF_SEC);
// # Edit previous post
cy.getLastPostId().then(() => {
cy.uiGetPostTextBox().type('{uparrow}');
// * Edit Post Input should appear
cy.get('#edit_textbox').should('be.visible');
// * Edit to the post message and type ENTER
cy.get('#edit_textbox').invoke('val', '').clear().type(editMessage).type('{enter}').wait(TIMEOUTS.HALF_SEC);
});
cy.getLastPostId().then((postId) => {
// * Post should have "Edited"
cy.get(`#postEdited_${postId}`).
should('be.visible').
should('contain', 'Edited');
});
});
it('MM-T1273 - @[character]+TAB', () => {
const userName = `${testUser.username}`;
@@ -56,43 +56,6 @@ describe('channel unread posts', () => {
});
});
it('MM-T246 Mark Post as Unread', () => {
// # Login as other user
cy.apiLogin(otherUser);
// # Switch to channelA
switchToChannel(channelA);
// # Mark the last post as unread
cy.getLastPostId().then((postId) => {
cy.uiClickPostDropdownMenu(postId, 'Mark as Unread');
});
// * Verify the notification separator line exists and present before the unread message
verifyPostNextToNewMessageSeparator('hello from current user: 4');
// # Switch to channelB
switchToChannel(channelB);
// * Verify the channelA has unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beUnread);
// # Switch to channelA
switchToChannel(channelA);
// * Verify the channelA has does not have unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beRead);
// * Verify the notification separator line exists and present before the unread message
verifyPostNextToNewMessageSeparator('hello from current user: 4');
// # Switch to channelB
switchToChannel(channelB);
// * Verify the channelA has does not have unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beRead);
});
it('MM-T256 Mark unread before a page of message in Channel', () => {
// # Login as other user
cy.apiLogin(otherUser);
@@ -403,58 +403,6 @@ describe('Mark as Unread', () => {
// The New Messages line should appear above the selected post
verifyPostNextToNewMessageSeparator('post3');
});
it('MM-T250 Mark as unread in the RHS', () => {
switchToChannel(channelA);
// # Open RHS (reply thread)
cy.clickPostCommentIcon(post1.id);
// # Mark the post as unread from RHS
cy.uiClickPostDropdownMenu(post1.id, 'Mark as Unread', 'RHS_ROOT');
// * Verify the New Messages line should appear above the selected post
verifyPostNextToNewMessageSeparator('post1');
// * Verify the channelA has unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beUnread);
// * Verify the RHS does not have the NotificationSeparator line
cy.get('#rhsContainer').find('.NotificationSeparator').should('not.exist');
// # Switch to channelB
switchToChannel(channelB);
// # Switch to channelA
switchToChannel(channelA);
// * Verify the channelA does not have unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beRead);
// * Hover on the post with holding alt should show cursor
cy.get(`#post_${post2.id}`).trigger('mouseover').type('{alt}', {release: false}).should(showCursor);
// # Mouse click on the post holding alt
cy.get(`#post_${post2.id}`).type('{alt}', {release: false}).click();
// * Verify the post is marked as unread
verifyPostNextToNewMessageSeparator('post2');
// * Verify the channelA has unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beUnread);
// * Verify the RHS does not have the NotificationSeparator line
cy.get('#rhsContainer').find('.NotificationSeparator').should('not.exist');
// # Switch to channelB
switchToChannel(channelB);
// # Switch to channelA
switchToChannel(channelA);
// * Verify the channelA does not have unread in LHS
cy.get(`#sidebarItem_${channelA.name}`).should(beRead);
});
});
function markAsUnreadFromPost(post, rhs = false) {
@@ -1,197 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @messaging
import {verifyPostNextToNewMessageSeparator} from './helpers';
describe('Mark DM post as Unread ', () => {
beforeEach(function() {
cy.apiAdminLogin().
apiCreateUser().
its('user').as('userA').
apiInitSetup().
then(({user, team}) => cy.
apiAddUserToTeam(team.id, this.userA.id).
apiCreateDirectChannel([user.id, this.userA.id]).its('channel').as('dmChannel').
wrap(user).as('mainUser').
wrap(team).as('team').
wrap(`/${team.name}/messages/@${this.userA.username}`).as('link'),
);
});
it('MM-T248_1 Mark DM post as Unread', function() {
const NUMBER_OF_USER_A_UNREAD_MESSAGES = 4;
// # Post initial message from main user
cy.postMessageAs({
sender: this.mainUser,
message: 'Initial message',
channelId: this.dmChannel.id,
});
// # Post several messages from User A
cy.postListOfMessages({
numberOfMessages: 3,
sender: this.userA,
channelId: this.dmChannel.id,
});
// # Post messages from User A meant to be marked as unread
cy.postMessageAs({
sender: this.userA,
message: 'Unread from here',
channelId: this.dmChannel.id,
}).as('unreadFromHere');
cy.postListOfMessages({
numberOfMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES - 1,
sender: this.userA,
channelId: this.dmChannel.id,
});
// # Post more messages from main user
cy.postListOfMessages({
numberOfMessages: 3,
sender: this.mainUser,
channelId: this.dmChannel.id,
});
// # Visit the DM channel and open the thread in RHS
cy.apiLogin(this.mainUser).visit(this.link);
// # Мark the message from user A as unread
cy.then(() => cy.uiClickPostDropdownMenu(this.unreadFromHere.id, 'Mark as Unread'));
// * Verify that new message separator exists above the unread messages
cy.then(() => verifyPostNextToNewMessageSeparator(this.unreadFromHere.data.message));
// * Verify that DM-channel is marked as unread
cy.then(() => verifyChannelIsMarkedUnreadInLHS(this.userA.username, {
numberOfUnreadMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES,
}));
// # Leave DM-channel
cy.get('.SidebarChannel:contains(Off-Topic)').click();
// * Verify that DM-channel is still marked as unread, with the same "mention bubble"
cy.then(() => verifyChannelIsMarkedUnreadInLHS(this.userA.username, {
numberOfUnreadMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES,
}));
// # Return to DM-channel
cy.get(`.SidebarChannel:contains(${this.userA.username})`).click();
// * Verify that DM-channel is marked as read
cy.then(() => verifyChannelIsMarkedReadInLHS(this.userA.username));
// * Verify that new message separator exists above the unread messages
cy.then(() => verifyPostNextToNewMessageSeparator(this.unreadFromHere.data.message));
});
it('MM-T248_2 Mark DM post as Unread in a reply thread', function() {
const NUMBER_OF_USER_A_UNREAD_MESSAGES = 4;
// # Post initial message from main user
cy.postMessageAs({
sender: this.mainUser,
message: 'Initial message',
channelId: this.dmChannel.id,
}).as('root');
// # Post several messages from User A
cy.then(() => cy.postListOfMessages({
numberOfMessages: 3,
sender: this.userA,
channelId: this.dmChannel.id,
rootId: this.root.id,
}));
// # Post messages from User A meant to be marked as unread
cy.then(() => cy.postMessageAs({
sender: this.userA,
message: 'Unread from here',
channelId: this.dmChannel.id,
rootId: this.root.id,
})).as('unreadFromHere');
cy.then(() => cy.postListOfMessages({
numberOfMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES - 1,
sender: this.userA,
channelId: this.dmChannel.id,
rootId: this.root.id,
}));
// # Post more messages from main user
cy.then(() => cy.postListOfMessages({
numberOfMessages: 3,
sender: this.mainUser,
channelId: this.dmChannel.id,
rootId: this.root.id,
}));
// # Visit the DM channel and open the thread in RHS
cy.apiLogin(this.mainUser).visit(this.link);
cy.get('@root').its('id').then(cy.clickPostCommentIcon);
// # Мark the message from user A as unread
cy.then(() => cy.uiClickPostDropdownMenu(this.unreadFromHere.id, 'Mark as Unread', 'RHS_COMMENT'));
// * Verify that new message separator exists above the unread messages
cy.then(() => verifyPostNextToNewMessageSeparator(this.unreadFromHere.data.message));
// * Verify that DM-channel is marked as unread
cy.then(() => verifyChannelIsMarkedUnreadInLHS(this.userA.username, {
numberOfUnreadMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES,
}));
// # Leave DM-channel
cy.get('.SidebarChannel:contains(Off-Topic)').click();
// * Verify that DM-channel is still marked as unread, with the same "mention bubble"
cy.then(() => verifyChannelIsMarkedUnreadInLHS(this.userA.username, {
numberOfUnreadMessages: NUMBER_OF_USER_A_UNREAD_MESSAGES,
}));
// # Return to DM-channel
cy.get(`.SidebarChannel:contains(${this.userA.username})`).click();
// * Verify that DM-channel is marked as read
cy.then(() => verifyChannelIsMarkedReadInLHS(this.userA.username));
// * Verify that new message separator exists above the unread messages
cy.then(() => verifyPostNextToNewMessageSeparator(this.unreadFromHere.data.message));
});
});
function verifyChannelIsMarkedUnreadInLHS(channelName, {numberOfUnreadMessages}) {
cy.
// * Verify that DM-channel is marked as unread
get('.SidebarChannelGroup_content').
contains(channelName).
parent().
should('have.class', 'unread').
// * Verify that DM channel "mention bubble" contains number of unread messages except mainUser's ones
find('.badge').
should('contain.text', numberOfUnreadMessages);
}
function verifyChannelIsMarkedReadInLHS(channelName) {
cy.
// * Verify that DM-channel is marked as read
get('.SidebarChannelGroup_content').
contains(channelName).
parent().
should('not.have.class', 'unread').
// * Verify that DM channel "mention bubble" contains number of unread messages except mainUser's ones
find('.badge').
should('not.exist');
}
@@ -1,41 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @messaging
function emojiVerification(postId) {
// # set the postMessageTextId var
const postMessageTextId = `#postMessageText_${postId}`;
// * Check for the emoji attr of :) is exists
cy.get(`${postMessageTextId} p span span.emoticon`).should('have.attr', 'alt', ':slightly_smiling_face:');
// * Check for the punctuation('=') is exists without space
cy.get(`${postMessageTextId} p`).should('same.text', ':slightly_smiling_face:=');
}
describe('Messaging', () => {
before(() => {
// # Login as test user and visit off-topic
cy.apiInitSetup({loginAfter: true}).then(({offTopicUrl}) => {
cy.visit(offTopicUrl);
});
});
it('MM-T222 Emoji characters followed by punctuation', () => {
// # Post a message
const messageText = ':)=';
cy.postMessage(messageText);
// # Get Last Post ID
cy.getLastPostId().then((postId) => {
emojiVerification(postId);
});
});
});
@@ -1,40 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// [#] indicates a test step (e.g. # Go to a page)
// [*] indicates an assertion (e.g. * Check the title)
// Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @messaging
describe('Messaging', () => {
before(() => {
// # Login as test user and visit off-topic
cy.apiInitSetup({loginAfter: true}).then(({offTopicUrl}) => {
cy.visit(offTopicUrl);
});
});
it('MM-T95 Selecting an emoji from emoji picker should insert it at the cursor position', () => {
// # Write some text in the send box.
cy.uiGetPostTextBox().type('HelloWorld!');
// # Move the cursor to the middle of the text.
cy.uiGetPostTextBox().type('{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow}');
// # Open emoji picker
cy.uiOpenEmojiPicker();
// # Select the grinning emoji from the emoji picker.
cy.clickEmojiInEmojiPicker('grinning');
// * The emoji should be inserted as a Unicode character where the cursor is at the time of selection.
cy.uiGetPostTextBox().should('have.value', 'Hello\uD83D\uDE00World!');
cy.uiGetPostTextBox().type('{enter}');
// * The emoji should be displayed in the post at the position inserted.
cy.getLastPost().find('p').should('contain', 'Hello').and('contain', 'World!');
});
});
@@ -9,9 +9,6 @@
// Group: @channels @messaging
import * as TIMEOUTS from '@/fixtures/timeouts';
import {spyNotificationAs} from '@/support/notification';
describe('Group Message', () => {
let testTeam;
let testUser;
@@ -90,193 +87,4 @@ describe('Group Message', () => {
// * Verify that the user is found and is part of the GM together with the other user
cy.get('#moreDmModal .more-modal__row').should('be.visible').and('contain', otherUser2.username).and('contain', otherUser1.username);
});
it('MM-T460 - Add and Remove users whilst creating a group message', () => {
// # Create a group message with two other users
createGroupMessageWith(users.slice(0, 2));
// # Open channel menu and click Add Members
cy.uiOpenChannelMenu('Add Members');
// # Filter user by username
cy.get('#selectItems input').typeWithForce(users[2].username).wait(TIMEOUTS.HALF_SEC);
// # Click the first user on a filtered list
cy.get('#multiSelectList .clickable').first().click();
// * Assert that member info updates to reflect the new addition
cy.get('#multiSelectHelpMemberInfo').should('contain', 'You can add 4 more people');
// # Click the first user on an unfiltered list
cy.get('#multiSelectList .clickable').first().click();
// * Assert that member info updates to reflect the new addition
cy.get('#multiSelectHelpMemberInfo').should('contain', 'You can add 3 more people');
// # Remove user by clicking the remove(x) button
cy.get('#selectItems .react-select__multi-value__remove').first().click();
// * Assert that member info updates to reflect the new addition
cy.get('#multiSelectHelpMemberInfo').should('contain', 'You can add 4 more people');
// # Remove last user on the list by typing backspace
cy.get('#selectItems input').typeWithForce('{backspace}').wait(TIMEOUTS.HALF_SEC);
// * Assert that member info updates to reflect the new addition
cy.get('#multiSelectHelpMemberInfo').should('contain', 'You can add 5 more people');
});
it('MM-T465 - Assert that group message participant sees', () => {
// # Create a group message with two other users
const participants = users.slice(0, 2);
createGroupMessageWith(participants);
cy.wait(TIMEOUTS.HALF_SEC);
const sortedParticipants = participants.sort((a, b) => {
return a.username > b.username ? 1 : -1;
});
// * Assert that intro message includes the right copy
const expectedChannelInfo = 'This is the start of your group message history with these teammates.';
cy.get('#channelIntro p.channel-intro__text').first().should('contain', expectedChannelInfo);
cy.get('#channelIntro .profile-icon').should('have.length', '2');
cy.location().then((loc) => {
const channelId = loc.pathname.split('/').slice(-1)[0];
// * Assert that sidebar displays the group channel
cy.get(`#sidebarItem_${channelId}`).should('contain', `${sortedParticipants[0].username}, ${sortedParticipants[1].username}`);
// * Assert that member count shows next to the channel name
cy.get(`#sidebarItem_${channelId} .status`).eq(0).should('contain', '2');
});
});
it('MM-T469 - Post an @mention on a group channel', () => {
spyNotificationAs('withNotification', 'granted');
// # Create a group message with two other users
const participants = users.slice(0, 2);
createGroupMessageWith(participants);
cy.wait(TIMEOUTS.HALF_SEC);
// # Post a message as a different user
cy.getCurrentChannelId().then((channelId) => {
cy.postMessageAs({sender: participants[0], message: `@${testUser.username} Hello!!!`, channelId});
// * Assert that user receives notification
cy.wait(TIMEOUTS.HALF_SEC);
cy.get('@withNotification').should('have.been.called');
});
});
it('MM-T475 - Channel preferences, mute channel', () => {
spyNotificationAs('withNotification', 'granted');
// # Create a group message with two other users
const participants = users.slice(0, 2);
createGroupMessageWith(participants);
cy.wait(TIMEOUTS.HALF_SEC);
// # Clicks on Mute Channel through Notification Preferences
cy.uiOpenChannelMenu().within(() => {
// # Set Mute Channel to On
cy.get('#markUnreadEdit').click();
cy.get('#channelNotificationUnmute').click();
cy.get('#saveSetting').click();
// * Assert that channel is muted
cy.get('#toggleMute').should('be.visible');
});
// # Post a message as a different user
cy.getCurrentChannelId().then((channelId) => {
let channelName;
cy.location().then((loc) => {
channelName = loc.pathname.split('/').slice(-1)[0];
});
cy.postMessageAs({sender: participants[0], message: 'Hello all', channelId}).then(() => {
cy.visit(townsquareLink);
// * Assert that user does not receives a notification
cy.get('@withNotification').should('not.have.been.called');
// * Should not have unread mentions indicator.
cy.get(`#sidebarItem_${channelName}`).
scrollIntoView().
find('#unreadMentions').
should('not.exist');
});
cy.postMessageAs({sender: participants[0], message: `@${testUser.username} Hello!!!`, channelId}).then(() => {
cy.apiLogin(testUser);
cy.visit(townsquareLink);
// * Assert that user does not receives a notification
cy.get('@withNotification').should('not.have.been.called');
// * Should have unread mentions indicator.
cy.get(`#sidebarItem_${channelName}`).
scrollIntoView().
get('#unreadMentions').
should('exist');
});
});
});
it('MM-T478 - Open existing group message from More... section', () => {
// # Create a group message with two other users
const participants = users.slice(0, 2);
const sortedParticipants = participants.sort((a, b) => {
return a.username > b.username ? 1 : -1;
});
createGroupMessageWith(participants);
cy.wait(TIMEOUTS.HALF_SEC);
cy.location().then((loc) => {
const channelName = loc.pathname.split('/').slice(-1)[0];
// # Remove GM from the LHS
cy.uiGetChannelSidebarMenu(channelName).within(() => {
cy.findByText('Close Conversation').click();
});
// # Open DM modal
cy.uiAddDirectMessage().click().wait(TIMEOUTS.HALF_SEC);
// # Open previously closed group message
cy.get('#selectItems input').typeWithForce(participants[0].username).wait(TIMEOUTS.HALF_SEC);
cy.get('#multiSelectList .suggestion-list__item').last().click().wait(TIMEOUTS.HALF_SEC);
// * Verify that participants are listed in the input field
cy.get('#selectItems').should('contain', `${sortedParticipants[0].username}${sortedParticipants[1].username}`);
// # Open group message
cy.get('#saveItems').click().wait(TIMEOUTS.HALF_SEC);
// * Verify that page renders with the right information
cy.get('#channelHeaderTitle').should('contain', `${sortedParticipants[0].username}, ${sortedParticipants[1].username}`);
});
});
});
const createGroupMessageWith = (users) => {
const defaultUserLimit = 7;
cy.uiAddDirectMessage().click().wait(TIMEOUTS.HALF_SEC);
cy.get('#multiSelectHelpMemberInfo').should('contain', 'You can add 7 more people');
users.forEach((user, index) => {
cy.get('#selectItems input').typeWithForce(user.username).type('{enter}').wait(TIMEOUTS.HALF_SEC);
// * Assert that member info updates whilst adding new members
cy.get('#multiSelectHelpMemberInfo').should('contain', `You can add ${defaultUserLimit - (index + 1)} more people`);
});
// # Save group message member changes
cy.get('#saveItems').click().wait(TIMEOUTS.HALF_SEC);
};
@@ -1,46 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @messaging
describe('Message', () => {
let testChannel;
let testTeam;
before(() => {
// # Login as test user and go to off-topic
cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => {
testChannel = channel;
testTeam = team;
cy.visit(`/${testTeam.name}/channels/off-topic`);
});
});
it('MM-T175 Channel shortlinking still works when placed in brackets', () => {
// # Post a shortlink of channel
const shortLink = `(~${testChannel.name})`;
const longLink = `~${testChannel.display_name}`;
cy.postMessage('hello');
cy.uiGetPostTextBox().type(shortLink).type('{enter}');
cy.getLastPostId().then((postId) => {
// # Grab last message with the long link url and go to the link
const divPostId = `#postMessageText_${postId}`;
cy.get(divPostId).contains(longLink).click();
// * verify that the url is the same as what was just clicked on
cy.location('pathname').should('contain', `${testTeam.name}/channels/${testChannel.name}`);
// * verify that the channel title represents the same channel that was clicked on
cy.get('#channelHeaderTitle').should('contain', testChannel.display_name);
});
});
});
@@ -1,110 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @messaging
import * as TIMEOUTS from '@/fixtures/timeouts';
describe('Messaging', () => {
let testTeam;
let testChannel;
let testUser;
let userOne;
before(() => {
// # Wrap websocket to be able to connect and close connections on demand
cy.mockWebsockets();
// # Update config to enable "EnableReliableWebSockets"
cy.apiUpdateConfig({ServiceSettings: {EnableReliableWebSockets: true}});
// # Login as test user and go to town-square
cy.apiInitSetup().then(({team, channel, user}) => {
testUser = user;
testTeam = team;
testChannel = channel;
cy.apiCreateUser().then(({user: user1}) => {
userOne = user1;
cy.apiAddUserToTeam(testTeam.id, userOne.id).then(() => {
cy.apiAddUserToChannel(testChannel.id, userOne.id);
});
});
cy.apiLogin(testUser);
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
// # Post several messages to establish websocket connection
Cypress._.times(5, (i) => {
cy.postMessage(i);
});
});
});
it('MM-T94 RHS fetches messages on socket reconnect when a different channel is in center', () => {
// # Connect all sockets
window.mockWebsockets.forEach((value) => {
value.connect();
});
// # Post a message as another user
cy.postMessageAs({sender: userOne, message: 'abc', channelId: testChannel.id}).wait(TIMEOUTS.FIVE_SEC);
// # Click "Reply"
cy.getLastPostId().then((rootPostId) => {
cy.clickPostCommentIcon(rootPostId);
// # Post a message
cy.postMessageReplyInRHS('def');
// # Change channel
cy.uiGetLhsSection('CHANNELS').findByText('Town Square').click().then(() => {
// # Close all sockets
window.mockWebsockets.forEach((value) => {
if (value.close) {
value.close();
}
});
// # Post message as a different user
cy.postMessageAs({sender: userOne, message: 'ghi', channelId: testChannel.id, rootId: rootPostId});
// # Wait a short time to check whether the message appears or not
cy.wait(TIMEOUTS.FIVE_SEC);
// * Verify that only "def" is posted and not "ghi"
cy.get('#rhsContainer .post-right-comments-container').should('be.visible').children().should('have.length', 1);
cy.get('#rhsContainer .post-right-comments-container').within(() => {
cy.findByText('def').should('be.visible');
cy.findByText('ghi').should('not.exist');
}).then(() => {
// * Connect all sockets one more time
window.mockWebsockets.forEach((value) => {
value.connect();
});
// # Wait for sockets to be connected
cy.wait(TIMEOUTS.THREE_SEC);
cy.uiGetLhsSection('CHANNELS').findByText('Off-Topic').click();
cy.postMessage('any');
cy.uiGetLhsSection('CHANNELS').findByText('Town Square').click();
cy.postMessage('any');
cy.wait(TIMEOUTS.THREE_SEC);
// * Verify that both "def" and "ghi" are posted on websocket reconnect
cy.get('#rhsContainer .post-right-comments-container').should('be.visible').children().should('have.length', 2);
cy.get('#rhsContainer .post-right-comments-container').within(() => {
cy.findByText('def').should('be.visible');
cy.findByText('ghi').should('be.visible');
});
});
});
});
});
});
@@ -67,25 +67,6 @@ describe('Notifications', () => {
cy.get(`#sidebarItem_${channelA.name}`).wait(TIMEOUTS.ONE_SEC).should('have.class', 'unread-title');
cy.get(`#sidebarItem_${channelA.name} > #unreadMentions`).should('not.exist');
});
it('MM-T568 - Channel Notifications - Turn off Ignore mentions for @channel, @here and @all', () => {
cy.visit(`/${testTeam.name}/channels/${channelA.name}`);
// # Unset ignore mentions
setIgnoreMentions(false);
// # Go to a different channel
cy.visit(`/${testTeam.name}/channels/${channelB.name}`);
// # Post messages as another user on the first channel
cy.postMessageAs({sender: userB, message: '@all test', channelId: channelA.id});
cy.postMessageAs({sender: userB, message: '@channel test', channelId: channelA.id});
cy.postMessageAs({sender: userB, message: '@here test', channelId: channelA.id});
// * Assert the channel is unread with 3 mentions
cy.get(`#sidebarItem_${channelA.name}`).should('have.class', 'unread-title');
cy.get(`#sidebarItem_${channelA.name} > #unreadMentions`).should('exist').wait(TIMEOUTS.ONE_SEC).should('contain', '3');
});
});
function addNumberOfUsersToChannel(num = 1) {
@@ -29,44 +29,6 @@ describe('Search', () => {
});
});
it('MM-T350 - Searching displays results in RHS', () => {
const testSearch = '/test url test-search';
// # Post a message
cy.postMessage(testSearch);
cy.uiGetSearchContainer().click();
// # Search the world 'hello' that is generated by /test command
cy.uiGetSearchBox().type('hello{enter}').wait(TIMEOUTS.HALF_SEC);
cy.get('#search-items-container span').should('contain.text', 'hello');
// # RHS should be visible with search results
cy.get('#search-items-container').should('be.visible');
// * Verify different display formatting of the search results
cy.get('[data-testid="search-item-container"]').first().then(($result) => {
cy.wrap($result).contains('Basic word search: Hello world!');
cy.wrap($result).get('h5.markdown__heading').contains('Hello');
cy.wrap($result).get('.post-code.post-code--wrap code').contains('Hello');
cy.wrap($result).contains('#hello');
// # Jump to conversation
cy.wrap($result).get('a.search-item__jump').first().click();
cy.uiGetSearchContainer().click();
// # Hover search query clear icon
cy.get('.input-clear-x').first().trigger('mouseover', {force: true}).then(($span) => {
// # Click the clear query icon
cy.wrap($span).click({force: true});
// * Assert search results are intact
cy.get('[data-testid="search-item-container"]').should('be.visible');
});
});
});
it('MM-T2286 - Clicking a hashtag from a message opens messages with that hashtag on RHS', () => {
const testSearch = '/test url test-search';
@@ -1,144 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @search_date_filter
import {
getMsAndQueryForDate,
getTestMessages,
searchAndValidate,
setupTestData,
} from './helpers';
import {getAdminAccount} from '@/support/env';
describe('Search Date Filter', () => {
const testData = getTestMessages();
const admin = getAdminAccount();
let anotherAdmin;
let channelUrl;
let channelId;
before(() => {
cy.apiInitSetup({userPrefix: 'other-admin'}).
then(({team, channel, user, channelUrl: url}) => {
anotherAdmin = user;
channelUrl = url;
channelId = channel.id;
// # Visit test channel
cy.visit(channelUrl);
setupTestData(testData, {team, channel, admin, anotherAdmin});
});
});
beforeEach(() => {
cy.reload();
cy.postMessage(Date.now());
});
it('MM-T599 Edit date and search again', () => {
// # Create expected data
const targetMessage = 'calendarUpdate' + Date.now();
const targetDate = getMsAndQueryForDate(Date.UTC(2019, 0, 15, 9, 30));
// # Post message with unique text
cy.postMessageAs({sender: admin, message: targetMessage, channelId, createAt: targetDate.ms});
// # Set clock to custom date and visit test channel like reloading a page to take effect
cy.clock(targetDate.ms, ['Date']);
cy.reload();
// # Type on: into search field
cy.uiGetSearchBox().click().clear().type('on:');
// * Day picker should appear
cy.get('.DayPicker').
as('dayPicker').
should('be.visible');
// # Click on today's date
cy.get('@dayPicker').
find('.DayPicker-Day--today').click();
// * Search field should populate with the correct date, then send rest of query
cy.uiGetSearchBox().
should('have.value', 'on:2019-01-15 ').
click().
type(`${targetMessage}{enter}`).
should('be.empty');
cy.get('#loadingSpinner').should('not.exist');
// * Verify we see our single result
cy.uiGetRHSSearchContainer().
findAllByTestId('search-item-container').
should('be.visible').
and('have.length', 1).
find('.post-message').
should('have.text', targetMessage);
// # Visit test channel to reload a page
cy.visit(channelUrl);
cy.postMessage(Date.now());
// # Back space right after the date to bring up date picker again
cy.uiGetSearchBox().click().clear().
type(`on:2019-01-15 ${targetMessage}`).
type('{leftarrow}'.repeat(targetMessage.length + 1)).
type('{backspace}');
// * Day picker should be visible
cy.get('@dayPicker').should('be.visible');
// # Click on tomorrow's day
cy.get('@dayPicker').
find('.DayPicker-Day--today').
next('.DayPicker-Day').click();
// # Add message to search for, and hit enter
cy.uiGetSearchBox().
should('have.value', `on:2019-01-16 ${targetMessage}`).
click().
type('{enter}').
should('be.empty');
cy.get('#loadingSpinner').should('not.exist');
// * There should be no results
cy.uiGetRHSSearchContainer().
findAllByTestId('search-item-container').
should('have.length', 0);
});
it('MM-T595 Changing timezone changes day search results appears', () => {
const identifier = 'timezone' + Date.now();
const target = getMsAndQueryForDate(Date.UTC(2018, 9, 31, 23, 59));
const targetMessage = 'targetAM ' + identifier;
// # Post message with unique text
cy.postMessageAs({sender: admin, message: targetMessage, channelId, createAt: target.ms});
// * Verify result appears in current timezone
searchAndValidate(`on:${target.query} ${identifier}`, [targetMessage]);
// # Change timezone to alter what posts appear in results
changeTimezone('Europe/Brussels');
// * With same query, no results should appear
searchAndValidate(`on:${target.query} ${identifier}`);
});
});
function changeTimezone(timezone) {
cy.apiPatchMe({timezone: {automaticTimezone: '', manualTimezone: timezone, useAutomaticTimezone: 'false'}});
}
+2
View File
@@ -40,6 +40,8 @@ cd e2e-tests && TEST=playwright make
This approach uses the server's Docker image to create a consistent testing environment. It automatically configures the server with the necessary settings for Playwright tests and handles dependencies.
`ENABLED_DOCKER_SERVICES` defaults to the base `postgres inbucket` set (plus the auto-added `playwright` and `webhook-interactions` services). Expand it only when a test needs an extra dependency (for example `minio` for file storage or `openldap` for LDAP).
#### 2. Install dependencies and run the test.
```bash
+1
View File
@@ -16,6 +16,7 @@ export {
getOnPremServerConfig,
isWebhookTestServerReachable,
setupWebhookTestServer,
PlaywrightClient4,
} from './server';
export {
@@ -5,12 +5,25 @@ import type {Page} from '@playwright/test';
type NotificationData = {title: string} & NotificationOptions;
type MockWebSocket = {
wrappedSocket: WebSocket | null;
onopen: ((ev: Event) => void) | null;
onmessage: ((ev: MessageEvent) => void) | null;
onerror: ((ev: Event) => void) | null;
onclose: ((ev: CloseEvent) => void) | null;
readyState: number;
send(data: string | ArrayBuffer): void;
close(): void;
connect(): void;
};
// Extend the Window interface to add custom properties
declare global {
interface Window {
originalNotification: typeof Notification;
capturedNotifications: NotificationData[];
getNotifications: () => NotificationData[];
mockWebsockets: MockWebSocket[];
}
}
@@ -88,3 +101,100 @@ export async function waitForNotification(
console.error(`Notification not received within the timeout period of ${timeout}ms`);
return [];
}
/**
* `mockWebsockets` wraps `window.WebSocket` so the test can close and reopen the underlying
* socket connection(s) on demand, to simulate a socket disconnect/reconnect without navigating
* away from the page. Mocked sockets do not auto-connect on construction — call
* `connectWebsockets` to open them.
*
* Must be called on a `page` that has not yet navigated (e.g. right after `pw.testBrowser.login()`,
* before `channelsPage.goto(...)`), since it installs the override via `addInitScript` so it's in
* place before the app's own bundle constructs its WebSocket client.
*
* @param page Page object, not yet navigated
*/
export async function mockWebsockets(page: Page) {
await page.addInitScript(() => {
const RealWebSocket = window.WebSocket;
window.mockWebsockets = [];
class MockWebSocketImpl {
// Match the standard WebSocket.readyState values so client code comparing
// against `WebSocket.OPEN` (now this class, since it replaces window.WebSocket)
// sees real state transitions instead of coincidental `undefined === undefined`.
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
wrappedSocket: WebSocket | null = null;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
readyState: number = MockWebSocketImpl.CONNECTING;
private readonly args: [string | URL, (string | string[])?];
constructor(...args: [string | URL, (string | string[])?]) {
this.args = args;
window.mockWebsockets.push(this);
}
send(data: string | ArrayBuffer) {
if (this.wrappedSocket) {
this.wrappedSocket.send(data);
} else if (this.onerror) {
this.onerror(new Event('error'));
}
}
close() {
if (this.wrappedSocket) {
this.readyState = MockWebSocketImpl.CLOSING;
this.wrappedSocket.close(1000);
} else {
this.readyState = MockWebSocketImpl.CLOSED;
}
}
connect() {
const socket = new RealWebSocket(...this.args);
this.readyState = MockWebSocketImpl.CONNECTING;
socket.onopen = (ev) => {
this.readyState = MockWebSocketImpl.OPEN;
this.onopen?.(ev);
};
socket.onmessage = (ev) => this.onmessage?.(ev);
socket.onerror = (ev) => this.onerror?.(ev);
socket.onclose = (ev) => {
this.readyState = MockWebSocketImpl.CLOSED;
this.onclose?.(ev);
};
this.wrappedSocket = socket;
}
}
window.WebSocket = MockWebSocketImpl as unknown as typeof WebSocket;
});
}
/**
* Opens (or reopens) every mocked WebSocket tracked by `mockWebsockets`, simulating a reconnect.
* @param page Page object
*/
export async function connectWebsockets(page: Page) {
await page.evaluate(() => {
window.mockWebsockets.forEach((ws) => ws.connect());
});
}
/**
* Closes every mocked WebSocket tracked by `mockWebsockets`, simulating a disconnect.
* @param page Page object
*/
export async function closeWebsockets(page: Page) {
await page.evaluate(() => {
window.mockWebsockets.forEach((ws) => ws.close());
});
}
@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {PlaywrightClient4} from './playwright_client';
import {testConfig} from '@/test_config';
// Variable to hold cache
@@ -13,7 +14,7 @@ export async function makeClient(
userRequest?: UserRequest,
opts: {useCache?: boolean; skipLog?: boolean} = {useCache: true, skipLog: false},
): Promise<ClientCache> {
const client = new Client4();
const client = new PlaywrightClient4();
client.setUrl(testConfig.baseURL);
try {
@@ -53,6 +54,6 @@ type UserRequest = {
};
type ClientCache = {
client: Client4;
client: PlaywrightClient4;
user: UserProfile | null;
};
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
export {makeClient} from './client';
export {PlaywrightClient4} from './playwright_client';
export {createRandomChannel} from './channel';
export {getOnPremServerConfig, mergeWithOnPremServerConfig} from './default_config';
export {initSetup, getAdminClient} from './init';
@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {createRandomChannel} from './channel';
import {createNewUserProfile} from './user';
/**
* Client4 extended with Playwright test-setup helpers only.
* These are not part of the Mattermost server API — do not add real API wrappers here.
*/
export class PlaywrightClient4 extends Client4 {
async createPublicChannel(teamId: string, displayName = 'Public', name?: string): Promise<Channel> {
return this.createChannel(
createRandomChannel({
teamId,
name: name ?? displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
displayName,
unique: true,
}),
);
}
async createUsers(teamId: string, count: number, prefix = 'user'): Promise<UserProfile[]> {
const users: UserProfile[] = [];
for (let i = 0; i < count; i++) {
const user = await createNewUserProfile(this, {prefix});
await this.addToTeam(teamId, user.id);
users.push(user);
}
return users;
}
}
+13 -1
View File
@@ -50,7 +50,13 @@ import {
} from './test_action';
import {pages} from './ui/pages';
import {matchSnapshot} from './visual';
import {stubNotification, waitForNotification} from './mock_browser_api';
import {
closeWebsockets,
connectWebsockets,
mockWebsockets,
stubNotification,
waitForNotification,
} from './mock_browser_api';
import {duration, getRandomId, newTestPassword, simpleEmailRe, wait} from './util';
export {expect} from '@playwright/test';
@@ -120,6 +126,9 @@ export class PlaywrightExtended {
// ./mock_browser_api
readonly stubNotification;
readonly waitForNotification;
readonly mockWebsockets;
readonly connectWebsockets;
readonly closeWebsockets;
// ./server
readonly createNewUserProfile;
@@ -196,6 +205,9 @@ export class PlaywrightExtended {
// ./mock_browser_api
this.stubNotification = stubNotification;
this.waitForNotification = waitForNotification;
this.mockWebsockets = mockWebsockets;
this.connectWebsockets = connectWebsockets;
this.closeWebsockets = closeWebsockets;
// ./server
this.createNewUserProfile = createNewUserProfile;
@@ -27,6 +27,9 @@ export default class ChannelsCenterView {
readonly channelBanner;
readonly autotranslationBadge;
readonly flagPostConfirmationDialog;
readonly notificationSeparator;
readonly postViews;
readonly channelIntro;
constructor(container: Locator, page: Page) {
this.container = container;
@@ -44,6 +47,9 @@ export default class ChannelsCenterView {
page.getByRole('dialog', {name: 'Quarantine for Review'}),
page,
);
this.notificationSeparator = container.locator('.NotificationSeparator');
this.postViews = container.getByTestId('postView');
this.channelIntro = container.locator('#channelIntro');
}
async toBeVisible() {
@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class ChannelNotificationPreferencesModal {
readonly container: Locator;
readonly muteChannelCheckbox;
readonly ignoreMentionsCheckbox;
readonly saveButton;
constructor(container: Locator) {
this.container = container;
this.muteChannelCheckbox = container.getByRole('checkbox', {name: /Mute channel/i});
this.ignoreMentionsCheckbox = container.getByRole('checkbox', {
name: 'Ignore mentions for @channel, @here and @all',
});
this.saveButton = container.getByRole('button', {name: 'Save'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.container.getByText('Mute or ignore').waitFor();
}
async save() {
await this.saveButton.click();
}
}
@@ -11,6 +11,7 @@ export default class DirectChannelsModal {
readonly goButton;
readonly results;
readonly searchInput;
readonly memberLimitHelpText;
constructor(container: Locator) {
this.container = container;
@@ -18,6 +19,7 @@ export default class DirectChannelsModal {
this.goButton = container.getByRole('button', {name: 'Go'});
this.results = container.getByTestId('more-modal-list');
this.searchInput = container.getByRole('combobox', {name: 'Search for people'});
this.memberLimitHelpText = container.locator('#multiSelectHelpMemberInfo');
}
async toBeVisible() {
@@ -32,7 +34,19 @@ export default class DirectChannelsModal {
await row.click();
await expect(this.container.getByRole('button', {name: `Remove ${user.username}`})).toBeVisible();
await expect(this.getRemoveButton(user.username)).toBeVisible();
}
/**
* Locates the "Remove {username}" button for a selected user.
* @param username
*/
getRemoveButton(username: string): Locator {
return this.container.getByRole('button', {name: `Remove ${username}`});
}
async removeUser(username: string) {
await this.getRemoveButton(username).click();
}
async toHaveNUsersSelected(count: number) {
@@ -15,6 +15,8 @@ export default class ChannelsPost {
readonly body;
readonly profileIcon;
readonly emoticon;
readonly messageText;
readonly removePostButton;
@@ -32,6 +34,8 @@ export default class ChannelsPost {
this.body = container.getByTestId('post-body');
this.profileIcon = container.getByTestId('profile-icon');
this.emoticon = container.locator('.emoticon');
this.messageText = container.locator('.post-message__text p');
this.removePostButton = container.getByTestId('post-remove-button');
@@ -70,6 +74,14 @@ export default class ChannelsPost {
return this.profileIcon.getByAltText(`${username} profile image`);
}
/**
* Locates a rendered link with the given accessible name inside the post body.
* @param name
*/
getLink(name: string): Locator {
return this.container.getByRole('link', {name});
}
async openAThread() {
await this.container.hover();
await this.postMenu.toBeVisible();
@@ -44,4 +44,14 @@ export default class SearchBox {
getSelectedSuggestion() {
return this.searchHints.getByTestId('suggestion-selected');
}
/**
* Locates a day cell in the "on:" date-filter day picker by day-of-month.
* Matches on the leading day number in the accessible name (e.g. "15th January (Tuesday)"),
* so callers don't need to compute the ordinal suffix or day-of-week.
* @param dayOfMonth
*/
getDayPickerDay(dayOfMonth: number): Locator {
return this.container.getByRole('button', {name: new RegExp(`^${dayOfMonth}\\D`)});
}
}
@@ -30,16 +30,60 @@ export default class ChannelsSidebarLeft {
}
/**
* Clicks on the sidebar channel link with the given name.
* Locates the sidebar link with the given name.
* It can be any sidebar item name including channels, direct messages, or group messages, threads, etc.
* Falls back to matching by visible text since some sidebar items (e.g. DMs) may not resolve by the plain ID.
* @param name
*/
item(name: string): Locator {
return this.container
.locator(`#sidebarItem_${name}`)
.or(this.container.locator('.SidebarLink').filter({hasText: name}))
.first();
}
/**
* Clicks on the sidebar channel link with the given name.
* @param channelName
*/
async goToItem(channelName: string) {
const channel = this.container.locator(`#sidebarItem_${channelName}`);
const channel = this.item(channelName);
await channel.waitFor();
await channel.click();
}
/**
* Verifies the sidebar item with the given name is in the unread state.
* @param name
*/
async assertItemUnread(name: string) {
await expect(this.item(name)).toHaveClass(/unread|unread-title/);
}
/**
* Verifies the sidebar item with the given name is in the read (not unread) state.
* @param name
*/
async assertItemRead(name: string) {
await expect(this.item(name)).not.toHaveClass(/unread|unread-title/);
}
/**
* Locates the unread-mentions count badge nested inside the sidebar item with the given name.
* @param name
*/
unreadMentionsBadge(name: string): Locator {
return this.item(name).locator('#unreadMentions');
}
/**
* Locates the group message member-count badge nested inside the sidebar item with the given name.
* @param name
*/
memberCountBadge(name: string): Locator {
return this.item(name).locator('.status--group');
}
/**
* Verifies 'Drafts' as a sidebar link exists in LHS.
*/
@@ -26,6 +26,7 @@ export default class ChannelsSidebarRight {
readonly currentVersionEditedPosttext;
readonly restorePreviousPostVersionIcon;
readonly channelBanner;
readonly notificationSeparator;
constructor(container: Locator) {
this.container = container;
@@ -49,6 +50,7 @@ export default class ChannelsSidebarRight {
'button[aria-label="Select to restore an old message."]',
);
this.channelBanner = container.getByTestId('channel_banner_container');
this.notificationSeparator = container.locator('.NotificationSeparator');
}
async toBeVisible() {
@@ -15,6 +15,7 @@ import ChannelsHeader from './channels/header';
import ChannelsPost from './channels/post';
import ChannelsPostCreate from './channels/post_create';
import ChannelsPostEdit from './channels/post_edit';
import ChannelNotificationPreferencesModal from './channels/channel_notification_preferences_modal';
import ChannelSettingsModal from './channels/channel_settings/channel_settings_modal';
import ChannelsSidebarLeft from './channels/sidebar_left';
import ChannelsSidebarRight from './channels/sidebar_right';
@@ -86,6 +87,7 @@ const components = {
ChannelsAppBar,
ChannelsCenterView,
CreateTeamForm,
ChannelNotificationPreferencesModal,
ChannelsHeader,
ChannelsPost,
ChannelsPostCreate,
@@ -165,6 +167,7 @@ export {
ChannelsAppBar,
ChannelsCenterView,
CreateTeamForm,
ChannelNotificationPreferencesModal,
ChannelsHeader,
ChannelsPost,
ChannelsPostCreate,
@@ -6,6 +6,7 @@ import {expect} from '@playwright/test';
import {waitUntil} from 'async-wait-until';
import type {
ChannelNotificationPreferencesModal,
ChannelsPost,
SettingsModal,
TeamSettingsModal,
@@ -30,12 +31,14 @@ export default class ChannelsPage {
readonly messagePriority;
readonly channelSettingsModal;
readonly channelNotificationPreferencesModal;
readonly createTeamForm;
readonly deletePostModal;
readonly findChannelsModal;
readonly newChannelModal;
readonly browseChannelsModal;
readonly directChannelsModal;
readonly keyboardShortcutsModal;
public invitePeopleModal: InvitePeopleModal | undefined;
public membersInvitedModal: MembersInvitedModal | undefined;
readonly profileModal;
@@ -55,6 +58,9 @@ export default class ChannelsPage {
readonly emojiGifPickerPopup;
readonly scheduleMessageMenu;
readonly searchResultsContainer;
readonly searchResultItems;
constructor(page: Page) {
this.page = page;
@@ -70,6 +76,10 @@ export default class ChannelsPage {
// Modals
this.channelSettingsModal = new ChannelSettingsModal(page.getByRole('dialog', {name: 'Channel Settings'}));
this.channelNotificationPreferencesModal = new components.ChannelNotificationPreferencesModal(
page.getByRole('dialog', {name: 'Notification Preferences'}),
);
this.keyboardShortcutsModal = page.getByRole('dialog', {name: /Keyboard shortcuts/});
this.createTeamForm = new CreateTeamForm(page.getByTestId('create-team-form'));
this.deletePostModal = new components.DeletePostModal(page.locator('#deletePostModal'));
this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'}));
@@ -104,7 +114,17 @@ export default class ChannelsPage {
this.postContainer = page.getByTestId('post-message-text');
this.archivedChannelMessage = page.locator('#channelArchivedMessage');
page.locator('#channelHeaderDropdownMenu');
// Search results
this.searchResultsContainer = page.locator('#search-items-container');
this.searchResultItems = page.getByTestId('search-item-container');
}
/**
* Locates a search result item containing the given text.
* @param text
*/
getSearchResultItem(text: string) {
return this.page.getByTestId('search-item-container').filter({hasText: text});
}
async toBeVisible() {
@@ -221,6 +241,19 @@ export default class ChannelsPage {
return this.channelSettingsModal;
}
async openChannelNotificationPreferences(): Promise<ChannelNotificationPreferencesModal> {
await this.centerView.header.openChannelMenu();
await this.page.getByRole('menuitem', {name: 'Notification Preferences'}).click();
await this.channelNotificationPreferencesModal.toBeVisible();
return this.channelNotificationPreferencesModal;
}
async closeGroupMessage() {
await this.centerView.header.openChannelMenu();
await this.page.getByRole('menuitem', {name: 'Close Group Message'}).click();
}
async openSettings(): Promise<SettingsModal> {
await this.globalHeader.openSettings();
await this.settingsModal.toBeVisible();
@@ -1,19 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
import type {PlaywrightClient4, PlaywrightExtended} from '@mattermost/playwright-lib';
export const BOR_TAG = '@burn_on_read';
export type BorSetup = {
adminClient: Client4;
adminClient: PlaywrightClient4;
adminUser: UserProfile;
user: UserProfile;
userClient: Client4;
userClient: PlaywrightClient4;
team: Team;
offTopicUrl: string;
townSquareUrl: string;
@@ -64,40 +63,13 @@ export async function setupBorTest(
*/
export async function createSecondUser(
pw: PlaywrightExtended,
adminClient: Client4,
adminClient: PlaywrightClient4,
team: Team,
): Promise<UserProfile & {password: string}> {
const randomUser = await pw.random.user();
const user = await adminClient.createUser(randomUser, '', '');
(user as any).password = randomUser.password;
await adminClient.addToTeam(team.id, user.id);
const [user] = await adminClient.createUsers(team.id, 1);
return user as UserProfile & {password: string};
}
/**
* Create multiple users and add to team
* @param pw Playwright extended fixture
* @param adminClient Admin client for user creation
* @param team Team to add users to
* @param count Number of users to create
* @returns Array of created users with passwords
*/
export async function createMultipleUsers(
pw: PlaywrightExtended,
adminClient: Client4,
team: Team,
count: number,
): Promise<Array<UserProfile & {password: string}>> {
const users: Array<UserProfile & {password: string}> = [];
for (let i = 0; i < count; i++) {
const user = await createSecondUser(pw, adminClient, team);
users.push(user);
}
return users;
}
/**
* Parse recipient count from tooltip text
* @param tooltipText Tooltip text containing recipient info
@@ -14,7 +14,6 @@ import {
createParentPolicy,
assignChannelsToPolicy,
createPrivateChannel,
createPublicChannel,
createGroupConstrainedPrivateChannel,
setUserAttribute,
addAttributeRule,
@@ -86,7 +85,7 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const channel = await createPublicChannel(adminClient, team.id);
const channel = await adminClient.createPublicChannel(team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
@@ -22,7 +22,7 @@
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {newTestPassword, getRandomId} from '@mattermost/playwright-lib';
import {newTestPassword, getRandomId, type PlaywrightClient4} from '@mattermost/playwright-lib';
import {assignChannelsToPolicy, deletePolicy, unassignChannelsFromPolicy} from '../team_settings/helpers';
@@ -134,14 +134,8 @@ export async function setUserAttributeById(client: Client4, userId: string, fiel
/**
* Create a public channel and register cleanup for it.
*/
export async function createTrackedPublicChannel(client: Client4, teamId: string, ledger: CleanupLedger) {
const id = getRandomId();
const channel = await client.createChannel({
team_id: teamId,
name: `pub-${id}`,
display_name: `PUB-${id}`,
type: 'O',
} as any);
export async function createTrackedPublicChannel(client: PlaywrightClient4, teamId: string, ledger: CleanupLedger) {
const channel = await client.createPublicChannel(teamId);
ledger.add(() => client.deleteChannel(channel.id));
return channel;
}
@@ -0,0 +1,185 @@
// 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 users can be added and removed while creating a group message.
*/
test('MM-T460 Add and remove users while creating new Group Message', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const participants = await adminClient.createUsers(team.id, 3, 'gm-edit');
// # Open the Direct Messages modal and select two users
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const modal = await channelsPage.openDirectChannelsModal();
await modal.selectUser(participants[0]);
await modal.selectUser(participants[1]);
// * Verify two users are selected, and the remaining-members counter reflects the selection
// (a group message allows up to 8 members total, 7 besides the current user)
await expect(modal.getRemoveButton(participants[0].username)).toBeVisible();
await expect(modal.getRemoveButton(participants[1].username)).toBeVisible();
await expect(modal.memberLimitHelpText).toContainText('You can add 5 more people');
// # Remove one user via its remove button
await modal.removeUser(participants[1].username);
// * Verify the counter reflects the removal
await expect(modal.memberLimitHelpText).toContainText('You can add 6 more people');
// # Add a different user
await modal.selectUser(participants[2]);
// * Verify the selected list reflects the removal and addition, and the counter updates again
await expect(modal.getRemoveButton(participants[1].username)).toHaveCount(0);
await expect(modal.getRemoveButton(participants[2].username)).toBeVisible();
await expect(modal.memberLimitHelpText).toContainText('You can add 5 more people');
// # Remove the last selected user via backspace in the empty search input
await modal.searchInput.press('Backspace');
// * Verify the selected list and counter reflect the backspace removal
await expect(modal.getRemoveButton(participants[2].username)).toHaveCount(0);
await expect(modal.memberLimitHelpText).toContainText('You can add 6 more people');
});
/**
* @objective Verify the group message intro, sidebar label, and member count render for participants.
*/
test('MM-T465 Create a group message and show participant details', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const participants = await adminClient.createUsers(team.id, 2, 'gm-intro');
// # Create a group message from the Direct Messages modal
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const modal = await channelsPage.openDirectChannelsModal();
for (const participant of participants) {
await modal.selectUser(participant);
}
await modal.goToChannel();
// * Verify the group message intro, participant avatars, sidebar label, and member count. The
// sidebar label sorts the other participants' usernames with numeric-aware locale comparison
// (digit runs compare by value, not by character code), so match that here rather than a plain
// string sort.
const sortedParticipants = [...participants].sort((a, b) =>
a.username.localeCompare(b.username, undefined, {numeric: true}),
);
await expect(channelsPage.centerView.channelIntro).toContainText('This is the start of your group message history');
await expect(channelsPage.centerView.channelIntro.locator('.profile-icon')).toHaveCount(2);
await channelsPage.centerView.header.toHaveTitle(participants[0].username);
await channelsPage.centerView.header.toHaveTitle(participants[1].username);
await expect(channelsPage.sidebarLeft.item(participants[0].username)).toContainText(
`${sortedParticipants[0].username}, ${sortedParticipants[1].username}`,
);
await expect(channelsPage.sidebarLeft.memberCountBadge(participants[0].username)).toContainText('2');
// # Post a message so the group message persists in the sidebar
await channelsPage.postMessage('Hi group');
// * Verify the message appears in the group message
await (await channelsPage.getLastPost()).toContainText('Hi group');
});
/**
* @objective Verify a mention posted in a group message creates an unread mention for the mentioned participant.
*/
test('MM-T469 Create a group message and post a mention for another user', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [sender, secondParticipant] = await adminClient.createUsers(team.id, 2, 'gm-mention');
const gmChannel = await adminClient.createGroupChannel([user.id, sender.id, secondParticipant.id]);
// # View another channel, then have a participant mention the test user in the GM
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
await adminClient.createPost({
channel_id: gmChannel.id,
user_id: sender.id,
message: `@${user.username} Hello from GM`,
});
// * Verify a desktop notification fires for the mention
const notifications = await pw.waitForNotification(page, 1);
expect(notifications.length).toBeGreaterThanOrEqual(1);
// * Verify the group message becomes unread with a mention badge
await channelsPage.sidebarLeft.assertItemUnread(sender.username);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(sender.username)).toBeVisible();
});
/**
* @objective Verify muting a group message suppresses normal unread notification styling but keeps mention counts.
*/
test('MM-T475 Group Message Channel Preferences Mute channel', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [sender, secondParticipant] = await adminClient.createUsers(team.id, 2, 'gm-mute');
const gmChannel = await adminClient.createGroupChannel([user.id, sender.id, secondParticipant.id]);
// # Open the GM and mute it through notification preferences
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.gotoMessage(team.name, gmChannel.name);
await channelsPage.toBeVisible();
const notificationPreferences = await channelsPage.openChannelNotificationPreferences();
await notificationPreferences.muteChannelCheckbox.check();
await notificationPreferences.save();
// # Post a normal message from another participant while away
// `goto` is a full page navigation, so (re-)stub notifications only once settled on the away channel
await channelsPage.goto(team.name, 'off-topic');
await pw.stubNotification(page, 'granted');
await adminClient.createPost({channel_id: gmChannel.id, user_id: sender.id, message: 'Muted normal GM message'});
// * Verify no notification fires for the normal message while muted. Every GM post implicitly
// mentions all members server-side (server/channels/app/notification.go), so the mention badge
// still shows here even for a non-@mention message — that's current, correct behavior, not a bug.
await pw.wait(duration.two_sec);
expect(await page.evaluate(() => window.getNotifications())).toHaveLength(0);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(sender.username)).toBeVisible();
// # Post a message that mentions the user while away
await adminClient.createPost({
channel_id: gmChannel.id,
user_id: sender.id,
message: `@${user.username} Muted mention GM message`,
});
// * Verify muting still suppresses the notification, but the mention indicator appears
await pw.wait(duration.two_sec);
expect(await page.evaluate(() => window.getNotifications())).toHaveLength(0);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(sender.username)).toBeVisible();
});
/**
* @objective Verify a closed group message can be reopened from the Direct Messages modal.
*/
test('MM-T478 Closing group message channels and re-opening via Direct Messages modal', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const participants = await adminClient.createUsers(team.id, 2, 'gm-reopen');
const gmChannel = await adminClient.createGroupChannel([user.id, participants[0].id, participants[1].id]);
// # Open then close the group message conversation
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.gotoMessage(team.name, gmChannel.name);
await channelsPage.toBeVisible();
await channelsPage.closeGroupMessage();
await expect(channelsPage.sidebarLeft.item(participants[0].username)).toHaveCount(0);
// # Reopen the same GM from the Direct Messages modal. Searching by a single member's username
// only surfaces an option to start a new conversation with that one person, not the existing
// group message, so reselect both participants to resurface the existing conversation.
const modal = await channelsPage.openDirectChannelsModal();
await modal.selectUser(participants[0]);
await modal.selectUser(participants[1]);
await modal.goToChannel();
// * Verify the existing group message opens again
await channelsPage.centerView.header.toHaveTitle(participants[0].username);
await channelsPage.centerView.header.toHaveTitle(participants[1].username);
});
@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify the keyboard shortcuts modal opens from Ctrl/Cmd+/ and /shortcuts, displays the
* platform-specific upload shortcut, and can be closed by the shortcut, its close button, or Escape.
*/
test('MM-T1239 CTRL/CMD+/ and /shortcuts open keyboard shortcuts', async ({pw}) => {
const {user, team} = await pw.initSetup();
// # Open the keyboard shortcuts modal with the shortcut
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.centerView.postCreate.input.focus();
await page.keyboard.press('ControlOrMeta+/');
// * Verify the shortcuts modal opens and shows the platform-specific "Upload files" shortcut
const modal = channelsPage.keyboardShortcutsModal;
await expect(modal).toBeVisible();
const filesSection = modal.locator('.subsection').filter({hasText: 'Files'});
await expect(filesSection).toBeVisible();
await expect(filesSection.getByText(process.platform === 'darwin' ? '⌘' : 'Ctrl')).toBeVisible();
await expect(filesSection.getByText('U', {exact: true})).toBeVisible();
// # Close the modal by pressing the same shortcut again
await page.keyboard.press('ControlOrMeta+/');
await expect(modal).not.toBeVisible();
// # Reopen via the slash command and close using the modal's close button
await channelsPage.postMessage('/shortcuts');
await expect(modal).toBeVisible();
await modal.getByRole('button', {name: 'Close'}).click();
await expect(modal).not.toBeVisible();
// # Reopen via the slash command and close by pressing Escape
await channelsPage.postMessage('/shortcuts');
await expect(modal).toBeVisible();
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible();
});
/**
* @objective Verify Ctrl/Cmd+K channel switch keeps focus so typed characters are not lost.
*/
test('MM-T1242 CTRL/CMD+K typed characters are not lost after switching channels', async ({pw}) => {
const {user, team} = await pw.initSetup();
const message = 'Hello World!';
// # Open quick switcher, select the current channel, and type into the focused page
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.centerView.postCreate.input.focus();
await page.keyboard.press('ControlOrMeta+K');
await expect(channelsPage.findChannelsModal.input).toBeVisible();
await channelsPage.findChannelsModal.input.fill('off');
await channelsPage.findChannelsModal.selectChannel('off-topic');
await channelsPage.centerView.header.toHaveTitle('Off-Topic');
await page.keyboard.type(message);
// * Verify typed characters land in the post textbox
await expect(channelsPage.centerView.postCreate.input).toHaveValue(message);
});
/**
* @objective Verify Ctrl/Cmd+Up and Ctrl/Cmd+Down cycle through previous messages in the post textbox.
*/
test('MM-T1254 CTRL/CMD+UP and CTRL/CMD+DOWN cycle previous messages', async ({pw}) => {
const {user, team} = await pw.initSetup();
const messages = ['post 1', 'post 2', 'post 3', 'post 4', 'post 5'];
// # Post several messages and focus the textbox
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
for (const message of messages) {
await channelsPage.postMessage(message);
}
await channelsPage.centerView.postCreate.input.focus();
// * Verify Ctrl/Cmd+Up cycles backward through message history
for (const message of [...messages].reverse()) {
await page.keyboard.press('ControlOrMeta+ArrowUp');
await expect(channelsPage.centerView.postCreate.input).toHaveValue(message);
}
// * Verify one extra Ctrl/Cmd+Up past the oldest message does not change the displayed message
await page.keyboard.press('ControlOrMeta+ArrowUp');
await expect(channelsPage.centerView.postCreate.input).toHaveValue(messages[0]);
// * Verify Ctrl/Cmd+Down cycles forward through message history
for (const message of messages.slice(1)) {
await page.keyboard.press('ControlOrMeta+ArrowDown');
await expect(channelsPage.centerView.postCreate.input).toHaveValue(message);
}
});
/**
* @objective Verify Up arrow opens inline edit for the previous message and saving marks the post as edited.
*/
test('MM-T1260 UP arrow edits the previous post', async ({pw}) => {
const {user, team} = await pw.initSetup();
// # Post a message and press Up from the center textbox
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.postMessage('Test');
const postId = await channelsPage.centerView.getLastPostID();
await channelsPage.centerView.postCreate.input.focus();
await page.keyboard.press('ArrowUp');
// # Edit and save the previous message
await channelsPage.centerView.postEdit.toBeVisible();
await channelsPage.centerView.postEdit.writeMessage('Edit Test');
await channelsPage.centerView.postEdit.sendMessage();
// * Verify the post was edited and has the edited marker
const editedPost = await channelsPage.getLastPost();
await editedPost.toContainText('Edit Test');
await expect(channelsPage.centerView.editedPostIcon(postId)).toContainText('Edited');
});
@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, type ChannelsPage} from '@mattermost/playwright-lib';
/**
* @objective Verify a public channel post can be marked unread from the post menu.
*/
test('MM-T246 Mark Post as Unread', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [author] = await adminClient.createUsers(team.id, 1, 'unread-author');
const channelA = await adminClient.createPublicChannel(team.id, 'Unread A');
const channelB = await adminClient.createPublicChannel(team.id, 'Unread B');
await adminClient.addToChannel(user.id, channelA.id);
await adminClient.addToChannel(user.id, channelB.id);
await adminClient.addToChannel(author.id, channelA.id);
// # Create messages and mark the last one as unread
await adminClient.createPost({channel_id: channelA.id, user_id: author.id, message: 'hello from current user: 1'});
const unreadPost = await adminClient.createPost({
channel_id: channelA.id,
user_id: author.id,
message: 'hello from current user: 4',
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelA.name);
await channelsPage.toBeVisible();
await markPostAsUnread(channelsPage, unreadPost.id);
// * Verify the unread separator appears at the selected post
await expectUnreadSeparator(channelsPage, 'hello from current user: 4');
// # Switch away and back to the marked channel
await channelsPage.sidebarLeft.goToItem(channelB.name);
// * Verify the original channel is unread while away
await channelsPage.sidebarLeft.assertItemUnread(channelA.name);
await channelsPage.sidebarLeft.goToItem(channelA.name);
// * Verify opening the channel marks it read while preserving the unread separator
await channelsPage.sidebarLeft.assertItemRead(channelA.name);
await expectUnreadSeparator(channelsPage, 'hello from current user: 4');
// # Switch away again
await channelsPage.sidebarLeft.goToItem(channelB.name);
// * Verify the channel remains read, not reverted to unread by leaving again
await channelsPage.sidebarLeft.assertItemRead(channelA.name);
});
/**
* @objective Verify a direct-message post can be marked unread and clears when revisited, preserving the
* mention count across own trailing messages.
*/
test('MM-T248 Mark Direct Message post as Unread', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [otherUser] = await adminClient.createUsers(team.id, 1, 'dm-unread');
const dmChannel = await adminClient.createDirectChannel([user.id, otherUser.id]);
// # Post an initial message, some messages from the other user, the message to mark unread, more
// messages from the other user, then trailing messages from the main user (which should not count
// toward the unread mention badge)
await adminClient.createPost({channel_id: dmChannel.id, user_id: user.id, message: 'Initial message'});
for (let i = 0; i < 3; i++) {
await adminClient.createPost({channel_id: dmChannel.id, user_id: otherUser.id, message: `Before unread ${i}`});
}
const unreadPost = await adminClient.createPost({
channel_id: dmChannel.id,
user_id: otherUser.id,
message: 'Unread from here',
});
for (let i = 0; i < 3; i++) {
await adminClient.createPost({channel_id: dmChannel.id, user_id: otherUser.id, message: `After unread ${i}`});
}
for (let i = 0; i < 3; i++) {
await adminClient.createPost({channel_id: dmChannel.id, user_id: user.id, message: `Own message ${i}`});
}
// # Open the DM channel and mark a post as unread
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, `@${otherUser.username}`);
await channelsPage.toBeVisible();
await markPostAsUnread(channelsPage, unreadPost.id);
// * Verify unread state appears for the DM, with the mention badge counting all 7 of the other
// user's messages, excluding the main user's own 4 messages (1 initial + 3 trailing)
await expectUnreadSeparator(channelsPage, 'Unread from here');
await channelsPage.sidebarLeft.assertItemUnread(otherUser.username);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(otherUser.username)).toHaveText('7');
// # Leave and return to the DM channel
await channelsPage.sidebarLeft.goToItem('off-topic');
await channelsPage.sidebarLeft.assertItemUnread(otherUser.username);
await expect(channelsPage.sidebarLeft.unreadMentionsBadge(otherUser.username)).toHaveText('7');
await channelsPage.sidebarLeft.goToItem(otherUser.username);
// * Verify the DM is marked read after revisiting
await channelsPage.sidebarLeft.assertItemRead(otherUser.username);
await expectUnreadSeparator(channelsPage, 'Unread from here');
});
/**
* @objective Verify a reply post can be marked unread from within an open RHS thread.
*/
test('MM-T248_2 Mark Direct Message post as Unread in a reply thread', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [otherUser] = await adminClient.createUsers(team.id, 1, 'dm-thread-unread');
const dmChannel = await adminClient.createDirectChannel([user.id, otherUser.id]);
// # Post a root message, replies from the other user, the reply to mark unread, more replies from
// the other user, then trailing replies from the main user
const root = await adminClient.createPost({channel_id: dmChannel.id, user_id: user.id, message: 'Initial message'});
for (let i = 0; i < 3; i++) {
await adminClient.createPost({
channel_id: dmChannel.id,
user_id: otherUser.id,
message: `Before unread ${i}`,
root_id: root.id,
});
}
const unreadPost = await adminClient.createPost({
channel_id: dmChannel.id,
user_id: otherUser.id,
message: 'Unread from here',
root_id: root.id,
});
for (let i = 0; i < 3; i++) {
await adminClient.createPost({
channel_id: dmChannel.id,
user_id: otherUser.id,
message: `After unread ${i}`,
root_id: root.id,
});
}
for (let i = 0; i < 3; i++) {
await adminClient.createPost({
channel_id: dmChannel.id,
user_id: user.id,
message: `Own message ${i}`,
root_id: root.id,
});
}
// # Open the DM channel, open the thread in the RHS, and mark the reply as unread
// (marking unread from within an open thread only affects thread-level state under CRT, whether
// the channel is a public channel or a DM, so the LHS/mention badge are unaffected by this action)
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, `@${otherUser.username}`);
await channelsPage.toBeVisible();
await (await channelsPage.centerView.getPostById(root.id)).reply();
await markPostAsUnread(channelsPage, unreadPost.id, true);
// * Verify the RHS shows the unread separator for the reply
await expect(channelsPage.sidebarRight.notificationSeparator).toBeVisible();
});
/**
* @objective Verify a thread post can be marked unread from the RHS.
*/
test('MM-T250 Mark as unread in the RHS', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [author] = await adminClient.createUsers(team.id, 1, 'rhs-unread');
const channelA = await adminClient.createPublicChannel(team.id, 'RHS Unread A');
const channelB = await adminClient.createPublicChannel(team.id, 'RHS Unread B');
await adminClient.addToChannel(user.id, channelA.id);
await adminClient.addToChannel(user.id, channelB.id);
await adminClient.addToChannel(author.id, channelA.id);
// # Create a thread and mark the root post unread from the RHS
const root = await adminClient.createPost({channel_id: channelA.id, user_id: author.id, message: 'post1'});
const reply = await adminClient.createPost({
channel_id: channelA.id,
user_id: author.id,
message: 'post2',
root_id: root.id,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelA.name);
await channelsPage.toBeVisible();
await (await channelsPage.centerView.getPostById(root.id)).reply();
await markPostAsUnread(channelsPage, root.id, true);
// * Verify the RHS shows the unread separator (marking unread from the RHS only affects
// thread-level read state under CRT, so the center channel view is unaffected)
await expect(channelsPage.sidebarRight.notificationSeparator).toBeVisible();
// # Switch away and back
await channelsPage.sidebarLeft.goToItem(channelB.name);
await channelsPage.sidebarLeft.goToItem(channelA.name);
// * Verify the channel returns to read state
await channelsPage.sidebarLeft.assertItemRead(channelA.name);
// # Reopen the thread and mark the reply unread too
await (await channelsPage.centerView.getPostById(root.id)).reply();
await markPostAsUnread(channelsPage, reply.id, true);
// * Verify the RHS shows the unread separator for the reply as well (thread-level state only,
// consistent with marking the root unread above — LHS channel state is unaffected either way)
await expect(channelsPage.sidebarRight.notificationSeparator).toBeVisible();
});
// Alt+click is a real shortcut for "Mark as Unread", equivalent to the post menu's own
// "Mark as Unread" item — used here since the menu item click is flaky against the virtualized post list.
async function markPostAsUnread(channelsPage: ChannelsPage, postId: string, rhs = false) {
const post = rhs
? await channelsPage.sidebarRight.getPostById(postId)
: await channelsPage.centerView.getPostById(postId);
await post.toBeVisible();
await post.container.scrollIntoViewIfNeeded();
await post.container.click({modifiers: ['Alt']});
}
async function expectUnreadSeparator(channelsPage: ChannelsPage, message: string) {
await expect(channelsPage.centerView.notificationSeparator).toBeVisible();
await expect(channelsPage.centerView.postViews.filter({hasText: message})).toBeVisible();
}
@@ -0,0 +1,121 @@
// 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 the RHS thread refreshes replies when the center channel has changed.
*/
test('MM-T94 RHS fetches messages on reconnect while a different channel is in center', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [author] = await adminClient.createUsers(team.id, 1, 'reconnect-author');
const threadChannel = await adminClient.createPublicChannel(team.id, 'RHS Reconnect');
const offTopic = await adminClient.getChannelByName(team.id, 'off-topic');
await adminClient.addToChannel(user.id, threadChannel.id);
await adminClient.addToChannel(author.id, threadChannel.id);
// # Open a thread in the RHS and add an initial reply
const root = await adminClient.createPost({
channel_id: threadChannel.id,
user_id: author.id,
message: 'reconnect root',
});
const {channelsPage, page} = await pw.testBrowser.login(user);
await pw.mockWebsockets(page);
await channelsPage.goto(team.name, threadChannel.name);
await channelsPage.toBeVisible();
await pw.connectWebsockets(page);
await (await channelsPage.centerView.getPostById(root.id)).reply();
await channelsPage.sidebarRight.postMessage('def');
// # Change the center channel, then go offline and have another user reply while disconnected
await channelsPage.sidebarLeft.goToItem(offTopic.name);
await pw.closeWebsockets(page);
await adminClient.createPost({channel_id: threadChannel.id, user_id: author.id, message: 'ghi', root_id: root.id});
await pw.wait(duration.four_sec);
// * Verify the reply posted while disconnected has not reached the RHS
await expect(channelsPage.sidebarRight.container).toContainText('def');
await expect(channelsPage.sidebarRight.container).not.toContainText('ghi');
// # Reconnect and nudge the client with some activity so it resyncs missed messages
await pw.connectWebsockets(page);
await channelsPage.sidebarLeft.goToItem('town-square');
await channelsPage.postMessage('nudge 1');
await channelsPage.sidebarLeft.goToItem(offTopic.name);
await channelsPage.postMessage('nudge 2');
await pw.wait(duration.two_sec);
// * Verify the RHS fetches both replies after reconnecting
await channelsPage.sidebarRight.toContainText('def');
await channelsPage.sidebarRight.toContainText('ghi');
});
/**
* @objective Verify selecting an emoji from the picker inserts it at the current caret position.
*/
test('MM-T95 Selecting an emoji from emoji picker should insert it at the cursor position', async ({pw}) => {
const {user, team} = await pw.initSetup();
// # Log in and place the caret between "Hello" and "World"
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await channelsPage.centerView.postCreate.writeMessage('HelloWorld!');
for (let i = 0; i < 'World!'.length; i++) {
await channelsPage.centerView.postCreate.input.press('ArrowLeft');
}
// # Select the grinning emoji from the picker
await channelsPage.centerView.postCreate.openEmojiPicker();
await channelsPage.emojiGifPickerPopup.toBeVisible();
await channelsPage.emojiGifPickerPopup.clickEmoji('grinning');
// * Verify the emoji was inserted at the caret and can be posted
await expect(channelsPage.centerView.postCreate.input).toHaveValue('Hello 😀 World!');
await channelsPage.centerView.postCreate.sendMessage();
const lastPost = await channelsPage.getLastPost();
await lastPost.toContainText('Hello 😀 World!');
});
/**
* @objective Verify channel short-linking still works when the channel reference is surrounded by brackets.
*/
test('MM-T175 Channel short-linking still works when placed in brackets', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const linkedChannel = await adminClient.createPublicChannel(team.id, 'Shortlink Target');
await adminClient.addToChannel(user.id, linkedChannel.id);
// # Post a bracketed channel shortlink from a different channel
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await channelsPage.postMessage(`(~${linkedChannel.name})`);
// # Click the rendered channel link
const lastPost = await channelsPage.getLastPost();
await lastPost.getLink(linkedChannel.display_name).click();
// * Verify the linked channel opens
await expect(page).toHaveURL(`/${team.name}/channels/${linkedChannel.name}`);
await channelsPage.centerView.header.toHaveTitle(linkedChannel.display_name);
});
/**
* @objective Verify an emoji followed by punctuation renders as an emoji without separating the punctuation.
*/
test('MM-T222 Emoji characters followed by punctuation', async ({pw}) => {
const {user, team} = await pw.initSetup();
// # Post an emoticon followed by punctuation
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await channelsPage.postMessage(':)=');
// * Verify the emoticon renders and the punctuation remains immediately adjacent, with no stray
// whitespace inserted between the rendered emoji and the punctuation
const lastPost = await channelsPage.getLastPost();
await expect(lastPost.emoticon.first()).toHaveAttribute('aria-label', ':slightly_smiling_face:');
await expect(lastPost.messageText.first()).toHaveText(':)=');
});
@@ -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');
});
@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {duration, expect, test, type ChannelsPage} from '@mattermost/playwright-lib';
/**
* @objective Verify message search displays matching results in the right-hand side, rendering markdown
* content correctly, and that jumping to the conversation and clearing the query keep results intact.
*/
test('MM-T350 Searching displays results in the RHS', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.getChannelByName(team.id, 'off-topic');
const identifier = pw.random.id();
const message = [
`Basic word search: Hello world! #hello ${identifier}`,
'',
'##### Hello',
'',
'```',
'Hello',
'```',
].join('\n');
// # Create and search for a message with markdown content: a heading, a code block, and a hashtag
await adminClient.createPost({channel_id: channel.id, user_id: user.id, message});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const query = `hello ${identifier}`;
await submitSearch(channelsPage, query);
// * Verify the matching result appears in the RHS with each markdown element rendered distinctly
await expectSearchResult(channelsPage, 'Hello world', query);
const result = channelsPage.getSearchResultItem('Hello world');
await expect(result.locator('h5.markdown__heading')).toContainText('Hello');
await expect(result.locator('.post-code.post-code--wrap code')).toContainText('Hello');
await expect(result).toContainText('#hello');
// # Jump to the conversation from the search result
await result.getByRole('link', {name: 'Jump'}).click();
// # Reopen search and clear the query text
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.clearIfPossible();
// * Verify the search results remain visible after clearing the query text
await expect(channelsPage.searchResultsContainer).toBeVisible();
});
/**
* @objective Verify changing timezone changes which posts match an on: date filter.
*/
test('MM-T595 Changing timezone changes day search results appears', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.getChannelByName(team.id, 'off-topic');
const identifier = `timezone-${pw.random.id()}`;
const targetMessage = `targetAM ${identifier}`;
const targetTimestamp = Date.UTC(2018, 9, 31, 23, 59);
// # Create a post close to a day boundary and search in UTC
await adminClient.patchUser({
id: user.id,
timezone: {automaticTimezone: '', manualTimezone: 'UTC', useAutomaticTimezone: 'false'},
});
await adminClient.createPost({
channel_id: channel.id,
user_id: user.id,
message: targetMessage,
create_at: targetTimestamp,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const utcQuery = `on:2018-10-31 ${identifier}`;
await submitSearch(channelsPage, utcQuery);
// * Verify the result appears for the UTC date
await expectSearchResult(channelsPage, targetMessage, utcQuery);
// # Change timezone and run the same date-filtered search
await adminClient.patchUser({
id: user.id,
timezone: {automaticTimezone: '', manualTimezone: 'Europe/Brussels', useAutomaticTimezone: 'false'},
});
await channelsPage.page.reload();
await submitSearch(channelsPage, utcQuery);
// * Verify the post no longer matches the previous day in the new timezone
await expectNoSearchResult(channelsPage, targetMessage);
});
/**
* @objective Verify editing a date-filtered search query, via the interactive day picker, updates the search results.
*/
test('MM-T599 Edit date and search again', async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.getChannelByName(team.id, 'off-topic');
const targetMessage = `calendarUpdate-${pw.random.id()}`;
const targetTimestamp = Date.UTC(2019, 0, 15, 9, 30);
// # Create a dated post and pin the client's clock to that date, so "today" in the day picker is Jan 15, 2019
await adminClient.createPost({
channel_id: channel.id,
user_id: user.id,
message: targetMessage,
create_at: targetTimestamp,
});
const {channelsPage, page} = await pw.testBrowser.login(user);
await page.clock.setFixedTime(targetTimestamp);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Type "on:" to open the day picker and click today's date
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.clearIfPossible();
await channelsPage.searchBox.searchInput.fill('on:');
await channelsPage.searchBox.getDayPickerDay(15).click();
// * Verify the search box auto-populates with the selected date
await expect(channelsPage.searchBox.searchInput).toHaveValue('on:2019-01-15 ');
// # Complete and submit the query
await channelsPage.searchBox.searchInput.press('End');
await channelsPage.searchBox.searchInput.pressSequentially(targetMessage);
await channelsPage.searchBox.searchInput.press('Enter');
await expect(channelsPage.searchResultsContainer).toBeVisible();
// * Verify exactly one matching result for the original date
await expect(channelsPage.searchResultItems).toHaveCount(1);
await expect(channelsPage.getSearchResultItem(targetMessage)).toBeVisible();
// # Reopen the channel, then reopen the day picker by backspacing right after the date
await channelsPage.goto(team.name, channel.name);
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.clearIfPossible();
const originalDateQuery = `on:2019-01-15 ${targetMessage}`;
await channelsPage.searchBox.searchInput.fill(originalDateQuery);
for (let i = 0; i < targetMessage.length + 1; i++) {
await channelsPage.searchBox.searchInput.press('ArrowLeft');
}
await channelsPage.searchBox.searchInput.press('Backspace');
// # Click the day after the pinned date
await channelsPage.searchBox.getDayPickerDay(16).click();
// * Verify the search box updates to the edited date, then submit
await expect(channelsPage.searchBox.searchInput).toHaveValue(`on:2019-01-16 ${targetMessage}`);
await channelsPage.searchBox.searchInput.press('Enter');
// * Verify the original post is not returned for the edited date
await expect(channelsPage.searchResultItems).toHaveCount(0);
});
async function submitSearch(channelsPage: ChannelsPage, query: string) {
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.clearIfPossible();
await channelsPage.searchBox.searchInput.fill(query);
await channelsPage.searchBox.searchInput.press('Enter');
await expect(channelsPage.searchResultsContainer).toBeVisible();
}
async function expectSearchResult(
channelsPage: ChannelsPage,
text: string,
query?: string,
timeout = duration.half_min,
) {
const result = channelsPage.getSearchResultItem(text);
await expect(async () => {
if (await result.isVisible({timeout: duration.one_sec}).catch(() => false)) {
return;
}
if (query) {
await submitSearch(channelsPage, query);
} else {
await channelsPage.searchBox.searchInput.press('Enter');
}
await expect(result).toBeVisible({timeout: duration.one_sec * 5});
}).toPass({timeout});
}
async function expectNoSearchResult(channelsPage: ChannelsPage, text: string) {
await expect(channelsPage.getSearchResultItem(text)).toHaveCount(0);
}
@@ -211,11 +211,6 @@ export async function createGroupConstrainedPrivateChannel(client: Client4, team
} as any);
}
export async function createPublicChannel(client: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
return client.createChannel({team_id: teamId, name: `pub-${id}`, display_name: `PUB-${id}`, type: 'O'} as any);
}
export async function createTeamAdmin(adminClient: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
const user = await adminClient.createUser(
@@ -15,7 +15,6 @@ import {
assignChannelsToPolicy,
unassignChannelsFromPolicy,
createPrivateChannel,
createPublicChannel,
createTeamAdmin,
setUserAttribute,
addAttributeRule,
@@ -784,7 +783,7 @@ test.describe('Team Settings Modal - Policy Editor', () => {
// # Create 55 public channels — more than the 50-result autocomplete cap
for (let i = 0; i < 55; i++) {
const pub = await createPublicChannel(adminClient, team.id);
const pub = await adminClient.createPublicChannel(team.id);
await adminClient.addToChannel(teamAdmin.id, pub.id);
}