diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/add_people_to_channel_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/add_people_to_channel_modal.ts new file mode 100644 index 00000000000..c1a88da24e7 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/add_people_to_channel_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 AddPeopleToChannelModal { + readonly container: Locator; + + readonly closeButton; + readonly alreadyInChannelLabel; + + constructor(container: Locator) { + this.container = container; + + this.closeButton = container.getByRole('button', {name: 'Close'}); + this.alreadyInChannelLabel = container.getByText('Already in channel'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + /** + * Types into the auto-focused react-select search input. The input is not a + * standard textbox, so type via the keyboard once the modal is visible. + */ + async search(text: string) { + await this.toBeVisible(); + await this.container.page().keyboard.type(text); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/marketplace_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/marketplace_modal.ts new file mode 100644 index 00000000000..56cf7c5b5c8 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/marketplace_modal.ts @@ -0,0 +1,29 @@ +// 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 MarketplaceModal { + readonly container: Locator; + + readonly closeButton; + + constructor(container: Locator) { + this.container = container; + + this.closeButton = container.getByRole('button', {name: 'Close'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async notToBeVisible() { + await expect(this.container).not.toBeVisible(); + } + + async close() { + await this.closeButton.click(); + } +} 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 068fd0ab00c..fea3d3a51d2 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 @@ -17,6 +17,8 @@ export default class ChannelsSidebarRight { readonly closeButton; readonly expandButton; readonly collapseButton; + readonly manageMembersButton; + readonly addMembersButton; readonly postCreate; readonly rhsPostBody; readonly scheduledPostIndicator; @@ -47,6 +49,10 @@ export default class ChannelsSidebarRight { this.expandButton = container.getByRole('button', {name: 'Expand Sidebar Icon'}); this.collapseButton = container.getByRole('button', {name: 'Collapse Sidebar Icon'}); + // Member-management controls shown in the channel members list (RHS). + this.manageMembersButton = container.getByRole('button', {name: 'Manage'}); + this.addMembersButton = container.getByRole('button', {name: 'Add'}); + this.editTextbox = container.locator('#edit_textbox'); this.postEdit = new ChannelsPostEdit(container.getByTestId('post-edit-container')); this.currentVersionEditedPosttext = (postID: any) => container.locator(`#rhsPostMessageText_${postID} p`); diff --git a/e2e-tests/playwright/lib/src/ui/components/global_header.ts b/e2e-tests/playwright/lib/src/ui/components/global_header.ts index 8b45e53760d..25a38e61cbe 100644 --- a/e2e-tests/playwright/lib/src/ui/components/global_header.ts +++ b/e2e-tests/playwright/lib/src/ui/components/global_header.ts @@ -18,6 +18,7 @@ export default class GlobalHeader { readonly helpButton; readonly searchBox; readonly userProfileMenu; + readonly appMarketplaceMenuItem; constructor(channelsPage: ChannelsPage, container: Locator) { this.channelsPage = channelsPage; @@ -31,6 +32,9 @@ export default class GlobalHeader { this.helpButton = container.getByRole('button', {name: 'Help'}); this.searchBox = container.locator('#searchFormContainer'); this.userProfileMenu = container.locator('#userAccountMenuButton'); + + // Rendered in a portal at the page level once the product switch menu is open. + this.appMarketplaceMenuItem = container.page().getByRole('menuitem', {name: 'App Marketplace'}); } async toBeVisible(name: string) { @@ -42,6 +46,14 @@ export default class GlobalHeader { await this.container.getByRole('link', {name}).click(); } + /** + * Opens the product switch menu and selects the "App Marketplace" item. + */ + async openAppMarketplace() { + await this.productSwitchMenu.click(); + await this.appMarketplaceMenuItem.click(); + } + async openSettings() { await expect(this.settingsButton).toBeVisible(); await this.settingsButton.click(); diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index db2a1c2ca7c..b227211af47 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -19,6 +19,8 @@ import ChannelNotificationPreferencesModal from './channels/channel_notification import ChannelSettingsModal from './channels/channel_settings/channel_settings_modal'; import ChannelsSidebarLeft from './channels/sidebar_left'; import ChannelsSidebarRight from './channels/sidebar_right'; +import AddPeopleToChannelModal from './channels/add_people_to_channel_modal'; +import MarketplaceModal from './channels/marketplace_modal'; import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog'; import DeletePostModal from './channels/delete_post_modal'; import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal'; @@ -98,6 +100,8 @@ const components = { ChannelSettingsModal, ChannelsSidebarLeft, ChannelsSidebarRight, + AddPeopleToChannelModal, + MarketplaceModal, DeletePostConfirmationDialog, DeletePostModal, DeleteScheduledPostModal, @@ -181,6 +185,8 @@ export { ChannelSettingsModal, ChannelsSidebarLeft, ChannelsSidebarRight, + AddPeopleToChannelModal, + MarketplaceModal, DeletePostConfirmationDialog, DeletePostModal, DeleteScheduledPostModal, diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 66920d95666..3e147567933 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -49,6 +49,7 @@ export default class ChannelsPage { readonly scheduleMessageModal; readonly burnOnReadConfirmationModal; readonly searchResultsPanel; + readonly marketplaceModal; readonly archivedChannelMessage; readonly postContainer; @@ -102,6 +103,7 @@ export default class ChannelsPage { page.getByRole('dialog').filter({hasText: /burn|delete/i}), ); this.searchResultsPanel = new components.SearchResultsPanel(page.locator('#searchContainer')); + this.marketplaceModal = new components.MarketplaceModal(page.getByRole('dialog', {name: 'App Marketplace'})); // Menus // The channel header dropdown menu's accessible name is " Channel Menu". @@ -161,6 +163,10 @@ export default class ChannelsPage { return this.invitePeopleModal; } + getAddPeopleToChannelModal() { + return new components.AddPeopleToChannelModal(this.page.getByRole('dialog', {name: /Add people to/})); + } + async getMembersInvitedModal(teamDisplayName: string) { this.membersInvitedModal = new components.MembersInvitedModal( this.page.getByRole('dialog', {name: `invited to ${teamDisplayName}`}), diff --git a/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_read_only.spec.ts b/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_restrictions.spec.ts similarity index 53% rename from e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_read_only.spec.ts rename to e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_restrictions.spec.ts index 40f8e3e81d1..099889176ce 100644 --- a/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_read_only.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channel_restrictions.spec.ts @@ -43,3 +43,41 @@ test('MM-T1716 hides center and RHS message boxes in an archived channel', {tag: await channelsPage.sidebarRight.toBeVisible(); await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible(); }); + +/** + * @objective Verify that opening the reply thread of a saved post from an archived channel does not offer a reply box. + */ +test('MM-T1722 opens a saved post from an archived channel without a reply box', {tag: '@channels'}, async ({pw}) => { + // # Create and log in as a test user, and prepare a channel they belong to + const {user, team, adminClient, userClient} = await pw.initSetup(); + const message = `Archived saved post ${pw.random.id()}`; + const channel = await adminClient.createPublicChannel(team.id, 'Archive Saved'); + await adminClient.addToChannel(user.id, channel.id); + + // # Post a message in the channel, then archive the channel + const post = await adminClient.createPost({channel_id: channel.id, message}); + await adminClient.deleteChannel(channel.id); + + // # Save the post for the test user + await userClient.savePreferences(user.id, [ + {user_id: user.id, category: 'flagged_post', name: post.id, value: 'true'}, + ]); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open the Saved messages panel + await channelsPage.globalHeader.openSavedMessages(); + + // * Verify the saved post from the archived channel is listed + await channelsPage.searchResultsPanel.toBeVisible(); + await channelsPage.searchResultsPanel.toContainText(message); + + // # Click the reply arrow on the saved post + await channelsPage.searchResultsPanel.replyToResultWithText(message); + + // * Verify the thread opens without a reply box because the channel is archived + await channelsPage.sidebarRight.toBeVisible(); + await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channels.spec.ts b/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channels.spec.ts new file mode 100644 index 00000000000..f526a84b7b5 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/archived_channels/archived_channels.spec.ts @@ -0,0 +1,64 @@ +// 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 member list of an archived channel is viewable but read-only (no member management). + */ +test('MM-T1671 shows a read-only member list for an archived channel', {tag: '@channels'}, async ({pw}) => { + const {adminClient, adminUser, team, user} = await pw.initSetup(); + const channel = await adminClient.createPublicChannel(team.id, `Archive ${pw.random.id()}`); + await adminClient.addToChannel(user.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Archive the channel + await channelsPage.archiveChannel(); + + // # Open the channel members list + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.members.click(); + await channelsPage.sidebarRight.toBeVisible(); + + // * Verify the member list is shown with the channel members + await expect(channelsPage.sidebarRight.container.getByText(adminUser.username).first()).toBeVisible(); + + // * Verify member management controls are not available for the archived channel + await expect(channelsPage.sidebarRight.manageMembersButton).not.toBeVisible(); + await expect(channelsPage.sidebarRight.addMembersButton).not.toBeVisible(); +}); + +/** + * @objective Verify a post from an archived channel can be found in search and its Jump link opens the + * archived channel in read-only mode. + */ +test('MM-T1679 opens an archived channel by jumping from search results', {tag: '@channels'}, async ({pw}) => { + const {adminClient, team, user} = await pw.initSetup(); + const channel = await adminClient.createPublicChannel(team.id, `Archived ${pw.random.id()}`); + await adminClient.addToChannel(user.id, channel.id); + const message = `archived post ${pw.random.id()}`; + 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(); + + // # Archive the channel, then move to another channel + await channelsPage.archiveChannel(); + await channelsPage.sidebarLeft.goToItem('town-square'); + await channelsPage.centerView.header.toHaveTitle('Town Square'); + + // # Search for the message posted in the archived channel + await channelsPage.searchFor(message); + + // # Jump to the post from the search results + await expect(channelsPage.searchResultsPanel.getResultByText(message)).toBeVisible(); + await channelsPage.searchResultsPanel.jumpToResultWithText(message); + + // * Verify the archived channel is opened in read-only mode + await channelsPage.centerView.header.toHaveTitle(channel.display_name); + await expect(channelsPage.archivedChannelMessage).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/archived_channels/saved_post_reply_from_archived_channel.spec.ts b/e2e-tests/playwright/specs/functional/channels/archived_channels/saved_post_reply_from_archived_channel.spec.ts deleted file mode 100644 index 51b6fd3ff9c..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/archived_channels/saved_post_reply_from_archived_channel.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that opening the reply thread of a saved post from an archived channel does not offer a reply box. - */ -test('MM-T1722 opens a saved post from an archived channel without a reply box', {tag: '@channels'}, async ({pw}) => { - // # Create and log in as a test user, and prepare a channel they belong to - const {user, team, adminClient, userClient} = await pw.initSetup(); - const message = `Archived saved post ${pw.random.id()}`; - const channel = await adminClient.createPublicChannel(team.id, 'Archive Saved'); - await adminClient.addToChannel(user.id, channel.id); - - // # Post a message in the channel, then archive the channel - const post = await adminClient.createPost({channel_id: channel.id, message}); - await adminClient.deleteChannel(channel.id); - - // # Save the post for the test user - await userClient.savePreferences(user.id, [ - {user_id: user.id, category: 'flagged_post', name: post.id, value: 'true'}, - ]); - - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Open the Saved messages panel - await channelsPage.globalHeader.openSavedMessages(); - - // * Verify the saved post from the archived channel is listed - await channelsPage.searchResultsPanel.toBeVisible(); - await channelsPage.searchResultsPanel.toContainText(message); - - // # Click the reply arrow on the saved post - await channelsPage.searchResultsPanel.replyToResultWithText(message); - - // * Verify the thread opens without a reply box because the channel is archived - await channelsPage.sidebarRight.toBeVisible(); - await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/add_existing_member.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/add_existing_member.spec.ts new file mode 100644 index 00000000000..1781d549e8e --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/add_existing_member.spec.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that a user already in a channel is shown as "Already in channel" (not addable) in the + * add-people modal. + */ +test( + 'MM-T1809 marks an existing channel member as already in channel in the add people modal', + {tag: '@channel_settings'}, + async ({pw}) => { + const {adminClient, team, user} = await pw.initSetup(); + const [member] = await adminClient.createUsers(team.id, 1, 'existing'); + const channel = await adminClient.createPublicChannel(team.id, `Members ${pw.random.id()}`); + await adminClient.addToChannel(user.id, channel.id); + await adminClient.addToChannel(member.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Open the channel members list and the add-people modal + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.members.click(); + await channelsPage.sidebarRight.toBeVisible(); + await channelsPage.sidebarRight.addMembersButton.click(); + + const addModal = channelsPage.getAddPeopleToChannelModal(); + await addModal.toBeVisible(); + + // # Search for the member who is already in the channel (the react-select input is auto-focused) + await addModal.search(member.username); + + // * Verify the member is marked as already in the channel + await expect(addModal.alreadyInChannelLabel).toBeVisible(); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_header_popover.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_headers.spec.ts similarity index 56% rename from e2e-tests/playwright/specs/functional/channels/channel_settings/channel_header_popover.spec.ts rename to e2e-tests/playwright/specs/functional/channels/channel_settings/channel_headers.spec.ts index 600d6c9facb..a84b5d5bee6 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_header_popover.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_headers.spec.ts @@ -51,3 +51,41 @@ test( await expect(popover).not.toBeVisible(); }, ); + +/** + * @objective Verify a channel header can be added from the "Set header" link in a new channel's intro, + * that cancelling the modal makes no change, and that the saved header appears in the channel info. + */ +test('MM-T880 adds a channel header from the intro Set header link', {tag: '@channel_settings'}, async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Create a new public channel so its intro (with the "Set header" link) is shown + const channelName = `channel header ${pw.random.id()}`; + await channelsPage.newChannel(channelName, 'O'); + await channelsPage.centerView.header.toHaveTitle(channelName); + + // # Open the Edit Header modal from the intro and cancel without entering text + await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click(); + await channelsPage.editChannelHeaderModal.toBeVisible(); + await channelsPage.editChannelHeaderModal.cancel(); + + // # Reopen the Edit Header modal and save a header + const header = 'this is the channel header'; + await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click(); + await channelsPage.editChannelHeaderModal.toBeVisible(); + await channelsPage.editChannelHeaderModal.setHeader(header); + + // * Verify the header-change system message is posted + await channelsPage.centerView.waitUntilLastPostContains(`updated the channel header to: ${header}`); + + // # Open the channel info panel from the channel header menu + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.viewInfo.click(); + await channelsPage.sidebarRight.toBeVisible(); + + // * Verify the saved header appears in the channel info panel + await expect(channelsPage.sidebarRight.container.getByText(header)).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/leave_channel_other_window.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts similarity index 58% rename from e2e-tests/playwright/specs/functional/channels/channel_settings/leave_channel_other_window.spec.ts rename to e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts index a5d25b3a376..c2d234b99e7 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/leave_channel_other_window.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts @@ -3,6 +3,40 @@ import {expect, test} from '@mattermost/playwright-lib'; +/** + * @objective Verify that creating and archiving a public channel in one browser session is synced in + * real time to another session of the same user. + */ +test('MM-T837 syncs public channel create and archive across sessions', {tag: '@channel_settings'}, async ({pw}) => { + const {user, team} = await pw.initSetup(); + + // # Log in as the same user in two separate browser sessions + const {channelsPage: firstSession} = await pw.testBrowser.login(user); + await firstSession.goto(team.name, 'town-square'); + await firstSession.toBeVisible(); + + const {channelsPage: secondSession} = await pw.testBrowser.login(user); + await secondSession.goto(team.name, 'town-square'); + await secondSession.toBeVisible(); + + // # Create a public channel in the first session + const channelName = `RF ${pw.random.id()}`; + await firstSession.newChannel(channelName, 'O'); + await firstSession.centerView.header.toHaveTitle(channelName); + + // * Verify the new channel appears in the sidebar of both sessions + await expect(firstSession.sidebarLeft.container.getByText(channelName)).toBeVisible(); + await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible(); + + // # Archive the channel in the first session, then navigate away from the archived channel + await firstSession.archiveChannel(); + await firstSession.sidebarLeft.goToItem('town-square'); + + // * Verify the channel is removed from the sidebar of both sessions + await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); + await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); +}); + /** * @objective Verify that leaving a channel in one browser session removes it from the sidebar of another * session that is viewing a different channel, without disrupting that other session's current view. diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/device_sync_create_channel.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/device_sync_create_channel.spec.ts deleted file mode 100644 index f7711de2f6a..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/device_sync_create_channel.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that creating and archiving a public channel in one browser session is synced in - * real time to another session of the same user. - */ -test('MM-T837 syncs public channel create and archive across sessions', {tag: '@channel_settings'}, async ({pw}) => { - const {user, team} = await pw.initSetup(); - - // # Log in as the same user in two separate browser sessions - const {channelsPage: firstSession} = await pw.testBrowser.login(user); - await firstSession.goto(team.name, 'town-square'); - await firstSession.toBeVisible(); - - const {channelsPage: secondSession} = await pw.testBrowser.login(user); - await secondSession.goto(team.name, 'town-square'); - await secondSession.toBeVisible(); - - // # Create a public channel in the first session - const channelName = `RF ${pw.random.id()}`; - await firstSession.newChannel(channelName, 'O'); - await firstSession.centerView.header.toHaveTitle(channelName); - - // * Verify the new channel appears in the sidebar of both sessions - await expect(firstSession.sidebarLeft.container.getByText(channelName)).toBeVisible(); - await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible(); - - // # Archive the channel in the first session, then navigate away from the archived channel - await firstSession.archiveChannel(); - await firstSession.sidebarLeft.goToItem('town-square'); - - // * Verify the channel is removed from the sidebar of both sessions - await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); - await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/combined_join_leave_messages.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/membership_updates.spec.ts similarity index 54% rename from e2e-tests/playwright/specs/functional/channels/channel_settings/combined_join_leave_messages.spec.ts rename to e2e-tests/playwright/specs/functional/channels/channel_settings/membership_updates.spec.ts index 5f5c0f9859d..113bf28f162 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/combined_join_leave_messages.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/membership_updates.spec.ts @@ -1,7 +1,47 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {test} from '@mattermost/playwright-lib'; +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that a user viewing a thread in the right-hand side can no longer reply once they are + * removed from the channel. + */ +test( + 'MM-T843 removes the reply box in the right-hand side after the user is removed from the channel', + {tag: '@channel_settings'}, + async ({pw}) => { + const {adminClient, adminUser, team, user} = await pw.initSetup(); + + // # Create a channel, add the user, and post a root message as the admin + const channel = await adminClient.createPublicChannel(team.id, `Remove ${pw.random.id()}`); + await adminClient.addToChannel(user.id, channel.id); + const rootMessage = `root ${pw.random.id()}`; + await adminClient.createPost({channel_id: channel.id, user_id: adminUser.id, message: rootMessage}); + + // # Log in as the user and open the thread in the right-hand side + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + const rootPost = await channelsPage.getLastPost(); + await rootPost.reply(); + await channelsPage.sidebarRight.toBeVisible(); + + // # Post a reply in the right-hand side + const reply = `reply ${pw.random.id()}`; + await channelsPage.sidebarRight.postMessage(reply); + await channelsPage.sidebarRight.toContainText(reply); + + // * Verify the reply box is available before removal + await expect(channelsPage.sidebarRight.postCreate.input).toBeVisible(); + + // # Remove the user from the channel + await adminClient.removeFromChannel(user.id, channel.id); + + // * Verify the reply box in the right-hand side is no longer available + await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible({timeout: pw.duration.ten_sec}); + }, +); /** * @objective Verify that users added to a public channel in one batch are combined into a single system diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/create_channel_modal_cancel.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/modal_behavior.spec.ts similarity index 62% rename from e2e-tests/playwright/specs/functional/channels/channel_settings/create_channel_modal_cancel.spec.ts rename to e2e-tests/playwright/specs/functional/channels/channel_settings/modal_behavior.spec.ts index cfc40f55863..dda5a0163b7 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/create_channel_modal_cancel.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/modal_behavior.spec.ts @@ -3,6 +3,40 @@ import {expect, test} from '@mattermost/playwright-lib'; +/** + * @objective Verify that releasing a mouse drag that started inside a modal but ends outside it does not + * close the modal, while a genuine click outside the modal does close it. + */ +test('MM-T841 keeps a modal open when a drag is released outside it', {tag: '@channel_settings'}, async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Open the Channel Settings modal + const channelSettings = await channelsPage.openChannelSettings(); + const modal = channelSettings.container; + + // # Press the mouse down inside the modal, drag to outside the modal, then release + const box = await modal.boundingBox(); + if (!box) { + throw new Error('Channel Settings modal has no bounding box'); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(5, 5); + await page.mouse.up(); + + // * Verify the modal remains open because the press started inside it + await expect(modal).toBeVisible(); + + // # Click outside the modal (both press and release on the backdrop) + await page.mouse.click(5, 5); + + // * Verify the modal now closes + await expect(modal).not.toBeVisible(); +}); + /** * @objective Verify that cancelling the create-channel modal does not create or join a channel, and that * leaving a channel removes it from the sidebar. diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/modal_outside_click.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/modal_outside_click.spec.ts deleted file mode 100644 index 1ba4ba7d5ac..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/modal_outside_click.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that releasing a mouse drag that started inside a modal but ends outside it does not - * close the modal, while a genuine click outside the modal does close it. - */ -test('MM-T841 keeps a modal open when a drag is released outside it', {tag: '@channel_settings'}, async ({pw}) => { - const {user, team} = await pw.initSetup(); - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'off-topic'); - await channelsPage.toBeVisible(); - - // # Open the Channel Settings modal - const channelSettings = await channelsPage.openChannelSettings(); - const modal = channelSettings.container; - - // # Press the mouse down inside the modal, drag to outside the modal, then release - const box = await modal.boundingBox(); - if (!box) { - throw new Error('Channel Settings modal has no bounding box'); - } - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.down(); - await page.mouse.move(5, 5); - await page.mouse.up(); - - // * Verify the modal remains open because the press started inside it - await expect(modal).toBeVisible(); - - // # Click outside the modal (both press and release on the backdrop) - await page.mouse.click(5, 5); - - // * Verify the modal now closes - await expect(modal).not.toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/removed_user_cannot_reply.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/removed_user_cannot_reply.spec.ts deleted file mode 100644 index 05c912baf60..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/removed_user_cannot_reply.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a user viewing a thread in the right-hand side can no longer reply once they are - * removed from the channel. - */ -test( - 'MM-T843 removes the reply box in the right-hand side after the user is removed from the channel', - {tag: '@channel_settings'}, - async ({pw}) => { - const {adminClient, adminUser, team, user} = await pw.initSetup(); - - // # Create a channel, add the user, and post a root message as the admin - const channel = await adminClient.createPublicChannel(team.id, `Remove ${pw.random.id()}`); - await adminClient.addToChannel(user.id, channel.id); - const rootMessage = `root ${pw.random.id()}`; - await adminClient.createPost({channel_id: channel.id, user_id: adminUser.id, message: rootMessage}); - - // # Log in as the user and open the thread in the right-hand side - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, channel.name); - await channelsPage.toBeVisible(); - const rootPost = await channelsPage.getLastPost(); - await rootPost.reply(); - await channelsPage.sidebarRight.toBeVisible(); - - // # Post a reply in the right-hand side - const reply = `reply ${pw.random.id()}`; - await channelsPage.sidebarRight.postMessage(reply); - await channelsPage.sidebarRight.toContainText(reply); - - // * Verify the reply box is available before removal - await expect(channelsPage.sidebarRight.postCreate.input).toBeVisible(); - - // # Remove the user from the channel - await adminClient.removeFromChannel(user.id, channel.id); - - // * Verify the reply box in the right-hand side is no longer available - await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible({timeout: pw.duration.ten_sec}); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/set_channel_header.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/set_channel_header.spec.ts deleted file mode 100644 index c80b2693bd9..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/set_channel_header.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify a channel header can be added from the "Set header" link in a new channel's intro, - * that cancelling the modal makes no change, and that the saved header appears in the channel info. - */ -test('MM-T880 adds a channel header from the intro Set header link', {tag: '@channel_settings'}, async ({pw}) => { - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Create a new public channel so its intro (with the "Set header" link) is shown - const channelName = `channel header ${pw.random.id()}`; - await channelsPage.newChannel(channelName, 'O'); - await channelsPage.centerView.header.toHaveTitle(channelName); - - // # Open the Edit Header modal from the intro and cancel without entering text - await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click(); - await channelsPage.editChannelHeaderModal.toBeVisible(); - await channelsPage.editChannelHeaderModal.cancel(); - - // # Reopen the Edit Header modal and save a header - const header = 'this is the channel header'; - await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click(); - await channelsPage.editChannelHeaderModal.toBeVisible(); - await channelsPage.editChannelHeaderModal.setHeader(header); - - // * Verify the header-change system message is posted - await channelsPage.centerView.waitUntilLastPostContains(`updated the channel header to: ${header}`); - - // # Open the channel info panel from the channel header menu - const channelMenu = await channelsPage.openChannelMenu(); - await channelMenu.viewInfo.click(); - await channelsPage.sidebarRight.toBeVisible(); - - // * Verify the saved header appears in the channel info panel - await expect(channelsPage.sidebarRight.container.getByText(header)).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/delete_parent_post_dm.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/delete_parent_post_dm.spec.ts deleted file mode 100644 index 0747665f709..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/delete_parent_post_dm.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that deleting a parent post from a direct message reply thread removes the post, its reply, and closes the thread. - */ -test( - 'MM-T454 deletes a parent post in a direct message from the reply thread', - {tag: '@direct_messages'}, - async ({pw}) => { - // # Create the test user plus another user to direct message - const {user, team, adminClient} = await pw.initSetup(); - const [otherUser] = await adminClient.createUsers(team.id, 1, 'other'); - - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Open a direct message channel with the other user - const dmModal = await channelsPage.openDirectChannelsModal(); - await dmModal.selectUser(otherUser); - await dmModal.goToChannel(); - - // # Post a parent message and reply to it in the thread - const parent = `This is the parent post ${pw.random.id()}`; - await channelsPage.postMessage(parent); - const parentPost = await channelsPage.getLastPost(); - const parentId = await parentPost.getId(); - await parentPost.reply(); - await channelsPage.sidebarRight.toBeVisible(); - await channelsPage.sidebarRight.postMessage(`This is a reply ${pw.random.id()}`); - - // # Delete the parent post (which has a reply) while its thread is open. - // The parent is deleted from the center post menu, which is equivalent to and more - // stable than the virtualized RHS root menu; the resulting behavior is identical. - const centerParent = await channelsPage.centerView.getPostById(parentId); - await centerParent.hover(); - await centerParent.postMenu.openDotMenu(); - await channelsPage.postDotMenu.deleteMenuItem.click(); - await channelsPage.deletePostModal.toBeVisible(); - await channelsPage.deletePostModal.confirm(); - - // * Verify the reply thread closes and the parent post is gone from the center channel - await expect(channelsPage.sidebarRight.container).not.toBeVisible(); - await expect(channelsPage.centerView.container.getByText(parent, {exact: true})).not.toBeVisible(); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/direct_message_actions.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/direct_message_actions.spec.ts new file mode 100644 index 00000000000..fc3ff06ea55 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/direct_message_actions.spec.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that mentioning a user inside a different pair of users' direct message does not notify the mentioned user. + */ +test('MM-T448 does not notify a user mentioned in a different DM', {tag: '@direct_messages'}, async ({pw}) => { + // # Create the test user plus two more users on the team + const {user, team, adminClient} = await pw.initSetup(); + const [partner, mentioned] = await adminClient.createUsers(team.id, 2, 'dmuser'); + + const token = `dmmention${pw.random.id()}`; + + // # As the test user, open a DM with the partner and mention the third user there + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + const dmModal = await channelsPage.openDirectChannelsModal(); + await dmModal.selectUser(partner); + await dmModal.goToChannel(); + await channelsPage.postMessage(`@${mentioned.username} ${token}`); + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText(token); + + // # Log in as the mentioned user and open Recent Mentions + const {channelsPage: mentionedPage} = await pw.testBrowser.login(mentioned); + await mentionedPage.goto(team.name, 'town-square'); + await mentionedPage.toBeVisible(); + await mentionedPage.globalHeader.openRecentMentions(); + + // * Verify the mentioned user did not receive the mention from the other users' DM + await mentionedPage.searchResultsPanel.toBeVisible(); + await expect(mentionedPage.searchResultsPanel.getResultByText(token)).toHaveCount(0); +}); + +/** + * @objective Verify that deleting a parent post from a direct message reply thread removes the post, its reply, and closes the thread. + */ +test( + 'MM-T454 deletes a parent post in a direct message from the reply thread', + {tag: '@direct_messages'}, + async ({pw}) => { + // # Create the test user plus another user to direct message + const {user, team, adminClient} = await pw.initSetup(); + const [otherUser] = await adminClient.createUsers(team.id, 1, 'other'); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open a direct message channel with the other user + const dmModal = await channelsPage.openDirectChannelsModal(); + await dmModal.selectUser(otherUser); + await dmModal.goToChannel(); + + // # Post a parent message and reply to it in the thread + const parent = `This is the parent post ${pw.random.id()}`; + await channelsPage.postMessage(parent); + const parentPost = await channelsPage.getLastPost(); + const parentId = await parentPost.getId(); + await parentPost.reply(); + await channelsPage.sidebarRight.toBeVisible(); + await channelsPage.sidebarRight.postMessage(`This is a reply ${pw.random.id()}`); + + // # Delete the parent post (which has a reply) while its thread is open. + // The parent is deleted from the center post menu, which is equivalent to and more + // stable than the virtualized RHS root menu; the resulting behavior is identical. + const centerParent = await channelsPage.centerView.getPostById(parentId); + await centerParent.hover(); + await centerParent.postMenu.openDotMenu(); + await channelsPage.postDotMenu.deleteMenuItem.click(); + await channelsPage.deletePostModal.toBeVisible(); + await channelsPage.deletePostModal.confirm(); + + // * Verify the reply thread closes and the parent post is gone from the center channel + await expect(channelsPage.sidebarRight.container).not.toBeVisible(); + await expect(channelsPage.centerView.container.getByText(parent, {exact: true})).not.toBeVisible(); + }, +); + +/** + * @objective Verify that a direct message can be opened from a user's profile popover on their post. + */ +test('MM-T455 opens a direct message from a profile popover', {tag: '@direct_messages'}, async ({pw}) => { + // # Create the test user plus another user who posts in a shared channel + const {user, team, adminClient} = await pw.initSetup(); + const [author] = await adminClient.createUsers(team.id, 1, 'author'); + const offTopic = await adminClient.getChannelByName(team.id, 'off-topic'); + await adminClient.addToChannel(author.id, offTopic.id); + + const token = `popover dm ${pw.random.id()}`; + const {client: authorClient} = await pw.makeClient(author); + await authorClient.createPost({channel_id: offTopic.id, message: token}); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Open the author's profile popover from their post and click Message + const post = await channelsPage.getLastPost(); + const popover = await channelsPage.openProfilePopover(post); + await popover.message(); + + // * Verify a direct message channel with the author is opened + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/@${author.username}`); + await channelsPage.centerView.header.toHaveTitle(author.username); + + // # Post a message in the direct message channel + const dmMessage = `direct message ${pw.random.id()}`; + await channelsPage.postMessage(dmMessage); + + // * Verify the direct message is posted + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText(dmMessage); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_add_user.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_add_user.spec.ts deleted file mode 100644 index 3fc7cf92074..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_add_user.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -import {getChannelSlugFromUrl} from './helpers'; - -/** - * @objective Verify that a group message lists its members and that adding another member creates a new group message. - */ -test( - 'MM-T467 adds a user to a group message to create a new group message', - {tag: '@direct_messages'}, - async ({pw}) => { - // # Create the test user plus three more users on the team - const {user, team, adminClient} = await pw.initSetup(); - const [member1, member2, member3] = await adminClient.createUsers(team.id, 3, 'gm'); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Create a group message with two members - const dmModal = await channelsPage.openDirectChannelsModal(); - await dmModal.selectUser(member1); - await dmModal.selectUser(member2); - await dmModal.goToChannel(); - const firstSlug = getChannelSlugFromUrl(page); - - // * Verify the group message lists both members in the header - await channelsPage.centerView.header.toHaveTitle(member1.username); - await channelsPage.centerView.header.toHaveTitle(member2.username); - - // # Create a group message that adds a third member - const dmModal2 = await channelsPage.openDirectChannelsModal(); - await dmModal2.selectUser(member1); - await dmModal2.selectUser(member2); - await dmModal2.selectUser(member3); - await dmModal2.goToChannel(); - - // * Verify a new, different group message channel is created that includes the added member - const secondSlug = getChannelSlugFromUrl(page); - expect(secondSlug).not.toBe(firstSlug); - await channelsPage.centerView.header.toHaveTitle(member3.username); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_close_reopen.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_close_reopen.spec.ts deleted file mode 100644 index 926c37421d8..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_close_reopen.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -import {getChannelSlugFromUrl} from './helpers'; - -/** - * @objective Verify that a closed group message can be reopened via a saved message and via the Direct Messages modal. - * - * MM-T477 and MM-T479 are duplicates of MM-T476 and are covered by this test. - */ -test( - 'MM-T476 MM-T477 MM-T479 closes and reopens a group message via saved messages and the DM modal', - {tag: '@direct_messages'}, - async ({pw}) => { - // # Create the test user plus two more users on the team - const {user, team, adminClient, userClient} = await pw.initSetup(); - const [member1, member2] = await adminClient.createUsers(team.id, 2, 'gm'); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Create a group message and post a message, then save that message - const dmModal = await channelsPage.openDirectChannelsModal(); - await dmModal.selectUser(member1); - await dmModal.selectUser(member2); - await dmModal.goToChannel(); - const slug = getChannelSlugFromUrl(page); - - const token = `message to save ${pw.random.id()}`; - await channelsPage.postMessage(token); - const savedPost = await channelsPage.getLastPost(); - const savedPostId = await savedPost.getId(); - await userClient.savePreferences(user.id, [ - {user_id: user.id, category: 'flagged_post', name: savedPostId, value: 'true'}, - ]); - - // # Close the group message conversation - await channelsPage.sidebarLeft.closeConversationAndWait(slug); - - // # Reopen the group message by jumping to the saved message - await channelsPage.globalHeader.openSavedMessages(); - await channelsPage.searchResultsPanel.toBeVisible(); - await channelsPage.searchResultsPanel.toContainText(token); - await channelsPage.searchResultsPanel.jumpToResultWithText(token); - - // * Verify the group message channel is reopened - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/${slug}`); - - // # Close it again and reopen it via the Direct Messages modal - await channelsPage.sidebarLeft.closeConversationAndWait(slug); - const dmModal2 = await channelsPage.openDirectChannelsModal(); - await dmModal2.selectUser(member1); - await dmModal2.selectUser(member2); - await dmModal2.goToChannel(); - - // * Verify the group message channel is reopened again - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/${slug}`); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_creation_members.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_creation_members.spec.ts new file mode 100644 index 00000000000..63a8348ff7b --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_creation_members.spec.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +import {getChannelSlugFromUrl} from './helpers'; + +/** + * @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 that a group message lists its members and that adding another member creates a new group message. + */ +test( + 'MM-T467 adds a user to a group message to create a new group message', + {tag: '@direct_messages'}, + async ({pw}) => { + // # Create the test user plus three more users on the team + const {user, team, adminClient} = await pw.initSetup(); + const [member1, member2, member3] = await adminClient.createUsers(team.id, 3, 'gm'); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Create a group message with two members + const dmModal = await channelsPage.openDirectChannelsModal(); + await dmModal.selectUser(member1); + await dmModal.selectUser(member2); + await dmModal.goToChannel(); + const firstSlug = getChannelSlugFromUrl(page); + + // * Verify the group message lists both members in the header + await channelsPage.centerView.header.toHaveTitle(member1.username); + await channelsPage.centerView.header.toHaveTitle(member2.username); + + // # Create a group message that adds a third member + const dmModal2 = await channelsPage.openDirectChannelsModal(); + await dmModal2.selectUser(member1); + await dmModal2.selectUser(member2); + await dmModal2.selectUser(member3); + await dmModal2.goToChannel(); + + // * Verify a new, different group message channel is created that includes the added member + const secondSlug = getChannelSlugFromUrl(page); + expect(secondSlug).not.toBe(firstSlug); + await channelsPage.centerView.header.toHaveTitle(member3.username); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_lifecycle.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_lifecycle.spec.ts new file mode 100644 index 00000000000..0834c269a3d --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_lifecycle.spec.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {duration, expect, test} from '@mattermost/playwright-lib'; + +import {getChannelSlugFromUrl} from './helpers'; + +/** + * @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); +}); + +/** + * @objective Verify that a closed group message can be reopened via a saved message and via the Direct Messages modal. + * + * MM-T477 and MM-T479 are duplicates of MM-T476 and are covered by this test. + */ +test( + 'MM-T476 MM-T477 MM-T479 closes and reopens a group message via saved messages and the DM modal', + {tag: '@direct_messages'}, + async ({pw}) => { + // # Create the test user plus two more users on the team + const {user, team, adminClient, userClient} = await pw.initSetup(); + const [member1, member2] = await adminClient.createUsers(team.id, 2, 'gm'); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Create a group message and post a message, then save that message + const dmModal = await channelsPage.openDirectChannelsModal(); + await dmModal.selectUser(member1); + await dmModal.selectUser(member2); + await dmModal.goToChannel(); + const slug = getChannelSlugFromUrl(page); + + const token = `message to save ${pw.random.id()}`; + await channelsPage.postMessage(token); + const savedPost = await channelsPage.getLastPost(); + const savedPostId = await savedPost.getId(); + await userClient.savePreferences(user.id, [ + {user_id: user.id, category: 'flagged_post', name: savedPostId, value: 'true'}, + ]); + + // # Close the group message conversation + await channelsPage.sidebarLeft.closeConversationAndWait(slug); + + // # Reopen the group message by jumping to the saved message + await channelsPage.globalHeader.openSavedMessages(); + await channelsPage.searchResultsPanel.toBeVisible(); + await channelsPage.searchResultsPanel.toContainText(token); + await channelsPage.searchResultsPanel.jumpToResultWithText(token); + + // * Verify the group message channel is reopened + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/${slug}`); + + // # Close it again and reopen it via the Direct Messages modal + await channelsPage.sidebarLeft.closeConversationAndWait(slug); + const dmModal2 = await channelsPage.openDirectChannelsModal(); + await dmModal2.selectUser(member1); + await dmModal2.selectUser(member2); + await dmModal2.goToChannel(); + + // * Verify the group message channel is reopened again + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/${slug}`); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mention_recreate.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mentions.spec.ts similarity index 61% rename from e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mention_recreate.spec.ts rename to e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mentions.spec.ts index ca91941dffc..a20badd0e70 100644 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mention_recreate.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_mentions.spec.ts @@ -5,6 +5,34 @@ import {expect, test} from '@mattermost/playwright-lib'; import {getChannelSlugFromUrl} from './helpers'; +/** + * @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 that a group message can be created with a mention, closed, and then recreated with the same members. * 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 deleted file mode 100644 index 796bd041710..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_messages.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -// 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/direct_messages_modal/mention_in_other_dm.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/mention_in_other_dm.spec.ts deleted file mode 100644 index 6b8f5474d28..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/mention_in_other_dm.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that mentioning a user inside a different pair of users' direct message does not notify the mentioned user. - */ -test('MM-T448 does not notify a user mentioned in a different DM', {tag: '@direct_messages'}, async ({pw}) => { - // # Create the test user plus two more users on the team - const {user, team, adminClient} = await pw.initSetup(); - const [partner, mentioned] = await adminClient.createUsers(team.id, 2, 'dmuser'); - - const token = `dmmention${pw.random.id()}`; - - // # As the test user, open a DM with the partner and mention the third user there - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - const dmModal = await channelsPage.openDirectChannelsModal(); - await dmModal.selectUser(partner); - await dmModal.goToChannel(); - await channelsPage.postMessage(`@${mentioned.username} ${token}`); - const lastPost = await channelsPage.getLastPost(); - await lastPost.toContainText(token); - - // # Log in as the mentioned user and open Recent Mentions - const {channelsPage: mentionedPage} = await pw.testBrowser.login(mentioned); - await mentionedPage.goto(team.name, 'town-square'); - await mentionedPage.toBeVisible(); - await mentionedPage.globalHeader.openRecentMentions(); - - // * Verify the mentioned user did not receive the mention from the other users' DM - await mentionedPage.searchResultsPanel.toBeVisible(); - await expect(mentionedPage.searchResultsPanel.getResultByText(token)).toHaveCount(0); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/open_dm_from_profile_popover.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/open_dm_from_profile_popover.spec.ts deleted file mode 100644 index 21f3898ec20..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/open_dm_from_profile_popover.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a direct message can be opened from a user's profile popover on their post. - */ -test('MM-T455 opens a direct message from a profile popover', {tag: '@direct_messages'}, async ({pw}) => { - // # Create the test user plus another user who posts in a shared channel - const {user, team, adminClient} = await pw.initSetup(); - const [author] = await adminClient.createUsers(team.id, 1, 'author'); - const offTopic = await adminClient.getChannelByName(team.id, 'off-topic'); - await adminClient.addToChannel(author.id, offTopic.id); - - const token = `popover dm ${pw.random.id()}`; - const {client: authorClient} = await pw.makeClient(author); - await authorClient.createPost({channel_id: offTopic.id, message: token}); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'off-topic'); - await channelsPage.toBeVisible(); - - // # Open the author's profile popover from their post and click Message - const post = await channelsPage.getLastPost(); - const popover = await channelsPage.openProfilePopover(post); - await popover.message(); - - // * Verify a direct message channel with the author is opened - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/@${author.username}`); - await channelsPage.centerView.header.toHaveTitle(author.username); - - // # Post a message in the direct message channel - const dmMessage = `direct message ${pw.random.id()}`; - await channelsPage.postMessage(dmMessage); - - // * Verify the direct message is posted - const lastPost = await channelsPage.getLastPost(); - await lastPost.toContainText(dmMessage); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/emoji_picker/scroll_disabled_with_picker.spec.ts b/e2e-tests/playwright/specs/functional/channels/emoji_picker/scroll_disabled_with_picker.spec.ts new file mode 100644 index 00000000000..eefcc89653c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/emoji_picker/scroll_disabled_with_picker.spec.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that while the emoji picker is open over a post, scrolling the center channel with + * PageUp is disabled (the posts stay in place). + */ +test('MM-T2365 disables center channel scrolling while the emoji picker is open', {tag: '@messaging'}, async ({pw}) => { + const {adminClient, team, user} = await pw.initSetup(); + const channel = await adminClient.createPublicChannel(team.id, `Scrolling ${pw.random.id()}`); + await adminClient.addToChannel(user.id, channel.id); + + // # Fill the channel with enough posts to make it scrollable + for (let i = 0; i < 40; i++) { + await adminClient.createPost({channel_id: channel.id, user_id: user.id, message: `filler ${i}`}); + } + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Post a message and open the emoji (reaction) picker over it + const message = `react target ${pw.random.id()}`; + await channelsPage.postMessage(message); + const post = await channelsPage.getLastPost(); + await post.openReactionPicker(); + await expect(channelsPage.reactionEmojiPicker.container).toBeVisible(); + + // # Record the target post position, then press PageUp + const before = await post.container.boundingBox(); + await page.keyboard.press('PageUp'); + await pw.wait(pw.duration.one_sec); + const after = await post.container.boundingBox(); + + // * Verify the post did not move (the channel did not scroll while the picker was open) + if (!before || !after) { + throw new Error('Expected the post to have a bounding box while the emoji picker is open'); + } + expect(Math.abs(after.y - before.y)).toBeLessThan(5); + + // # Close the emoji picker and press PageUp again + await page.keyboard.press('Escape'); + await expect(channelsPage.reactionEmojiPicker.container).not.toBeVisible(); + await page.keyboard.press('PageUp'); + await pw.wait(pw.duration.one_sec); + + // * Verify scrolling works again once the picker is closed (control: the post now moves) + const afterClose = await post.container.boundingBox(); + if (!afterClose) { + throw new Error('Expected the post to have a bounding box after the emoji picker is closed'); + } + expect(Math.abs(afterClose.y - before.y)).toBeGreaterThan(5); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_tab_selection.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_and_help_shortcuts.spec.ts similarity index 66% rename from e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_tab_selection.spec.ts rename to e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_and_help_shortcuts.spec.ts index 707bd5a4ddb..900796a3753 100644 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_tab_selection.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/autocomplete_and_help_shortcuts.spec.ts @@ -39,3 +39,26 @@ test('MM-T1274 completes emoji autocomplete with Tab', {tag: '@keyboard_shortcut // Current behavior inserts the rendered emoji glyph rather than the `:tomato:` shortcode. expect(await postCreate.getInputValue()).toContain('🍅'); }); + +/** + * @objective Verify that selecting "Keyboard shortcuts" from the Help menu opens the Keyboard Shortcuts modal. + */ +test('MM-T1279 opens keyboard shortcuts modal from the Help menu', {tag: '@keyboard_shortcuts'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open the Help menu and click "Keyboard shortcuts" + await channelsPage.globalHeader.openKeyboardShortcuts(); + + // * Verify the Keyboard Shortcuts modal is displayed + await expect(channelsPage.keyboardShortcutsModal).toBeVisible(); + + // # Close the modal with the Escape key + await channelsPage.page.keyboard.press('Escape'); + + // * Verify the Keyboard Shortcuts modal is no longer displayed + await expect(channelsPage.keyboardShortcutsModal).not.toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts similarity index 59% rename from e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts rename to e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts index e007eca7faf..447bb7de594 100644 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts @@ -3,6 +3,59 @@ import {expect, test} from '@mattermost/playwright-lib'; +/** + * @objective Verify the Ctrl/Cmd+K quick switcher can find and open a direct message, a group message, + * and a public channel. + */ +test( + 'MM-T1247 finds and opens a direct message, group message, and channel with Ctrl/Cmd+K', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {adminClient, team, user} = await pw.initSetup(); + const [member1, member2] = await adminClient.createUsers(team.id, 2, 'switch'); + + // # Create a direct message, a group message, and a public channel for the user + const dm = await adminClient.createDirectChannel([user.id, member1.id]); + await adminClient.createPost({channel_id: dm.id, user_id: member1.id, message: 'dm hello'}); + const gm = await adminClient.createGroupChannel([user.id, member1.id, member2.id]); + await adminClient.createPost({channel_id: gm.id, user_id: member2.id, message: 'gm hello'}); + const publicChannel = await adminClient.createPublicChannel(team.id, `Switcher ${pw.random.id()}`); + await adminClient.addToChannel(user.id, publicChannel.id); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + const {findChannelsModal} = channelsPage; + const options = () => findChannelsModal.container.getByRole('option'); + + // # Open the quick switcher and open the direct message with member1 (the option with only member1) + await page.keyboard.press('ControlOrMeta+K'); + await findChannelsModal.input.fill(member1.username); + await options().filter({hasText: member1.username, hasNotText: member2.username}).first().click(); + + // * Verify the direct message with member1 is opened + await channelsPage.centerView.header.toHaveTitle(member1.username); + + // # Open the quick switcher and open the group message (the option containing both members) + await page.keyboard.press('ControlOrMeta+K'); + await findChannelsModal.input.fill(member2.username); + await options().filter({hasText: member1.username}).filter({hasText: member2.username}).first().click(); + + // * Verify the group message (containing both members) is opened + await channelsPage.centerView.header.toHaveTitle(member1.username); + await channelsPage.centerView.header.toHaveTitle(member2.username); + + // # Open the quick switcher and open the public channel + await page.keyboard.press('ControlOrMeta+K'); + await findChannelsModal.input.fill(publicChannel.display_name); + await options().filter({hasText: publicChannel.display_name}).first().click(); + + // * Verify the public channel is opened + await channelsPage.centerView.header.toHaveTitle(publicChannel.display_name); + }, +); + /** * @objective Verify that pressing Shift+Up in the textbox in center channel opens the thread for the last post in RHS * and correctly focuses the reply textbox, even when there are large messages with attachments from other users. diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/find_channels_dm_gm.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/find_channels_dm_gm.spec.ts deleted file mode 100644 index a49ca702501..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/find_channels_dm_gm.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify the Ctrl/Cmd+K quick switcher can find and open a direct message, a group message, - * and a public channel. - */ -test( - 'MM-T1247 finds and opens a direct message, group message, and channel with Ctrl/Cmd+K', - {tag: '@keyboard_shortcuts'}, - async ({pw}) => { - const {adminClient, team, user} = await pw.initSetup(); - const [member1, member2] = await adminClient.createUsers(team.id, 2, 'switch'); - - // # Create a direct message, a group message, and a public channel for the user - const dm = await adminClient.createDirectChannel([user.id, member1.id]); - await adminClient.createPost({channel_id: dm.id, user_id: member1.id, message: 'dm hello'}); - const gm = await adminClient.createGroupChannel([user.id, member1.id, member2.id]); - await adminClient.createPost({channel_id: gm.id, user_id: member2.id, message: 'gm hello'}); - const publicChannel = await adminClient.createPublicChannel(team.id, `Switcher ${pw.random.id()}`); - await adminClient.addToChannel(user.id, publicChannel.id); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - const {findChannelsModal} = channelsPage; - const options = () => findChannelsModal.container.getByRole('option'); - - // # Open the quick switcher and open the direct message with member1 (the option with only member1) - await page.keyboard.press('ControlOrMeta+K'); - await findChannelsModal.input.fill(member1.username); - await options().filter({hasText: member1.username, hasNotText: member2.username}).first().click(); - - // * Verify the direct message with member1 is opened - await channelsPage.centerView.header.toHaveTitle(member1.username); - - // # Open the quick switcher and open the group message (the option containing both members) - await page.keyboard.press('ControlOrMeta+K'); - await findChannelsModal.input.fill(member2.username); - await options().filter({hasText: member1.username}).filter({hasText: member2.username}).first().click(); - - // * Verify the group message (containing both members) is opened - await channelsPage.centerView.header.toHaveTitle(member1.username); - await channelsPage.centerView.header.toHaveTitle(member2.username); - - // # Open the quick switcher and open the public channel - await page.keyboard.press('ControlOrMeta+K'); - await findChannelsModal.input.fill(publicChannel.display_name); - await options().filter({hasText: publicChannel.display_name}).first().click(); - - // * Verify the public channel is opened - await channelsPage.centerView.header.toHaveTitle(publicChannel.display_name); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/help_menu_keyboard_shortcuts.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/help_menu_keyboard_shortcuts.spec.ts deleted file mode 100644 index f707da4dfa4..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/help_menu_keyboard_shortcuts.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that selecting "Keyboard shortcuts" from the Help menu opens the Keyboard Shortcuts modal. - */ -test('MM-T1279 opens keyboard shortcuts modal from the Help menu', {tag: '@keyboard_shortcuts'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Open the Help menu and click "Keyboard shortcuts" - await channelsPage.globalHeader.openKeyboardShortcuts(); - - // * Verify the Keyboard Shortcuts modal is displayed - await expect(channelsPage.keyboardShortcutsModal).toBeVisible(); - - // # Close the modal with the Escape key - await channelsPage.page.keyboard.press('Escape'); - - // * Verify the Keyboard Shortcuts modal is no longer displayed - await expect(channelsPage.keyboardShortcutsModal).not.toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_message_editing_composition.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_message_editing_composition.spec.ts new file mode 100644 index 00000000000..4d2f2383eda --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_message_editing_composition.spec.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify pressing Up arrow in an empty message box opens the edit box for the last post in both + * the center channel and a thread, and that Escape closes the edit box without saving. + * + * MM-T1263 covers the same behavior as MM-T1262 and is covered by this test. + */ +test( + 'MM-T1262 MM-T1263 opens and cancels the edit box with Up arrow and Escape', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message and press Up arrow from the empty center message box + const message = `edit shortcut ${pw.random.id()}`; + await channelsPage.postMessage(message); + await channelsPage.centerView.postCreate.input.focus(); + await page.keyboard.press('ArrowUp'); + + // * Verify the edit box opens with the last message + await channelsPage.centerView.postEdit.toBeVisible(); + await expect(channelsPage.centerView.postEdit.input).toHaveValue(message); + + // # Close the edit box with Escape + await page.keyboard.press('Escape'); + + // * Verify the edit box closes and the post remains unchanged + await channelsPage.centerView.postEdit.toNotBeVisible(); + await channelsPage.centerView.waitUntilLastPostContains(message); + + // # Open a thread on the last post and post a reply + const post = await channelsPage.getLastPost(); + await post.reply(); + await channelsPage.sidebarRight.toBeVisible(); + const reply = `reply shortcut ${pw.random.id()}`; + await channelsPage.sidebarRight.postMessage(reply); + await channelsPage.sidebarRight.toContainText(reply); + + // # Press Up arrow from the empty thread reply box + await channelsPage.sidebarRight.postCreate.input.focus(); + await page.keyboard.press('ArrowUp'); + + // * Verify the thread edit box opens with the reply, then closes with Escape + await channelsPage.sidebarRight.postEdit.toBeVisible(); + await expect(channelsPage.sidebarRight.postEdit.input).toHaveValue(reply); + await page.keyboard.press('Escape'); + await channelsPage.sidebarRight.postEdit.toNotBeVisible(); + + // * Verify the reply is still present after cancelling the edit + await channelsPage.sidebarRight.toContainText(reply); + }, +); + +/** + * @objective Verify Shift+Enter adds new lines in the message box to compose a fenced code block, which is + * then posted as a code block. + * + * MM-T1268 covers the same behavior as MM-T1267 and is covered by this test. + */ +test( + 'MM-T1267 MM-T1268 composes and posts a code block using Shift+Enter for new lines', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Compose a fenced code block using Shift+Enter for new lines + const code = `code ${pw.random.id()}`; + const input = channelsPage.centerView.postCreate.input; + await input.focus(); + await input.pressSequentially('```'); + await page.keyboard.press('Shift+Enter'); + await input.pressSequentially(code); + await page.keyboard.press('Shift+Enter'); + await input.pressSequentially('```'); + + // * Verify Shift+Enter produced the multi-line fenced code block in the message box + await expect(input).toHaveValue(`\`\`\`\n${code}\n\`\`\``); + + // # Post the code block (Enter inserts a newline inside a fenced block, so use the send button) + await channelsPage.centerView.postCreate.sendMessage(); + + // * Verify the code block is posted with the code content and the fence markers consumed (rendered as a code block, not literal backticks) + await channelsPage.centerView.waitUntilLastPostContains(code); + const post = await channelsPage.getLastPost(); + await post.toContainText(code); + await post.toNotContainText('```'); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_navigation_modal_search_shortcuts.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_navigation_modal_search_shortcuts.spec.ts new file mode 100644 index 00000000000..873edb5f90c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_navigation_modal_search_shortcuts.spec.ts @@ -0,0 +1,84 @@ +// 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 for moving to the previous and next channel in the sidebar + * switch between channels. + */ +test( + 'MM-T1259 moves to the previous and next channel with the keyboard', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + + // # Move to the next channel with the keyboard + await channelsPage.centerView.postCreate.input.focus(); + await page.keyboard.press('Alt+ArrowDown'); + + // * Verify the next channel in the sidebar is shown + await channelsPage.centerView.header.toHaveTitle('Town Square'); + + // # Move to the previous channel with the keyboard + await page.keyboard.press('Alt+ArrowUp'); + + // * Verify the original channel is shown again + await channelsPage.centerView.header.toHaveTitle('Off-Topic'); + }, +); + +/** + * @objective Verify Ctrl/Cmd+Shift+K opens the Direct Messages modal, and Escape closes it. + */ +test( + 'MM-T1276 opens the Direct Messages modal with the keyboard shortcut', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Focus the message box and press the Direct Messages shortcut + await channelsPage.centerView.postCreate.input.focus(); + await page.keyboard.press('ControlOrMeta+Shift+K'); + + // * Verify the Direct Messages modal opens + await channelsPage.directChannelsModal.toBeVisible(); + + // # Close the modal with Escape + await page.keyboard.press('Escape'); + + // * Verify the Direct Messages modal closes + await expect(channelsPage.directChannelsModal.container).not.toBeVisible(); + }, +); + +/** + * @objective Verify Ctrl/Cmd+Shift+F opens the search box prefilled with the current channel filter. + * + * MM-T4872 covers the same behavior as MM-T1435 and is covered by this test. + */ +test( + 'MM-T1435 MM-T4872 prefills the search box with the channel filter using the keyboard shortcut', + {tag: '@keyboard_shortcuts'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Focus the message box and press the in-channel search shortcut + await channelsPage.centerView.postCreate.input.focus(); + await page.keyboard.press('ControlOrMeta+Shift+F'); + + // * Verify the search box opens prefilled with the current channel filter + await channelsPage.searchBox.toBeVisible(); + await expect(channelsPage.searchBox.searchInput).toHaveValue('in:off-topic '); + }, +); 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_help_navigation.spec.ts similarity index 53% rename from e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts.spec.ts rename to e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts_help_navigation.spec.ts index 733be01bfd1..d6e299c0c49 100644 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts_help_navigation.spec.ts @@ -64,64 +64,3 @@ test('MM-T1242 CTRL/CMD+K typed characters are not lost after switching channels // * 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. - * - * MM-T1256 and MM-T1257 cover the same behavior as MM-T1254 and are covered by this test. - */ -test('MM-T1254 MM-T1256 MM-T1257 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/keyboard_shortcuts/keyboard_shortcuts_message_history_editing.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts_message_history_editing.spec.ts new file mode 100644 index 00000000000..73379033536 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/keyboard_shortcuts_message_history_editing.spec.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify Ctrl/Cmd+Up and Ctrl/Cmd+Down cycle through previous messages in the post textbox. + * + * MM-T1256, MM-T1257, and MM-T1258 cover the same behavior as MM-T1254 and are covered by this test. + */ +test('MM-T1254 MM-T1256 MM-T1257 MM-T1258 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/channel_and_dm.spec.ts similarity index 53% rename from e2e-tests/playwright/specs/functional/channels/mark_as_unread/mark_as_unread.spec.ts rename to e2e-tests/playwright/specs/functional/channels/mark_as_unread/channel_and_dm.spec.ts index 82df51118f3..82a6a23f38e 100644 --- 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/channel_and_dm.spec.ts @@ -99,109 +99,6 @@ test('MM-T248 Mark Direct Message post as Unread', async ({pw}) => { 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) diff --git a/e2e-tests/playwright/specs/functional/channels/mark_as_unread/thread_and_rhs.spec.ts b/e2e-tests/playwright/specs/functional/channels/mark_as_unread/thread_and_rhs.spec.ts new file mode 100644 index 00000000000..eced79b0849 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/mark_as_unread/thread_and_rhs.spec.ts @@ -0,0 +1,118 @@ +// 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 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']}); +} diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/emoji_behavior.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/emoji_behavior.spec.ts new file mode 100644 index 00000000000..19936419269 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/messaging/emoji_behavior.spec.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @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 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(':)='); +}); + +/** + * @objective Verify that a leading colon is ignored when searching in the emoji picker. + */ +test('MM-T156 ignores a leading colon when searching the emoji picker', {tag: '@messaging'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message and open the emoji reaction picker on it + await channelsPage.postMessage(`emoji picker search ${pw.random.id()}`); + const post = await channelsPage.getLastPost(); + await post.openReactionPicker(); + + // * Verify the emoji picker is open + await channelsPage.reactionEmojiPicker.toBeVisible(); + + // # Search using a leading colon + await channelsPage.reactionEmojiPicker.searchEmoji(':tax'); + + // * Verify the leading colon is ignored and the taxi emoji is returned + await expect(channelsPage.reactionEmojiPicker.getEmoji('taxi')).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/emoji_picker_search.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/emoji_picker_search.spec.ts deleted file mode 100644 index 63055993295..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/messaging/emoji_picker_search.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a leading colon is ignored when searching in the emoji picker. - */ -test('MM-T156 ignores a leading colon when searching the emoji picker', {tag: '@messaging'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message and open the emoji reaction picker on it - await channelsPage.postMessage(`emoji picker search ${pw.random.id()}`); - const post = await channelsPage.getLastPost(); - await post.openReactionPicker(); - - // * Verify the emoji picker is open - await channelsPage.reactionEmojiPicker.toBeVisible(); - - // # Search using a leading colon - await channelsPage.reactionEmojiPicker.searchEmoji(':tax'); - - // * Verify the leading colon is ignored and the taxi emoji is returned - await expect(channelsPage.reactionEmojiPicker.getEmoji('taxi')).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/jumbo_emoji.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/jumbo_emoji.spec.ts new file mode 100644 index 00000000000..697d64c8552 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/messaging/jumbo_emoji.spec.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +async function emojiHeight(post: {emoticon: {boundingBox: () => Promise<{height: number} | null>}}): Promise { + const box = await post.emoticon.boundingBox(); + if (!box) { + throw new Error('Expected the emoji to have a bounding box'); + } + return box.height; +} + +/** + * @objective Verify an emoji-only message renders as a jumbo (large) emoji, and that leading/trailing + * whitespace around the emoji does not stop it from rendering jumbo. + */ +test( + 'MM-T2179 renders emoji-only messages as jumbo even when surrounded by whitespace', + {tag: '@messaging'}, + async ({pw}) => { + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Post an emoji inline with text (renders at the normal inline size) + await channelsPage.postMessage('jumbo test :taco:'); + const inlineHeight = await emojiHeight(await channelsPage.getLastPost()); + + // # Post the same emoji on its own surrounded by whitespace + await channelsPage.postMessage(' :taco: '); + const jumboHeight = await emojiHeight(await channelsPage.getLastPost()); + + // * Verify the whitespace-surrounded emoji-only message renders larger (jumbo) than the inline emoji + expect(jumboHeight).toBeGreaterThan(inlineHeight); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/message_delivery_and_links.spec.ts similarity index 62% rename from e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts rename to e2e-tests/playwright/specs/functional/channels/messaging/message_delivery_and_links.spec.ts index 76d41ac1cb0..0b16ccdd1ad 100644 --- a/e2e-tests/playwright/specs/functional/channels/messaging/messaging.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/messaging/message_delivery_and_links.spec.ts @@ -51,33 +51,6 @@ test('MM-T94 RHS fetches messages on reconnect while a different channel is in c 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. */ @@ -100,22 +73,3 @@ test('MM-T175 Channel short-linking still works when placed in brackets', async 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/messaging/permalink_timestamp.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/permalink_timestamp.spec.ts deleted file mode 100644 index 6e1dea2f4c2..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/messaging/permalink_timestamp.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that clicking a post timestamp in the RHS or center channel highlights the post in the center channel. - */ -test('MM-T176 highlights the center post when its timestamp is clicked', {tag: '@messaging'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message and open its thread, then reply in the RHS - await channelsPage.postMessage(`timestamp highlight ${pw.random.id()}`); - const rootPost = await channelsPage.getLastPost(); - const rootId = await rootPost.getId(); - await rootPost.reply(); - await channelsPage.sidebarRight.toBeVisible(); - await channelsPage.sidebarRight.postMessage('Reply to test timestamp click'); - - // # Click the root post timestamp in the RHS - await page.locator(`#RHS_ROOT_time_${rootId}`).click(); - - // * Verify the matching post is highlighted in the center channel - const centerPost = await channelsPage.centerView.getPostById(rootId); - await expect(centerPost.container).toHaveClass(/post--highlight/); - - // # Close the RHS and wait for the highlight to fade - await channelsPage.sidebarRight.close(); - await expect(centerPost.container).not.toHaveClass(/post--highlight/); - - // # Click the same post timestamp in the center channel - await page.locator(`#CENTER_time_${rootId}`).click(); - - // * Verify the post is highlighted again in the center channel - await expect(centerPost.container).toHaveClass(/post--highlight/); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/permalink_first_post.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/permalinks.spec.ts similarity index 58% rename from e2e-tests/playwright/specs/functional/channels/messaging/permalink_first_post.spec.ts rename to e2e-tests/playwright/specs/functional/channels/messaging/permalinks.spec.ts index fc793c793e4..d1736f21cc5 100644 --- a/e2e-tests/playwright/specs/functional/channels/messaging/permalink_first_post.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/messaging/permalinks.spec.ts @@ -3,6 +3,42 @@ import {expect, test} from '@mattermost/playwright-lib'; +/** + * @objective Verify that clicking a post timestamp in the RHS or center channel highlights the post in the center channel. + */ +test('MM-T176 highlights the center post when its timestamp is clicked', {tag: '@messaging'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message and open its thread, then reply in the RHS + await channelsPage.postMessage(`timestamp highlight ${pw.random.id()}`); + const rootPost = await channelsPage.getLastPost(); + const rootId = await rootPost.getId(); + await rootPost.reply(); + await channelsPage.sidebarRight.toBeVisible(); + await channelsPage.sidebarRight.postMessage('Reply to test timestamp click'); + + // # Click the root post timestamp in the RHS + await page.locator(`#RHS_ROOT_time_${rootId}`).click(); + + // * Verify the matching post is highlighted in the center channel + const centerPost = await channelsPage.centerView.getPostById(rootId); + await expect(centerPost.container).toHaveClass(/post--highlight/); + + // # Close the RHS and wait for the highlight to fade + await channelsPage.sidebarRight.close(); + await expect(centerPost.container).not.toHaveClass(/post--highlight/); + + // # Click the same post timestamp in the center channel + await page.locator(`#CENTER_time_${rootId}`).click(); + + // * Verify the post is highlighted again in the center channel + await expect(centerPost.container).toHaveClass(/post--highlight/); +}); + /** * @objective Verify that following a permalink to the first post in a channel loads the channel start without an endless loading indicator above it. */ diff --git a/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_logout.spec.ts b/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_logout.spec.ts deleted file mode 100644 index a0a433940de..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_logout.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a user who belongs to multiple teams can switch between them and log out to the sign-in page. - * - * MM-T432 is a duplicate of MM-T430 and is covered by this test. - */ -test('MM-T430 MM-T432 switches between teams and logs out', {tag: '@multi_team'}, async ({pw}) => { - // # Create a user in one team, then add them to a second team - const {user, team, adminClient} = await pw.initSetup(); - const secondTeam = await pw.createNewTeam(adminClient, { - name: 'team', - displayName: 'Second Team', - type: 'O', - unique: true, - }); - await adminClient.addToTeam(secondTeam.id, user.id); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'off-topic'); - await channelsPage.toBeVisible(); - - // # Switch to the second team - await channelsPage.switchToTeam(secondTeam.name); - - // * Verify the second team is displayed - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${secondTeam.name}/`); - - // # Switch back to the first team - await channelsPage.switchToTeam(team.name); - - // * Verify the first team is displayed - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${team.name}/`); - - // # Log out - await channelsPage.logout(); - - // * Verify the user is returned to the sign-in page - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain('/login'); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_navigation.spec.ts b/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_navigation.spec.ts new file mode 100644 index 00000000000..8a158b41392 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/multi_team/multi_team_navigation.spec.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that a user who belongs to multiple teams can switch between them and log out to the sign-in page. + * + * MM-T432 is a duplicate of MM-T430 and is covered by this test. + */ +test('MM-T430 MM-T432 switches between teams and logs out', {tag: '@multi_team'}, async ({pw}) => { + // # Create a user in one team, then add them to a second team + const {user, team, adminClient} = await pw.initSetup(); + const secondTeam = await pw.createNewTeam(adminClient, { + name: 'team', + displayName: 'Second Team', + type: 'O', + unique: true, + }); + await adminClient.addToTeam(secondTeam.id, user.id); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Switch to the second team + await channelsPage.switchToTeam(secondTeam.name); + + // * Verify the second team is displayed + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${secondTeam.name}/`); + + // # Switch back to the first team + await channelsPage.switchToTeam(team.name); + + // * Verify the first team is displayed + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${team.name}/`); + + // # Log out + await channelsPage.logout(); + + // * Verify the user is returned to the sign-in page + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain('/login'); +}); + +/** + * @objective Verify that reopening the app returns the user to the previously viewed team and channel. + */ +test('MM-T431 reopens to the previously viewed team and channel', {tag: '@multi_team'}, async ({pw}) => { + // # Create a user in one team, then add them to a second team + const {user, team, adminClient} = await pw.initSetup(); + const secondTeam = await pw.createNewTeam(adminClient, { + name: 'team', + displayName: 'Second Team', + type: 'O', + unique: true, + }); + await adminClient.addToTeam(secondTeam.id, user.id); + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Switch to the second team and view its Off-Topic channel + await channelsPage.switchToTeam(secondTeam.name); + await channelsPage.sidebarLeft.goToItem('off-topic'); + await expect + .poll(() => page.url(), {timeout: pw.duration.ten_sec}) + .toContain(`/${secondTeam.name}/channels/off-topic`); + + // # Reopen the app by navigating to the root URL + await page.goto('/'); + + // * Verify the app returns to the previously viewed team and channel + await channelsPage.toBeVisible(); + await expect + .poll(() => page.url(), {timeout: pw.duration.ten_sec}) + .toContain(`/${secondTeam.name}/channels/off-topic`); +}); + +/** + * @objective Verify that the right-hand-side reply panel can be expanded to overlay the center channel and collapsed back while staying open. + */ +test('MM-T440 expands and collapses the RHS reply panel', {tag: '@multi_team'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message and open its thread in the RHS + await channelsPage.postMessage(`rhs expand ${pw.random.id()}`); + const post = await channelsPage.getLastPost(); + await post.reply(); + await channelsPage.sidebarRight.toBeVisible(); + + // * Verify the RHS starts collapsed with an expand control + await expect(channelsPage.sidebarRight.expandButton).toBeVisible(); + + // # Expand the RHS + await channelsPage.sidebarRight.expand(); + + // * Verify the RHS is expanded (a collapse control is now shown) + await expect(channelsPage.sidebarRight.collapseButton).toBeVisible(); + + // # Collapse the RHS again + await channelsPage.sidebarRight.collapse(); + + // * Verify the RHS collapses but stays open, and the center channel is visible again + await expect(channelsPage.sidebarRight.expandButton).toBeVisible(); + await channelsPage.sidebarRight.toBeVisible(); + await expect(channelsPage.centerView.postCreate.input).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/multi_team/reopen_last_viewed.spec.ts b/e2e-tests/playwright/specs/functional/channels/multi_team/reopen_last_viewed.spec.ts deleted file mode 100644 index 2e0d4993471..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/multi_team/reopen_last_viewed.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that reopening the app returns the user to the previously viewed team and channel. - */ -test('MM-T431 reopens to the previously viewed team and channel', {tag: '@multi_team'}, async ({pw}) => { - // # Create a user in one team, then add them to a second team - const {user, team, adminClient} = await pw.initSetup(); - const secondTeam = await pw.createNewTeam(adminClient, { - name: 'team', - displayName: 'Second Team', - type: 'O', - unique: true, - }); - await adminClient.addToTeam(secondTeam.id, user.id); - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Switch to the second team and view its Off-Topic channel - await channelsPage.switchToTeam(secondTeam.name); - await channelsPage.sidebarLeft.goToItem('off-topic'); - await expect - .poll(() => page.url(), {timeout: pw.duration.ten_sec}) - .toContain(`/${secondTeam.name}/channels/off-topic`); - - // # Reopen the app by navigating to the root URL - await page.goto('/'); - - // * Verify the app returns to the previously viewed team and channel - await channelsPage.toBeVisible(); - await expect - .poll(() => page.url(), {timeout: pw.duration.ten_sec}) - .toContain(`/${secondTeam.name}/channels/off-topic`); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/multi_team/rhs_expand_overlay.spec.ts b/e2e-tests/playwright/specs/functional/channels/multi_team/rhs_expand_overlay.spec.ts deleted file mode 100644 index c360f072b7a..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/multi_team/rhs_expand_overlay.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that the right-hand-side reply panel can be expanded to overlay the center channel and collapsed back while staying open. - */ -test('MM-T440 expands and collapses the RHS reply panel', {tag: '@multi_team'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message and open its thread in the RHS - await channelsPage.postMessage(`rhs expand ${pw.random.id()}`); - const post = await channelsPage.getLastPost(); - await post.reply(); - await channelsPage.sidebarRight.toBeVisible(); - - // * Verify the RHS starts collapsed with an expand control - await expect(channelsPage.sidebarRight.expandButton).toBeVisible(); - - // # Expand the RHS - await channelsPage.sidebarRight.expand(); - - // * Verify the RHS is expanded (a collapse control is now shown) - await expect(channelsPage.sidebarRight.collapseButton).toBeVisible(); - - // # Collapse the RHS again - await channelsPage.sidebarRight.collapse(); - - // * Verify the RHS collapses but stays open, and the center channel is visible again - await expect(channelsPage.sidebarRight.expandButton).toBeVisible(); - await channelsPage.sidebarRight.toBeVisible(); - await expect(channelsPage.centerView.postCreate.input).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mention_case.spec.ts b/e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mentions.spec.ts similarity index 57% rename from e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mention_case.spec.ts rename to e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mentions.spec.ts index bf3294f83e4..0468d46c961 100644 --- a/e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mention_case.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/notifications/channel_wide_mentions.spec.ts @@ -8,6 +8,71 @@ const NOTIFY_ALL_THRESHOLD = 5; const NOTIFY_ALL_TITLE = 'Confirm sending notifications to entire channel'; +/** + * @objective Verify that channel-wide mentions with uppercase letters trigger notifications and are properly highlighted. + * + * @precondition + * - Two users are members of the same team + * - Notification permissions are granted in the browser + */ +test( + 'MM-T483 triggers notification with uppercase channel-wide mention and highlights message for all users', + {tag: '@notifications'}, + async ({pw}) => { + // # Initialize setup and get the required users and team + const {team, adminUser, user} = await pw.initSetup(); + + // # Log in as the admin in one browser session and navigate to the "town-square" channel + const {page: adminPage, channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser); + await adminChannelsPage.goto(team.name, 'town-square'); + await adminChannelsPage.toBeVisible(); + + // # Stub the Notification in the admin's browser to capture notifications + await pw.stubNotification(adminPage, 'granted'); + + // # Log in as the regular user in a separate browser and navigate to the "off-topic" channel + const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(user); + await otherChannelsPage.goto(team.name, 'off-topic'); + await otherChannelsPage.toBeVisible(); + + // # Post a channel-wide mention message "@ALL" in uppercase from the user's browser + const message = `@ALL good morning, ${team.name}!`; + await otherChannelsPage.postMessage(message); + + // * Verify notification is received in the admin's browser with correct content + const notifications = await pw.waitForNotification(adminPage); + expect(notifications.length).toBe(1); + + const notification = notifications[0]; + expect(notification.title).toBe('Off-Topic'); + expect(notification.body).toBe(`@${user.username}: ${message}`); + + // The Notifications API tag must not leak the message body; the app uses the + // opaque post ID as the tag so notifications coalesce per conversation. + const otherLastPost = await otherChannelsPage.getLastPost(); + expect(notification.tag).toBe(await otherLastPost.getId()); + expect(notification.tag).not.toContain(message); + + expect(notification.icon).toContain('.png'); + expect(notification.requireInteraction).toBe(false); + expect(notification.silent).toBe(false); + + // * Verify the last post as viewed by the regular user in the "off-topic" channel contains the message and is highlighted + await otherLastPost.toContainText(message); + await expect(otherLastPost.container.locator('.mention--highlight')).toBeVisible(); + await expect(otherLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); + + // # Navigate admin to the "off-topic" channel + await adminChannelsPage.goto(team.name, 'off-topic'); + + // * Verify the message is posted and highlighted correctly for the admin user + const adminLastPost = await adminChannelsPage.getLastPost(); + await adminLastPost.toContainText(message); + await expect(adminLastPost.container.locator('.mention--highlight')).toBeVisible(); + await expect(adminLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); + }, +); + /** * @objective Verify a channel-wide mention typed in mixed case (@channEL) is recognized as a mention, * prompts the notify-all confirmation, and is posted with its original casing preserved. diff --git a/e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_click.spec.ts b/e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_navigation.spec.ts similarity index 64% rename from e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_click.spec.ts rename to e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_navigation.spec.ts index 9b8cdefd4eb..1998e866776 100644 --- a/e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_click.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/notifications/desktop_notification_navigation.spec.ts @@ -103,3 +103,57 @@ test( await channelsPage.centerView.waitUntilLastPostContains(message); }, ); + +/** + * @objective Verify a user in "Do Not Disturb" does not receive desktop notifications, and receives them + * again once the status is cleared. + * + * @precondition + * - Notification permissions are granted in the browser + */ +test( + 'MM-T495 suppresses desktop notifications while Do Not Disturb is enabled', + {tag: '@notifications'}, + async ({pw}) => { + const {adminClient, adminUser, team, user} = await pw.initSetup(); + + // # Create a direct message channel between the admin and the user + const dmChannel = await adminClient.createDirectChannel([adminUser.id, user.id]); + + // # Log in and view Town Square (not the DM), then stub notifications + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + await pw.stubNotification(page, 'granted'); + + // # Send a direct message from the admin while the user is available + const firstMessage = `DND baseline ${pw.random.id()}`; + await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: firstMessage}); + + // * Verify the direct message triggers a desktop notification + const notifications = await pw.waitForNotification(page); + expect(notifications.length).toBe(1); + expect(notifications[0].body).toContain(firstMessage); + + // # Enable Do Not Disturb via the slash command + await channelsPage.postMessage('/dnd'); + + // * Verify the Do Not Disturb system message confirms it is enabled + await channelsPage.centerView.waitUntilLastPostContains('Do Not Disturb is enabled'); + + // # Send another direct message from the admin while the user is in Do Not Disturb + const secondMessage = `DND suppressed ${pw.random.id()}`; + await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: secondMessage}); + + // * Verify no second desktop notification is received while in Do Not Disturb + const suppressed = await pw.waitForNotification(page, 2, pw.duration.two_sec); + expect(suppressed).toHaveLength(0); + + // # Clear the status back to online via the slash command + await channelsPage.postMessage('/online'); + + // * Verify the account menu reflects the online status + const accountMenu = await channelsPage.openUserAccountMenu(); + await expect(accountMenu.online).toHaveAttribute('aria-checked', 'true'); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/notifications/do_not_disturb.spec.ts b/e2e-tests/playwright/specs/functional/channels/notifications/do_not_disturb.spec.ts deleted file mode 100644 index 9e5e6f1dd0c..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/notifications/do_not_disturb.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify a user in "Do Not Disturb" does not receive desktop notifications, and receives them - * again once the status is cleared. - * - * @precondition - * - Notification permissions are granted in the browser - */ -test( - 'MM-T495 suppresses desktop notifications while Do Not Disturb is enabled', - {tag: '@notifications'}, - async ({pw}) => { - const {adminClient, adminUser, team, user} = await pw.initSetup(); - - // # Create a direct message channel between the admin and the user - const dmChannel = await adminClient.createDirectChannel([adminUser.id, user.id]); - - // # Log in and view Town Square (not the DM), then stub notifications - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - await pw.stubNotification(page, 'granted'); - - // # Send a direct message from the admin while the user is available - const firstMessage = `DND baseline ${pw.random.id()}`; - await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: firstMessage}); - - // * Verify the direct message triggers a desktop notification - const notifications = await pw.waitForNotification(page); - expect(notifications.length).toBe(1); - expect(notifications[0].body).toContain(firstMessage); - - // # Enable Do Not Disturb via the slash command - await channelsPage.postMessage('/dnd'); - - // * Verify the Do Not Disturb system message confirms it is enabled - await channelsPage.centerView.waitUntilLastPostContains('Do Not Disturb is enabled'); - - // # Send another direct message from the admin while the user is in Do Not Disturb - const secondMessage = `DND suppressed ${pw.random.id()}`; - await adminClient.createPost({channel_id: dmChannel.id, user_id: adminUser.id, message: secondMessage}); - - // * Verify no second desktop notification is received while in Do Not Disturb - const suppressed = await pw.waitForNotification(page, 2, pw.duration.two_sec); - expect(suppressed).toHaveLength(0); - - // # Clear the status back to online via the slash command - await channelsPage.postMessage('/online'); - - // * Verify the account menu reflects the online status - const accountMenu = await channelsPage.openUserAccountMenu(); - await expect(accountMenu.online).toHaveAttribute('aria-checked', 'true'); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/notifications/notification.spec.ts b/e2e-tests/playwright/specs/functional/channels/notifications/notification.spec.ts deleted file mode 100644 index a299e688fe0..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/notifications/notification.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that channel-wide mentions with uppercase letters trigger notifications and are properly highlighted. - * - * @precondition - * - Two users are members of the same team - * - Notification permissions are granted in the browser - */ -test( - 'MM-T483 triggers notification with uppercase channel-wide mention and highlights message for all users', - {tag: '@notifications'}, - async ({pw}) => { - // # Initialize setup and get the required users and team - const {team, adminUser, user} = await pw.initSetup(); - - // # Log in as the admin in one browser session and navigate to the "town-square" channel - const {page: adminPage, channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser); - await adminChannelsPage.goto(team.name, 'town-square'); - await adminChannelsPage.toBeVisible(); - - // # Stub the Notification in the admin's browser to capture notifications - await pw.stubNotification(adminPage, 'granted'); - - // # Log in as the regular user in a separate browser and navigate to the "off-topic" channel - const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(user); - await otherChannelsPage.goto(team.name, 'off-topic'); - await otherChannelsPage.toBeVisible(); - - // # Post a channel-wide mention message "@ALL" in uppercase from the user's browser - const message = `@ALL good morning, ${team.name}!`; - await otherChannelsPage.postMessage(message); - - // * Verify notification is received in the admin's browser with correct content - const notifications = await pw.waitForNotification(adminPage); - expect(notifications.length).toBe(1); - - const notification = notifications[0]; - expect(notification.title).toBe('Off-Topic'); - expect(notification.body).toBe(`@${user.username}: ${message}`); - - // The Notifications API tag must not leak the message body; the app uses the - // opaque post ID as the tag so notifications coalesce per conversation. - const otherLastPost = await otherChannelsPage.getLastPost(); - expect(notification.tag).toBe(await otherLastPost.getId()); - expect(notification.tag).not.toContain(message); - - expect(notification.icon).toContain('.png'); - expect(notification.requireInteraction).toBe(false); - expect(notification.silent).toBe(false); - - // * Verify the last post as viewed by the regular user in the "off-topic" channel contains the message and is highlighted - await otherLastPost.toContainText(message); - await expect(otherLastPost.container.locator('.mention--highlight')).toBeVisible(); - await expect(otherLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); - - // # Navigate admin to the "off-topic" channel - await adminChannelsPage.goto(team.name, 'off-topic'); - - // * Verify the message is posted and highlighted correctly for the admin user - const adminLastPost = await adminChannelsPage.getLastPost(); - await adminLastPost.toContainText(message); - await expect(adminLastPost.container.locator('.mention--highlight')).toBeVisible(); - await expect(adminLastPost.container.locator('.mention--highlight').getByText('@ALL')).toBeVisible(); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/plugin_marketplace/view_marketplace.spec.ts b/e2e-tests/playwright/specs/functional/channels/plugin_marketplace/view_marketplace.spec.ts new file mode 100644 index 00000000000..36e63a2f08d --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/plugin_marketplace/view_marketplace.spec.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify a system admin can open and close the plugin (App) Marketplace. + * + * MM-T1946 and MM-T1947 cover the same behavior as MM-T1945 and are covered by this test. + */ +test('MM-T1945 MM-T1946 MM-T1947 system admin can view the plugin marketplace', {tag: '@marketplace'}, async ({pw}) => { + const {adminUser, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open the App Marketplace from the product menu + await channelsPage.globalHeader.openAppMarketplace(); + + // * Verify the Marketplace is shown + const marketplace = channelsPage.marketplaceModal; + await marketplace.toBeVisible(); + + // # Close the Marketplace + await marketplace.close(); + + // * Verify the Marketplace is closed + await marketplace.notToBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_box_navigation.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_box_navigation.spec.ts deleted file mode 100644 index b0ccd948308..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_box_navigation.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that the top navigation buttons remain clickable and open their panels while focus is in the search box. - */ -test('MM-T367 clicks top navigation buttons while the search box has focus', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // The open search box traps focus and marks the top-right header buttons aria-hidden, so match hidden too. - // (The channel-info button lives in the channel header, which the search popup overlays, so it is not - // reachable while the search box is open in the current UI and is intentionally not exercised here.) - const recentMentionsButton = page.getByRole('button', {name: 'Recent mentions', includeHidden: true}); - const savedMessagesButton = page.getByRole('button', {name: 'Saved messages', includeHidden: true}); - - // # Put focus in the search box and type text, then click Recent mentions - await channelsPage.globalHeader.openSearch(); - await channelsPage.searchBox.toBeVisible(); - await channelsPage.searchBox.searchInput.fill('some text'); - await recentMentionsButton.click(); - - // * Verify the Recent Mentions panel opens - await channelsPage.searchResultsPanel.toHaveHeading('Recent Mentions'); - - // # Focus the search box again and click Saved messages - await channelsPage.globalHeader.openSearch(); - await channelsPage.searchBox.toBeVisible(); - await channelsPage.searchBox.searchInput.fill('some text'); - await savedMessagesButton.click(); - - // * Verify the Saved messages panel opens - await expect( - channelsPage.searchResultsPanel.container.getByRole('heading', {name: 'Saved messages'}).first(), - ).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_date_filter.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts similarity index 97% rename from e2e-tests/playwright/specs/functional/channels/search/search_date_filter.spec.ts rename to e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts index 8509f7d2617..1dedf554550 100644 --- a/e2e-tests/playwright/specs/functional/channels/search/search_date_filter.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts @@ -3,11 +3,6 @@ import {expect, test} from '@mattermost/playwright-lib'; -// Returns the UTC calendar date of a timestamp formatted as the `on:` filter expects (YYYY-MM-DD). -function toDateFilter(timestamp: number): string { - return new Date(timestamp).toISOString().slice(0, 10); -} - /** * @objective Verify the "on:" search date filter can be set by clicking a day in the calendar picker, * and that the resulting date-filtered search returns the matching post. @@ -105,3 +100,7 @@ test('MM-T600 updates the search date with the keyboard', {tag: '@search'}, asyn // * Verify the post is no longer returned for the later date await expect(channelsPage.searchResultItems).toHaveCount(0); }); + +function toDateFilter(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10); +} diff --git a/e2e-tests/playwright/specs/functional/channels/search/search.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_timezone_edit.spec.ts similarity index 75% rename from e2e-tests/playwright/specs/functional/channels/search/search.spec.ts rename to e2e-tests/playwright/specs/functional/channels/search/search_date_filter_timezone_edit.spec.ts index 15c205acc71..4a6ed037893 100644 --- a/e2e-tests/playwright/specs/functional/channels/search/search.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_timezone_edit.spec.ts @@ -3,50 +3,6 @@ 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. */ diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_delete_parent_post.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_delete_parent_post.spec.ts deleted file mode 100644 index 3631e2dcbf1..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_delete_parent_post.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that deleting a parent post from the search results removes both the post and its reply. - */ -test('MM-T381 deletes a parent post from the search results', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user, and open a channel - const {user, team, adminClient} = await pw.initSetup(); - const channel = await adminClient.createPublicChannel(team.id, 'Delete Parent', 'delete-parent'); - await adminClient.addToChannel(user.id, channel.id); - - const parentToken = `parentpost${pw.random.id()}`; - const replyToken = `replypost${pw.random.id()}`; - - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, channel.name); - await channelsPage.toBeVisible(); - - // # Post a message and reply to it, then close the thread - await channelsPage.postMessage(parentToken); - const post = await channelsPage.getLastPost(); - await post.reply(); - await channelsPage.sidebarRight.toBeVisible(); - await channelsPage.sidebarRight.postMessage(replyToken); - await channelsPage.sidebarRight.close(); - - // * Verify both the parent post and its reply are searchable before deletion - await channelsPage.searchFor(replyToken); - await expect(channelsPage.searchResultsPanel.getResultByText(replyToken)).toHaveCount(1); - await channelsPage.searchFor(parentToken); - await expect(channelsPage.searchResultsPanel.getResultByText(parentToken)).toHaveCount(1); - - // # Delete the parent post from the search result - await channelsPage.searchResultsPanel.openResultDotMenu(parentToken); - await channelsPage.postDotMenu.deleteMenuItem.click(); - await channelsPage.deletePostModal.toBeVisible(); - await channelsPage.deletePostModal.confirm(); - - // * Verify the deleted parent post is removed from the open search results in real time - await expect(channelsPage.searchResultsPanel.getResultByText(parentToken)).toHaveCount(0); - - // * Verify the reply was also removed (searching for it returns no results) - await channelsPage.searchFor(replyToken); - await expect(channelsPage.searchResultsPanel.getResultByText(replyToken)).toHaveCount(0); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_help_text.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_help_text.spec.ts deleted file mode 100644 index cea6d057944..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_help_text.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that the search box shows help text when opened from the channel and again after opening a thread. - */ -test('MM-T370 shows search help text when the search box is opened', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message and open its thread - await channelsPage.postMessage(`help text ${pw.random.id()}`); - - // # Open the search box - await channelsPage.globalHeader.openSearch(); - await channelsPage.searchBox.toBeVisible(); - - // * Verify the search help text is shown - await expect(channelsPage.page.getByText('Filter your search with:')).toBeVisible(); - - // # Close the search box, then open a thread - await channelsPage.page.keyboard.press('Escape'); - await expect(channelsPage.searchBox.container).not.toBeVisible(); - const post = await channelsPage.getLastPost(); - await post.reply(); - await channelsPage.sidebarRight.toBeVisible(); - await channelsPage.globalHeader.openSearch(); - await channelsPage.searchBox.toBeVisible(); - - // * Verify the search help text is shown again - await expect(channelsPage.page.getByText('Filter your search with:')).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_quotes.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_highlight_quotes.spec.ts deleted file mode 100644 index bb10d356127..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_quotes.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that searching with trailing double quotes highlights only the searched terms, with no unexpected highlighting. - */ -test('MM-T354 highlights only the searched terms when searching with double quotes', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message that contains the two search words plus an unrelated unique word - const marker = `zzz${pw.random.id()}`; - await channelsPage.postMessage(`test tool ${marker}`); - - // # Search using the two words followed by an empty double-quoted phrase - await channelsPage.searchFor('test tool ""'); - - // * Verify the message is returned - await channelsPage.searchResultsPanel.toContainText(marker); - - // * Verify only the two searched words are highlighted (no unexpected highlighting) - const highlights = channelsPage.searchResultsPanel.getHighlightedTerms(); - await expect(highlights).toHaveCount(2); - await expect(highlights.filter({hasText: 'test'})).toHaveCount(1); - await expect(highlights.filter({hasText: 'tool'})).toHaveCount(1); - await expect(highlights.filter({hasText: marker})).toHaveCount(0); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_while_typing.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_highlight_while_typing.spec.ts deleted file mode 100644 index d1c25de4a40..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_while_typing.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that the search result highlighting reflects the submitted query and does not change while the search input is edited before submitting again. - */ -test( - 'MM-T371 keeps search result highlighting unchanged while editing the search input', - {tag: '@search'}, - async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message that contains two distinct words - const suffix = pw.random.id(); - const firstWord = `apple${suffix}`; - const secondWord = `banana${suffix}`; - await channelsPage.postMessage(`${firstWord} ${secondWord}`); - - // # Search for the first word - await channelsPage.searchFor(firstWord); - - // * Verify the result contains both words but highlights only the searched (first) word - await channelsPage.searchResultsPanel.toBeVisible(); - await channelsPage.searchResultsPanel.toContainText(secondWord); - await expect(channelsPage.searchResultsPanel.getHighlightedTerms()).toHaveText([firstWord]); - - // # Reopen the search box and replace the query with the second word without submitting - await channelsPage.globalHeader.openSearch(); - await channelsPage.searchBox.toBeVisible(); - const {searchInput} = channelsPage.searchBox; - await expect(searchInput).toHaveValue(firstWord); - await searchInput.fill(secondWord); - - // * Verify the search results still highlight the originally searched (first) word - await expect(channelsPage.searchResultsPanel.getHighlightedTerms()).toHaveText([firstWord]); - }, -); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_saved_pinned.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_highlighting.spec.ts similarity index 52% rename from e2e-tests/playwright/specs/functional/channels/search/search_highlight_saved_pinned.spec.ts rename to e2e-tests/playwright/specs/functional/channels/search/search_highlighting.spec.ts index 72a497b1d5c..74ed0f2ba8c 100644 --- a/e2e-tests/playwright/specs/functional/channels/search/search_highlight_saved_pinned.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/search/search_highlighting.spec.ts @@ -3,6 +3,73 @@ import {expect, test} from '@mattermost/playwright-lib'; +/** + * @objective Verify that searching with trailing double quotes highlights only the searched terms, with no unexpected highlighting. + */ +test('MM-T354 highlights only the searched terms when searching with double quotes', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message that contains the two search words plus an unrelated unique word + const marker = `zzz${pw.random.id()}`; + await channelsPage.postMessage(`test tool ${marker}`); + + // # Search using the two words followed by an empty double-quoted phrase + await channelsPage.searchFor('test tool ""'); + + // * Verify the message is returned + await channelsPage.searchResultsPanel.toContainText(marker); + + // * Verify only the two searched words are highlighted (no unexpected highlighting) + const highlights = channelsPage.searchResultsPanel.getHighlightedTerms(); + await expect(highlights).toHaveCount(2); + await expect(highlights.filter({hasText: 'test'})).toHaveCount(1); + await expect(highlights.filter({hasText: 'tool'})).toHaveCount(1); + await expect(highlights.filter({hasText: marker})).toHaveCount(0); +}); + +/** + * @objective Verify that the search result highlighting reflects the submitted query and does not change while the search input is edited before submitting again. + */ +test( + 'MM-T371 keeps search result highlighting unchanged while editing the search input', + {tag: '@search'}, + async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message that contains two distinct words + const suffix = pw.random.id(); + const firstWord = `apple${suffix}`; + const secondWord = `banana${suffix}`; + await channelsPage.postMessage(`${firstWord} ${secondWord}`); + + // # Search for the first word + await channelsPage.searchFor(firstWord); + + // * Verify the result contains both words but highlights only the searched (first) word + await channelsPage.searchResultsPanel.toBeVisible(); + await channelsPage.searchResultsPanel.toContainText(secondWord); + await expect(channelsPage.searchResultsPanel.getHighlightedTerms()).toHaveText([firstWord]); + + // # Reopen the search box and replace the query with the second word without submitting + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + const {searchInput} = channelsPage.searchBox; + await expect(searchInput).toHaveValue(firstWord); + await searchInput.fill(secondWord); + + // * Verify the search results still highlight the originally searched (first) word + await expect(channelsPage.searchResultsPanel.getHighlightedTerms()).toHaveText([firstWord]); + }, +); + /** * @objective Verify that search term highlighting appears in the search results panel but does not persist in the saved or pinned message panels. */ diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_in_channel_autocomplete.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_in_channel_autocomplete.spec.ts new file mode 100644 index 00000000000..6ba9122024c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search_in_channel_autocomplete.spec.ts @@ -0,0 +1,47 @@ +// 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 "in:" search autocomplete shows a renamed channel by both its new display name and + * its original handle, so it stays findable after a display-name change. + */ +test( + 'MM-T1449 shows the channel display name and handle in the in: search autocomplete', + {tag: '@search'}, + async ({pw}) => { + const {adminClient, team, user} = await pw.initSetup(); + + // # Create a public channel whose handle differs from its display name, then add the user + const id = pw.random.id(); + const handle = `original-${id}`; + const updatedName = `Updated ${id}`; + const channel = await adminClient.createPublicChannel(team.id, `Original ${id}`, handle); + await adminClient.addToChannel(user.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Rename the channel's display name via Channel Settings + const channelSettings = await channelsPage.openChannelSettings(); + const infoSettings = await channelSettings.openInfoTab(); + await infoSettings.updateName(updatedName); + await channelSettings.save(); + await channelSettings.close(); + await channelsPage.centerView.header.toHaveTitle(updatedName); + + // # Open search and type an "in:" filter matching the channel's original handle + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + await channelsPage.searchBox.searchInput.fill('in:'); + await channelsPage.searchBox.searchInput.pressSequentially(handle); + + // * Verify the autocomplete shows the channel by its new display name and its original handle + const suggestion = channelsPage.searchBox.container.getByRole('option').filter({hasText: updatedName}); + await expect(suggestion).toBeVisible(); + await expect(suggestion).toContainText(updatedName); + await expect(suggestion).toContainText(`~${handle}`); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_input_help_navigation.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_input_help_navigation.spec.ts new file mode 100644 index 00000000000..d06b291630c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search_input_help_navigation.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that a wildcard (*) is disregarded when it is not immediately preceded by text. + */ +test('MM-T351 disregards a wildcard that is not preceded by text', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post two messages, one with a longer variant of the search term + const token = `qwerty${pw.random.id()}`; + await channelsPage.postMessage(`${token}jkl`); + await channelsPage.postMessage(token); + + // # Search using a trailing wildcard separated by a space + await channelsPage.searchFor(`${token} *`); + + // * Verify the wildcard is disregarded and the exact-term message is returned + await channelsPage.searchResultsPanel.toContainText(token); +}); + +/** + * @objective Verify that the top navigation buttons remain clickable and open their panels while focus is in the search box. + */ +test('MM-T367 clicks top navigation buttons while the search box has focus', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // The open search box traps focus and marks the top-right header buttons aria-hidden, so match hidden too. + // (The channel-info button lives in the channel header, which the search popup overlays, so it is not + // reachable while the search box is open in the current UI and is intentionally not exercised here.) + const recentMentionsButton = page.getByRole('button', {name: 'Recent mentions', includeHidden: true}); + const savedMessagesButton = page.getByRole('button', {name: 'Saved messages', includeHidden: true}); + + // # Put focus in the search box and type text, then click Recent mentions + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + await channelsPage.searchBox.searchInput.fill('some text'); + await recentMentionsButton.click(); + + // * Verify the Recent Mentions panel opens + await channelsPage.searchResultsPanel.toHaveHeading('Recent Mentions'); + + // # Focus the search box again and click Saved messages + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + await channelsPage.searchBox.searchInput.fill('some text'); + await savedMessagesButton.click(); + + // * Verify the Saved messages panel opens + await expect( + channelsPage.searchResultsPanel.container.getByRole('heading', {name: 'Saved messages'}).first(), + ).toBeVisible(); +}); + +/** + * @objective Verify that the search box shows help text when opened from the channel and again after opening a thread. + */ +test('MM-T370 shows search help text when the search box is opened', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message and open its thread + await channelsPage.postMessage(`help text ${pw.random.id()}`); + + // # Open the search box + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + + // * Verify the search help text is shown + await expect(channelsPage.page.getByText('Filter your search with:')).toBeVisible(); + + // # Close the search box, then open a thread + await channelsPage.page.keyboard.press('Escape'); + await expect(channelsPage.searchBox.container).not.toBeVisible(); + const post = await channelsPage.getLastPost(); + await post.reply(); + await channelsPage.sidebarRight.toBeVisible(); + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.toBeVisible(); + + // * Verify the search help text is shown again + await expect(channelsPage.page.getByText('Filter your search with:')).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_jump_to_post.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_jump_to_post.spec.ts deleted file mode 100644 index 06a5a8c5b40..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_jump_to_post.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that jumping from a search result opens a post that is not already displayed in the center channel. - */ -test('MM-T380 jumps from a search result to a post in another channel', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user, and create a channel with many posts - const {user, team, adminClient} = await pw.initSetup(); - const linkChannel = await adminClient.createPublicChannel(team.id, 'Link Test', 'link-test'); - await adminClient.addToChannel(user.id, linkChannel.id); - - // # Post a target message surrounded by many other messages so it is not initially loaded elsewhere - const token = `asparagus${pw.random.id()}`; - for (let i = 0; i < 4; i++) { - await adminClient.createPost({channel_id: linkChannel.id, message: `RF Random Post before ${i}`}); - } - await adminClient.createPost({channel_id: linkChannel.id, message: token}); - for (let i = 0; i < 8; i++) { - await adminClient.createPost({channel_id: linkChannel.id, message: `RF Random Post after ${i}`}); - } - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'off-topic'); - await channelsPage.toBeVisible(); - - // # Search for the target message from a different channel - await channelsPage.searchFor(token); - await channelsPage.searchResultsPanel.toContainText(token); - - // # Jump to the post from the search results - await channelsPage.searchResultsPanel.jumpToResultWithText(token); - - // * Verify navigation to the target channel and that the target post is displayed in the center - await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/channels/${linkChannel.name}`); - await expect(channelsPage.centerView.container.getByText(token, {exact: true})).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_result_actions_navigation.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_result_actions_navigation.spec.ts new file mode 100644 index 00000000000..ec5511f4795 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search_result_actions_navigation.spec.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that a full username containing '-' or '_' is shown as a mention link in the mentioned user's search results. + */ +test('MM-T348 shows a full special-character username as a link in search results', {tag: '@search'}, async ({pw}) => { + // # Create a user whose username contains '-' and '_', plus a user who mentions them + const {user: mentioningUser, team, adminClient, userClient} = await pw.initSetup(); + const [specialUser] = await adminClient.createUsers(team.id, 1, 'test-user_'); + const townSquare = await adminClient.getChannelByName(team.id, 'town-square'); + await adminClient.addToChannel(specialUser.id, townSquare.id); + + // # The first user posts a message mentioning the special-character username + const token = `mention${pw.random.id()}`; + await userClient.createPost({ + channel_id: townSquare.id, + message: `@${specialUser.username} ${token}`, + user_id: mentioningUser.id, + }); + + // # Log in as the mentioned user and open Recent Mentions + const {channelsPage} = await pw.testBrowser.login(specialUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + await channelsPage.globalHeader.openRecentMentions(); + + // * Verify the mention shows the full username as a clickable mention (rendered as a button) + await channelsPage.searchResultsPanel.toBeVisible(); + const result = channelsPage.searchResultsPanel.getResultByText(token); + await expect(result.getByRole('button', {name: `@${specialUser.username}`})).toBeVisible(); +}); + +/** + * @objective Verify that jumping from a search result opens a post that is not already displayed in the center channel. + */ +test('MM-T380 jumps from a search result to a post in another channel', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user, and create a channel with many posts + const {user, team, adminClient} = await pw.initSetup(); + const linkChannel = await adminClient.createPublicChannel(team.id, 'Link Test', 'link-test'); + await adminClient.addToChannel(user.id, linkChannel.id); + + // # Post a target message surrounded by many other messages so it is not initially loaded elsewhere + const token = `asparagus${pw.random.id()}`; + for (let i = 0; i < 4; i++) { + await adminClient.createPost({channel_id: linkChannel.id, message: `RF Random Post before ${i}`}); + } + await adminClient.createPost({channel_id: linkChannel.id, message: token}); + for (let i = 0; i < 8; i++) { + await adminClient.createPost({channel_id: linkChannel.id, message: `RF Random Post after ${i}`}); + } + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'off-topic'); + await channelsPage.toBeVisible(); + + // # Search for the target message from a different channel + await channelsPage.searchFor(token); + await channelsPage.searchResultsPanel.toContainText(token); + + // # Jump to the post from the search results + await channelsPage.searchResultsPanel.jumpToResultWithText(token); + + // * Verify navigation to the target channel and that the target post is displayed in the center + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/channels/${linkChannel.name}`); + await expect(channelsPage.centerView.container.getByText(token, {exact: true})).toBeVisible(); +}); + +/** + * @objective Verify that deleting a parent post from the search results removes both the post and its reply. + */ +test('MM-T381 deletes a parent post from the search results', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user, and open a channel + const {user, team, adminClient} = await pw.initSetup(); + const channel = await adminClient.createPublicChannel(team.id, 'Delete Parent', 'delete-parent'); + await adminClient.addToChannel(user.id, channel.id); + + const parentToken = `parentpost${pw.random.id()}`; + const replyToken = `replypost${pw.random.id()}`; + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Post a message and reply to it, then close the thread + await channelsPage.postMessage(parentToken); + const post = await channelsPage.getLastPost(); + await post.reply(); + await channelsPage.sidebarRight.toBeVisible(); + await channelsPage.sidebarRight.postMessage(replyToken); + await channelsPage.sidebarRight.close(); + + // * Verify both the parent post and its reply are searchable before deletion + await channelsPage.searchFor(replyToken); + await expect(channelsPage.searchResultsPanel.getResultByText(replyToken)).toHaveCount(1); + await channelsPage.searchFor(parentToken); + await expect(channelsPage.searchResultsPanel.getResultByText(parentToken)).toHaveCount(1); + + // # Delete the parent post from the search result + await channelsPage.searchResultsPanel.openResultDotMenu(parentToken); + await channelsPage.postDotMenu.deleteMenuItem.click(); + await channelsPage.deletePostModal.toBeVisible(); + await channelsPage.deletePostModal.confirm(); + + // * Verify the deleted parent post is removed from the open search results in real time + await expect(channelsPage.searchResultsPanel.getResultByText(parentToken)).toHaveCount(0); + + // * Verify the reply was also removed (searching for it returns no results) + await channelsPage.searchFor(replyToken); + await expect(channelsPage.searchResultsPanel.getResultByText(replyToken)).toHaveCount(0); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_result_display_refresh_scroll.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_result_display_refresh_scroll.spec.ts new file mode 100644 index 00000000000..ab287f9ceeb --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/search_result_display_refresh_scroll.spec.ts @@ -0,0 +1,148 @@ +// 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 that a new search replaces the previous results instead of combining them. + */ +test('MM-T355 replaces old search results with new results', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user + const {user, team} = await pw.initSetup(); + const {channelsPage} = await pw.testBrowser.login(user); + const helloToken = `hello${pw.random.id()}`; + const writingToken = `writing${pw.random.id()}`; + + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post a message in Town Square and search for it + await channelsPage.postMessage(`${helloToken} there everyone`); + await channelsPage.searchFor(helloToken); + + // * Verify the first message is shown in the results + await channelsPage.searchResultsPanel.toContainText(helloToken); + + // # Post a different message in another channel and run a new search for it + await channelsPage.sidebarLeft.goToItem('off-topic'); + await channelsPage.postMessage(`${writingToken} to you here`); + await channelsPage.searchFor(writingToken); + + // * Verify the new results are shown and the old results are gone + await channelsPage.searchResultsPanel.toContainText(writingToken); + await expect(channelsPage.searchResultsPanel.getResultByText(helloToken)).toHaveCount(0); +}); + +/** + * @objective Verify that scrolling the center channel while the search results panel is open does not change the results. + */ +test('MM-T382 keeps search results unchanged while scrolling the center channel', {tag: '@search'}, async ({pw}) => { + // # Create and log in as a test user, and open a channel with many matching posts + const {user, team, adminClient} = await pw.initSetup(); + const channel = await adminClient.createPublicChannel(team.id, 'Search Scroll', 'search-scroll'); + await adminClient.addToChannel(user.id, channel.id); + + const token = `searchfun${pw.random.id()}`; + const messageCount = 30; + for (let i = 0; i < messageCount; i++) { + await adminClient.createPost({channel_id: channel.id, message: `${token} ${i}`}); + } + + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + // # Search for the matching posts + await channelsPage.searchFor(token); + await channelsPage.searchResultsPanel.toBeVisible(); + const resultCountBefore = await channelsPage.searchResultsPanel.getResultItems().count(); + expect(resultCountBefore).toBeGreaterThan(0); + + // # Scroll the center channel up to load older messages + const box = await channelsPage.centerView.container.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + for (let i = 0; i < 6; i++) { + await page.mouse.wheel(0, -1500); + await pw.wait(pw.duration.half_sec); + } + + // * Verify the search results panel is still open and its results are unchanged + await channelsPage.searchResultsPanel.toBeVisible(); + await channelsPage.searchResultsPanel.toContainText(token); + expect(await channelsPage.searchResultsPanel.getResultItems().count()).toBe(resultCountBefore); +}); + +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}); +} diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_result_mention_link.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_result_mention_link.spec.ts deleted file mode 100644 index 20d3b22b248..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_result_mention_link.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a full username containing '-' or '_' is shown as a mention link in the mentioned user's search results. - */ -test('MM-T348 shows a full special-character username as a link in search results', {tag: '@search'}, async ({pw}) => { - // # Create a user whose username contains '-' and '_', plus a user who mentions them - const {user: mentioningUser, team, adminClient, userClient} = await pw.initSetup(); - const [specialUser] = await adminClient.createUsers(team.id, 1, 'test-user_'); - const townSquare = await adminClient.getChannelByName(team.id, 'town-square'); - await adminClient.addToChannel(specialUser.id, townSquare.id); - - // # The first user posts a message mentioning the special-character username - const token = `mention${pw.random.id()}`; - await userClient.createPost({ - channel_id: townSquare.id, - message: `@${specialUser.username} ${token}`, - user_id: mentioningUser.id, - }); - - // # Log in as the mentioned user and open Recent Mentions - const {channelsPage} = await pw.testBrowser.login(specialUser); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - await channelsPage.globalHeader.openRecentMentions(); - - // * Verify the mention shows the full username as a clickable mention (rendered as a button) - await channelsPage.searchResultsPanel.toBeVisible(); - const result = channelsPage.searchResultsPanel.getResultByText(token); - await expect(result.getByRole('button', {name: `@${specialUser.username}`})).toBeVisible(); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_results_refresh.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_results_refresh.spec.ts deleted file mode 100644 index 3389aed346d..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_results_refresh.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a new search replaces the previous results instead of combining them. - */ -test('MM-T355 replaces old search results with new results', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - const helloToken = `hello${pw.random.id()}`; - const writingToken = `writing${pw.random.id()}`; - - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post a message in Town Square and search for it - await channelsPage.postMessage(`${helloToken} there everyone`); - await channelsPage.searchFor(helloToken); - - // * Verify the first message is shown in the results - await channelsPage.searchResultsPanel.toContainText(helloToken); - - // # Post a different message in another channel and run a new search for it - await channelsPage.sidebarLeft.goToItem('off-topic'); - await channelsPage.postMessage(`${writingToken} to you here`); - await channelsPage.searchFor(writingToken); - - // * Verify the new results are shown and the old results are gone - await channelsPage.searchResultsPanel.toContainText(writingToken); - await expect(channelsPage.searchResultsPanel.getResultByText(helloToken)).toHaveCount(0); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_scroll_center.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_scroll_center.spec.ts deleted file mode 100644 index 277c42b37ae..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_scroll_center.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {expect, test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that scrolling the center channel while the search results panel is open does not change the results. - */ -test('MM-T382 keeps search results unchanged while scrolling the center channel', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user, and open a channel with many matching posts - const {user, team, adminClient} = await pw.initSetup(); - const channel = await adminClient.createPublicChannel(team.id, 'Search Scroll', 'search-scroll'); - await adminClient.addToChannel(user.id, channel.id); - - const token = `searchfun${pw.random.id()}`; - const messageCount = 30; - for (let i = 0; i < messageCount; i++) { - await adminClient.createPost({channel_id: channel.id, message: `${token} ${i}`}); - } - - const {channelsPage, page} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, channel.name); - await channelsPage.toBeVisible(); - - // # Search for the matching posts - await channelsPage.searchFor(token); - await channelsPage.searchResultsPanel.toBeVisible(); - const resultCountBefore = await channelsPage.searchResultsPanel.getResultItems().count(); - expect(resultCountBefore).toBeGreaterThan(0); - - // # Scroll the center channel up to load older messages - const box = await channelsPage.centerView.container.boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - for (let i = 0; i < 6; i++) { - await page.mouse.wheel(0, -1500); - await pw.wait(pw.duration.half_sec); - } - - // * Verify the search results panel is still open and its results are unchanged - await channelsPage.searchResultsPanel.toBeVisible(); - await channelsPage.searchResultsPanel.toContainText(token); - expect(await channelsPage.searchResultsPanel.getResultItems().count()).toBe(resultCountBefore); -}); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_wildcard.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_wildcard.spec.ts deleted file mode 100644 index b460f9740f2..00000000000 --- a/e2e-tests/playwright/specs/functional/channels/search/search_wildcard.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {test} from '@mattermost/playwright-lib'; - -/** - * @objective Verify that a wildcard (*) is disregarded when it is not immediately preceded by text. - */ -test('MM-T351 disregards a wildcard that is not preceded by text', {tag: '@search'}, async ({pw}) => { - // # Create and log in as a test user - const {user, team} = await pw.initSetup(); - const {channelsPage} = await pw.testBrowser.login(user); - await channelsPage.goto(team.name, 'town-square'); - await channelsPage.toBeVisible(); - - // # Post two messages, one with a longer variant of the search term - const token = `qwerty${pw.random.id()}`; - await channelsPage.postMessage(`${token}jkl`); - await channelsPage.postMessage(token); - - // # Search using a trailing wildcard separated by a space - await channelsPage.searchFor(`${token} *`); - - // * Verify the wildcard is disregarded and the exact-term message is returned - await channelsPage.searchResultsPanel.toContainText(token); -}); diff --git a/webapp/channels/src/components/common/auto_height_switcher.tsx b/webapp/channels/src/components/common/auto_height_switcher.tsx index 2f7d46a710f..b965387b2b3 100644 --- a/webapp/channels/src/components/common/auto_height_switcher.tsx +++ b/webapp/channels/src/components/common/auto_height_switcher.tsx @@ -39,8 +39,23 @@ const AutoHeightSwitcher = ({showSlot, onTransitionEnd, slot1 = null, slot2 = nu } else { // switch slots using height animation prevSlot.current = showSlot; - setAnimate(true); + + if (animate) { + // A previous transition is still in flight, so the Transition's + // `in` prop is already `true`; toggling it `true` again is a + // no-op and `onEnter` will not re-run. Swap the child directly + // to the newly selected slot so the correct content is shown + // once the in-flight transition ends (otherwise the stale slot + // stays rendered). + setChild(showSlot === AutoHeightSlots.SLOT1 ? slot1 : slot2); + } else { + setAnimate(true); + } } + + // `animate` is read to detect an in-flight transition but intentionally excluded from the + // deps so this effect only runs on a slot change, not on every animation state toggle. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [showSlot, slot1, slot2]); useEffect(() => {