diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_k_channel_switch_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_k_channel_switch_spec.js deleted file mode 100644 index aece046689c..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_k_channel_switch_spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_2_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_2_spec.js index 86762345d5f..272fc118dd6 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_2_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/keyboard_shortcuts_2_spec.js @@ -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}`; diff --git a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/channel_unread_spec.js b/e2e-tests/cypress/tests/integration/channels/mark_as_unread/channel_unread_spec.js index 97524870698..428ad6610ab 100644 --- a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/channel_unread_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/mark_as_unread/channel_unread_spec.js @@ -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); diff --git a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_as_unread_spec.js b/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_as_unread_spec.js index 237ec6d514a..f68078aba44 100644 --- a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_as_unread_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_as_unread_spec.js @@ -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) { diff --git a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_dm_post_as_unread_spec.js b/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_dm_post_as_unread_spec.js deleted file mode 100644 index c08651e3d02..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/mark_as_unread/mark_dm_post_as_unread_spec.js +++ /dev/null @@ -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'); -} diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_followed_by_punctuation_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_followed_by_punctuation_spec.js deleted file mode 100644 index 77b9f1c0375..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_followed_by_punctuation_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js deleted file mode 100644 index 58c9ff1aeba..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js +++ /dev/null @@ -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!'); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/group_message_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/group_message_spec.js index 6d1380b615b..bde96f8e5a9 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/group_message_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/group_message_spec.js @@ -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); -}; diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/message_shortlinking_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/message_shortlinking_spec.js deleted file mode 100644 index b38b3317d8e..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/messaging/message_shortlinking_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/receive_message_on_socket_reconnect_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/receive_message_on_socket_reconnect_spec.js deleted file mode 100644 index cf49386fee4..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/messaging/receive_message_on_socket_reconnect_spec.js +++ /dev/null @@ -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'); - }); - }); - }); - }); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/notifications/ignore_channel_mentions_spec.js b/e2e-tests/cypress/tests/integration/channels/notifications/ignore_channel_mentions_spec.js index fcea4a0fb34..7fef48c2f01 100644 --- a/e2e-tests/cypress/tests/integration/channels/notifications/ignore_channel_mentions_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/notifications/ignore_channel_mentions_spec.js @@ -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) { diff --git a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js index f73938494fe..8788637b388 100644 --- a/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/search/post_search_display_not_cloud_spec.js @@ -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'; diff --git a/e2e-tests/cypress/tests/integration/channels/search_filter/edit_spec.js b/e2e-tests/cypress/tests/integration/channels/search_filter/edit_spec.js deleted file mode 100644 index d59e64844ee..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/search_filter/edit_spec.js +++ /dev/null @@ -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'}}); -} diff --git a/e2e-tests/playwright/README.md b/e2e-tests/playwright/README.md index b6cca6085a4..1581224c691 100644 --- a/e2e-tests/playwright/README.md +++ b/e2e-tests/playwright/README.md @@ -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 diff --git a/e2e-tests/playwright/lib/src/index.ts b/e2e-tests/playwright/lib/src/index.ts index 8addde6ef36..3d132ebe294 100644 --- a/e2e-tests/playwright/lib/src/index.ts +++ b/e2e-tests/playwright/lib/src/index.ts @@ -16,6 +16,7 @@ export { getOnPremServerConfig, isWebhookTestServerReachable, setupWebhookTestServer, + PlaywrightClient4, } from './server'; export { diff --git a/e2e-tests/playwright/lib/src/mock_browser_api.ts b/e2e-tests/playwright/lib/src/mock_browser_api.ts index d1065abec4f..176ce919a92 100644 --- a/e2e-tests/playwright/lib/src/mock_browser_api.ts +++ b/e2e-tests/playwright/lib/src/mock_browser_api.ts @@ -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()); + }); +} diff --git a/e2e-tests/playwright/lib/src/server/client.ts b/e2e-tests/playwright/lib/src/server/client.ts index 70c0b509e50..60b7fbd4984 100644 --- a/e2e-tests/playwright/lib/src/server/client.ts +++ b/e2e-tests/playwright/lib/src/server/client.ts @@ -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 { - 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; }; diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index d17c712d328..42cba631735 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -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'; diff --git a/e2e-tests/playwright/lib/src/server/playwright_client.ts b/e2e-tests/playwright/lib/src/server/playwright_client.ts new file mode 100644 index 00000000000..341460b259c --- /dev/null +++ b/e2e-tests/playwright/lib/src/server/playwright_client.ts @@ -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 { + 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 { + 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; + } +} diff --git a/e2e-tests/playwright/lib/src/test_fixture.ts b/e2e-tests/playwright/lib/src/test_fixture.ts index 9e578f3fd9a..3c94b7aec79 100644 --- a/e2e-tests/playwright/lib/src/test_fixture.ts +++ b/e2e-tests/playwright/lib/src/test_fixture.ts @@ -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; diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts index f9c6c6f888c..46d77ac96ee 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts @@ -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() { diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/channel_notification_preferences_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/channel_notification_preferences_modal.ts new file mode 100644 index 00000000000..27467a2eeaf --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/channel_notification_preferences_modal.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts index 4850beb78e5..6b3a406a7fa 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts @@ -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) { diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts index 3142c58791d..2e06dbf8bdf 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts @@ -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(); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/search_box.ts b/e2e-tests/playwright/lib/src/ui/components/channels/search_box.ts index a4eb9fa0b3d..d0c3f3ac045 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/search_box.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/search_box.ts @@ -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`)}); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts index 71fece2d579..3c9298ba5b6 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts @@ -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. */ diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_right.ts b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_right.ts index 35757eaed7f..8798d59b87b 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_right.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_right.ts @@ -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() { diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index 1d7e83a3488..08600da1631 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -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, diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 8f3482035b1..a09f159b58d 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -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 { + 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 { await this.globalHeader.openSettings(); await this.settingsModal.toBeVisible(); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts index 987b7b64334..94c37b947c3 100644 --- a/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts @@ -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 { - 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> { - const users: Array = []; - - 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 diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_settings_access_control.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_settings_access_control.spec.ts index 07e2180ebca..f9d52ee5905 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_settings_access_control.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_settings_access_control.spec.ts @@ -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); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/helpers.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/helpers.ts index 6e40121e6e8..61771016527 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/helpers.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/helpers.ts @@ -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; } diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_messages.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_messages.spec.ts new file mode 100644 index 00000000000..796bd041710 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_messages.spec.ts @@ -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); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts.spec.ts new file mode 100644 index 00000000000..2839dc0f6c9 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts.spec.ts @@ -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'); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/mark_as_unread/mark_as_unread.spec.ts b/e2e-tests/playwright/specs/functional/channels/mark_as_unread/mark_as_unread.spec.ts new file mode 100644 index 00000000000..82df51118f3 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/mark_as_unread/mark_as_unread.spec.ts @@ -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(); +} diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts new file mode 100644 index 00000000000..76d41ac1cb0 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts @@ -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(':)='); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/notifications/channel_mentions.spec.ts b/e2e-tests/playwright/specs/functional/channels/notifications/channel_mentions.spec.ts new file mode 100644 index 00000000000..4b920c977ab --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/notifications/channel_mentions.spec.ts @@ -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'); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search.spec.ts new file mode 100644 index 00000000000..15c205acc71 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search.spec.ts @@ -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); +} diff --git a/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts b/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts index 23a5280d72d..8e81224ff77 100644 --- a/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts +++ b/e2e-tests/playwright/specs/functional/channels/team_settings/helpers.ts @@ -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( diff --git a/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_policy_editor.spec.ts b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_policy_editor.spec.ts index caab3184f40..d44cb81f4de 100644 --- a/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_policy_editor.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/team_settings/team_settings_policy_editor.spec.ts @@ -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); }