E2E/Playwright: Migrate RFQA browser tests (batch 6, 101-120) (#37431)

* E2E/Playwright: Migrate RFQA browser tests (batch 4, 61-80)

Migrate the fourth batch of Rainforest QA browser tests into Playwright
(migration plan None-core #34-53, keys MM-T480..MM-T1257): 17 new/covered
specs across notifications, search, channel_settings, sidebar_left,
keyboard_shortcuts, and direct_messages_modal, plus 3 duplicate keys folded
into existing tests (MM-T480, MM-T1256, MM-T1257).

POM/infra: add a ChannelMenu page object for the channel header dropdown, an
EditChannelHeaderModal, a header field on the channel-settings info tab, a
channel-wide-mentions checkbox on NotificationsSettings, a getConfirmModal
helper, and a clickNotification browser-API mock helper. Give the channel
header menu and the confirm modal proper accessible names so they can be
targeted semantically. Notification specs run on all browsers now that the
Notification API stub works headless on Chromium and Firefox.

* E2E/Playwright: Migrate RFQA browser tests (batch 5, 81-100)

Migrate the fifth batch of Rainforest QA browser tests into Playwright across
keyboard_shortcuts, search, archived_channels, channel_settings,
plugin_marketplace, messaging, and emoji_picker (keys MM-T1247..MM-T2365),
folding duplicate coverage into existing keyboard-shortcut specs and
documenting skipped cases inline.

POM/infra: add AddPeopleToChannelModal and MarketplaceModal page objects,
manage/add member buttons on the channel members RHS, and an App Marketplace
entry on the global header; target the search "Jump" link through the existing
SearchResultsPanel POM.

Also fix a webapp bug where a post body disappeared when an inline edit was
cancelled mid-animation (found via the up-arrow edit shortcut spec).

* E2E/Playwright: Migrate RFQA browser tests (batch 6, 101-120)

Migrate the sixth batch of Rainforest QA browser tests into Playwright
(migration plan None-core #74-93, keys MM-T2547..MM-T5604) across multi_team,
channel_settings, system_console, keyboard_shortcuts, search,
plugin_marketplace, messaging, channel_bookmarks, and custom_groups.

Fold duplicate coverage into existing specs (MM-T3680 into the marketplace
spec; MM-T4872 is already covered by the batch-5 search-prefill spec) and
document the plugin-install skip (MM-T4023, same external-download limitation
as MM-T1987).

POM/infra: add a ChannelBookmarksCreateModal page object and Bookmarks Bar
submenu actions on the ChannelMenu, plus a channel-bookmarks-bar locator and
bookmark-create-modal on ChannelsPage.

* E2E/Playwright: Fail on missing scroll bounding boxes

* E2E/Playwright: Locate user group dialog semantically

* E2E/Playwright: Address batch 6 review feedback

* E2E/Playwright: Locate user group rows semantically

* E2E/Playwright: Group related batch 6 specs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
sabril
2026-07-13 19:03:56 +08:00
committed by GitHub
co-authored by Cursor Agent
parent 7748bf749e
commit d628dbc0ec
23 changed files with 795 additions and 18 deletions
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
@@ -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,
@@ -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> 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}`}),
@@ -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();
});
@@ -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();
});
@@ -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();
},
);
@@ -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);
});
@@ -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();
},
);
@@ -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();
});
@@ -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');
});
@@ -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();
},
);
@@ -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();
},
);
@@ -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);
}
@@ -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');
});
@@ -46,7 +46,7 @@ exports[`component/view_user_group_modal should match snapshot 1`] = `
</button>
<h1
class="modal-title"
id="userGroupsModalLabel"
id="viewUserGroupModalLabel"
>
Group Name
</h1>
@@ -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();
});
@@ -88,7 +88,7 @@ const ViewUserGroupModalHeader = ({
return (
<Modal.Title
componentClass='h1'
id='userGroupsModalLabel'
id='viewUserGroupModalLabel'
>
{group.display_name}
{