diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/channel_bookmarks_create_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/channel_bookmarks_create_modal.ts new file mode 100644 index 00000000000..4aac95136be --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/channel_bookmarks_create_modal.ts @@ -0,0 +1,39 @@ +// 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 ChannelBookmarksCreateModal { + readonly container: Locator; + + readonly linkInput; + readonly titleInput; + readonly addButton; + readonly cancelButton; + + constructor(container: Locator) { + this.container = container; + + this.linkInput = container.getByTestId('linkInput'); + this.titleInput = container.getByTestId('titleInput'); + this.addButton = container.getByRole('button', {name: 'Add bookmark'}); + this.cancelButton = container.getByRole('button', {name: 'Cancel'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async notToBeVisible() { + await expect(this.container).not.toBeVisible(); + } + + /** + * Fills the link field and confirms, creating a link bookmark. + */ + async addLink(url: string) { + await this.linkInput.fill(url); + await this.addButton.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/channel_menu.ts b/e2e-tests/playwright/lib/src/ui/components/channels/channel_menu.ts index b1330c0f084..92f1546d9b6 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/channel_menu.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/channel_menu.ts @@ -17,6 +17,8 @@ export default class ChannelMenu { readonly notificationPreferences: Locator; readonly channelSettings: Locator; readonly bookmarksBar: Locator; + readonly addBookmarkLink: Locator; + readonly addBookmarkFile: Locator; readonly members: Locator; readonly moveTo: Locator; readonly leaveChannel: Locator; @@ -32,6 +34,10 @@ export default class ChannelMenu { this.notificationPreferences = this.item('Notification Preferences'); this.channelSettings = this.item('Channel Settings'); this.bookmarksBar = this.item('Bookmarks Bar'); + + // The Bookmarks Bar submenu items render in a portal outside this menu's container. + this.addBookmarkLink = container.page().getByRole('menuitem', {name: 'Add a link'}); + this.addBookmarkFile = container.page().getByRole('menuitem', {name: 'Attach a file'}); this.members = this.item('Members'); this.moveTo = this.item('Move to...'); this.leaveChannel = this.item('Leave Channel'); @@ -49,4 +55,12 @@ export default class ChannelMenu { item(name: string | RegExp): Locator { return this.container.getByRole('menuitem', {name}); } + + /** + * Opens the "Bookmarks Bar" submenu by hovering its parent item. + */ + async openBookmarksSubmenu() { + await this.bookmarksBar.hover(); + await expect(this.addBookmarkLink).toBeVisible(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/leave_team_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/leave_team_modal.ts new file mode 100644 index 00000000000..68b132ecec2 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/leave_team_modal.ts @@ -0,0 +1,28 @@ +// 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'; + +/** + * The "Leave the team?" confirmation dialog, shown from the team menu's Leave team action. + */ +export default class LeaveTeamModal { + readonly container: Locator; + + readonly yesButton; + + constructor(container: Locator) { + this.container = container; + + this.yesButton = container.getByRole('button', {name: 'Yes'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async confirm() { + await this.yesButton.click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts index cd18fdf940a..d98b60bd9a9 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts @@ -17,6 +17,7 @@ export default class ChannelsPost { readonly profileIcon; readonly emoticon; readonly messageText; + readonly editedIndicator; readonly removePostButton; @@ -36,6 +37,7 @@ export default class ChannelsPost { this.profileIcon = container.getByTestId('profile-icon'); this.emoticon = container.locator('.emoticon'); this.messageText = container.locator('.post-message__text p'); + this.editedIndicator = container.getByRole('button', {name: 'Edited'}); this.removePostButton = container.getByTestId('post-remove-button'); @@ -89,6 +91,13 @@ export default class ChannelsPost { await this.postMenu.replyButton.click(); } + /** + * Clicks the "Edited" indicator to open the post's edit history in the right sidebar. + */ + async openEditHistory() { + await this.editedIndicator.click(); + } + async reply() { await this.container.hover(); await this.postMenu.toBeVisible(); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/user_groups_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/user_groups_modal.ts new file mode 100644 index 00000000000..7f0d238f17d --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/user_groups_modal.ts @@ -0,0 +1,47 @@ +// 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'; + +/** + * The User Groups list modal, opened from the product menu's "User Groups" item. + */ +export default class UserGroupsModal { + readonly container: Locator; + + readonly createGroupButton; + readonly searchInput; + readonly filterButton; + + constructor(container: Locator) { + this.container = container; + + this.createGroupButton = container.getByRole('button', {name: 'Create Group'}); + this.searchInput = container.getByTestId('searchInput'); + this.filterButton = container.locator('#groupsFilterDropdown'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + /** + * Locates a group row by its display name. + */ + getGroup(displayName: string): Locator { + return this.container.getByRole('button', {name: `${displayName} group`, exact: true}); + } + + async openGroup(displayName: string) { + await this.getGroup(displayName).click(); + } + + /** + * Switches the list filter to show archived groups. + */ + async filterArchived() { + await this.filterButton.click(); + await this.container.page().getByRole('menuitem', {name: 'Archived Groups'}).click(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/view_user_group_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/view_user_group_modal.ts new file mode 100644 index 00000000000..a1ad35d8719 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/view_user_group_modal.ts @@ -0,0 +1,41 @@ +// 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'; + +/** + * The single-group view modal, shown after opening a group from the User Groups list. + */ +export default class ViewUserGroupModal { + readonly container: Locator; + + readonly actionsButton; + readonly archiveGroupItem; + readonly restoreGroupButton; + readonly addPeopleButton; + + constructor(container: Locator) { + this.container = container; + + this.actionsButton = container.getByRole('button', {name: 'User group actions'}); + this.restoreGroupButton = container.getByRole('button', {name: 'Restore Group'}); + this.addPeopleButton = container.getByRole('button', {name: 'Add people'}); + + // The group actions menu renders in a portal outside the modal. + this.archiveGroupItem = container.page().getByRole('menuitem', {name: 'Archive Group'}); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async archive() { + await this.actionsButton.click(); + await this.archiveGroupItem.click(); + } + + async restore() { + await this.restoreGroupButton.click(); + } +} 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 25a38e61cbe..d49b51419e9 100644 --- a/e2e-tests/playwright/lib/src/ui/components/global_header.ts +++ b/e2e-tests/playwright/lib/src/ui/components/global_header.ts @@ -19,6 +19,7 @@ export default class GlobalHeader { readonly searchBox; readonly userProfileMenu; readonly appMarketplaceMenuItem; + readonly userGroupsMenuItem; constructor(channelsPage: ChannelsPage, container: Locator) { this.channelsPage = channelsPage; @@ -35,6 +36,7 @@ export default class GlobalHeader { // Rendered in a portal at the page level once the product switch menu is open. this.appMarketplaceMenuItem = container.page().getByRole('menuitem', {name: 'App Marketplace'}); + this.userGroupsMenuItem = container.page().getByRole('menuitem', {name: 'User Groups'}); } async toBeVisible(name: string) { @@ -54,6 +56,14 @@ export default class GlobalHeader { await this.appMarketplaceMenuItem.click(); } + /** + * Opens the product switch menu and selects the "User Groups" item. + */ + async openUserGroups() { + await this.productSwitchMenu.click(); + await this.userGroupsMenuItem.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 b227211af47..57909b88c9e 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -20,7 +20,11 @@ import ChannelSettingsModal from './channels/channel_settings/channel_settings_m import ChannelsSidebarLeft from './channels/sidebar_left'; import ChannelsSidebarRight from './channels/sidebar_right'; import AddPeopleToChannelModal from './channels/add_people_to_channel_modal'; +import ChannelBookmarksCreateModal from './channels/channel_bookmarks_create_modal'; +import LeaveTeamModal from './channels/leave_team_modal'; import MarketplaceModal from './channels/marketplace_modal'; +import UserGroupsModal from './channels/user_groups_modal'; +import ViewUserGroupModal from './channels/view_user_group_modal'; import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog'; import DeletePostModal from './channels/delete_post_modal'; import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal'; @@ -101,7 +105,11 @@ const components = { ChannelsSidebarLeft, ChannelsSidebarRight, AddPeopleToChannelModal, + ChannelBookmarksCreateModal, + LeaveTeamModal, MarketplaceModal, + UserGroupsModal, + ViewUserGroupModal, DeletePostConfirmationDialog, DeletePostModal, DeleteScheduledPostModal, @@ -186,7 +194,11 @@ export { ChannelsSidebarLeft, ChannelsSidebarRight, AddPeopleToChannelModal, + ChannelBookmarksCreateModal, + LeaveTeamModal, MarketplaceModal, + UserGroupsModal, + ViewUserGroupModal, 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 3e147567933..f2a3897be06 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -50,6 +50,10 @@ export default class ChannelsPage { readonly burnOnReadConfirmationModal; readonly searchResultsPanel; readonly marketplaceModal; + readonly channelBookmarksBar; + readonly bookmarkCreateModal; + readonly userGroupsModal; + readonly leaveTeamModal; readonly archivedChannelMessage; readonly postContainer; @@ -104,6 +108,12 @@ export default class ChannelsPage { ); this.searchResultsPanel = new components.SearchResultsPanel(page.locator('#searchContainer')); this.marketplaceModal = new components.MarketplaceModal(page.getByRole('dialog', {name: 'App Marketplace'})); + this.channelBookmarksBar = page.getByTestId('channel-bookmarks-container'); + this.bookmarkCreateModal = new components.ChannelBookmarksCreateModal( + page.getByRole('dialog', {name: 'Add a bookmark'}), + ); + this.userGroupsModal = new components.UserGroupsModal(page.locator('#userGroupsModal')); + this.leaveTeamModal = new components.LeaveTeamModal(page.getByRole('dialog', {name: 'Leave the team?'})); // Menus // The channel header dropdown menu's accessible name is " Channel Menu". @@ -167,6 +177,10 @@ export default class ChannelsPage { return new components.AddPeopleToChannelModal(this.page.getByRole('dialog', {name: /Add people to/})); } + getViewUserGroupModal(groupDisplayName: string) { + return new components.ViewUserGroupModal(this.page.getByRole('dialog', {name: groupDisplayName, exact: true})); + } + 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/channel_bookmarks/bookmark_creation.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_bookmarks/bookmark_creation.spec.ts new file mode 100644 index 00000000000..447312d5b4e --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_bookmarks/bookmark_creation.spec.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import path from 'node:path'; + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that on a licensed server the Bookmarks Bar option is available in the channel menu with + * an "Add a link" action, even when the channel has no bookmarks yet. + * + * MM-T5601 covers the same behavior as MM-T5600 and is covered by this test. + */ +test('MM-T5600 MM-T5601 shows the Bookmarks Bar option in the channel menu', {tag: '@channels'}, 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(); + + // # Open the channel menu + const channelMenu = await channelsPage.openChannelMenu(); + + // * Verify the Bookmarks Bar option is available + await expect(channelMenu.bookmarksBar).toBeVisible(); + + // # Open the Bookmarks Bar submenu + await channelMenu.openBookmarksSubmenu(); + + // * Verify the "Add a link" action is available + await expect(channelMenu.addBookmarkLink).toBeVisible(); +}); + +/** + * @objective Verify a user can add a link bookmark from the channel menu's Bookmarks Bar submenu and see it + * in the channel bookmarks bar. + */ +test('MM-T5602 adds a link bookmark to the channel bookmarks bar', {tag: '@channels'}, 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(); + + // # Open the Bookmarks Bar submenu from the channel menu and choose "Add a link" + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.openBookmarksSubmenu(); + await channelMenu.addBookmarkLink.click(); + + // # Enter a link and add the bookmark + await channelsPage.bookmarkCreateModal.toBeVisible(); + await channelsPage.bookmarkCreateModal.addLink('https://www.mattermost.com'); + + // * Verify the bookmark appears in the channel bookmarks bar + await expect(channelsPage.channelBookmarksBar).toBeVisible(); + await expect(channelsPage.channelBookmarksBar.getByRole('link', {name: /mattermost/i})).toBeVisible(); +}); + +/** + * @objective Verify a user can add a file bookmark from the channel menu's Bookmarks Bar submenu and see it + * in the channel bookmarks bar. + */ +test('MM-T5603 adds a file bookmark to the channel bookmarks bar', {tag: '@channels'}, 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(); + + // # Open the Bookmarks Bar submenu and choose "Attach a file", selecting a file + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.openBookmarksSubmenu(); + const fileChooserPromise = page.waitForEvent('filechooser'); + await channelMenu.addBookmarkFile.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.resolve('asset/mattermost-icon_128x128.png')); + + // # Confirm the file bookmark + await channelsPage.bookmarkCreateModal.toBeVisible(); + await channelsPage.bookmarkCreateModal.addButton.click(); + + // * Verify the file bookmark appears in the channel bookmarks bar + await expect(channelsPage.channelBookmarksBar).toBeVisible(); + await expect(channelsPage.channelBookmarksBar.getByText(/mattermost-icon/i)).toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_bookmarks/bookmark_metadata.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_bookmarks/bookmark_metadata.spec.ts new file mode 100644 index 00000000000..3e2309c43a2 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_bookmarks/bookmark_metadata.spec.ts @@ -0,0 +1,31 @@ +// 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 entering a link in the bookmark create modal auto-fills the bookmark title from the + * link. + */ +test('MM-T5604 auto-fills the bookmark title from the entered link', {tag: '@channels'}, 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(); + + // # Open the bookmark create modal via the channel menu's Bookmarks Bar submenu + const channelMenu = await channelsPage.openChannelMenu(); + await channelMenu.openBookmarksSubmenu(); + await channelMenu.addBookmarkLink.click(); + await channelsPage.bookmarkCreateModal.toBeVisible(); + + // # Enter a link + await channelsPage.bookmarkCreateModal.linkInput.fill('https://www.amazon.com'); + + // * Verify the title field is auto-filled from the link + await expect(channelsPage.bookmarkCreateModal.titleInput).toHaveValue(/amazon/i); + + // # Cancel without saving + await channelsPage.bookmarkCreateModal.cancelButton.click(); + await channelsPage.bookmarkCreateModal.notToBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts index c2d234b99e7..e795c93231b 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/cross_session_sync.spec.ts @@ -81,3 +81,48 @@ test( await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); }, ); + +/** + * @objective Verify that leaving a channel in one session, while another session of the same user is + * viewing that same channel, redirects both sessions to the default channel and removes it from both sidebars. + * + * MM-T2554 covers the same behavior as MM-T2549 and is covered by this test. + */ +test( + 'MM-T2549 MM-T2554 leaving a viewed channel redirects the other session viewing it to Town Square', + {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 = `Leave ${pw.random.id()}`; + await firstSession.newChannel(channelName, 'O'); + await firstSession.centerView.header.toHaveTitle(channelName); + + // # Bring the second session to the same channel + await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible(); + await secondSession.sidebarLeft.container.getByText(channelName).click(); + await secondSession.centerView.header.toHaveTitle(channelName); + + // # Leave the channel from the first session while both sessions are viewing it + const channelMenu = await firstSession.openChannelMenu(); + await channelMenu.leaveChannel.click(); + + // * Verify the first session is redirected to Town Square and the channel is gone from its sidebar + await firstSession.centerView.header.toHaveTitle('Town Square'); + await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); + + // * Verify the second session is also redirected to Town Square and the channel is removed from its sidebar + await secondSession.centerView.header.toHaveTitle('Town Square'); + await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible(); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/custom_groups/custom_group_management.spec.ts b/e2e-tests/playwright/specs/functional/channels/custom_groups/custom_group_management.spec.ts new file mode 100644 index 00000000000..8819885ece6 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/custom_groups/custom_group_management.spec.ts @@ -0,0 +1,80 @@ +// 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 custom user group can be archived, viewed under the archived filter, and restored. + */ +test('MM-T5584 views and unarchives a custom group', {tag: '@channels'}, async ({pw}) => { + const {user, team, userClient} = await pw.initSetup(); + + // # Create a custom group owned by the user + const groupName = `group${pw.random.id()}`.toLowerCase(); + const group = await userClient.createGroupWithUserIds({ + name: groupName, + display_name: `Testing Group ${pw.random.id()}`, + source: 'custom', + allow_reference: true, + user_ids: [user.id], + }); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + const groupModal = channelsPage.getViewUserGroupModal(group.display_name); + + // # Open the User Groups modal from the product menu + await channelsPage.globalHeader.openUserGroups(); + await channelsPage.userGroupsModal.toBeVisible(); + + // # Open the group and archive it + await channelsPage.userGroupsModal.openGroup(group.display_name); + await groupModal.archive(); + + // # Filter the list to archived groups and open the archived group + await channelsPage.userGroupsModal.filterArchived(); + await channelsPage.userGroupsModal.openGroup(group.display_name); + + // # Restore the group + await groupModal.restore(); + + // * Verify the group is restored (member management is available again) + await expect(groupModal.addPeopleButton).toBeVisible(); +}); + +/** + * @objective Verify a custom user group can be invited to a channel with the /invite slash command, adding + * the group's members to the channel. + */ +test('MM-T5598 invites a custom group to a channel', {tag: '@channels'}, async ({pw}) => { + const {user, team, userClient, adminClient} = await pw.initSetup(); + + // # Create a second team member and a custom group containing both users + const [member] = await adminClient.createUsers(team.id, 1, 'member'); + const groupName = `group${pw.random.id()}`.toLowerCase(); + await userClient.createGroupWithUserIds({ + name: groupName, + display_name: `Group ${pw.random.id()}`, + source: 'custom', + allow_reference: true, + user_ids: [user.id, member.id], + }); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Create a new channel + const channelName = `Group Channel ${pw.random.id()}`; + await channelsPage.newChannel(channelName, 'O'); + await channelsPage.centerView.header.toHaveTitle(channelName); + + // # Invite the custom group to the channel with the slash command + await channelsPage.centerView.postCreate.writeMessage(`/invite @${groupName} `); + await channelsPage.centerView.postCreate.sendMessage(); + + // * Verify the group's member was added to the channel + await channelsPage.centerView.waitUntilLastPostContains('added to the channel'); + await channelsPage.centerView.waitUntilLastPostContains(member.username); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts index 447bb7de594..6f657b13db3 100644 --- a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/channel_search_and_thread_shortcuts.spec.ts @@ -141,3 +141,36 @@ test( await userChannelsPage.sidebarRight.close(); }, ); + +/** + * @objective Verify Ctrl/Cmd+. opens and closes the right-hand sidebar. + */ +test( + 'MM-T4692 opens and closes the right sidebar 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(); + + // # Post a message and open its reply thread in the right sidebar + await channelsPage.centerView.postCreate.postMessage('this post is from today'); + const post = await channelsPage.getLastPost(); + await post.openAThread(); + await channelsPage.sidebarRight.toBeVisible(); + + // # Press the toggle shortcut to close the right sidebar + await page.keyboard.press('ControlOrMeta+.'); + + // * Verify the right sidebar is closed + await expect(channelsPage.sidebarRight.container).not.toBeVisible(); + + // # Press the toggle shortcut again to reopen the right sidebar + await page.keyboard.press('ControlOrMeta+.'); + + // * Verify the right sidebar is shown again with the reply thread + await channelsPage.sidebarRight.toBeVisible(); + await expect(channelsPage.sidebarRight.container.getByText('this post is from today')).toBeVisible(); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/message_formatting_and_emoji_picker.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/message_formatting_and_emoji_picker.spec.ts new file mode 100644 index 00000000000..0b3b3cf9c54 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/message_formatting_and_emoji_picker.spec.ts @@ -0,0 +1,78 @@ +// 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+B and Ctrl/Cmd+I wrap the message text in bold and italic markdown and + * that the posted message renders the formatting. + */ +test('MM-T3405 formats text as bold and italic with keyboard shortcuts', {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(); + + const postCreate = channelsPage.centerView.postCreate; + + // # Type a message, select it, and apply the bold shortcut + await postCreate.input.click(); + await postCreate.input.fill('bolded text'); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.press('ControlOrMeta+B'); + + // * Verify the text is wrapped in bold markdown + await expect(postCreate.input).toHaveValue('**bolded text**'); + + // # Post the bold message + await postCreate.sendMessage(); + + // * Verify the posted message renders as bold without literal markdown characters + const boldPost = await channelsPage.getLastPost(); + await expect(boldPost.container.locator('strong')).toHaveText('bolded text'); + await boldPost.toNotContainText('**'); + + // # Type another message, select it, and apply the italic shortcut + await postCreate.input.click(); + await postCreate.input.fill('italic text'); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.press('ControlOrMeta+I'); + + // * Verify the text is wrapped in italic markdown + await expect(postCreate.input).toHaveValue('*italic text*'); + + // # Post the italic message + await postCreate.sendMessage(); + + // * Verify the posted message renders as italic without literal markdown characters + const italicPost = await channelsPage.getLastPost(); + await expect(italicPost.container.locator('em')).toHaveText('italic text'); + await italicPost.toNotContainText('*'); +}); + +/** + * @objective Verify the Ctrl/Cmd+Shift+\ shortcut opens the emoji picker to react to the last message, + * and that it can be dismissed. + */ +test('MM-T4693 opens the emoji picker 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(); + + // # Post a message so there is a last message to react to + await channelsPage.centerView.postCreate.postMessage('this is a test'); + await channelsPage.centerView.postCreate.input.focus(); + + // # Press the emoji picker shortcut + await page.keyboard.press('ControlOrMeta+Shift+\\'); + + // * Verify the emoji picker opens + await expect(channelsPage.reactionEmojiPicker.container).toBeVisible(); + + // # Dismiss the emoji picker + await page.keyboard.press('Escape'); + + // * Verify the emoji picker is closed + await expect(channelsPage.reactionEmojiPicker.container).not.toBeVisible(); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/messaging/edit_history_restore.spec.ts b/e2e-tests/playwright/specs/functional/channels/messaging/edit_history_restore.spec.ts new file mode 100644 index 00000000000..2c9bdbbe425 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/messaging/edit_history_restore.spec.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify a user can view a message's edit history and restore a previous version, and that + * cancelling the restore confirmation keeps the edited version. + */ +test('MM-T5537 views message edit history and restores a previous version', {tag: '@messaging'}, 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 + const message = 'This is a message to post and then edit'; + await channelsPage.postMessage(message); + + // # Open the inline edit box for the last post, append text, and save + await channelsPage.centerView.postCreate.input.focus(); + await page.keyboard.press('ArrowUp'); + await channelsPage.centerView.postEdit.toBeVisible(); + await channelsPage.centerView.postEdit.input.press('End'); + await channelsPage.centerView.postEdit.input.pressSequentially('. I have now edited this message.'); + await channelsPage.centerView.postEdit.sendMessage(); + + // * Verify the post shows the edited text + const editedMessage = `${message}. I have now edited this message.`; + const post = await channelsPage.getLastPost(); + await post.toContainText(editedMessage); + + // # Open the edit history from the "Edited" indicator + await post.openEditHistory(); + await channelsPage.sidebarRight.toBeVisible(); + + // # Open the restore confirmation for the previous version, then cancel it + await channelsPage.sidebarRight.restorePreviousPostVersionIcon.first().click(); + const restoreModal = channelsPage.centerView.postEdit.restorePostConfirmationDialog; + await restoreModal.toBeVisible(); + await restoreModal.cancelButton.click(); + await restoreModal.notToBeVisible(); + + // * Verify the edited version is still displayed + await post.toContainText(editedMessage); + + // # Restore the previous version and confirm + await channelsPage.sidebarRight.restorePreviousPostVersionIcon.first().click(); + await restoreModal.toBeVisible(); + await restoreModal.confirmRestore(); + await restoreModal.notToBeVisible(); + + // * Verify the post is reverted to the original message + const restoredPost = await channelsPage.getLastPost(); + await restoredPost.toContainText(message); + await restoredPost.toNotContainText('I have now edited this message'); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/multi_team/team_membership_changes.spec.ts b/e2e-tests/playwright/specs/functional/channels/multi_team/team_membership_changes.spec.ts new file mode 100644 index 00000000000..106986c264f --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/multi_team/team_membership_changes.spec.ts @@ -0,0 +1,88 @@ +// 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 team can leave it, is moved to another team they belong to, and + * can rejoin the team they left. + */ +test('MM-T2547 leaves a team while viewing it and rejoins it', {tag: '@multi_team'}, async ({pw}) => { + const {user, team, adminClient} = await pw.initSetup(); + + // # Make the team open so it can be rejoined from the team selection page + await adminClient.patchTeam({id: team.id, allow_open_invite: true}); + + // # Add the user to a second team so there is another team to fall back to + 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(); + + // # Leave the current team from the team menu + await channelsPage.sidebarLeft.teamMenuButton.click(); + await channelsPage.teamMenu.toBeVisible(); + await channelsPage.teamMenu.clickLeaveTeam(); + + // # Confirm leaving in the dialog + await channelsPage.leaveTeamModal.toBeVisible(); + await channelsPage.leaveTeamModal.confirm(); + + // * Verify the user is moved to the other team and the left team is gone from the team sidebar + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${secondTeam.name}/`); + await expect(page.locator(`#${team.name}TeamButton`)).not.toBeVisible(); + + // # Rejoin the left team from the team selection page + await page.goto('/select_team'); + await page.getByRole('link', {name: team.display_name}).click(); + + // * Verify the rejoined team is displayed again with its button in the sidebar + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${team.name}/`); + await expect(page.locator(`#${team.name}TeamButton`)).toBeVisible(); +}); + +/** + * @objective Verify that when an admin removes a user from the team they are viewing, the user is moved off + * that team in real time, and is re-added when the admin invites them back. + */ +test( + 'MM-T2548 removes the user from the team they are viewing and re-adds them', + {tag: '@multi_team'}, + async ({pw}) => { + const {user, team, adminClient} = await pw.initSetup(); + + // # Add the user to a second team so there is another team to fall back to when removed + 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(); + await expect(page.locator(`#${team.name}TeamButton`)).toBeVisible(); + + // # Remove the user from the team they are viewing + await adminClient.removeFromTeam(team.id, user.id); + + // * Verify the user is moved off the removed team and its button disappears from the sidebar + await expect(page.locator(`#${team.name}TeamButton`)).not.toBeVisible(); + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/${secondTeam.name}/`); + + // # Re-add the user to the team + await adminClient.addToTeam(team.id, user.id); + + // * Verify the team reappears in the sidebar for the user + await expect(page.locator(`#${team.name}TeamButton`)).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 index 36e63a2f08d..dd33bbfda28 100644 --- 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 @@ -6,24 +6,28 @@ 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. + * MM-T1946, MM-T1947, and MM-T3680 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(); +test( + 'MM-T1945 MM-T1946 MM-T1947 MM-T3680 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(); + // # Open the App Marketplace from the product menu + await channelsPage.globalHeader.openAppMarketplace(); - // * Verify the Marketplace is shown - const marketplace = channelsPage.marketplaceModal; - await marketplace.toBeVisible(); + // * Verify the Marketplace is shown + const marketplace = channelsPage.marketplaceModal; + await marketplace.toBeVisible(); - // # Close the Marketplace - await marketplace.close(); + // # Close the Marketplace + await marketplace.close(); - // * Verify the Marketplace is closed - await marketplace.notToBeVisible(); -}); + // * Verify the Marketplace is closed + await marketplace.notToBeVisible(); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts index 1dedf554550..4b8f0520b83 100644 --- a/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/search/search_date_filter_picker_keyboard.spec.ts @@ -101,6 +101,39 @@ test('MM-T600 updates the search date with the keyboard', {tag: '@search'}, asyn await expect(channelsPage.searchResultItems).toHaveCount(0); }); +/** + * @objective Verify that pressing Backspace after a completed date filter reopens the calendar day picker. + */ +test('MM-T3997 reopens the date picker when backspacing after a date filter', {tag: '@search'}, 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(); + + // # Open search and type a "before:" date filter to open the day picker + await channelsPage.globalHeader.openSearch(); + await channelsPage.searchBox.clearIfPossible(); + await channelsPage.searchBox.searchInput.fill('before:'); + await expect(channelsPage.searchBox.getDayPickerDay(15)).toBeVisible(); + + // # Select a day from the picker + await channelsPage.searchBox.getDayPickerDay(15).click(); + + // * Verify the date is added to the query and the picker closes + await expect(channelsPage.searchBox.searchInput).toHaveValue(/before:\d{4}-\d{2}-\d{2} /); + await expect(channelsPage.searchBox.getDayPickerDay(15)).not.toBeVisible(); + + // # Backspace into the completed date filter so the date becomes partial again + await channelsPage.searchBox.searchInput.click(); + await page.keyboard.press('End'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + + // * Verify the search input now holds a partial date and the day picker reappears + await expect(channelsPage.searchBox.searchInput).toHaveValue(/before:\d{4}-\d{2}-1$/); + await expect(channelsPage.searchBox.getDayPickerDay(15)).toBeVisible(); +}); + function toDateFilter(timestamp: number): string { return new Date(timestamp).toISOString().slice(0, 10); } diff --git a/e2e-tests/playwright/specs/functional/channels/system_console/demoted_admin_redirect.spec.ts b/e2e-tests/playwright/specs/functional/channels/system_console/demoted_admin_redirect.spec.ts new file mode 100644 index 00000000000..0bf1da68225 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/system_console/demoted_admin_redirect.spec.ts @@ -0,0 +1,31 @@ +// 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 system admin viewing the System Console loses access and is returned to the + * channel view when another admin demotes them to a regular member. + */ +test('MM-T2552 redirects a demoted admin out of the System Console', {tag: '@system_console'}, async ({pw}) => { + const {user, adminClient} = await pw.initSetup(); + + // # Promote the user to system admin + await adminClient.updateUserRoles(user.id, 'system_user system_admin'); + + const {channelsPage, systemConsolePage, page} = await pw.testBrowser.login(user); + + // # Open the System Console as the admin + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Demote the user back to a regular member + await adminClient.updateUserRoles(user.id, 'system_user'); + + // # Reload the console after the role change + await page.reload(); + + // * Verify the user no longer has console access and is returned to the channel view + await channelsPage.toBeVisible(); + await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).not.toContain('/admin_console'); +}); diff --git a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap index 2342ea9514d..a06f46c6083 100644 --- a/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap +++ b/webapp/channels/src/components/view_user_group_modal/__snapshots__/view_user_group_modal.test.tsx.snap @@ -46,7 +46,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `

Group Name

diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx index eb8e4c07829..d3e2f4cb61a 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal.test.tsx @@ -104,6 +104,7 @@ describe('component/view_user_group_modal', () => { initialState as any, {useMockedStore: true}, ); + expect(screen.getByRole('dialog', {name: group.display_name})).toBeInTheDocument(); expect(baseElement).toMatchSnapshot(); }); diff --git a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx index 39164012362..48d9d934a8e 100644 --- a/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx +++ b/webapp/channels/src/components/view_user_group_modal/view_user_group_modal_header/view_user_group_modal_header.tsx @@ -88,7 +88,7 @@ const ViewUserGroupModalHeader = ({ return ( {group.display_name} {