snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import type {UserProfile} from '@mattermost/types/users';
import type {ChannelsPage} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
test('MM-63451 should be able to navigate the account settings menu with the keyboard after opening it with the mouse', async ({
pw,
}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on the account menu button
await channelsPage.globalHeader.accountMenuButton.click();
await testMenuWithKeyboard(user, page, channelsPage);
});
test('MM-63451 should be able to navigate the account settings menu with the keyboard after opening it with the keyboard', async ({
pw,
}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Focus the account menu button
await channelsPage.globalHeader.accountMenuButton.focus();
await expect(channelsPage.globalHeader.accountMenuButton).toBeFocused();
await page.keyboard.press('Space');
await testMenuWithKeyboard(user, page, channelsPage);
});
async function testMenuWithKeyboard(user: UserProfile, page: Page, channelsPage: ChannelsPage) {
// * Should start focused on the first menu item
await expect(page.getByRole('menuitem', {name: '@' + user.username})).toBeFocused();
// * Should be able to scroll down through the menu with the keyboard
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Set custom status'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Online'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Away'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Offline'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Profile'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Log Out'})).toBeFocused();
// * Should be able to scroll back up through the menu with the keyboard
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Profile'})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Offline'})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
// * Should be able to move into the submenu by pressing the right arrow
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('menuitem', {name: "Don't clear"})).toBeFocused();
// * Should be able to scroll through the submenu with the keyboard
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '30 mins'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '1 hour'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '2 hours'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Tomorrow'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Choose date and time'})).toBeFocused();
// * Should wrap around when you reach the end
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: "Don't clear"})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Choose date and time'})).toBeFocused();
// * Should be able to close the submenu by pressing the left arrow
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
// * Should be able to close the menu by pressing escape
await page.keyboard.press('Escape');
await expect(page.getByRole('menuitem')).toHaveCount(0);
// * Should be focused back on the menu button
await expect(channelsPage.globalHeader.accountMenuButton).toBeFocused();
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('Settings sidebar should be keyboard accessible', async ({axe, pw}) => {
const {user} = await pw.initSetup();
// # Log in as a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Initialize Axe
const ab = axe.builder(page).disableRules([
'color-contrast',
// Known issue: These fail due to the way we've grouped plugin setting tabs together in the LHS
'aria-required-children',
'aria-required-parent',
]);
// # Visit default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Open settings modal
const settingsModal = await channelsPage.globalHeader.openSettings();
// * The settings modal should have no accessibility violations
const accessibilityScanResults = await ab.analyze();
expect(accessibilityScanResults.violations).toHaveLength(0);
// # Focus the sidebar
await settingsModal.container.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// * The Notifications tab should start focused
await expect(page.getByRole('tab', {name: 'Notifications'})).toBeFocused();
await expect(page.getByText('Desktop and mobile notifications')).toBeVisible();
// * Pressing the down arrow should focus and show the Display tab
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('tab', {name: 'Display'})).toBeFocused();
await expect(page.getByText('Theme', {exact: true})).toBeVisible();
// * Pressing the down arrow should focus and show the Sidebar tab
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('tab', {name: 'Sidebar'})).toBeFocused();
await expect(page.getByText('Group unread channels separately')).toBeVisible();
// * Pressing the down arrow should focus and show the Advanced tab
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('tab', {name: 'Advanced'})).toBeFocused();
await expect(page.getByText('Enable Post Formatting')).toBeVisible();
// * Pressing the up arrow should go back through the tabs
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('tab', {name: 'Sidebar'})).toBeFocused();
await expect(page.getByText('Group unread channels separately')).toBeVisible();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('tab', {name: 'Display'})).toBeFocused();
await expect(page.getByText('Theme', {exact: true})).toBeVisible();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('tab', {name: 'Notifications'})).toBeFocused();
await expect(page.getByText('Desktop and mobile notifications')).toBeVisible();
});

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify accessibility support in Add people to Channel Dialog screen
*/
test(
'MM-T1468 Accessibility Support in Add people to Channel Dialog screen',
{tag: ['@accessibility', '@add_people_channel']},
async ({pw}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, adminUser, adminClient} = await pw.initSetup();
// # Turn off email invitations, which replace the no-results message asserted below with an
// # invite-to-team prompt
await adminClient.patchConfig({ServiceSettings: {EnableEmailInvitations: false}});
// # Create a channel in the team
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Test Channel',
name: 'test-channel',
}),
);
// # Create additional users and add to team
for (let i = 0; i < 5; i++) {
const newUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team.id, newUser.id);
}
// # Log in as admin
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit the test channel
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open channel menu and click Members
await channelsPage.centerView.header.openChannelMenu();
const membersMenuItem = page.locator('#channelMembers');
await membersMenuItem.click();
// # Click the Add people button
const membersRhs = page.locator('#rhsContainer');
await expect(membersRhs).toBeVisible();
const addMembers = membersRhs.locator('button.add-members');
await expect(addMembers).toBeEnabled();
await addMembers.click();
// * Verify the Add people dialog is visible
const dialog = page.getByRole('dialog').first();
await expect(dialog).toBeVisible();
// * Verify the heading with channel name
const modalName = `Add people to ${channel.display_name}`;
await expect(dialog.getByRole('heading', {name: modalName})).toBeVisible();
await pw.wait(pw.duration.one_sec);
// * Verify the search input has proper accessibility attributes
const searchInput = dialog.getByLabel('Search for people or groups');
await expect(searchInput).toBeVisible();
await expect(searchInput).toHaveAttribute('aria-autocomplete', 'list');
// # Search for a text and navigate with arrow keys
await pw.wait(pw.duration.half_sec);
await searchInput.fill('u');
await pw.wait(pw.duration.half_sec);
// # Navigate down through the list
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowUp');
// * Verify the selected row has the correct class
const selectedRow = dialog.locator('#multiSelectList').locator('.more-modal__row--selected');
await expect(selectedRow).toBeVisible();
// * Verify image alt is displayed for user profile
const avatar = selectedRow.locator('img.Avatar');
await expect(avatar).toHaveAttribute('alt', 'user profile image');
// * Verify screen reader live region exists and has proper attributes
const srOnlyRegion = dialog.locator('.filtered-user-list div.sr-only:not([role="status"])');
await expect(srOnlyRegion).toHaveAttribute('aria-live', 'polite');
await expect(srOnlyRegion).toHaveAttribute('aria-atomic', 'true');
// # Search for an invalid text
await searchInput.fill('somethingwhichdoesnotexist');
// * Check if the no results message is displayed with proper accessibility
const noResultsMessage = dialog.getByText(/No matches found|No results found matching/i);
await expect(noResultsMessage).toBeVisible();
const noResultsWrapper = dialog.locator('.multi-select__wrapper');
await expect(noResultsWrapper).toHaveAttribute('aria-live', 'polite');
},
);
/**
* @objective Verify Add people to Channel dialog passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Add people to Channel dialog',
{tag: ['@accessibility', '@add_people_channel', '@snapshots']},
async ({pw, axe}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create a channel in the team
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Test Channel',
name: 'test-channel',
}),
);
// # Create additional users and add to team
for (let i = 0; i < 3; i++) {
const newUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team.id, newUser.id);
}
// # Log in as admin
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit the test channel
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open channel menu and click Members
await channelsPage.centerView.header.openChannelMenu();
const membersMenuItem = page.locator('#channelMembers');
await membersMenuItem.click();
// # Click the Add people button
const membersRhs = page.locator('#rhsContainer');
await expect(membersRhs).toBeVisible();
const addMembers = membersRhs.locator('button.add-members');
await expect(addMembers).toBeEnabled();
await addMembers.click();
// * Verify the Add people dialog is visible
const dialog = page.getByRole('dialog').first();
await expect(dialog).toBeVisible();
await pw.wait(pw.duration.one_sec);
// * Verify aria snapshot of Add people to Channel dialog
await expect(dialog).toMatchAriaSnapshot(`
- dialog "Add people to Test Channel":
- document:
- heading "Add people to Test Channel" [level=1]
- button "Close"
- log
- text: Search for people or groups
- combobox "Search for people or groups"
- button "Cancel"
- button "Add"
`);
// * Analyze the Add people dialog for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include('[role="dialog"]')
.analyze();
// * Should have no violations
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify accessibility support in Browse Channels Dialog screen
*/
test(
'MM-T1467 Accessibility Support in Browse Channels Dialog screen',
{tag: ['@accessibility', '@browse_channels']},
async ({pw}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, user, adminClient} = await pw.initSetup();
// # Create two channels with purposes for testing aria-label
const channel1 = await adminClient.createChannel({
team_id: team.id,
name: 'accessibility-' + Date.now(),
display_name: 'Accessibility',
type: 'O',
purpose: 'some purpose',
});
const channel2 = await adminClient.createChannel({
team_id: team.id,
name: 'z-accessibility-' + Date.now(),
display_name: 'Z Accessibility',
type: 'O',
purpose: 'other purpose',
});
// # Log in as regular user
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the Browse Channels modal
const dialog = await channelsPage.openBrowseChannelsModal();
// * Verify the search input exists
const searchInput = dialog.searchInput;
await expect(searchInput).toBeVisible();
// # Wait for channel list to load
await dialog.toBeDoneLoading();
// # Hide already joined channels
const hideJoinedCheckbox = dialog.hideJoinedCheckbox;
await hideJoinedCheckbox.click();
// # Focus on Create Channel button and tab through elements
// Tab order: Close → Search → Channel type filter → Hide Archived → Hide Joined → first channel
const createChannelButton = dialog.createNewChannelButton;
await createChannelButton.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// * Verify channel name is highlighted and has proper aria-label
await dialog.toHaveChannelAsNthResult(channel1.name, 0);
const channel1AriaLabel = `${channel1.display_name.toLowerCase()}, ${channel1.purpose.toLowerCase()}`;
const channel1Item = dialog.container.getByLabel(channel1AriaLabel);
await expect(channel1Item).toBeVisible();
await expect(channel1Item).toBeFocused();
// # Press Tab again to move to next channel
await page.keyboard.press('Tab');
// * Verify focus moved to next channel
await dialog.toHaveChannelAsNthResult(channel2.name, 1);
const channel2AriaLabel = `${channel2.display_name.toLowerCase()}, ${channel2.purpose.toLowerCase()}`;
const channel2Item = dialog.container.getByLabel(channel2AriaLabel);
await expect(channel2Item).toBeFocused();
},
);
/**
* @objective Verify Browse Channels dialog passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Browse Channels dialog',
{tag: ['@accessibility', '@browse_channels', '@snapshots']},
async ({pw, axe}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, user} = await pw.initSetup();
// # Log in as regular user
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the Browse Channels modal
const dialog = await channelsPage.openBrowseChannelsModal();
// * Verify aria snapshot of Browse Channels dialog
await expect(dialog.container).toMatchAriaSnapshot(`
- dialog "Browse Channels":
- document:
- heading "Browse Channels" [level=1]
- button "Create New Channel"
- button "Close"
- textbox "Search Channels"
- /text: \\d+ Results/
- /status: \\d+ Results/
- status: Channel type filter set to All
- button "Channel type filter"
- checkbox "Hide archived channels" [checked]: Hide Archived
- checkbox "Hide joined channels": Hide Joined
- search
`);
// * Analyze the Browse Channels dialog for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include('[role="dialog"]')
.analyze();
// * Should have no violations
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify accessibility support in Direct Messages Dialog screen
*/
test(
'MM-T1466 Accessibility Support in Direct Messages Dialog screen',
{tag: ['@accessibility', '@direct_messages']},
async ({pw}) => {
// # Skip test if no license for Guest Accounts
await pw.skipIfNoLicense();
// # Initialize setup with admin and another user
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create additional user and add to team
const user2 = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team.id, user2.id);
// # Log in as admin
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Click on the Write a direct message button to open the Direct Messages dialog
const writeDirectMessageButton = page.getByRole('button', {name: 'Write a direct message'});
await writeDirectMessageButton.click();
// * Verify the Direct Messages dialog is visible
const dialog = page.getByRole('dialog', {name: 'Direct Messages'});
await expect(dialog).toBeVisible();
// * Verify the heading
await expect(dialog.getByRole('heading', {name: 'Direct Messages'})).toBeVisible();
// * Verify the search input has proper accessibility attributes
const searchInput = dialog.getByLabel('Search for people');
await expect(searchInput).toBeVisible();
await expect(searchInput).toHaveAttribute('aria-autocomplete', 'list');
// # Search for a text and navigate with arrow keys
await searchInput.fill('s');
await pw.wait(pw.duration.half_sec);
// # Navigate down through the list
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowUp');
// * Verify the selected row has the correct class
const selectedRow = dialog.locator('#multiSelectList').locator('.more-modal__row--selected');
await expect(selectedRow).toBeVisible();
// * Verify image alt is displayed for user profile
const avatar = selectedRow.locator('img.Avatar');
await expect(avatar).toHaveAttribute('alt', 'user profile image');
// * Verify screen reader live region exists and has proper attributes
const srOnlyRegion = dialog.locator('.filtered-user-list div.sr-only:not([role="status"])');
await expect(srOnlyRegion).toHaveAttribute('aria-live', 'polite');
await expect(srOnlyRegion).toHaveAttribute('aria-atomic', 'true');
// # Search for an invalid text
const invalidSearchTerm = 'somethingwhichdoesnotexist';
await searchInput.clear();
await searchInput.fill(invalidSearchTerm);
await pw.wait(pw.duration.half_sec);
// * Check if the no results message is displayed with proper accessibility
const noResultsWrapper = dialog.locator('.multi-select__wrapper');
await expect(noResultsWrapper).toHaveAttribute('aria-live', 'polite');
await expect(noResultsWrapper).toContainText(`No results found matching ${invalidSearchTerm}`);
},
);
/**
* @objective Verify Direct Messages dialog passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Direct Messages dialog',
{tag: ['@accessibility', '@direct_messages', '@snapshots']},
async ({pw, axe}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, user} = await pw.initSetup();
// # Log in as admin
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Click on the Write a direct message button to open the Direct Messages dialog
const writeDirectMessageButton = page.getByRole('button', {name: 'Write a direct message'});
await writeDirectMessageButton.click();
// * Verify the Direct Messages dialog is visible
const dialog = page.getByRole('dialog', {name: 'Direct Messages'});
await expect(dialog).toBeVisible();
await pw.wait(pw.duration.one_sec);
// * Verify aria snapshot of Direct Messages dialog (key structural elements only)
await expect(dialog).toMatchAriaSnapshot(`
- dialog "Direct Messages":
- document:
- heading "Direct Messages" [level=1]
- button "Close"
- application:
- log
- text: Search for people
- combobox "Search for people"
- button "Go"
`);
// * Analyze the Direct Messages dialog for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include('[role="dialog"]')
// TODO: Address scrollable-region-focusable violation in the Direct Messages dialog
// The multiSelectList and sr-only status elements need to be keyboard accessible
.disableRules(['color-contrast', 'scrollable-region-focusable'])
.analyze();
// * Should have no violations
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test.fixme('Base channel accessibility', async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.postMessage('hello');
// # Analyze the page
// Disable 'color-contrast' to be addressed by MM-53814
const accessibilityScanResults = await axe.builder(page, {disableColorContrast: true}).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
});
test('Post actions tab support', async ({pw, axe}) => {
// # Create and sign in a new user
const {user, adminClient} = await pw.initSetup();
const config = await adminClient.getConfig();
const license = await adminClient.getClientLicenseOld();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.postMessage('hello');
const post = await channelsPage.getLastPost();
await post.hover();
await post.postMenu.toBeVisible();
// # Open the dot menu
await post.postMenu.dotMenuButton.press('Enter');
// * Dot menu should be visible and have focused
await channelsPage.postDotMenu.toBeVisible();
await expect(channelsPage.postDotMenu.replyMenuItem).toBeFocused();
// # Analyze the page
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include('.MuiList-root.MuiList-padding')
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
// * Should move focus to Forward after arrow down
await channelsPage.postDotMenu.replyMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.forwardMenuItem).toBeFocused();
// * Should move focus to Follow message after arrow down
await channelsPage.postDotMenu.forwardMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.followMessageMenuItem).toBeFocused();
// * Should move focus to Mark as Unread after arrow down
await channelsPage.postDotMenu.followMessageMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.markAsUnreadMenuItem).toBeFocused();
// * Should move focus to Save after arrow down
await channelsPage.postDotMenu.markAsUnreadMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.saveMenuItem).toBeFocused();
// * Should move focus to Remind after arrow down
await channelsPage.postDotMenu.saveMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.remindMenuItem).toBeFocused();
// * Should move focus to Pin to Channel after arrow down
await channelsPage.postDotMenu.remindMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.pinToChannelMenuItem).toBeFocused();
if (config.FeatureFlags.MoveThreadsEnabled && license.IsLicensed === 'true') {
// * Should move focus to Move Thread after arrow down
await channelsPage.postDotMenu.pinToChannelMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.moveThreadMenuItem).toBeFocused();
// * Should move focus to Copy Text after arrow down
await channelsPage.postDotMenu.moveThreadMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyTextMenuItem).toBeFocused();
} else {
// * Should move focus to Copy Text after arrow down
await channelsPage.postDotMenu.pinToChannelMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyTextMenuItem).toBeFocused();
}
// * Should move focus to Copy Link after arrow down
await channelsPage.postDotMenu.copyTextMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyLinkMenuItem).toBeFocused();
// * Should move focus to Edit after arrow down
await channelsPage.postDotMenu.copyLinkMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.editMenuItem).toBeFocused();
// * Should move focus to Delete after arrow down
// "Quarantine for Review" is inserted between Edit and Delete when content flagging is on.
await channelsPage.postDotMenu.editMenuItem.press('ArrowDown');
if (config.ContentFlaggingSettings?.EnableContentFlagging) {
const quarantineMenuItem = page
.getByRole('menu', {name: 'Post extra options'})
.getByRole('menuitem')
.filter({hasText: 'Quarantine for Review'});
await expect(quarantineMenuItem).toBeFocused();
await page.keyboard.press('ArrowDown');
}
await expect(channelsPage.postDotMenu.deleteMenuItem).toBeFocused();
// * Then, should move focus back to Reply after arrow down
await channelsPage.postDotMenu.deleteMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.replyMenuItem).toBeFocused();
// * Should move focus to Delete after arrow uo
await channelsPage.postDotMenu.container.press('ArrowUp');
expect(await channelsPage.postDotMenu.deleteMenuItem).toBeFocused();
// # Set focus to Remind
await channelsPage.postDotMenu.remindMenuItem.focus();
await expect(channelsPage.postDotMenu.remindMenuItem).toBeFocused();
// * Reminder menu should still be hidden
await expect(channelsPage.postReminderMenu.container).toBeHidden();
// # Press arrow right
await channelsPage.postDotMenu.remindMenuItem.press('ArrowRight');
// * Reminder menu should be visible
expect(channelsPage.postReminderMenu.container).toBeVisible();
// * Should have focus on 30 mins after submenu opens
expect(await channelsPage.postReminderMenu.thirtyMinsMenuItem).toBeFocused();
// * Should move focus to 1 hour after arrow down
await channelsPage.postReminderMenu.thirtyMinsMenuItem.press('ArrowDown');
expect(await channelsPage.postReminderMenu.oneHourMenuItem).toBeFocused();
// * Should move focus to 2 hours after arrow down
await channelsPage.postReminderMenu.oneHourMenuItem.press('ArrowDown');
expect(await channelsPage.postReminderMenu.twoHoursMenuItem).toBeFocused();
// * Should move focus to Tomorrow after arrow down
await channelsPage.postReminderMenu.twoHoursMenuItem.press('ArrowDown');
expect(await channelsPage.postReminderMenu.tomorrowMenuItem).toBeFocused();
// * Should move focus to Custom after arrow down
await channelsPage.postReminderMenu.tomorrowMenuItem.press('ArrowDown');
expect(await channelsPage.postReminderMenu.customMenuItem).toBeFocused();
// * Then, should move focus back to 30 mins after arrow down
await channelsPage.postReminderMenu.customMenuItem.press('ArrowDown');
expect(await channelsPage.postReminderMenu.thirtyMinsMenuItem).toBeFocused();
// * Should hide Reminder menu and focus to Remind menu after arrow left
await channelsPage.postReminderMenu.container.press('ArrowLeft');
await expect(channelsPage.postReminderMenu.container).toBeHidden();
await expect(channelsPage.postDotMenu.remindMenuItem).toBeFocused();
// * Should hide Dot menu of Escape
await channelsPage.postDotMenu.container.press('Escape');
await expect(channelsPage.postDotMenu.container).toBeHidden();
});

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify accessibility support in Invite People Flow
*/
test(
'MM-T1515 Verify Accessibility Support in Invite People Flow',
{tag: ['@accessibility', '@invite_people']},
async ({pw}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, adminUser} = await pw.initSetup();
// # Log in as admin
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open team menu and click Invite people
await channelsPage.sidebarLeft.teamMenuButton.click();
await channelsPage.teamMenu.toBeVisible();
const invitePeopleMenuItem = page.locator("#sidebarTeamMenu li:has-text('Invite people')");
await invitePeopleMenuItem.click();
// * Verify the Invite People modal has proper accessibility attributes
const inviteModal = page.getByTestId('invitationModal');
await expect(inviteModal).toBeVisible();
await expect(inviteModal).toHaveAttribute('aria-modal', 'true');
await expect(inviteModal).toHaveAttribute('aria-labelledby', 'invitation_modal_title');
await expect(inviteModal).toHaveAttribute('role', 'dialog');
// * Verify the modal title is visible and contains correct text
const modalTitle = page.locator('#invitation_modal_title');
await expect(modalTitle).toBeVisible();
await expect(modalTitle).toContainText('Invite people to');
// # Get the close button and verify accessibility
const closeButton = inviteModal.locator('button.icon-close');
await expect(closeButton).toHaveAttribute('aria-label', 'Close');
// # Focus on close button and verify tab navigation works
await closeButton.focus();
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Tab');
// * Verify focus returns to close button
await expect(closeButton).toBeFocused();
},
);
/**
* @objective Verify Invite People dialog passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Invite People dialog',
{tag: ['@accessibility', '@invite_people', '@snapshots']},
async ({pw, axe}) => {
// # Skip test if no license
await pw.skipIfNoLicense();
// # Initialize setup
const {team, user, adminClient} = await pw.initSetup();
// # Turn off email invitations, which change the invite input's placeholder from
// # "Add members" to "Enter a name or email address" in the snapshot below
await adminClient.patchConfig({ServiceSettings: {EnableEmailInvitations: false}});
// # Log in as user
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town-square channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open team menu and click Invite people
await channelsPage.sidebarLeft.teamMenuButton.click();
await channelsPage.teamMenu.toBeVisible();
const invitePeopleMenuItem = page.locator("#sidebarTeamMenu li:has-text('Invite people')");
await invitePeopleMenuItem.click();
// * Verify the Invite People modal is visible
const inviteModal = page.getByTestId('invitationModal');
await expect(inviteModal).toBeVisible();
await pw.wait(pw.duration.one_sec);
// * Verify aria snapshot of Invite People dialog (key structural elements only)
await expect(inviteModal).toMatchAriaSnapshot(`
- dialog:
- document:
- heading [level=1]
- button
- text: "To:"
- log
- text: Add members
- combobox "Invite People"
- listbox
- button
- button "Invite" [disabled]
`);
// * Analyze the Invite People dialog for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include('[data-testid="invitationModal"]')
.analyze();
// * Should have no violations
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,437 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify keyboard navigation through interactive elements in Advanced settings
*/
test(
'navigate on keyboard tab between interactive elements',
{tag: ['@accessibility', '@settings', '@advanced_settings']},
async ({pw}) => {
// # Create and sign in a new user
const {user, adminClient} = await pw.initSetup();
const config = await adminClient.getConfig();
const wysiwygEnabled =
config.FeatureFlags?.WysiwygEditor === true || config.FeatureFlags?.WysiwygEditor === 'true';
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
const advancedSettings = settingsModal.advancedSettings;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible and focus should be on the modal
await expect(settingsModal.container).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.container);
// # Press Tab and verify focus is on Close button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.closeButton);
// # Click on Advanced tab to open it
await settingsModal.advancedTab.click();
// * Advanced Settings panel should be visible
await expect(advancedSettings.container).toBeVisible();
// # Press Tab to move focus to Send Messages on CTRL+ENTER button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.ctrlEnterEditButton);
if (wysiwygEnabled) {
// # Press Tab to move focus to Rich text editing button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.wysiwygEditorEditButton);
}
// # Press Tab to move focus to Enable Post Formatting button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.postFormattingEditButton);
// # Press Tab to move focus to Enable Join/Leave Messages button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.joinLeaveEditButton);
// # Press Tab to move focus to Scroll position button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.scrollPositionEditButton);
// # Press Tab to move focus to Allow message drafts to sync button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(advancedSettings.syncDraftsEditButton);
},
);
/**
* @objective Verify Advanced settings panel passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Advanced settings panel',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user, adminClient} = await pw.initSetup();
const config = await adminClient.getConfig();
const wysiwygEnabled =
config.FeatureFlags?.WysiwygEditor === true || config.FeatureFlags?.WysiwygEditor === 'true';
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
// * Advanced Settings panel should be visible
await expect(settingsModal.advancedSettings.container).toBeVisible();
const wysiwygSnapshot = wysiwygEnabled
? `
- heading "Rich text editing (Beta)" [level=4]
- button "Rich text editing (Beta) Edit"
- text: /(Markdown editing [(]default[)]|Rich text editing.*)/`
: '';
// * Verify aria snapshot of Advanced settings tab
await expect(settingsModal.advancedSettings.container).toMatchAriaSnapshot(`
- tabpanel "advanced":
- heading "Advanced Settings" [level=3]
- heading /Send Messages on (CTRL|⌘)\\+ENTER/ [level=4]
- button /Send Messages on (CTRL|⌘)\\+ENTER Edit/
- text: /(On for all messages|On only for code blocks starting with \`\`\`|Off)/${wysiwygSnapshot}
- heading "Enable Post Formatting" [level=4]
- button "Enable Post Formatting Edit"
- text: /(On|Off)/
- heading "Enable Join/Leave Messages" [level=4]
- button "Enable Join/Leave Messages Edit"
- text: /(On|Off)/
- heading "Scroll position when viewing an unread channel" [level=4]
- button "Scroll position when viewing an unread channel Edit"
- text: /(Start me where I left off|Start me at the newest message)/
- heading "Allow message drafts to sync with the server" [level=4]
- button "Allow message drafts to sync with the server Edit"
- text: /(On|Off)/
`);
// * Analyze the Advanced settings panel for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include(settingsModal.advancedSettings.id)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify Send Messages on CTRL+ENTER section passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Send Messages on CTRL+ENTER section',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
const advancedSettings = settingsModal.advancedSettings;
// # Click Edit on Send Messages section
await advancedSettings.ctrlEnterEditButton.click();
// * Verify aria snapshot of Send Messages section when expanded
await advancedSettings.expandedSection.waitFor();
await expect(advancedSettings.expandedSection).toMatchAriaSnapshot(`
- heading /Send Messages on (CTRL|⌘)\\+ENTER/ [level=4]
- group /Send Messages on (CTRL|⌘)\\+ENTER/:
- text: /Send Messages on (CTRL|⌘)\\+ENTER/
- radio "On for all messages"
- text: On for all messages
- radio /(On only for code blocks starting with \`\`\`|On only for code blocks starting with \\\`\\\`\\\`)/ [checked]
- text: /(On only for code blocks starting with \`\`\`|On only for code blocks starting with \\\`\\\`\\\`)/
- radio "Off"
- text: /Off When enabled, (CTRL|⌘) \\+ ENTER will send the message and ENTER inserts a new line\\./
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(advancedSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify Enable Post Formatting section passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Enable Post Formatting section',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
const advancedSettings = settingsModal.advancedSettings;
// # Click Edit on Enable Post Formatting section
await advancedSettings.postFormattingEditButton.click();
// * Verify aria snapshot of Enable Post Formatting section when expanded
await advancedSettings.expandedSection.waitFor();
await expect(advancedSettings.expandedSection).toMatchAriaSnapshot(`
- heading "Enable Post Formatting" [level=4]
- group "Enable Post Formatting":
- radio /(On|Off)/ [checked]
- text: /(On|Off)/
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(advancedSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify Enable Join/Leave Messages section passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Enable Join/Leave Messages section',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
const advancedSettings = settingsModal.advancedSettings;
// # Click Edit on Enable Join/Leave Messages section
await advancedSettings.joinLeaveEditButton.click();
// * Verify aria snapshot of Enable Join/Leave Messages section when expanded
await advancedSettings.expandedSection.waitFor();
await expect(advancedSettings.expandedSection).toMatchAriaSnapshot(`
- heading "Enable Join/Leave Messages" [level=4]
- group "Enable Join/Leave Messages":
- text: Enable Join/Leave Messages
- radio "On" [checked]
- text: "On"
- radio "Off"
- text: /Off When "On", System Messages saying a user has joined or left a channel will be visible\\./
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(advancedSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify Scroll position section passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Scroll position section',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
const advancedSettings = settingsModal.advancedSettings;
// # Click Edit on Scroll position section
await advancedSettings.scrollPositionEditButton.click();
// * Verify aria snapshot of Scroll position section when expanded
await advancedSettings.expandedSection.waitFor();
await expect(advancedSettings.expandedSection).toMatchAriaSnapshot(`
- heading "Scroll position when viewing an unread channel" [level=4]
- group "Scroll position when viewing an unread channel":
- text: Scroll position when viewing an unread channel
- radio "Start me where I left off" [checked]
- text: Start me where I left off
- radio "Start me at the newest message"
- text: /Start me at the newest message Choose your scroll position when you view an unread channel\\./
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(advancedSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify message drafts sync section passes accessibility scan and matches aria-snapshot
*/
test(
'accessibility scan and aria-snapshot of Allow message drafts to sync section',
{tag: ['@accessibility', '@settings', '@advanced_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.advancedTab.click();
const advancedSettings = settingsModal.advancedSettings;
// # Click Edit on Allow message drafts to sync section
await advancedSettings.syncDraftsEditButton.click();
// * Verify aria snapshot of Allow message drafts to sync section when expanded
await advancedSettings.expandedSection.waitFor();
await expect(advancedSettings.expandedSection).toMatchAriaSnapshot(`
- heading "Allow message drafts to sync with the server" [level=4]
- group "Allow message drafts to sync with the server":
- text: Allow message drafts to sync with the server
- radio "On" [checked]
- text: "On"
- radio "Off"
- text: /Off When enabled, message drafts are synced with the server/
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(advancedSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test(
'navigate on keyboard tab between interactive elements',
{tag: ['@accessibility', '@settings', '@notification_settings']},
async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
const notificationsSettings = settingsModal.notificationsSettings;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible and focus should be on the modal
await expect(settingsModal.container).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.container);
// # Press Tab and verify focus is on Close button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.closeButton);
// # Press Tab to move focus to Notifications tab
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.notificationsTab);
// # Press Tab to move focus to Learn more link
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.learnMoreText);
// # Press Tab to move focus to Desktop and mobile notifications button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.desktopAndMobileEditButton);
// # Press Tab to move focus to Desktop notification sounds button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.desktopNotificationSoundEditButton);
// # Press Tab to move focus to Email notifications button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.emailEditButton);
// # Press Tab to move focus to Auto-follow threads on channel-wide mentions button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.channelMentionAutoFollowEditButton);
// # Press Tab to move focus to Keywords that trigger notifications button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.keywordsTriggerNotificationsEditButton);
// # Press Tab to move focus to Keywords that get highlighted (without notifications) button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.keywordsGetHighlightedEditButton);
// # Press Tab to move focus to Send a test notification button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.testNotificationButton);
// # Press Tab to move focus to Troubleshooting docs button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.troubleshootingDocsButton);
},
);
test(
'accessibility scan and aria-snapshot of Notifications settings panel',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user, adminClient} = await pw.initSetup();
const clientConfig = await adminClient.getClientConfig();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// * Verify aria snapshot of Notifications settings tab
await expect(settingsModal.notificationsSettings.container).toMatchAriaSnapshot(`
- tabpanel "notifications":
- heading "Notifications" [level=3]
- link "Learn more about notifications":
- /url: https://mattermost.com/pl/about-notifications?utm_source=mattermost&utm_medium=in-product&utm_content=user_settings_notifications&uid=${user.id}&sid=${clientConfig.DiagnosticId}&edition=enterprise&server_version=${clientConfig.Version}
- img
- heading "Desktop and mobile notifications Permission required" [level=4]:
- img
- button "Desktop and mobile notifications Permission required Edit"
- text: Mentions, direct messages, and group messages
- heading "Desktop notification sounds" [level=4]
- button "Desktop notification sounds Edit"
- text: "\\"Bing\\" for messages"
- heading "Email notifications" [level=4]
- button "Email notifications Edit"
- heading "Auto-follow threads on channel-wide mentions" [level=4]
- button "Auto-follow threads on channel-wide mentions Edit"
- text: "On"
- heading "Keywords that trigger notifications" [level=4]
- button "Keywords that trigger notifications Edit"
- text: "\\"@${user.username}\\", \\"@channel\\", \\"@all\\", \\"@here\\""
- heading "Keywords that get highlighted (without notifications)" [level=4]
- button "Keywords that get highlighted (without notifications) Edit"
- heading "Troubleshooting notifications" [level=4]
- paragraph: Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
- button "Send a test notification"
- button "Troubleshooting docs 󰏌"
`);
// * Analyze the Notifications settings panel for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include(settingsModal.notificationsSettings.id)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
test(
'accessibility scan and aria-snapshot of Desktop and mobile notifications section',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user, adminClient} = await pw.initSetup();
// # Send push notifications, without which the mobile settings in this section are replaced
// # by a "not enabled by your system administrator" message
await adminClient.patchConfig({EmailSettings: {SendPushNotifications: true}});
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
const notificationsSettings = settingsModal.notificationsSettings;
// # Click Edit on Desktop and mobile notifications section
await notificationsSettings.desktopAndMobileEditButton.click();
// * Verify aria snapshot of Desktop and mobile notifications section when expanded
await notificationsSettings.expandedSection.waitFor();
await expect(notificationsSettings.expandedSection).toMatchAriaSnapshot({
name: 'desktop_and_mobile_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(notificationsSettings.expandedSectionId)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
test(
'accessibility scan and aria-snapshot of Desktop notification sounds section',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
const notificationsSettings = settingsModal.notificationsSettings;
// # Click Edit on Desktop notification sounds section
await notificationsSettings.desktopNotificationSoundEditButton.click();
// * Verify aria snapshot of Desktop notification sounds section when expanded
await notificationsSettings.expandedSection.waitFor();
await expect(notificationsSettings.expandedSection).toMatchAriaSnapshot({
name: 'desktop_notification_sounds_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(notificationsSettings.expandedSectionId)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
test(
'accessibility scan and aria-snapshot of Email notifications section',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
const notificationsSettings = settingsModal.notificationsSettings;
// # Click Edit on Email notifications section
await notificationsSettings.emailEditButton.click();
// * Verify aria snapshot of Email notifications section when expanded
await notificationsSettings.expandedSection.waitFor();
await expect(notificationsSettings.expandedSection).toMatchAriaSnapshot({
name: 'email_notifications_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(notificationsSettings.expandedSectionId)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
test(
'accessibility scan and aria-snapshot of Keywords that trigger notifications section',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
const notificationsSettings = settingsModal.notificationsSettings;
// # Click Edit on Keywords that trigger notifications section
await notificationsSettings.keywordsTriggerNotificationsEditButton.click();
// * Verify aria snapshot of Keywords that trigger notifications section when expanded
await notificationsSettings.expandedSection.waitFor();
await expect(notificationsSettings.expandedSection).toMatchAriaSnapshot(`
- heading "Keywords that trigger notifications" [level=4]
- group "Keywords that trigger notifications":
- checkbox "Your case-sensitive first name \\"${user.first_name}\\""
- text: Your case-sensitive first name "${user.first_name}"
- checkbox "Your non case-sensitive username \\"${user.username}\\""
- text: Your non case-sensitive username "${user.username}"
- checkbox "Channel-wide mentions \\"@channel\\", \\"@all\\", \\"@here\\"" [checked]
- text: Channel-wide mentions "@channel", "@all", "@here"
- checkbox "Other non case-sensitive words, press Tab or use commas to separate keywords:"
- text: "Other non case-sensitive words, press Tab or use commas to separate keywords:"
- log
- combobox "Keywords that trigger notifications"
- text: Notifications are triggered when someone sends a message that includes your username ("@${user.username}") or any of the options selected above.
- separator
- alert
- button "Save"
- button "Cancel"
`);
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(notificationsSettings.expandedSectionId)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
test(
'accessibility scan and aria-snapshot of Keywords that get highlighted (without notifications) section',
{tag: ['@accessibility', '@settings', '@notification_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
const notificationsSettings = settingsModal.notificationsSettings;
// # Click Edit on Keywords that get highlighted (without notifications) section
await notificationsSettings.keywordsGetHighlightedEditButton.click();
// * Verify aria snapshot of Keywords that get highlighted (without notifications) section when expanded
await notificationsSettings.expandedSection.waitFor();
await expect(notificationsSettings.expandedSection).toMatchAriaSnapshot({
name: 'keywords_that_get_highlighted_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(notificationsSettings.expandedSectionId)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,22 @@
- heading "Desktop and mobile notifications" [level=4]
- text: 󰀪
- heading "Browser notification permission was denied" [level=4]
- paragraph: You're missing important message and call notifications from Mattermost. To start receiving notifications, please enable notifications for Mattermost in your browser settings.
- button "How to enable notifications"
- group "Send notifications for:":
- radio "All new messages"
- radio "Mentions, direct messages, and group messages" [checked]
- text: Mentions, direct messages, and group messages
- radio "Nothing"
- checkbox "Notify me about replies to threads I'm following" [checked]
- text: Notify me about replies to threads I'm following
- separator
- checkbox "Use different settings for my mobile devices"
- text: "Use different settings for my mobile devices Trigger mobile notifications when I am:"
- log
- text: Online, away, or offline
- combobox "Trigger mobile notifications when I am:"
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,10 @@
- heading "Desktop notification sounds" [level=4]
- checkbox "Message notification sound" [checked]
- text: Message notification sound
- log
- text: Bing
- combobox "Message notification sound"
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,16 @@
- heading "Email notifications" [level=4]
- group:
- group:
- radio "On" [checked]
- text: "On"
- radio "Off"
- text: "Off"
- text: When enabled, email notifications are sent for mentions and direct messages when you are offline or away for more than 5 minutes.
- separator
- group:
- checkbox "Notify me about replies to threads I’m following" [checked]
- text: Notify me about replies to threads I’m following
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,9 @@
- heading "Keywords that get highlighted (without notifications)" [level=4]
- text: "Enter non case-sensitive keywords, press Tab or use commas to separate them: option , selected.Select is focused ,type to refine list, press Down to open the menu, press left to focus selected values"
- log
- combobox
- text: These keywords will be shown to you with a highlight when anyone sends a message that includes them.
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,307 @@
// 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 focus is properly managed when opening and closing settings modal using keyboard interactions
*/
test(
'manages focus when opening and closing settings modal with keyboard',
{tag: ['@accessibility', '@settings']},
async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible and focus should be on the modal
await expect(settingsModal.container).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.container);
// # Press Tab and verify focus is on Close button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.closeButton);
// # Press Enter and verify Settings modal is closed and focus is back on Settings button
await page.keyboard.press('Enter');
await expect(settingsModal.container).not.toBeVisible();
await pw.toBeFocusedWithFocusVisible(globalHeader.settingsButton);
// # Open Settings modal again
await page.keyboard.press('Enter');
await expect(settingsModal.container).toBeVisible();
// # Press Escape and verify Settings modal is closed and focus is back on Settings button
await page.keyboard.press('Escape');
await expect(settingsModal.container).not.toBeVisible();
await pw.toBeFocusedWithFocusVisible(globalHeader.settingsButton);
},
);
/**
* @objective Verify that users can navigate between settings tabs using arrow keys
*/
test('navigates between settings tabs using arrow keys', {tag: ['@accessibility', '@settings']}, async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await channelsPage.globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible and focus should be on the modal
await expect(settingsModal.container).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.container);
// # Press Tab twice and verify focus is on Notifications tab and Notifications Settings panel is visible
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await expect(settingsModal.notificationsTab).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.notificationsTab);
await expect(settingsModal.notificationsSettings.container).toBeVisible();
// # Press arrow down key and verify focus is on Display tab and Display Settings panel is visible
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(settingsModal.displayTab);
await expect(settingsModal.displaySettings.container).toBeVisible();
// # Press arrow down key and verify focus is on Sidebar tab and Sidebar Settings panel is visible
await page.keyboard.press('ArrowDown');
await expect(settingsModal.sidebarTab).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.sidebarTab);
await expect(settingsModal.sidebarSettings.container).toBeVisible();
// # Press Tab and verify focus is on Advanced tab and Advanced Settings panel is visible
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(settingsModal.advancedTab);
await expect(settingsModal.advancedSettings.container).toBeVisible();
// # Press arrow down through the tabs plugins add after Advanced, Calls among them
const builtInTabs = 4;
const lastTab = settingsModal.tabs.last();
for (let i = 0; i < (await settingsModal.tabs.count()) - builtInTabs; i++) {
await page.keyboard.press('ArrowDown');
}
await pw.toBeFocusedWithFocusVisible(lastTab);
// # Press arrow down key and verify focus is back on Notifications tab and Notifications Settings panel is visible
await page.keyboard.press('ArrowDown');
await expect(settingsModal.notificationsTab).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.notificationsTab);
await expect(settingsModal.notificationsSettings.container).toBeVisible();
// # Press arrow up key and verify focus wraps to the last tab
await page.keyboard.press('ArrowUp');
await pw.toBeFocusedWithFocusVisible(lastTab);
});
/**
* @objective Verify that notifications settings panel meets WCAG accessibility standards
*/
test(
'passes accessibility scan on notifications settings panel',
{tag: ['@accessibility', '@settings']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await channelsPage.globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal on notifications panel should be visible
await expect(settingsModal.container).toBeVisible();
await expect(settingsModal.notificationsSettings.container).toBeVisible();
// * Analyze the Settings modal with notifications panel
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.disableRules([
'color-contrast',
// Known issue: These fail due to the way setting tabs are grouped.
'aria-required-children',
'aria-required-parent',
])
.include(settingsModal.getContainerId())
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify that display settings panel meets WCAG accessibility standards
*/
test(
'passes accessibility scan on display settings panel',
{tag: ['@accessibility', '@settings']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await channelsPage.globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings dialog should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Display tab
await settingsModal.openDisplayTab();
// * Display Settings panel should be visible
await expect(settingsModal.displaySettings.container).toBeVisible();
// * Analyze the Settings modal with display panel
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.disableRules([
'color-contrast',
// Known issue: These fail due to the way setting tabs are grouped.
'aria-required-children',
'aria-required-parent',
])
.include(settingsModal.getContainerId())
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify that sidebar settings panel meets WCAG accessibility standards
*/
test(
'passes accessibility scan on sidebar settings panel',
{tag: ['@accessibility', '@settings']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await channelsPage.globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings dialog should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Sidebar tab
await settingsModal.openSidebarTab();
// * Sidebar Settings panel should be visible
await expect(settingsModal.sidebarSettings.container).toBeVisible();
// * Analyze the Settings modal with sidebar panel
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.disableRules([
'color-contrast',
// Known issue: These fail due to the way setting tabs are grouped.
'aria-required-children',
'aria-required-parent',
])
.include(settingsModal.getContainerId())
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify that advanced settings panel meets WCAG accessibility standards
*/
test(
'passes accessibility scan on advanced settings panel',
{tag: ['@accessibility', '@settings']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await channelsPage.globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings dialog should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Advanced tab
await settingsModal.openAdvancedTab();
// * Advanced Settings panel should be visible
await expect(settingsModal.advancedSettings.container).toBeVisible();
// * Analyze the Settings modal with advanced panel
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.disableRules([
'color-contrast',
// Known issue: These fail due to the way setting tabs are grouped.
'aria-required-children',
'aria-required-parent',
])
.include(settingsModal.getContainerId())
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify keyboard navigation works correctly through all interactive elements in the Sidebar settings panel
*/
test(
'navigate on keyboard tab between interactive elements',
{tag: ['@accessibility', '@settings', '@sidebar_settings']},
async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
const sidebarSettings = settingsModal.sidebarSettings;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible and focus should be on the modal
await expect(settingsModal.container).toBeVisible();
await pw.toBeFocusedWithFocusVisible(settingsModal.container);
// # Press Tab and verify focus is on Close button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.closeButton);
// # Press Tab to move focus to Notifications tab
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(settingsModal.notificationsTab);
// # Press ArrowDown to navigate to Display tab
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(settingsModal.displayTab);
// # Press ArrowDown to navigate to Sidebar tab
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(settingsModal.sidebarTab);
// # Press Tab to move focus to Group unread channels separately button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(sidebarSettings.groupUnreadEditButton);
// # Press Tab to move focus to Number of direct messages to show button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(sidebarSettings.limitVisibleDMsEditButton);
},
);
/**
* @objective Verify the Sidebar settings panel passes accessibility scan and matches aria snapshot
*/
test(
'accessibility scan and aria-snapshot of Sidebar settings panel',
{tag: ['@accessibility', '@settings', '@sidebar_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Sidebar tab
await settingsModal.openSidebarTab();
// * Verify aria snapshot of Sidebar settings tab
await expect(settingsModal.sidebarSettings.container).toMatchAriaSnapshot(`
- tabpanel "sidebar":
- heading "Sidebar Settings" [level=3]
- heading "Group unread channels separately" [level=4]
- button "Group unread channels separately Edit": Edit
- text: "Off"
- heading "Number of direct messages to show" [level=4]
- button "Number of direct messages to show Edit": Edit
- text: "40"
`);
// * Analyze the Sidebar settings panel for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include(settingsModal.sidebarSettings.id)
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify the Group unread channels separately section passes accessibility scan when expanded
*/
test(
'accessibility scan and aria-snapshot of Group unread channels separately section',
{tag: ['@accessibility', '@settings', '@sidebar_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Sidebar tab
await settingsModal.openSidebarTab();
const sidebarSettings = settingsModal.sidebarSettings;
// # Click Edit on Group unread channels separately section
await sidebarSettings.groupUnreadEditButton.click();
// * Verify aria snapshot of Group unread channels separately section when expanded
await sidebarSettings.expandedSection.waitFor();
await expect(sidebarSettings.expandedSection).toMatchAriaSnapshot({
name: 'group_unread_channels_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe.builder(page).include(sidebarSettings.expandedSectionId).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify the Number of direct messages to show section passes accessibility scan when expanded
*/
test(
'accessibility scan and aria-snapshot of Number of direct messages to show section',
{tag: ['@accessibility', '@settings', '@sidebar_settings', '@snapshots']},
async ({pw, axe}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const globalHeader = channelsPage.globalHeader;
const settingsModal = channelsPage.settingsModal;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus to Settings button and press Enter
await globalHeader.settingsButton.focus();
await page.keyboard.press('Enter');
// * Settings modal should be visible
await expect(settingsModal.container).toBeVisible();
// # Open Sidebar tab
await settingsModal.openSidebarTab();
const sidebarSettings = settingsModal.sidebarSettings;
// # Click Edit on Number of direct messages to show section
await sidebarSettings.limitVisibleDMsEditButton.click();
// * Verify aria snapshot of Number of direct messages to show section when expanded
await sidebarSettings.expandedSection.waitFor();
await expect(sidebarSettings.expandedSection).toMatchAriaSnapshot({
name: 'number_of_direct_messages_section.yml',
});
// # Analyze the expanded section for accessibility issues
const accessibilityScanResults = await axe
.builder(page)
.include(sidebarSettings.expandedSectionId)
.exclude('#react-select-2-input') // Exclude react-select input that has a known accessibility issue
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);

View File

@@ -0,0 +1,9 @@
- heading "Group unread channels separately" [level=4]
- group "Group unread channels separately":
- radio "On"
- radio "Off" [checked]
- text: Off When enabled, all unread channels and direct messages will be grouped together in the sidebar.
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,10 @@
- heading "Number of direct messages to show" [level=4]
- group "Number of direct messages to show":
- text: /Number of direct messages to show option \d+, selected\.Select is focused , press Down to open the menu,/
- log
- combobox
- text: You can also change these settings in the direct messages sidebar menu.
- separator
- alert
- button "Save"
- button "Cancel"

View File

@@ -0,0 +1,326 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify arrow key navigation through all menu items in the team menu
*/
test(
'navigate using arrow keys between menu items',
{tag: ['@accessibility', '@team_menu', '@keyboard_navigation']},
async ({pw}) => {
// # Initialize setup and make the user a team admin to see all menu items
const {user, team, adminClient} = await pw.initSetup();
await adminClient.updateTeamMemberSchemeRoles(team.id, user.id, true, true);
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus on team menu button and press Enter to open the menu
await sidebarLeft.teamMenuButton.focus();
await page.keyboard.press('Enter');
await pw.logFocusedElement(page);
// * Team menu should be visible and focus should be on first menu item - "Invite people"
await teamMenu.toBeVisible();
await pw.toBeFocusedWithFocusVisible(teamMenu.invitePeople);
// # Press ArrowDown to move focus to "Team settings" menu item
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(teamMenu.teamSettings);
// # Press ArrowDown to move focus to "Manage members" menu item
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(teamMenu.manageMembers);
// # Press ArrowDown to move focus to "Leave team" menu item
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(teamMenu.leaveTeam);
// # Press ArrowDown to move focus to "Create a team" menu item
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(teamMenu.createTeam);
// # Press ArrowDown to move focus to "Learn about teams" menu item
await page.keyboard.press('ArrowDown');
await pw.toBeFocusedWithFocusVisible(teamMenu.learnAboutTeams);
},
);
/**
* @objective Verify Tab key navigation escapes menu and moves to next focusable element
*/
test(
'navigate using Tab key to escape menu',
{tag: ['@accessibility', '@team_menu', '@keyboard_navigation']},
async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
const {page, channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Set focus on team menu button and press Enter to open the menu
await sidebarLeft.teamMenuButton.focus();
await page.keyboard.press('Enter');
// * Team menu should be visible and focus should be on first menu item
await teamMenu.toBeVisible();
await pw.toBeFocusedWithFocusVisible(teamMenu.invitePeople);
// # Press Tab to exit menu and move to next focusable element outside menu
await page.keyboard.press('Tab');
// * Team menu should be closed and focus should move to next focusable element
await expect(teamMenu.container).not.toBeVisible();
// * Focus should be on the next focusable element - "Browse or create channels" button
await expect(sidebarLeft.teamMenuButton).not.toBeFocused();
await expect(sidebarLeft.browseOrCreateChannelButton).toBeFocused();
},
);
/**
* @objective Verify menu items can be activated using Enter and Space keys
*/
test(
'activate menu items using Enter and Space keys',
{tag: ['@accessibility', '@team_menu', '@keyboard_navigation']},
async ({pw}) => {
// # Initialize setup and make the user a team admin to see all menu items
const {user, team, adminClient} = await pw.initSetup();
await adminClient.updateTeamMemberSchemeRoles(team.id, user.id, true, true);
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on team menu button to open the menu
await sidebarLeft.teamMenuButton.focus();
await page.keyboard.press('Enter');
// * Team menu should be visible
await teamMenu.toBeVisible();
// # Set focus on "Team settings" menu item and press Enter
await teamMenu.teamSettings.focus();
await page.keyboard.press('Enter');
// * Team settings modal should open (wait for navigation or modal)
await channelsPage.teamSettingsModal.toBeVisible();
// # Close the modal using Escape key
await page.keyboard.press('Escape');
await expect(channelsPage.teamSettingsModal.container).not.toBeVisible();
// # Reopen team menu
await sidebarLeft.teamMenuButton.focus();
await page.keyboard.press('Enter');
await teamMenu.toBeVisible();
// # Set focus on "Learn about teams" menu item and press Space
await teamMenu.invitePeople.focus();
await page.keyboard.press(' ');
// * Should navigate to learn about teams documentation or open modal
const invitePeopleModal = await channelsPage.getInvitePeopleModal(team.display_name);
await invitePeopleModal.toBeVisible();
},
);
/**
* @objective Verify Escape key closes the team menu
*/
test('close menu using Escape key', {tag: ['@accessibility', '@team_menu', '@keyboard_navigation']}, async ({pw}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
const {page, channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on team menu button to open the menu
await sidebarLeft.teamMenuButton.click();
// * Team menu should be visible
await teamMenu.toBeVisible();
// # Press Escape key
await page.keyboard.press('Escape');
// * Team menu should be closed
await expect(teamMenu.container).not.toBeVisible();
// * Focus should return to the team menu button
await pw.toBeFocusedWithFocusVisible(sidebarLeft.teamMenuButton);
});
/**
* @objective Verify team menu accessibility compliance and aria-snapshot structure
*/
test(
'accessibility scan and aria-snapshot of team menu',
{tag: ['@accessibility', '@team_menu', '@snapshots']},
async ({pw, axe}) => {
// # Initialize setup and make the user a team admin to see all menu items
const {user, team, adminClient} = await pw.initSetup();
await adminClient.updateTeamMemberSchemeRoles(team.id, user.id, true, true);
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on team menu button to open the menu
await sidebarLeft.teamMenuButton.click();
// * Team menu should be visible
await teamMenu.toBeVisible();
// * Verify aria snapshot of team menu structure
await expect(teamMenu.container).toMatchAriaSnapshot();
// # Analyze the team menu for accessibility issues
const accessibilityScanResults = await axe
.builder(page, {disableColorContrast: true})
.include(teamMenu.getContainerId())
.analyze();
// * Should have no violations
expect(accessibilityScanResults.violations).toHaveLength(0);
},
);
/**
* @objective Verify menu items have proper ARIA attributes for screen readers
*/
test(
'verify ARIA attributes and screen reader support',
{tag: ['@accessibility', '@team_menu', '@screen_reader']},
async ({pw}) => {
// # Initialize setup and make the user a team admin to see all menu items
const {user, team, adminClient} = await pw.initSetup();
await adminClient.updateTeamMemberSchemeRoles(team.id, user.id, true, true);
// # Log in a user in new browser context
const {channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on team menu button to open the menu
await sidebarLeft.teamMenuButton.click();
// * Team menu should be visible
await teamMenu.toBeVisible();
// * Verify menu container has correct role
await expect(teamMenu.container).toHaveAttribute('role', 'menu');
// * Verify menu items have correct roles and attributes
await expect(teamMenu.invitePeople).toHaveAttribute('role', 'menuitem');
await expect(teamMenu.invitePeople).toHaveAttribute('aria-haspopup', 'dialog');
await expect(teamMenu.teamSettings).toHaveAttribute('role', 'menuitem');
await expect(teamMenu.teamSettings).toHaveAttribute('aria-haspopup', 'dialog');
await expect(teamMenu.manageMembers).toHaveAttribute('role', 'menuitem');
await expect(teamMenu.manageMembers).toHaveAttribute('aria-haspopup', 'dialog');
await expect(teamMenu.leaveTeam).toHaveAttribute('role', 'menuitem');
await expect(teamMenu.leaveTeam).toHaveAttribute('aria-haspopup', 'dialog');
await expect(teamMenu.createTeam).toHaveAttribute('role', 'menuitem');
await expect(teamMenu.learnAboutTeams).toHaveAttribute('role', 'menuitem');
// * Verify menu items are focusable
await expect(teamMenu.invitePeople).toHaveAttribute('tabindex', '0');
// * Verify menu items have accessible names
await expect(teamMenu.invitePeople).toHaveAccessibleName('Invite people Add or invite people to the team');
await expect(teamMenu.teamSettings).toHaveAccessibleName('Team settings');
await expect(teamMenu.manageMembers).toHaveAccessibleName('Manage members');
await expect(teamMenu.leaveTeam).toHaveAccessibleName('Leave team');
await expect(teamMenu.createTeam).toHaveAccessibleName('Create a team');
await expect(teamMenu.learnAboutTeams).toHaveAccessibleName('Learn about teams');
},
);
/**
* @objective Verify menu items with icons have proper visual indicators and alt text
*/
test(
'verify visual indicators and icon accessibility',
{tag: ['@accessibility', '@team_menu', '@visual_indicators']},
async ({pw}) => {
// # Initialize setup and make the user a team admin to see all menu items
const {user, team, adminClient} = await pw.initSetup();
await adminClient.updateTeamMemberSchemeRoles(team.id, user.id, true, true);
// # Log in a user in new browser context
const {channelsPage} = await pw.testBrowser.login(user);
const sidebarLeft = channelsPage.sidebarLeft;
const teamMenu = channelsPage.teamMenu;
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on team menu button to open the menu
await sidebarLeft.teamMenuButton.click();
// * Team menu should be visible
await teamMenu.toBeVisible();
// * Verify icons have aria-hidden attribute (decorative)
const menuItems = [
teamMenu.invitePeople,
teamMenu.teamSettings,
teamMenu.manageMembers,
teamMenu.leaveTeam,
teamMenu.createTeam,
teamMenu.learnAboutTeams,
];
for (const menuItem of menuItems) {
// * Each menu item should contain an SVG icon with aria-hidden
const icon = menuItem.locator('svg').first();
await expect(icon).toHaveAttribute('aria-hidden', 'true');
// * Menu item should have visible focus indicator on focus
await menuItem.focus();
// * Verify the menu item is focused and has focus-visible styles
await pw.toBeFocusedWithFocusVisible(menuItem);
}
},
);

View File

@@ -0,0 +1,9 @@
- menu:
- menuitem "Invite people Add or invite people to the team"
- menuitem "Team settings"
- menuitem "Manage members"
- menuitem "Leave team"
- separator
- menuitem "Create a team"
- separator
- menuitem "Learn about teams"

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('Theme settings should be keyboard accessible', async ({axe, pw}) => {
const {user} = await pw.initSetup();
// # Log in as a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Initialize Axe
const ab = axe.builder(page).disableRules([
'color-contrast',
// Known issue: These fail due to the way we've grouped plugin setting tabs together in the LHS
'aria-required-children',
'aria-required-parent',
]);
// # Visit default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Open settings modal
const settingsModal = await channelsPage.globalHeader.openSettings();
// * The settings modal should have no accessibility violations
let accessibilityScanResults = await ab.analyze();
expect(accessibilityScanResults.violations).toHaveLength(0);
// # Open display tab
await settingsModal.container.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
// * The display tab should be open
const {displaySettings} = settingsModal;
await displaySettings.toBeVisible();
// * The display tab should have no accessibility violations
accessibilityScanResults = await ab.analyze();
expect(accessibilityScanResults.violations).toHaveLength(0);
// # Open the theme section
await page.keyboard.press('Tab');
await page.keyboard.press('Space');
// * The theme section should be open
await displaySettings.verifySectionIsExpanded('theme');
// * The Premade Themes option should be focused
await expect(page.getByLabel('Premade Themes')).toBeFocused();
// * The theme section for premade themes should have no accessibility violations
accessibilityScanResults = await ab.analyze();
expect(accessibilityScanResults.violations).toHaveLength(0);
// * Should be able to tab through the options
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Denim'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Sapphire'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Quartz'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Indigo'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Onyx'})).toBeFocused();
await page.keyboard.press('Tab');
// Note: There's no "Apply to all your teams" option because this user is only on one team
await expect(page.getByRole('link', {name: 'See other themes'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Save'})).toBeFocused();
// # Go back to the premade/custom option
await page.getByLabel('Premade Themes').focus();
// # Switch to the custom theme section
await page.keyboard.press('ArrowDown');
// * The Custom Theme option should be focused
await expect(page.getByLabel('Custom Theme')).toBeFocused();
// * Check the ARIA of the Sidebar Styles section
await page.keyboard.press('Tab');
const sidebarStyles = page.getByRole('button', {name: 'Sidebar Styles'});
await expect(sidebarStyles).toBeFocused();
await expect(sidebarStyles).toHaveAttribute('aria-expanded', 'false');
// * Check that we can tab over the collapsed section to the next section and then back again
await page.keyboard.press('Tab');
await expect(page.getByRole('button', {name: 'Center Channel Styles'})).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(sidebarStyles).toBeFocused();
// * Check that we can expand the section
await page.keyboard.press('Enter');
await expect(sidebarStyles).toHaveAttribute('aria-expanded', 'true');
// # Wait for the expanding animation to be open
await expect(page.getByLabel('Sidebar Styles')).toHaveCSS('overflow-y', 'visible');
// * Check that we can tab through color pickers
await page.keyboard.press('Tab');
await expect(page.getByLabel('Sidebar BG', {exact: true})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Sidebar Text', {exact: true})).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Sidebar Header BG')).toBeFocused();
// * The theme section for custom themes should have no accessibility violations
accessibilityScanResults = await ab.analyze();
expect(accessibilityScanResults.violations).toHaveLength(0);
});

View File

@@ -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';
test.beforeEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
await adminClient.patchConfig({
TeamSettings: {EnableOpenServer: true},
SamlSettings: {Enable: false},
LdapSettings: {Enable: false},
});
});
test('/login accessibility quick check', async ({pw, axe}) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to login page
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
// # Analyze the page
const accessibilityScanResults = await axe.builder(pw.loginPage.page).analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
});
test('/login accessibility tab support', async ({pw}) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to login page
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
// * Should have focused at login input on page load
expect(await pw.loginPage.loginInput).toBeFocused();
// * Should move focus to password input after tab
await pw.loginPage.loginInput.press('Tab');
expect(await pw.loginPage.passwordInput).toBeFocused();
// * Should move focus to password toggle button after tab
await pw.loginPage.passwordInput.press('Tab');
expect(await pw.loginPage.passwordToggleButton).toBeFocused();
// * Should move focus to forgot password link after tab
await pw.loginPage.passwordToggleButton.press('Tab');
expect(await pw.loginPage.forgotPasswordLink).toBeFocused();
// * Should move focus to forgot password link after tab
await pw.loginPage.forgotPasswordLink.press('Tab');
expect(await pw.loginPage.signInButton).toBeFocused();
// * Should move focus to about link after tab
await pw.loginPage.signInButton.press('Tab');
expect(await pw.loginPage.footer.aboutLink).toBeFocused();
// * Should move focus to privacy policy link after tab
await pw.loginPage.footer.aboutLink.press('Tab');
expect(await pw.loginPage.footer.privacyPolicyLink).toBeFocused();
// * Should move focus to terms link after tab
await pw.loginPage.footer.privacyPolicyLink.press('Tab');
expect(await pw.loginPage.footer.termsLink).toBeFocused();
// * Should move focus to help link after tab
await pw.loginPage.footer.termsLink.press('Tab');
expect(await pw.loginPage.footer.helpLink).toBeFocused();
// # Move focus to login input
await pw.loginPage.loginInput.focus();
expect(await pw.loginPage.loginInput).toBeFocused();
// * Should move focus to login body after shift+tab
await pw.loginPage.loginInput.press('Shift+Tab');
expect(await pw.loginPage.createAccountLink).toBeFocused();
// * Should move focus to login body after tab
await pw.loginPage.createAccountLink.press('Shift+Tab');
expect(await pw.loginPage.header.logo).toBeFocused();
});

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('/reset_password accessibility quick check', async ({pw, axe}) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to reset password page
await pw.resetPasswordPage.goto();
await pw.resetPasswordPage.toBeVisible();
// # Analyze the page
const accessibilityScanResults = await axe
.builder(pw.resetPasswordPage.page, {disableColorContrast: true})
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
});
test('/reset_password accessibility tab support', async ({pw}) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to reset password page
await pw.resetPasswordPage.goto();
await pw.resetPasswordPage.toBeVisible();
// * Should have focused at email input on page load
expect(await pw.resetPasswordPage.emailInput).toBeFocused();
// * Should move focus to reset button after tab
await pw.resetPasswordPage.emailInput.press('Tab');
expect(await pw.resetPasswordPage.resetButton).toBeFocused();
// * Should move focus to about link after tab
await pw.resetPasswordPage.resetButton.press('Tab');
expect(await pw.resetPasswordPage.footer.aboutLink).toBeFocused();
// * Should move focus to privacy policy link after tab
await pw.resetPasswordPage.footer.aboutLink.press('Tab');
expect(await pw.resetPasswordPage.footer.privacyPolicyLink).toBeFocused();
// * Should move focus to terms link after tab
await pw.resetPasswordPage.footer.privacyPolicyLink.press('Tab');
expect(await pw.resetPasswordPage.footer.termsLink).toBeFocused();
// * Should move focus to help link after tab
await pw.resetPasswordPage.footer.termsLink.press('Tab');
expect(await pw.resetPasswordPage.footer.helpLink).toBeFocused();
// # Move focus to email input
await pw.resetPasswordPage.emailInput.focus();
expect(await pw.resetPasswordPage.emailInput).toBeFocused();
// * Should move focus to back button after shift+tab
await pw.resetPasswordPage.emailInput.press('Shift+Tab');
expect(await pw.resetPasswordPage.header.backButton).toBeFocused();
});

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('/signup_user_complete accessibility quick check', async ({pw, axe}) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to reset password page
await pw.signupPage.goto();
await pw.signupPage.toBeVisible();
// # Analyze the page
const accessibilityScanResults = await axe
.builder(pw.signupPage.page, {disableColorContrast: true, disableLinkInTextBlock: true})
.analyze();
// * Should have no violation
expect(accessibilityScanResults.violations).toHaveLength(0);
});
test('/signup_user_complete accessibility tab support', async ({pw}, testInfo) => {
// Set up the page not to redirect to the landing page
await pw.hasSeenLandingPage();
// # Go to reset password page
await pw.signupPage.goto();
await pw.signupPage.toBeVisible();
// * Should have focused at email input on page load
expect(await pw.signupPage.emailInput).toBeFocused();
// * Should move focus to username input after tab
await pw.signupPage.emailInput.press('Tab');
expect(await pw.signupPage.usernameInput).toBeFocused();
// * Should move focus to password input after tab
await pw.signupPage.usernameInput.press('Tab');
expect(await pw.signupPage.passwordInput).toBeFocused();
// * Should move focus to password toggle button after tab
await pw.signupPage.passwordInput.press('Tab');
expect(await pw.signupPage.passwordToggleButton).toBeFocused();
// * Should move focus to terms and privacy checkbox after tab
await pw.signupPage.passwordToggleButton.press('Tab');
expect(await pw.signupPage.termsAndPrivacyCheckBox).toBeFocused();
// * Should move focus to acceptable use policy link after tab
await pw.signupPage.termsAndPrivacyCheckBox.press('Tab');
expect(await pw.signupPage.termsAndPrivacyAcceptableUsePolicyLink).toBeFocused();
// * Should move focus to privacy policy link after tab
await pw.signupPage.termsAndPrivacyAcceptableUsePolicyLink.press('Tab');
expect(await pw.signupPage.termsAndPrivacyPrivacyPolicyLink).toBeFocused();
// * Should move focus to about link after tab (skips disabled create account button)
await pw.signupPage.termsAndPrivacyPrivacyPolicyLink.press('Tab');
expect(await pw.signupPage.footer.aboutLink).toBeFocused();
// * Should move focus to privacy policy link after tab
await pw.signupPage.footer.aboutLink.press('Tab');
expect(await pw.signupPage.footer.privacyPolicyLink).toBeFocused();
// * Should move focus to terms link after tab
await pw.signupPage.footer.privacyPolicyLink.press('Tab');
expect(await pw.signupPage.footer.termsLink).toBeFocused();
// * Should move focus to help link after tab
await pw.signupPage.footer.termsLink.press('Tab');
expect(await pw.signupPage.footer.helpLink).toBeFocused();
// # Move focus to email input
await pw.signupPage.emailInput.focus();
expect(await pw.signupPage.emailInput).toBeFocused();
if (testInfo.project.name === 'ipad') {
// * Should move focus to header back button after shift+tab
await pw.signupPage.bodyCard.press('Shift+Tab');
expect(await pw.signupPage.header.backButton).toBeFocused();
// * Should move focus to log in link after shift+tab
await pw.signupPage.header.backButton.press('Shift+Tab');
expect(await pw.signupPage.loginLink).toBeFocused();
// * Should move focus to header logo after shift+tab
await pw.signupPage.loginLink.press('Shift+Tab');
expect(await pw.signupPage.header.logo).toBeFocused();
} else {
// * Should move focus to log in link after shift+tab
await pw.signupPage.bodyCard.press('Shift+Tab');
expect(await pw.signupPage.loginLink).toBeFocused();
// * Should move focus to header back button after shift+tab
await pw.signupPage.loginLink.press('Shift+Tab');
expect(await pw.signupPage.header.backButton).toBeFocused();
// * Should move focus to header logo after shift+tab
await pw.signupPage.header.backButton.press('Shift+Tab');
expect(await pw.signupPage.header.logo).toBeFocused();
}
});

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {z} from 'zod';
const FileInfoSchema = z.object({
id: z.string(),
user_id: z.string(),
channel_id: z.string(),
create_at: z.number().int(),
update_at: z.number().int(),
delete_at: z.number().int(),
name: z.string(),
extension: z.string(),
size: z.number().int(),
mime_type: z.string(),
mini_preview: z.nullable(z.any()),
remote_id: z.string(),
archived: z.boolean(),
});
export const FileUploadResponseSchema = z.object({
file_infos: z.array(FileInfoSchema),
client_ids: z.array(z.string()),
});

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {ServerChannel} from '@mattermost/types/channels';
import type {FileUploadResponse} from '@mattermost/types/files';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {expect, test, getFileFromAsset, getBlobFromAsset} from '@mattermost/playwright-lib';
import {FileUploadResponseSchema} from './schema';
let userClient: Client4;
let user: UserProfile;
let team: Team;
let townSquareChannel: ServerChannel;
const filename = 'mattermost-icon_128x128.png';
const file = getFileFromAsset(filename);
const blob = getBlobFromAsset(filename);
test.beforeEach(async ({pw}) => {
({userClient, user, team} = await pw.initSetup());
townSquareChannel = await userClient.getChannelByName(team.id, 'town-square');
});
test('should succeed with File', async ({pw}) => {
// # Prepare data with File
const clientId = pw.random.id();
const formData = new FormData();
formData.set('channel_id', townSquareChannel.id);
formData.set('client_ids', clientId);
formData.set('files', file, filename);
// # Do upload then validate the response
const data = await userClient.uploadFile(formData);
validateFileUploadResponse(data, clientId, user.id, townSquareChannel.id);
});
test('should succeed with Blob', async ({pw}) => {
// # Prepare data with Blob
const clientId = pw.random.id();
const formData = new FormData();
formData.set('channel_id', townSquareChannel.id);
formData.set('client_ids', clientId);
formData.set('files', blob, filename);
// # Do upload then validate the response
const data = await userClient.uploadFile(formData);
validateFileUploadResponse(data, clientId, user.id, townSquareChannel.id);
});
test('should succeed even with channel_id only', async () => {
// # Set without channel ID
const formData = new FormData();
formData.set('channel_id', townSquareChannel.id);
// # Do upload then validate the response
const data = await userClient.uploadFile(formData);
// * Validate that it doe snot throw an error
const validate = () => FileUploadResponseSchema.parse(data);
expect(validate).not.toThrow();
// * Validate that file_infos and client_ids are as expected
expect(data.client_ids).toMatchObject([]);
expect(data.file_infos.length).toBe(0);
});
test('should fail on invalid channel ID', async ({pw}) => {
const clientId = pw.random.id();
// # Set with invalid channel ID
let formData = new FormData();
formData.set('channel_id', 'invalid.channel.id');
formData.set('client_ids', clientId);
formData.set('files', file, filename);
await expect(userClient.uploadFile(formData)).rejects.toThrowError(
'Invalid or missing channel_id parameter in request URL.',
);
// # Set without channel ID
formData = new FormData();
formData.set('client_ids', clientId);
formData.set('files', file, filename);
await expect(userClient.uploadFile(formData)).rejects.toThrowError(
'Invalid or missing channel_id in request body.',
);
});
test('should fail on missing files', async ({pw}) => {
const clientId = pw.random.id();
// # Set with invalid channel ID
const formData = new FormData();
formData.set('channel_id', townSquareChannel.id);
formData.set('client_ids', clientId);
await expect(userClient.uploadFile(formData)).rejects.toThrowError(
'Unable to upload file(s). Have 1 client_ids for 0 files.',
);
});
test('should fail on incorrect order setting up FormData', async ({pw}) => {
const clientId = pw.random.id();
// # Set with files before client_ids
const formData = new FormData();
formData.set('channel_id', townSquareChannel.id);
formData.set('files', file, filename);
formData.set('client_ids', clientId);
await expect(userClient.uploadFile(formData)).rejects.toThrowError(
'Invalid or missing client_ids in request body.',
);
});
function validateFileUploadResponse(data: FileUploadResponse, clientId: string, userId: string, channelId: string) {
// * Validate the schema
const validate = () => FileUploadResponseSchema.parse(data);
expect(validate).not.toThrow();
// * Validate that file_infos and client_ids are as expected
expect(data.client_ids).toMatchObject([clientId]);
expect(data.file_infos.length).toBe(1);
// * Validate important contents of file_infos
const fileInfo = data.file_infos[0];
expect(fileInfo.user_id).toBe(userId);
expect(fileInfo.channel_id).toBe(channelId);
expect(fileInfo.delete_at).toBe(0);
expect(fileInfo.extension).toBe('png');
expect(fileInfo.mime_type).toBe('image/png');
expect(fileInfo.archived).toBe(false);
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* Verify that profile popover shows correct user information based on privacy settings
* and continues to show correct information after at-mention autocomplete is used.
*
* Precondition:
* 1. A test server configured with privacy settings to hide email and full name
* 2. Two user accounts, with one user able to see their own information
*/
test('Profile popover should show correct fields after at-mention autocomplete @user_profile', async ({pw}) => {
test.setTimeout(120000);
// Initialize with user's privacy settings set to hide email and full name
const {user, adminClient, team} = await pw.initSetup();
await adminClient.patchConfig({
PrivacySettings: {
ShowEmailAddress: false,
ShowFullName: false,
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.PrivacySettings?.ShowEmailAddress === false && cfg.PrivacySettings?.ShowFullName === false;
});
// Create and add another user using admin client
const testUser2 = await adminClient.createUser(await pw.random.user('other'), '', '');
await adminClient.addToTeam(team.id, testUser2.id);
// 1. Login as the first user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 2. Post a message with mentions of both users
await channelsPage.postMessage(`@${user.username} @${testUser2.username}`);
// 3. Open profile popover for the current user on first
const lastPost = await channelsPage.getLastPost();
await expect(lastPost.container).toContainText(`@${user.username}`);
await expect(lastPost.container).toContainText(`@${testUser2.username}`);
const firstMention = await lastPost.container.getByText(`@${user.username}`, {exact: true});
await firstMention.click();
const currentUserProfilePopover = channelsPage.userProfilePopover;
// * Verify all fields are visible for current user in the profile popover
await expect(currentUserProfilePopover.container.getByText(`@${user.username}`)).toBeVisible();
await expect(currentUserProfilePopover.container.getByText(`${user.first_name} ${user.last_name}`)).toBeVisible();
await expect(currentUserProfilePopover.container.getByText(user.email)).toBeVisible();
// 4. Close the current user's profile popover
await currentUserProfilePopover.close();
// 5. Open profile popover for the other user on second mention.
// Re-apply privacy settings in case a concurrent initSetup() reset them,
// then wait until the server confirms the value before proceeding.
await adminClient.patchConfig({
PrivacySettings: {
ShowEmailAddress: false,
ShowFullName: false,
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.PrivacySettings?.ShowEmailAddress === false && cfg.PrivacySettings?.ShowFullName === false;
});
// Reload the page so the browser fetches the new config synchronously.
// waitUntil above only confirms the server-side state; the browser updates its
// Redux store via WebSocket (CONFIG_CHANGED event) which can lag significantly.
// A full page reload forces a fresh /api/v4/config/client fetch, so the privacy
// settings are guaranteed to be in effect before we render any popover.
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// Re-locate the post after reload (DOM was replaced)
const lastPostAfterReload = await channelsPage.getLastPost();
await expect(lastPostAfterReload.container).toContainText(`@${testUser2.username}`);
const secondMention = lastPostAfterReload.container.getByText(`@${testUser2.username}`, {exact: true});
await secondMention.click();
const otherUserProfilePopover = channelsPage.userProfilePopover;
// * Verify only username is visible for other user in the profile popover
await expect(otherUserProfilePopover.container.getByText(`@${testUser2.username}`)).toBeVisible();
await expect(otherUserProfilePopover.container.getByText(testUser2.email)).not.toBeVisible();
// 6. Close the other user's profile popover
await otherUserProfilePopover.close();
// # Type an at-mention so autocomplete searches on each keystroke
await channelsPage.centerView.postCreate.typeMessage(`@${user.username}`);
// * Verify autocomplete suggestion appears
const suggestionList = channelsPage.centerView.postCreate.suggestionList;
await expect(suggestionList).toBeVisible();
await expect(suggestionList.getByText(`@${user.username}`)).toBeVisible();
// 8. Clear the message box and wait for the autocomplete overlay to fully
// disappear before interacting with the post — the overlay can otherwise
// block hover/click events on the message area and cause a flaky failure.
await channelsPage.centerView.postCreate.writeMessage('');
await expect(suggestionList).toBeHidden({timeout: 5000});
// 9. Open profile popover by clicking the @mention text directly (same
// approach as steps 3-4) — more reliable than openProfilePopover() which
// uses a hover-then-click sequence that the overlay can intercept.
const currentUserMention = lastPostAfterReload.container.getByText(`@${user.username}`, {exact: true});
await currentUserMention.click();
const profilePopoverAgain = channelsPage.userProfilePopover;
// * Verify all fields are still visible
await expect(profilePopoverAgain.container.getByText(`@${user.username}`)).toBeVisible();
await expect(profilePopoverAgain.container.getByText(`${user.first_name} ${user.last_name}`)).toBeVisible();
await expect(profilePopoverAgain.container.getByText(user.email)).toBeVisible();
});

View File

@@ -0,0 +1,292 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator, Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import {expect, test} from '@mattermost/playwright-lib';
/**
* E2E coverage for the Personal Access Token (PAT) expiry UI added in MM-68421.
*
* Enforcement is implied by ServiceSettings.MaximumPersonalAccessTokenLifetimeDays:
* 0 means tokens may never expire; a value > 0 requires every token to expire
* within that many days (there is no separate "enforce expiry" flag on the server).
*
* The expired-status badge is intentionally not covered here: the server rejects
* creating a token whose expiry is already in the past, so an expired token cannot
* be seeded via the API. That branch is exercised by the component unit tests.
*/
const TOKEN_ROLES = 'system_user system_user_access_token';
const DAY_MS = 24 * 60 * 60 * 1000;
// YYYY-MM-DD, n days from today in local time (matches the <input type="date"> format).
function isoPlusDays(n: number): string {
const d = new Date();
d.setDate(d.getDate() + n);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
// Enables tokens and sets the maximum lifetime, then waits for the server to report both. The
// maximum is server-wide, so every test states the value it needs rather than inheriting one.
async function setTokenPolicy(adminClient: Client4, maximumLifetimeDays: number) {
await adminClient.patchConfig({
ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: maximumLifetimeDays},
});
await expect
.poll(async () => {
const {ServiceSettings} = await adminClient.getConfig();
return {
enabled: ServiceSettings?.EnableUserAccessTokens,
maximumLifetimeDays: ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays,
};
})
.toEqual({enabled: true, maximumLifetimeDays});
}
// Open Account Settings > Security and expand the Personal Access Tokens section.
async function openTokensSection(page: Page): Promise<Locator> {
await page.locator('#userAccountMenuButton').click();
await page.getByRole('menuitem', {name: 'Profile'}).click();
const profileModal = page.getByRole('dialog', {name: 'Profile'});
await expect(profileModal).toBeVisible();
await profileModal.getByRole('tab', {name: 'Security'}).click();
await profileModal.locator('#tokensEdit').click();
return profileModal;
}
test.describe('Personal Access Tokens expiry @personal_access_tokens', () => {
test('shows the expiry picker with all presets and reveals the custom date input', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Open the Personal Access Tokens section and start creating a token
const modal = await openTokensSection(channelsPage.page);
await modal.getByRole('button', {name: 'Create Token'}).click();
// * The expiry select offers "No expiry", every preset, and a custom option
const expirySelect = modal.locator('#newTokenExpiry');
await expect(expirySelect).toBeVisible();
await expect(expirySelect.locator('option', {hasText: 'No expiry'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: '7 days'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: '30 days'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: '90 days'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: '1 year'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: /Custom date/})).toHaveCount(1);
// * The custom date input is hidden until the custom option is chosen
await expect(modal.locator('#newTokenExpiryCustom')).toBeHidden();
await expirySelect.selectOption('custom');
await expect(modal.locator('#newTokenExpiryCustom')).toBeVisible();
});
test('blocks submitting a custom expiry with no date chosen', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
await modal.getByRole('button', {name: 'Create Token'}).click();
// # Provide a description, pick the custom preset, then clear the date
await modal.locator('#newTokenDescription').fill('My token');
await modal.locator('#newTokenExpiry').selectOption('custom');
await modal.locator('#newTokenExpiryCustom').fill('');
// * The inline validation error surfaces and Save is disabled, so no token can be created
await expect(modal.getByText('An expiry date is required.')).toBeVisible();
await expect(modal.getByRole('button', {name: 'Save'})).toBeDisabled();
await expect(modal.getByText('Access Token:')).toBeHidden();
});
test('enforces expiry when a maximum lifetime is configured', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 30);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
await modal.getByRole('button', {name: 'Create Token'}).click();
// * "No expiry" and presets longer than the maximum are hidden; the enforced hint shows
const expirySelect = modal.locator('#newTokenExpiry');
await expect(expirySelect.locator('option', {hasText: 'No expiry'})).toHaveCount(0);
await expect(expirySelect.locator('option', {hasText: '90 days'})).toHaveCount(0);
await expect(expirySelect.locator('option', {hasText: '1 year'})).toHaveCount(0);
await expect(expirySelect.locator('option', {hasText: '7 days'})).toHaveCount(1);
await expect(expirySelect.locator('option', {hasText: '30 days'})).toHaveCount(1);
await expect(
modal.getByText('Your administrator requires all personal access tokens to have an expiry date.'),
).toBeVisible();
// # Choose a custom date beyond the configured maximum
await modal.locator('#newTokenDescription').fill('My token');
await expirySelect.selectOption('custom');
await modal.locator('#newTokenExpiryCustom').fill(isoPlusDays(60));
// * The over-the-limit error surfaces inline and Save is disabled
await expect(modal.getByText('Expiry can be at most 30 days from now.')).toBeVisible();
await expect(modal.getByRole('button', {name: 'Save'})).toBeDisabled();
});
test('creates a token with the default preset under a maximum lifetime', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 30);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
await modal.getByRole('button', {name: 'Create Token'}).click();
// # Accept the default preset (which equals the cap) and save
await modal.locator('#newTokenDescription').fill('My token');
await modal.getByRole('button', {name: 'Save'}).click();
// * The token is created (the server accepts the clamped expiry) and revealed
await expect(modal.getByText('Access Token:')).toBeVisible();
await expect(modal.getByText('Expiry can be at most 30 days from now.')).toBeHidden();
});
test('shows status and expiry for existing tokens', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed three tokens for the user: never-expiring, expiring soon, and disabled
await adminClient.createUserAccessToken(user.id, 'never expires token');
await adminClient.createUserAccessToken(user.id, 'expiring soon token', Date.now() + 3 * DAY_MS);
const disabledToken = await adminClient.createUserAccessToken(user.id, 'disabled token');
await adminClient.disableUserAccessToken(disabledToken.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
// * The never-expiring token is Active and shows "Never"
const neverRow = modal.locator('.setting-box__item', {hasText: 'never expires token'});
await expect(neverRow.getByText('Active')).toBeVisible();
await expect(neverRow.getByText(/Never/)).toBeVisible();
// * The soon-expiring token is Active and shows an "expires in N days" warning
const soonRow = modal.locator('.setting-box__item', {hasText: 'expiring soon token'});
await expect(soonRow.getByText('Active')).toBeVisible();
await expect(soonRow.getByText(/Expires in \d+ days?/)).toBeVisible();
// * The disabled token shows the Disabled badge
const disabledRow = modal.locator('.setting-box__item', {hasText: 'disabled token'});
await expect(disabledRow.getByText('Disabled')).toBeVisible();
});
test('regenerates a token and reveals the new secret', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed a token for the user
const originalToken = await adminClient.createUserAccessToken(user.id, 'rotate me token');
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
const tokenRow = modal.locator('.setting-box__item', {hasText: 'rotate me token'});
await expect(tokenRow.getByText(originalToken.id)).toBeVisible();
// # Click Regenerate and confirm the warning naming the token
await tokenRow.getByRole('link', {name: 'Regenerate'}).click();
const confirmModal = channelsPage.page.locator('#confirmModal');
await expect(confirmModal.getByText('Regenerate Token?')).toBeVisible();
await expect(confirmModal.getByText(/rotate me token/)).toBeVisible();
await confirmModal.getByRole('button', {name: 'Yes, Regenerate'}).click();
// * The new secret is revealed, the token keeps its id, and the old row is hidden
await expect(modal.getByText('Access Token:')).toBeVisible();
await expect(modal.getByText(originalToken.id)).toBeVisible();
await expect(tokenRow).toBeHidden();
// * The old secret no longer authenticates, the token is still enabled
const rotated = await adminClient.getUserAccessToken(originalToken.id);
expect(rotated.is_active).toBe(true);
expect(rotated.id).toBe(originalToken.id);
});
test('lets the user pick a new expiry when regenerating', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed a never-expiring token for the user
const originalToken = await adminClient.createUserAccessToken(user.id, 'expiring rotate token');
expect(originalToken.expires_at).toBe(0);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
const tokenRow = modal.locator('.setting-box__item', {hasText: 'expiring rotate token'});
// # Regenerate, choosing a 7-day expiry instead of the "no expiry" default
await tokenRow.getByRole('link', {name: 'Regenerate'}).click();
const confirmModal = channelsPage.page.locator('#confirmModal');
await confirmModal.locator('#regenerateTokenExpiry').selectOption('7d');
await confirmModal.getByRole('button', {name: 'Yes, Regenerate'}).click();
// * The rotated token now has a future expiry set
await expect(modal.getByText('Access Token:')).toBeVisible();
const rotated = await adminClient.getUserAccessToken(originalToken.id);
expect(rotated.expires_at).toBeGreaterThan(Date.now());
});
test('does not offer to regenerate a disabled token', async ({pw}) => {
test.setTimeout(120000);
const {user, adminClient} = await pw.initSetup();
await setTokenPolicy(adminClient, 0);
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
const disabledToken = await adminClient.createUserAccessToken(user.id, 'disabled token');
await adminClient.disableUserAccessToken(disabledToken.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const modal = await openTokensSection(channelsPage.page);
const disabledRow = modal.locator('.setting-box__item', {hasText: 'disabled token'});
// * No Regenerate link for a disabled token
await expect(disabledRow.getByRole('link', {name: 'Regenerate'})).toBeHidden();
});
});

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type {
PlaywrightTestArgs,
PlaywrightTestOptions,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
TestType,
} from '@playwright/test';
import type {ExtendedFixtures} from '@mattermost/playwright-lib';
import {duration, test as base} from '@mattermost/playwright-lib';
export {expect} from '@mattermost/playwright-lib';
// The server-side AI bridge mock is process-global: every test resets it and consumes from one shared
// FIFO completion queue, so AI bridge tests running concurrently (PW_WORKERS > 1) clobber each other.
// All workers share one machine, so serialize them with a cross-process file lock held per test.
//
// The lock is intentionally minimal: acquisition is a single atomic create (open with O_EXCL), and the
// lock is only ever removed by the worker that created it, on release. There is deliberately no
// stale-reclamation / hand-off path — reclaiming another worker's lock cannot be made race-free with
// filesystem primitives (it invites two workers into the critical section), so instead a lock is only
// released by its owner. Playwright runs fixture teardown even on test timeout/failure, so a held lock
// is released in every normal path; a genuinely leaked lock (hard worker crash) surfaces as a bounded
// acquire timeout on the other AI tests rather than as silent concurrent mock access.
const LOCK_PATH = path.join(os.tmpdir(), 'mm-e2e-ai-bridge-mock.lock');
// Poll interval while waiting for a held lock, and the extra budget granted for that wait so it does
// not eat into the per-test timeout.
const RETRY_INTERVAL_MS = duration.half_sec;
const LOCK_ACQUIRE_BUDGET_MS = duration.four_min;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function acquireAIBridgeLock(): Promise<() => Promise<void>> {
let handle: Awaited<ReturnType<typeof fs.open>>;
for (;;) {
try {
handle = await fs.open(LOCK_PATH, 'wx');
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
await sleep(RETRY_INTERVAL_MS);
}
}
let released = false;
return async () => {
if (released) {
return;
}
released = true;
await handle.close().catch(() => {});
await fs.rm(LOCK_PATH, {force: true});
};
}
type AIBridgeFixtures = {aiBridgeMockLock: void};
export const test: TestType<
PlaywrightTestArgs & PlaywrightTestOptions & ExtendedFixtures & AIBridgeFixtures,
PlaywrightWorkerArgs & PlaywrightWorkerOptions
> = base.extend<AIBridgeFixtures>({
aiBridgeMockLock: [
async ({}, use, testInfo) => {
const originalTimeout = testInfo.timeout;
// Raise the timeout before waiting, then hand the test its original budget once it holds
// the lock, so time spent queued does not count against the test.
const waitStart = Date.now();
testInfo.setTimeout(originalTimeout + LOCK_ACQUIRE_BUDGET_MS);
const release = await acquireAIBridgeLock();
testInfo.setTimeout(originalTimeout + (Date.now() - waitStart));
try {
await use();
} finally {
await release();
}
},
{auto: true},
],
});

View File

@@ -0,0 +1,717 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from './ai_bridge_fixture';
import {
createChannelWithManyPosts,
createRecapAndWaitForStatus,
createUnreadChannelFixture,
markAllCurrentChannelsRead,
setupRecapBridge,
waitForRecapStatus,
waitForRecordedRequestCount,
} from './recaps_helpers';
/**
* @objective Verify a user can create a selected-channels AI recap and receive the mocked summary without reloading the page
*/
test('creates selected-channels recap and auto-renders mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
const recapHighlight = `Deterministic highlight ${pw.random.id()}`;
const recapActionItem = `Deterministic action item ${pw.random.id()}`;
const recapTitle = `AI recap ${pw.random.id()}`;
const sourceMessage = `Please summarize this update ${pw.random.id()}`;
// # Initialize the test server state and configure one deterministic recap agent.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [recapActionItem],
}),
],
});
// # Seed a real unread post so the recap job has content to summarize.
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'AI Recap Selected Channel',
sourceMessage,
);
// # Login as the end user, create the recap through the selected-channels modal flow, and submit it.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectSelectedChannels();
await createRecapModal.enableRunOnce();
await createRecapModal.clickNext();
await createRecapModal.expectChannelSelectorVisible();
await createRecapModal.searchChannel(channel.display_name);
await createRecapModal.selectChannel(channel.display_name);
await createRecapModal.clickNext();
await createRecapModal.expectSummaryChannels([channel.display_name]);
await createRecapModal.startRecap();
// * Verify the recap first appears in its pending state.
const recap = recapsPage.getRecap(recapTitle);
await recap.toBeVisible();
await recap.expectProcessing();
// * Verify the recap completes and renders the mocked summary content without a manual page reload.
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
await expect(recap.container).toContainText(recapHighlight, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(recapActionItem, {timeout: pw.duration.one_min});
// * Verify the recorded bridge request used the recap_summary operation and included the unread source message.
await waitForRecordedRequestCount(pw, adminClient, 1);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequest = bridgeState.recorded_requests.find((request) => request.operation === 'recap_summary');
expect(recapRequest).toBeDefined();
expect(recapRequest?.agent_id).toBe(agent.id);
expect(recapRequest?.messages.some((message) => message.message.includes(sourceMessage))).toBe(true);
});
/**
* @objective Verify the all-unreads recap flow skips channel selection and only summarizes unread channels
*/
test('creates all-unreads recap for only unread channels', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Unread recap ${pw.random.id()}`;
const sourceMessageOne = `Unread source one ${pw.random.id()}`;
const sourceMessageTwo = `Unread source two ${pw.random.id()}`;
const highlightOne = `Unread highlight one ${pw.random.id()}`;
const highlightTwo = `Unread highlight two ${pw.random.id()}`;
// # Initialize the test server state, clear baseline unread state, and configure two deterministic recap completions.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
await markAllCurrentChannelsRead(userClient, team.id);
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [highlightOne],
actionItems: [],
}),
pw.recapCompletion({
highlights: [highlightTwo],
actionItems: [],
}),
],
});
const firstChannel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Unread recap one',
sourceMessageOne,
);
const secondChannel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Unread recap two',
sourceMessageTwo,
);
// # Open the all-unreads modal flow and advance directly to the summary step.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectAgent(agent.displayName);
await createRecapModal.selectAllUnreads();
await createRecapModal.enableRunOnce();
await createRecapModal.clickNext();
// * Verify the all-unreads flow skips the channel selector and includes only the unread channels in the summary.
await createRecapModal.expectChannelSelectorHidden();
await createRecapModal.expectSummaryChannels([firstChannel.display_name, secondChannel.display_name]);
await createRecapModal.startRecap();
// * Verify the completed recap renders summaries for both unread channels.
const recap = recapsPage.getRecap(recapTitle);
await expect(recap.container).toContainText(highlightOne, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(highlightTwo, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(firstChannel.display_name);
await expect(recap.container).toContainText(secondChannel.display_name);
// * Verify the bridge recorded one recap_summary request per unread channel and included both source messages.
await waitForRecordedRequestCount(pw, adminClient, 2);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
expect(
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageOne))),
).toBe(true);
expect(
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageTwo))),
).toBe(true);
});
/**
* @objective Verify recap creation is disabled when the AI bridge reports itself as unavailable
*/
test('disables recap creation when the bridge is unavailable', {tag: '@ai_recaps'}, async ({pw}) => {
// # Initialize the test server state and configure the recap bridge as unavailable.
const {adminClient, team, user} = await pw.initSetup();
await setupRecapBridge(pw, adminClient, {
available: false,
completions: [],
});
// # Open the recaps page as the end user.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
// * Verify the page shows the caught-up empty state and disables the Add a recap button with the expected reason.
await recapsPage.expectCaughtUpEmptyState();
await recapsPage.expectAddRecapDisabled('Agents Bridge is not enabled');
});
/**
* @objective Verify marking a recap as read moves it from the Unread tab to the Read tab
*/
test('moves a recap from unread to read when marked read', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Read state recap ${pw.random.id()}`;
const recapHighlight = `Read state highlight ${pw.random.id()}`;
const sourceMessage = `Read state source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Read state channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the unread recap and mark it as read from the recap card.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const unreadRecap = recapsPage.getRecap(recapTitle);
await unreadRecap.toBeVisible();
await unreadRecap.clickMarkRead();
// * Verify the recap disappears from the Unread tab and appears in the Read tab with the inline Mark read button removed.
await recapsPage.expectRecapNotVisible(recapTitle);
await recapsPage.switchToRead();
const readRecap = recapsPage.getRecap(recapTitle);
await readRecap.toBeVisible();
await expect(readRecap.markReadButton).not.toBeVisible();
await expect(readRecap.menuButton).toBeVisible();
});
/**
* @objective Verify deleting a recap removes it from the recaps list
*/
test('deletes a recap from the recaps page', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Delete recap ${pw.random.id()}`;
const recapHighlight = `Delete recap highlight ${pw.random.id()}`;
const sourceMessage = `Delete recap source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Delete recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, trigger the delete action, and confirm the delete modal.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.toBeVisible();
await recap.clickDelete();
await recapsPage.confirmDelete();
// * Verify the recap disappears from the list and the page returns to the caught-up empty state.
await recapsPage.expectRecapNotVisible(recapTitle);
await recapsPage.expectCaughtUpEmptyState();
});
/**
* @objective Verify regenerating a recap sends a new summary request and replaces the rendered summary with the latest mocked response
*/
test('regenerates a recap with a new mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Regenerated recap ${pw.random.id()}`;
const firstHighlight = `Original summary ${pw.random.id()}`;
const secondHighlight = `Regenerated summary ${pw.random.id()}`;
const sourceMessage = `Regenerate source ${pw.random.id()}`;
// # Initialize the test server state, queue two recap completions, and seed the first completed recap.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [firstHighlight],
actionItems: [],
}),
pw.recapCompletion({
highlights: [secondHighlight],
actionItems: [],
}),
],
});
const originalConfig = await adminClient.getConfig();
try {
await adminClient.patchConfig({
AIRecapSettings: {
EnforceCooldown: false,
},
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Regenerate recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, confirm the original summary is visible, and trigger regeneration from the recap menu.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
await recap.expectText(firstHighlight);
await recap.openMenuAction('Regenerate this recap');
// * Verify the regeneration request is recorded and then renders the regenerated summary.
await waitForRecordedRequestCount(pw, adminClient, 2);
await expect(recap.container).toContainText(secondHighlight, {timeout: pw.duration.one_min});
await expect(recap.container).not.toContainText(firstHighlight);
// * Verify two recap_summary requests were recorded for the original generation and the regeneration.
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
} finally {
await adminClient.updateConfig(originalConfig as any);
}
});
/**
* @objective Verify a failed recap renders the failed state and can recover after regeneration
*/
test('recovers a failed recap through regeneration', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Failed recap ${pw.random.id()}`;
const recoveredHighlight = `Recovered highlight ${pw.random.id()}`;
const sourceMessage = `Failed recap source ${pw.random.id()}`;
// # Initialize the test server state, queue a failing completion followed by a successful completion, and seed the failed recap.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
{error: 'deterministic recap failure', status_code: 500},
pw.recapCompletion({
highlights: [recoveredHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Failed recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'failed');
// # Open the failed recap and trigger regeneration from the recap menu.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const failedRecap = recapsPage.getRecap(recapTitle);
await failedRecap.toBeVisible();
await failedRecap.expectFailed();
await expect(failedRecap.markReadButton).not.toBeVisible();
await failedRecap.openMenuAction('Regenerate this recap');
// * Verify the failed recap returns to processing and then recovers into a completed recap.
await failedRecap.expectProcessing();
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
await recapsPage.page.reload();
await recapsPage.toBeVisible();
await failedRecap.expand();
await expect(failedRecap.container).toContainText(recoveredHighlight, {timeout: pw.duration.one_min});
});
/**
* @objective Verify recap channel card actions can mark a source channel as read and navigate back into that channel
*/
test('executes recap channel card actions', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Channel actions recap ${pw.random.id()}`;
const recapHighlight = `Channel action highlight ${pw.random.id()}`;
const sourceMessage = `Channel action source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap with one unread channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Channel action recap',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, mark the recap channel as read from the channel card, and then navigate back into the source channel.
const {page, recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
const recapChannelCard = recap.getChannelCard(channel.display_name);
await recapChannelCard.toBeVisible();
await recapChannelCard.openMenuAction('Mark this channel as read');
// * Verify the recap channel read action clears the unread count for the summarized channel.
await pw.waitUntil(
async () => {
const channelMember = await userClient.getMyChannelMember(channel.id);
return channelMember.mention_count === 0;
},
{timeout: pw.duration.one_min},
);
// # Use the channel card to navigate back into the original channel.
await recapChannelCard.clickChannelName();
// * Verify the user lands back in the source channel route.
await expect(page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`));
});
/**
* @objective Verify the server enforces MaxTokensPerRecap by trimming source posts before summarizing,
* and includes all posts when token enforcement is disabled
*/
test('enforces the per-recap token limit on the immediate recap path', {tag: '@ai_recaps'}, async ({pw}) => {
const limitedTitle = `Token limited recap ${pw.random.id()}`;
const unlimitedTitle = `Token unlimited recap ${pw.random.id()}`;
const highlight = `Token limit highlight ${pw.random.id()}`;
// # Initialize the test server state and queue one recap completion per immediate recap run.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({highlights: [highlight], actionItems: []}),
pw.recapCompletion({highlights: [highlight], actionItems: []}),
],
});
const originalConfig = await adminClient.getConfig();
// Each post is padded to ~400 characters (~100 estimated tokens at 4 chars/token), so a 150-token
// budget admits only the single newest post once enforcement is on.
const postCount = 10;
const messageLength = 400;
try {
// # Enforce a small per-recap token budget and disable the cooldown so two recaps can run back to back.
await adminClient.patchConfig({
AIRecapSettings: {
EnforceCooldown: false,
EnforceTokensPerRecap: true,
DefaultLimits: {
MaxTokensPerRecap: 150,
},
},
});
const {channel, markers} = await createChannelWithManyPosts(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Token limit channel',
postCount,
messageLength,
);
// # Run an immediate recap with token enforcement ON.
const limitedRecap = await createRecapAndWaitForStatus(
pw,
userClient,
limitedTitle,
[channel.id],
agent.id,
'completed',
);
// * Verify the server trimmed the source posts: fewer than were created are recorded on the recap channel.
const limitedChannel = limitedRecap.channels?.find((recapChannel) => recapChannel.channel_id === channel.id);
expect(limitedChannel).toBeDefined();
const limitedCount = limitedChannel?.source_post_ids.length ?? 0;
expect(limitedCount).toBeGreaterThan(0);
expect(limitedCount).toBeLessThan(postCount);
// # Disable token enforcement and run a second immediate recap over the same channel.
await adminClient.patchConfig({
AIRecapSettings: {
EnforceCooldown: false,
EnforceTokensPerRecap: false,
},
});
const unlimitedRecap = await createRecapAndWaitForStatus(
pw,
userClient,
unlimitedTitle,
[channel.id],
agent.id,
'completed',
);
// * Verify all created posts are recorded when enforcement is off, and that it exceeds the trimmed count.
const unlimitedChannel = unlimitedRecap.channels?.find(
(recapChannel) => recapChannel.channel_id === channel.id,
);
expect(unlimitedChannel).toBeDefined();
const unlimitedCount = unlimitedChannel?.source_post_ids.length ?? 0;
expect(unlimitedCount).toBeGreaterThanOrEqual(postCount);
expect(limitedCount).toBeLessThan(unlimitedCount);
// * Verify the recorded LLM payloads corroborate the trimming: fewer seeded posts reached the bridge
// for the enforced recap than for the unenforced one.
await waitForRecordedRequestCount(pw, adminClient, 2);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
const countMarkersInRequest = (request: (typeof recapRequests)[number]) => {
const text = request.messages.map((message) => message.message).join('\n');
return markers.filter((marker) => text.includes(marker)).length;
};
const limitedMarkers = countMarkersInRequest(recapRequests[0]);
const unlimitedMarkers = countMarkersInRequest(recapRequests[1]);
expect(limitedMarkers).toBeGreaterThan(0);
expect(limitedMarkers).toBeLessThan(postCount);
expect(unlimitedMarkers).toBe(postCount);
expect(limitedMarkers).toBeLessThan(unlimitedMarkers);
} finally {
await adminClient.updateConfig(originalConfig as any);
}
});
/**
* @objective Verify MaxTokensPerRecap is enforced as an independent per-channel cap: a single recap
* spanning two channels trims each channel's source posts to its own budget rather than sharing one
* cross-channel budget
*/
test('enforces the per-recap token limit independently per channel', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Per-channel token recap ${pw.random.id()}`;
const highlight = `Per-channel token highlight ${pw.random.id()}`;
// # Initialize the test server state and queue one recap completion per channel summarized.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({highlights: [highlight], actionItems: []}),
pw.recapCompletion({highlights: [highlight], actionItems: []}),
],
});
const originalConfig = await adminClient.getConfig();
// Each post is padded to ~400 characters (~100 estimated tokens), so a 150-token per-channel
// budget admits only the single newest post in each channel once enforcement is on.
const postCount = 6;
const messageLength = 400;
try {
// # Enforce a small per-channel token budget; disable cooldown so the immediate recap runs cleanly.
await adminClient.patchConfig({
AIRecapSettings: {
EnforceCooldown: false,
EnforceTokensPerRecap: true,
DefaultLimits: {
MaxTokensPerRecap: 150,
},
},
});
const first = await createChannelWithManyPosts(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Per-channel token channel one',
postCount,
messageLength,
);
const second = await createChannelWithManyPosts(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Per-channel token channel two',
postCount,
messageLength,
);
// # Run a single immediate recap covering both seeded channels.
const recap = await createRecapAndWaitForStatus(
pw,
userClient,
recapTitle,
[first.channel.id, second.channel.id],
agent.id,
'completed',
);
// * Verify each channel was trimmed independently to its own per-channel budget.
const firstChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === first.channel.id);
const secondChannel = recap.channels?.find((recapChannel) => recapChannel.channel_id === second.channel.id);
expect(firstChannel).toBeDefined();
expect(secondChannel).toBeDefined();
const firstCount = firstChannel?.source_post_ids.length ?? 0;
const secondCount = secondChannel?.source_post_ids.length ?? 0;
expect(firstCount).toBeGreaterThan(0);
expect(firstCount).toBeLessThan(postCount);
expect(secondCount).toBeGreaterThan(0);
expect(secondCount).toBeLessThan(postCount);
// * Verify the cap is per channel, not a shared cross-channel budget: a shared 150-token budget
// could only retain posts in one channel (exhausting it before the second), so both channels
// retaining posts means the combined kept count exceeds any single channel's budget.
expect(firstCount + secondCount).toBeGreaterThan(firstCount);
expect(firstCount + secondCount).toBeGreaterThan(secondCount);
// * Verify the recorded LLM payloads corroborate per-channel trimming: one request per channel,
// each carrying only its own trimmed subset of seeded markers.
await waitForRecordedRequestCount(pw, adminClient, 2);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
const countMarkers = (markers: string[]) => {
const text = recapRequests
.flatMap((request) => request.messages.map((message) => message.message))
.join('\n');
return markers.filter((marker) => text.includes(marker)).length;
};
const firstMarkers = countMarkers(first.markers);
const secondMarkers = countMarkers(second.markers);
expect(firstMarkers).toBe(firstCount);
expect(secondMarkers).toBe(secondCount);
} finally {
await adminClient.updateConfig(originalConfig as any);
}
});

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
type RecapBridgeSetup = {
agent: {
id: string;
displayName: string;
username: string;
};
service: {
id: string;
name: string;
type: string;
};
};
export async function setupRecapBridge(
pw: PlaywrightExtended,
adminClient: Client4,
{
available = true,
completions,
}: {
available?: boolean;
completions: Array<{completion?: string; error?: string; status_code?: number}>;
},
): Promise<RecapBridgeSetup> {
await pw.enableAIBridgeTestMode(adminClient, {enableRecaps: true});
await pw.resetAIBridgeMock(adminClient);
const {agent, service} = await pw.createMockAIAgent(adminClient, {
agent: {
id: `recap-agent-${pw.random.id()}`,
displayName: 'Recap Summary Agent',
username: `recap.summary.${pw.random.id()}`,
is_default: true,
},
service: {
id: `recap-service-${pw.random.id()}`,
name: 'Recap Summary Service',
type: 'anthropic',
},
});
await pw.configureAIBridgeMock(adminClient, {
status: {available},
agents: [agent],
services: [service],
agent_completions: {
recap_summary: completions,
},
record_requests: true,
});
return {agent, service};
}
export async function createUnreadChannelFixture(
pw: PlaywrightExtended,
adminClient: Client4,
adminUserId: string,
userId: string,
teamId: string,
displayName: string,
sourceMessage: string,
) {
const channel = await adminClient.createChannel(
pw.random.channel({
teamId,
name: `recap${pw.random.id()}`,
displayName,
unique: false,
}),
);
await adminClient.addToChannel(userId, channel.id);
await adminClient.createPost({
channel_id: channel.id,
user_id: adminUserId,
message: sourceMessage,
});
return channel;
}
// createChannelWithManyPosts seeds a channel (with the user added as a member) and posts `postCount`
// admin messages, each padded to roughly `messageLength` characters and tagged with a unique marker so
// tests can detect exactly which posts reached the recap. Posts are returned in creation order
// (oldest first); markers[i] corresponds to posts[i].
export async function createChannelWithManyPosts(
pw: PlaywrightExtended,
adminClient: Client4,
adminUserId: string,
userId: string,
teamId: string,
displayName: string,
postCount: number,
messageLength: number,
) {
const channel = await adminClient.createChannel(
pw.random.channel({
teamId,
name: `recap${pw.random.id()}`,
displayName,
unique: false,
}),
);
await adminClient.addToChannel(userId, channel.id);
const runId = pw.random.id();
const posts = [];
const markers: string[] = [];
for (let index = 0; index < postCount; index++) {
const marker = `RTM-${runId}-${index}`;
const padding = 'x'.repeat(Math.max(0, messageLength - marker.length - 1));
const message = `${marker} ${padding}`;
const post = await adminClient.createPost({
channel_id: channel.id,
user_id: adminUserId,
message,
});
posts.push(post);
markers.push(marker);
}
return {channel, posts, markers};
}
export async function createRecapAndWaitForStatus(
pw: PlaywrightExtended,
userClient: Client4,
recapTitle: string,
channelIds: string[],
agentId: string,
expectedStatus: string,
) {
const recap = await userClient.createRecap({
title: recapTitle,
channel_ids: channelIds,
agent_id: agentId,
});
await pw.waitUntil(
async () => {
const currentRecap = await userClient.getRecap(recap.id);
return currentRecap.status === expectedStatus;
},
{timeout: pw.duration.one_min},
);
return userClient.getRecap(recap.id);
}
export async function waitForRecapStatus(
pw: PlaywrightExtended,
userClient: Client4,
recapTitle: string,
expectedStatus: string,
) {
await pw.waitUntil(
async () => {
const recaps = await userClient.getRecaps(0, 60);
return recaps.some((recap) => recap.title === recapTitle && recap.status === expectedStatus);
},
{timeout: pw.duration.one_min},
);
}
export async function waitForRecordedRequestCount(pw: PlaywrightExtended, adminClient: Client4, requestCount: number) {
await pw.waitUntil(
async () => {
const bridgeState = await pw.getAIBridgeMock(adminClient);
return (
bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary').length ===
requestCount
);
},
{timeout: pw.duration.one_min},
);
}
export async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) {
const currentChannels = await userClient.getMyChannels(teamId);
await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id));
}

View File

@@ -0,0 +1,400 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from './ai_bridge_fixture';
import {createUnreadChannelFixture, setupRecapBridge} from './recaps_helpers';
const MONDAY = 1 << 1;
const WEDNESDAY = 1 << 3;
/**
* @objective Verify a user can create, list, pause, and resume a scheduled AI recap
*/
test('creates and manages a scheduled recap', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Scheduled recap ${pw.random.id()}`;
const sourceMessage = `Scheduled recap source ${pw.random.id()}`;
// # Initialize the test server state, configure a deterministic recap agent, and seed a channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Scheduled recap channel',
sourceMessage,
);
// # Create a selected-channel scheduled recap through the modal flow.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectSelectedChannels();
await createRecapModal.clickNext();
await createRecapModal.expectChannelSelectorVisible();
await createRecapModal.searchChannel(channel.display_name);
await createRecapModal.selectChannel(channel.display_name);
await createRecapModal.clickNext();
await createRecapModal.expectScheduleConfigurationVisible();
await createRecapModal.selectScheduleDay('M');
await createRecapModal.createSchedule();
// * Verify the scheduled recap is listed with stable schedule structure and active state.
await recapsPage.toBeVisible();
await recapsPage.switchToScheduled();
const scheduledRecap = recapsPage.getScheduledRecap(recapTitle);
await scheduledRecap.toBeVisible();
await scheduledRecap.expectSchedulePattern(/Mon|Monday/);
await scheduledRecap.expectSchedulePattern(/ at /);
await scheduledRecap.expectActive();
// * Verify pause and resume update both the UI and the stored scheduled recap.
await scheduledRecap.pause();
await scheduledRecap.resume();
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
const createdRecap = scheduledRecaps.find((recap) => recap.title === recapTitle);
expect(createdRecap).toBeDefined();
expect(createdRecap?.agent_id).toBe(agent.id);
expect(createdRecap?.channel_ids).toContain(channel.id);
expect(createdRecap?.days_of_week).toBe(MONDAY);
expect(createdRecap?.enabled).toBe(true);
});
/**
* @objective Verify the scheduled recap limit rejects creating more active scheduled recaps than configured
*/
test('rejects scheduled recap creation after the active schedule limit', {tag: '@ai_recaps'}, async ({pw}) => {
const titlePrefix = `Limited scheduled recap ${pw.random.id()}`;
const sourceMessage = `Scheduled limit source ${pw.random.id()}`;
// # Initialize an isolated user, configure the recap bridge, and set a one-scheduled-recap limit.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [],
});
const originalConfig = await adminClient.getConfig();
try {
await adminClient.patchConfig({
AIRecapSettings: {
EnforceScheduledRecaps: true,
DefaultLimits: {
MaxScheduledRecaps: 1,
},
},
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Scheduled limit recap channel',
sourceMessage,
);
const scheduledRecapInput = {
days_of_week: WEDNESDAY,
time_of_day: '09:00',
timezone: 'UTC',
time_period: 'last_24h' as const,
channel_mode: 'specific' as const,
channel_ids: [channel.id],
agent_id: agent.id,
is_recurring: true,
};
const allowedRecap = await userClient.createScheduledRecap({
...scheduledRecapInput,
title: `${titlePrefix} allowed`,
});
let blockedError: {status_code?: number; message?: string} | undefined;
try {
await userClient.createScheduledRecap({
...scheduledRecapInput,
title: `${titlePrefix} blocked`,
});
} catch (error) {
blockedError = error as {status_code?: number; message?: string};
}
// * Verify the API rejects the over-limit schedule without creating the second recap.
expect(blockedError?.status_code).toBe(400);
expect(blockedError?.message).toContain('scheduled recaps');
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
expect(scheduledRecaps.some((recap) => recap.id === allowedRecap.id)).toBe(true);
expect(scheduledRecaps.some((recap) => recap.title === `${titlePrefix} blocked`)).toBe(false);
} finally {
await adminClient.updateConfig(originalConfig as any);
}
});
/**
* @objective Verify a user can edit a scheduled recap's title and schedule days through the Options menu
*/
test('edits a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => {
const originalTitle = `Editable scheduled recap ${pw.random.id()}`;
const updatedTitle = `Edited scheduled recap ${pw.random.id()}`;
const sourceMessage = `Edit scheduled source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Edit scheduled recap channel',
sourceMessage,
);
// # Create a Monday-only scheduled recap through the API so the edit flow starts from a known state.
const createdRecap = await userClient.createScheduledRecap({
title: originalTitle,
days_of_week: MONDAY,
time_of_day: '09:00',
timezone: 'UTC',
time_period: 'last_24h',
channel_mode: 'specific',
channel_ids: [channel.id],
agent_id: agent.id,
is_recurring: true,
});
// # Open the Scheduled tab and launch the edit modal from the recap's Options menu.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
await recapsPage.switchToScheduled();
const scheduledRecap = recapsPage.getScheduledRecap(originalTitle);
await scheduledRecap.toBeVisible();
await scheduledRecap.editViaMenu();
// # Update the title on the first step and add Wednesday on the schedule step of the pre-filled modal.
const editModal = recapsPage.createRecapModal;
await editModal.toBeVisible();
await editModal.fillTitle(updatedTitle);
await editModal.clickNext();
await editModal.expectChannelSelectorVisible();
await editModal.clickNext();
await editModal.expectScheduleConfigurationVisible();
await editModal.selectScheduleDay('W');
await editModal.saveChanges();
// * Verify the list reflects the new title and the dropped original title, and shows the added day.
await recapsPage.toBeVisible();
const updatedRecap = recapsPage.getScheduledRecap(updatedTitle);
await updatedRecap.toBeVisible();
await updatedRecap.expectSchedulePattern(/Wed|Wednesday/);
await recapsPage.expectScheduledRecapNotVisible(originalTitle);
// * Verify the persisted scheduled recap carries the updated title and the Monday+Wednesday schedule.
await pw.waitUntil(
async () => {
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle);
return Boolean(persisted) && persisted?.days_of_week === (MONDAY | WEDNESDAY);
},
{timeout: pw.duration.one_min},
);
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
expect(scheduledRecaps.some((recap) => recap.title === originalTitle)).toBe(false);
const persisted = scheduledRecaps.find((recap) => recap.title === updatedTitle);
expect(persisted).toBeDefined();
expect(persisted?.days_of_week).toBe(MONDAY | WEDNESDAY);
expect(persisted?.channel_ids).toContain(channel.id);
// * Verify the edit updated the existing recap in place (same id) rather than recreating it, and
// that fields untouched by the edit (time_of_day) survived.
expect(persisted?.id).toBe(createdRecap.id);
expect(persisted?.time_of_day).toBe('09:00');
});
/**
* @objective Verify a user can delete a scheduled recap through the Options menu and confirm dialog
*/
test('deletes a scheduled recap through the options menu', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Deletable scheduled recap ${pw.random.id()}`;
const sourceMessage = `Delete scheduled source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Delete scheduled recap channel',
sourceMessage,
);
// # Create a scheduled recap through the API so the UI delete flow has something to remove.
const createdRecap = await userClient.createScheduledRecap({
title: recapTitle,
days_of_week: MONDAY,
time_of_day: '09:00',
timezone: 'UTC',
time_period: 'last_24h',
channel_mode: 'specific',
channel_ids: [channel.id],
agent_id: agent.id,
is_recurring: true,
});
// # Open the Scheduled tab, trigger Delete from the Options menu, and confirm the dialog.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
await recapsPage.switchToScheduled();
const scheduledRecap = recapsPage.getScheduledRecap(recapTitle);
await scheduledRecap.toBeVisible();
await scheduledRecap.deleteViaMenu();
await recapsPage.confirmDelete();
// * Verify the recap disappears from the list and the empty state returns.
await recapsPage.expectScheduledRecapNotVisible(recapTitle);
await recapsPage.expectScheduledEmptyState();
// * Verify the scheduled recap is no longer returned by the API.
await pw.waitUntil(
async () => {
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
return !scheduledRecaps.some((recap) => recap.id === createdRecap.id);
},
{timeout: pw.duration.one_min},
);
});
/**
* @objective Verify a fresh user with no scheduled recaps sees the scheduled empty-state call to action
*/
test('shows the scheduled empty state for a user with no scheduled recaps', {tag: '@ai_recaps'}, async ({pw}) => {
// # Initialize the test server state and configure the recap bridge so the page renders fully.
const {adminClient, team, user} = await pw.initSetup();
await setupRecapBridge(pw, adminClient, {
completions: [],
});
// # Open the recaps page and switch to the Scheduled tab.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
await recapsPage.switchToScheduled();
// * Verify the scheduled empty state heading, description, and create CTA are shown.
await recapsPage.expectScheduledEmptyState();
// * Verify the empty-state CTA is wired up: clicking it opens the create recap modal.
const createRecapModal = await recapsPage.openCreateRecapFromScheduledEmptyState();
await createRecapModal.toBeVisible();
});
/**
* @objective Verify the all-unreads scheduled flow skips channel selection and stores the all_unreads mode
*/
test('creates an all-unreads scheduled recap through the modal', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `All unreads scheduled recap ${pw.random.id()}`;
const sourceMessage = `All unreads scheduled source ${pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed an unread channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [],
});
await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'All unreads scheduled channel',
sourceMessage,
);
// # Open the modal, choose all-unreads mode, and advance directly to the schedule step.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectAllUnreads();
await createRecapModal.clickNext();
// * Verify the all-unreads flow skips the channel selector and lands on schedule configuration.
await createRecapModal.expectChannelSelectorHidden();
await createRecapModal.expectScheduleConfigurationVisible();
await createRecapModal.selectScheduleDay('M');
await createRecapModal.createSchedule();
// * Verify the scheduled recap is listed.
await recapsPage.toBeVisible();
await recapsPage.switchToScheduled();
await recapsPage.getScheduledRecap(recapTitle).toBeVisible();
// * Verify the persisted scheduled recap uses the all_unreads channel mode and is active.
await pw.waitUntil(
async () => {
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
return scheduledRecaps.some((recap) => recap.title === recapTitle);
},
{timeout: pw.duration.one_min},
);
const scheduledRecaps = await userClient.getScheduledRecaps(0, 60);
const persisted = scheduledRecaps.find((recap) => recap.title === recapTitle);
expect(persisted).toBeDefined();
expect(persisted?.channel_mode).toBe('all_unreads');
expect(persisted?.agent_id).toBe(agent.id);
expect(persisted?.days_of_week).toBe(MONDAY);
expect(persisted?.enabled).toBe(true);
// * Verify the all_unreads contract is fully pinned: no specific channels are persisted.
expect(persisted?.channel_ids ?? []).toHaveLength(0);
});

View File

@@ -0,0 +1,83 @@
// 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 after archiving a channel, neither the center message box nor the RHS reply box is available.
*/
test('MM-T1716 hides center and RHS message boxes in an archived channel', {tag: '@channels'}, async ({pw}) => {
// # Create and log in as a test user, then open a new channel
const {user, team, adminClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, 'Archive Read Only');
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Post a message and open its thread in the RHS
await channelsPage.postMessage('Test archive reply');
const post = await channelsPage.getLastPost();
const postId = await post.getId();
await post.reply();
// * Verify the RHS and its reply box are visible before archiving
await channelsPage.sidebarRight.toBeVisible();
await expect(channelsPage.sidebarRight.postCreate.input).toBeVisible();
// # Archive the channel
await channelsPage.archiveChannel();
// * Verify the center message box is no longer available
await expect(channelsPage.centerView.postCreate.input).not.toBeVisible();
// * Verify the RHS is closed after archiving
await expect(channelsPage.sidebarRight.container).not.toBeVisible();
// # Re-open the thread from the archived post (the last post is now the archive system message)
const archivedPost = await channelsPage.centerView.getPostById(postId);
await archivedPost.reply();
// * Verify the RHS reopens but the reply box is not available
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();
});

View File

@@ -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();
});

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {SystemConsolePage} from '@mattermost/playwright-lib';
import {
enableAutotranslationConfig,
disableAutotranslationConfig,
hasAutotranslationLicense,
expect,
test,
} from '@mattermost/playwright-lib';
test(
'permission exists; Channel Administrators have Manage Channel Auto Translation ON',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await gotoSystemSchemePage(systemConsolePage);
const scheme = systemConsolePage.permissionsSystemScheme;
await scheme.toBeVisible();
await scheme.expectManageChannelAutoTranslationChecked(scheme.channelAdministratorsSection);
},
);
test(
'System Administrators have Manage Channel Auto Translation ON',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await gotoSystemSchemePage(systemConsolePage);
const scheme = systemConsolePage.permissionsSystemScheme;
await scheme.toBeVisible();
await scheme.expectManageChannelAutoTranslationChecked(scheme.systemAdministratorsSection);
},
);
test.describe('autotranslation configuration tests', () => {
test(
'DM menu follows effective RestrictDMAndGM client config',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const originalConfig = await adminClient.getConfig();
try {
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
targetLanguages: ['en', 'es'],
});
await expect.poll(async () => (await adminClient.getClientConfig()).EnableAutoTranslation).toBe('true');
const restrictDMAndGM = (await adminClient.getClientConfig()).RestrictDMAndGMAutotranslation;
const shouldShowEditHeader = restrictDMAndGM === 'true';
const peerData = await pw.random.user('dm-peer');
const peerUser = await adminClient.createUser(peerData, '', '');
await adminClient.addToTeam(team.id, peerUser.id);
await adminClient.createDirectChannel([user.id, peerUser.id]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, `@${peerUser.username}`);
await channelsPage.toBeVisible();
await channelsPage.centerView.header.openChannelMenu();
if (shouldShowEditHeader) {
await expect(channelsPage.page.getByRole('menuitem', {name: 'Edit Header'})).toBeVisible();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Channel Settings'})).toHaveCount(0);
} else {
await expect(channelsPage.page.getByRole('menuitem', {name: 'Channel Settings'})).toBeVisible();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Edit Header'})).toHaveCount(0);
}
} finally {
await adminClient.updateConfig(originalConfig as any);
}
},
);
test(
'DM menu shows Edit Header when RestrictDMAndGM is enabled',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const originalConfig = await adminClient.getConfig();
try {
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
targetLanguages: ['en', 'es'],
});
const configAfterEnable = (await adminClient.getConfig()) as any;
await adminClient.updateConfig({
...configAfterEnable,
AutoTranslationSettings: {
...configAfterEnable.AutoTranslationSettings,
RestrictDMAndGM: true,
},
} as any);
await expect
.poll(
async () => ((await adminClient.getConfig()) as any)?.AutoTranslationSettings?.RestrictDMAndGM,
)
.toBe(true);
await expect.poll(async () => (await adminClient.getClientConfig()).EnableAutoTranslation).toBe('true');
await expect
.poll(async () => (await adminClient.getClientConfig()).RestrictDMAndGMAutotranslation)
.toBe('true');
const peerData = await pw.random.user('dm-peer-restricted');
const peerUser = await adminClient.createUser(peerData, '', '');
await adminClient.addToTeam(team.id, peerUser.id);
await adminClient.createDirectChannel([user.id, peerUser.id]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, `@${peerUser.username}`);
await channelsPage.toBeVisible();
await channelsPage.centerView.header.openChannelMenu();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Edit Header'})).toBeVisible();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Channel Settings'})).toHaveCount(0);
} finally {
await adminClient.updateConfig(originalConfig as any);
}
},
);
test(
'DM header saves from Channel Settings',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
targetLanguages: ['en', 'es'],
});
const otherUserData = await pw.random.user('dm-peer');
const otherUser = await adminClient.createUser(otherUserData, '', '');
await adminClient.addToTeam(team.id, otherUser.id);
const dmChannel = await adminClient.createDirectChannel([user.id, otherUser.id]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, `@${otherUser.username}`);
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.toBeVisible();
const newHeader = `DM header ${pw.random.id()}`;
await channelsPage.page.getByTestId('channel_settings_header_textbox').fill(newHeader);
await channelsPage.page.getByRole('button', {name: 'Save'}).click();
await channelSettingsModal.close();
await expect.poll(async () => (await adminClient.getChannel(dmChannel.id)).header).toBe(newHeader);
},
);
test(
'user without permission cannot enable autotranslation at channel level',
{
tag: ['@autotranslation', '@permissions'],
},
async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
// Enable autotranslation
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010',
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-perm-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Permission Test Channel',
type: 'O',
});
await adminClient.addToChannel(user.id, created.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
const configTabVisible = await channelSettingsModal.configurationTab.isVisible();
if (configTabVisible) {
const configurationTab = await channelSettingsModal.openConfigurationTab();
await expect(
configurationTab.container.getByTestId('channelTranslationToggle-button'),
).not.toBeVisible();
}
// Restore autotranslation to disabled via patchConfig (race-safe)
await disableAutotranslationConfig(adminClient);
},
);
});
async function gotoSystemSchemePage(systemConsolePage: SystemConsolePage) {
await systemConsolePage.sidebar.userManagement.permissions.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/user_management\/permissions/);
await systemConsolePage.page.getByRole('link', {name: 'Edit Scheme'}).click();
await systemConsolePage.page.waitForURL(/\/admin_console\/user_management\/permissions\/system_scheme/);
}

View File

@@ -0,0 +1,516 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
ChannelsPost,
disableAutotranslationConfig,
enableAutotranslationConfig,
enableChannelAutotranslation,
getAdminClient,
hasAutotranslationLicense,
setUserChannelAutotranslation,
expect,
test,
setMockSourceLanguage,
} from '@mattermost/playwright-lib';
// Autotranslation tests involve real UI interactions with plugin state and can run
// longer than the default 60 s in loaded CI. Set per-test timeout to 2 minutes.
test.beforeEach(async () => {
test.setTimeout(120000);
});
// Disable AutoTranslationSettings at end of file so leftover state cannot leak
// into other suites. Individual tests enable the feature via
// enableAutotranslationConfig() as needed.
test.afterAll(async () => {
try {
const {adminClient} = await getAdminClient({skipLog: true});
await disableAutotranslationConfig(adminClient);
} catch {
// Best-effort cleanup.
}
});
test(
'translated message has indicator; click opens Show Translation modal',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-modal-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Show Translation Modal Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
// Re-apply config immediately before posting so the server translates this message.
// A concurrent initSetup() can reset AutoTranslationSettings.Enable to false.
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
// Confirm Enable=true before posting — translation happens at creation time,
// so the config must be confirmed before posts are submitted.
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
// Verify the mock translation service is reachable before attempting to use it.
try {
await fetch(translationUrl, {signal: AbortSignal.timeout(3000)});
} catch {
test.skip(
true,
`Mock translation service not reachable at ${translationUrl}. ` +
'Start the service or set TRANSLATION_SERVICE_URL to run this test.',
);
return;
}
// Set Spanish source so the mock returns source='es', triggering es→en translation.
await setMockSourceLanguage(translationUrl, 'es');
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
// Create a second poster to show translation indicator (only visible with multiple users)
const poster2 = await pw.random.user('poster2');
const createdPoster2 = await adminClient.createUser(poster2, '', '');
await adminClient.addToTeam(team.id, createdPoster2.id);
await adminClient.addToChannel(createdPoster2.id, created.id);
const {client: posterClient2} = await pw.makeClient({
username: poster2.username,
password: poster2.password,
});
if (!posterClient2) {
throw new Error('Failed to create second poster client');
}
// Post Spanish message that's long enough for reliable detection
await posterClient.createPost({
channel_id: created.id,
message: 'Este es un texto para la modal de traducción automática que debe ser lo suficientemente largo',
user_id: createdPoster.id,
});
// Second user posts a message so the first user's translation indicator appears
await posterClient2.createPost({
channel_id: created.id,
message: 'Segundo usuario con mensaje más largo para mejor detección de idioma',
user_id: createdPoster2.id,
});
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Re-apply config + reload to counter concurrent initSetup() resets.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// Post-reload re-apply: a concurrent initSetup() may have reset
// Enable during the ~500ms reload window.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
// * Wait for post by searching for the Spanish original (mock appends "[translated to en]", no real translation)
const modalPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: 'Este es un texto para la modal de traducción automática'});
await expect(modalPost).toBeVisible({timeout: 15000});
// * Check for translation button - if it exists, click it and verify modal
const translationButton = modalPost.getByRole('button', {name: 'This post has been translated'});
const hasTranslationButton = (await translationButton.count()) > 0;
// Translation button should be present - test expects translation to happen
if (!hasTranslationButton) {
throw new Error(
'Translation button not found on post. Expected autotranslation to produce a translated message indicator.',
);
}
// Translation happened - verify the modal opens
await translationButton.click();
const showTranslationDialog = page.getByRole('dialog').filter({hasText: 'Show Translation'});
await expect(showTranslationDialog).toBeVisible();
await expect(showTranslationDialog.getByText('ORIGINAL')).toBeVisible();
await expect(showTranslationDialog.getByText('AUTO-TRANSLATED')).toBeVisible();
},
);
test(
'message actions include Show translation',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-dotmenu-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Dot Menu Show Translation Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
// Create a second poster to show translation indicator (only visible with multiple users)
const poster2 = await pw.random.user('poster2');
const createdPoster2 = await adminClient.createUser(poster2, '', '');
await adminClient.addToTeam(team.id, createdPoster2.id);
await adminClient.addToChannel(createdPoster2.id, created.id);
const {client: posterClient2} = await pw.makeClient({
username: poster2.username,
password: poster2.password,
});
if (!posterClient2) {
throw new Error('Failed to create second poster client');
}
// Re-apply config immediately before posting so the server translates this message.
// A concurrent initSetup() can reset AutoTranslationSettings.Enable to false.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
// Confirm Enable=true before posting — translation happens at creation time,
// so the config must be confirmed before posts are submitted.
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
// Verify the mock translation service is reachable before attempting to use it.
// setMockSourceLanguage() swallows connection errors internally, so we probe the
// service directly. If it is not running, skip rather than fail — the service is
// an external dependency started by CI but not typically present in local runs.
try {
await fetch(translationUrl, {signal: AbortSignal.timeout(3000)});
} catch {
test.skip(
true,
`Mock translation service not reachable at ${translationUrl}. ` +
'Start the service or set TRANSLATION_SERVICE_URL to run this test.',
);
return;
}
// Set Spanish source so the mock returns source='es', triggering es→en translation.
await setMockSourceLanguage(translationUrl, 'es');
// Post Spanish message that's long enough for reliable detection.
await posterClient.createPost({
channel_id: created.id,
message: 'Este mensaje es para probar el menú de acciones con la opción de mostrar traducción automática',
user_id: createdPoster.id,
});
// Second post ensures the first post's translation indicator is rendered
// (the UI only renders it for posts that are not the last in the channel).
await posterClient2.createPost({
channel_id: created.id,
message: 'Segundo usuario con mensaje más largo para mejor detección de idioma',
user_id: createdPoster2.id,
});
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Pre-reload re-apply: ensure Enable=true before the page loads so the client
// fetches posts with translations active.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// Post-reload re-apply: a concurrent initSetup() on another shard may have reset
// Enable during the ~500ms reload window. Re-confirm before badge check.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
// * Wait for the channel-level autotranslation badge — confirms the feature is active.
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
// * Wait for the translated target post to appear.
// The mock appends "[translated to en]" to the original Spanish text, confirming
// translation.state==='ready' so that "Show translation" will be in the dot menu.
const messagePost = channelsPage.centerView.container
.getByTestId('postView')
.filter({hasText: 'Este mensaje es para probar el menú de acciones'});
await expect(messagePost.getByText(/\[translated to en\]/i)).toBeVisible({timeout: 15000});
// * Open dot menu using the established hover → wait → click pattern
const post = new ChannelsPost(messagePost);
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.dotMenuButton.click();
// Move mouse away so it doesn't hover over Remind and trigger its submenu.
// The submenu's MUI portal sets aria-hidden on the main menu, breaking getByRole.
await page.mouse.move(0, 0);
await channelsPage.postDotMenu.toBeVisible();
// * Verify the "Show translation" menu item is present
await expect(channelsPage.postDotMenu.showTranslationMenuItem).toBeVisible({timeout: 10000});
},
);
test(
'any user can disable and enable again autotranslation for themselves in a channel',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-toggle-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Autotranslation Toggle Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Defeat concurrent initSetup() config resets: re-apply on every poll iteration until badge appears.
await expect
.poll(
async () => {
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
return channelsPage.centerView.autotranslationBadge.isVisible();
},
{timeout: 45000, intervals: [2000]},
)
.toBeTruthy();
await channelsPage.centerView.header.openChannelMenu();
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible();
// Re-apply config before opening menu to ensure "Enable autotranslation" option is present.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await channelsPage.centerView.header.openChannelMenu();
await page.getByRole('menuitem', {name: 'Enable autotranslation'}).click();
// Poll with config re-apply until badge reappears.
await expect
.poll(
async () => {
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
return channelsPage.centerView.autotranslationBadge.isVisible();
},
{timeout: 45000, intervals: [2000]},
)
.toBeTruthy();
},
);
test(
'autotranslation badge is only visible on translated channels',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const translatedChannelName = `autotranslation-badge-${pw.random.id()}`;
const translatedChannel = await adminClient.createChannel({
team_id: team.id,
name: translatedChannelName,
display_name: 'Translated Channel',
type: 'O',
});
await enableChannelAutotranslation(adminClient, translatedChannel.id);
await adminClient.addToChannel(user.id, translatedChannel.id);
await setUserChannelAutotranslation(userClient, translatedChannel.id, true);
const noTranslationChannelName = `no-translation-${pw.random.id()}`;
const noTranslationChannel = await adminClient.createChannel({
team_id: team.id,
name: noTranslationChannelName,
display_name: 'No Translation Channel',
type: 'O',
});
await adminClient.addToChannel(user.id, noTranslationChannel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, translatedChannelName);
await channelsPage.toBeVisible();
// Re-apply config + reload to counter concurrent initSetup() resets.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
// Post-reload re-apply: firing the CONFIG_CHANGED WebSocket event during page load
// rather than after prevents it from disrupting the badge render.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.toBeVisible();
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
await channelsPage.goto(team.name, noTranslationChannelName);
await channelsPage.toBeVisible();
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible();
},
);
test(
'unsupported language does not show channel badge and shows message in channel header menu',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-unsupported-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Unsupported Language Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await userClient.patchMe({locale: 'fr'});
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Re-apply config + reload to ensure the browser reads the latest AutoTranslationSettings.
// The badge should still be absent (French locale is not in targetLanguages), but the
// server config must be active so the channel header menu shows the "unsupported" notice.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await expect(channelsPage.centerView.autotranslationBadge).not.toBeVisible({timeout: 30000});
await channelsPage.centerView.header.openChannelMenu();
const channelMenu = page
.getByRole('menu')
.filter({has: page.getByRole('menuitem', {name: /Auto-translation|Channel Settings/})});
await expect(channelMenu.getByText('Auto-translation', {exact: true})).toBeVisible({timeout: 30000});
await expect(channelMenu.getByText('Your language is not supported')).toBeVisible({timeout: 30000});
const autotranslationItem = page.getByRole('menuitem', {name: /Auto-translation/});
await expect(autotranslationItem).toBeDisabled();
},
);

View File

@@ -0,0 +1,508 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
disableAutotranslationConfig,
enableAutotranslationConfig,
enableChannelAutotranslation,
getAdminClient,
hasAutotranslationLicense,
setUserChannelAutotranslation,
expect,
test,
setMockSourceLanguage,
} from '@mattermost/playwright-lib';
// Autotranslation tests involve real UI interactions with plugin state and can run
// longer than the default 60 s in loaded CI. Set per-test timeout to 2 minutes.
test.beforeEach(async () => {
test.setTimeout(120000);
});
// Disable AutoTranslationSettings at end of file so leftover state cannot leak
// into other suites. Individual tests enable the feature via
// enableAutotranslationConfig() as needed.
test.afterAll(async () => {
try {
const {adminClient} = await getAdminClient({skipLog: true});
await disableAutotranslationConfig(adminClient);
} catch {
// Best-effort cleanup.
}
});
test.fixme(
'auto-translation is ON by default for new channel members',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-default-on-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Default On Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
// Re-apply config immediately before the post so the server translates it.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
// Set Spanish source to ensure translation happens for new member
await setMockSourceLanguage(translationUrl, 'es');
await posterClient.createPost({
channel_id: created.id,
message: 'Hola para nuevo miembro',
user_id: createdPoster.id,
});
await adminClient.addToChannel(user.id, created.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Re-apply config + reload so the badge and translated text reflect the latest server config.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// * Verify translation badge is visible (indicates autotranslation is ON by default)
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
// * Verify post appeared with translation (mock server appends "[translated to en]" to original)
await expect(
channelsPage.centerView.container
.locator('[id^="post_"]')
.getByText('Hola para nuevo miembro [translated to en]', {exact: false}),
).toBeVisible();
},
);
test(
'opting out shows ephemeral message to user',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-ephemeral-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Ephemeral Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Re-apply config + reload so the badge reflects the latest AutoTranslationSettings.
// A concurrent initSetup() → updateConfig(defaultConfig) resets Enable to false,
// preventing the badge from appearing and the "Disable autotranslation" menu item
// from being shown.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// Wait for autotranslation state to be reflected in the header before opening the menu,
// so that the "Disable autotranslation" menu item is present when the menu opens.
await expect(channelsPage.centerView.autotranslationBadge).toBeVisible({timeout: 15000});
await channelsPage.centerView.header.openChannelMenu();
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
await expect(
channelsPage.centerView.container.locator('p').getByText(/You disabled Auto-translation for this channel/i),
).toBeVisible();
},
);
test.fixme(
'disabling for self reverts translated messages to original',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-revert-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Revert Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
const originalText = 'Solo texto original';
// Re-apply config immediately before posting so the server translates this message.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
// Set Spanish to ensure translation
await setMockSourceLanguage(translationUrl, 'es');
await posterClient.createPost({
channel_id: created.id,
message: originalText,
user_id: createdPoster.id,
});
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// Re-apply config + reload so the browser reads the latest AutoTranslationSettings.
// A concurrent initSetup() on another shard may have disabled autotranslation between
// the initial enableAutotranslationConfig call (before posting) and login.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
await channelsPage.page.reload();
await channelsPage.toBeVisible();
// * Verify post with translated text appears before disabling.
// Mock server appends "[translated to en]" to the original text. Translation
// is asynchronous and can lag several seconds in CI; use expect.poll to retry
// reliably rather than a fixed 15 s one-shot timeout.
const translatedText = 'Solo texto original [translated to en]';
const spanishPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: translatedText});
await expect
.poll(async () => spanishPost.isVisible(), {
timeout: 60000,
intervals: [500, 1500, 3000, 5000],
})
.toBe(true);
await channelsPage.centerView.header.openChannelMenu();
await page.getByRole('menuitem', {name: 'Disable autotranslation'}).click();
await page.getByRole('button', {name: 'Turn off auto-translation'}).click();
// * After disabling, wait for page to update and verify original text is shown
// Find the post containing the original text (skip system messages)
const userPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({has: page.locator('.post__body').filter({hasText: originalText})});
await expect(userPost).toBeVisible({timeout: 15000});
await expect(userPost).toContainText(originalText);
// * Verify translation indicator is no longer present
await expect(userPost.getByRole('button', {name: 'This post has been translated'})).not.toBeVisible();
},
);
test.fixme(
'messages only translate when source differs from user language',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-lang-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Language Rules Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
// Create a second poster to test translation indicators (only show with multiple users)
const poster2 = await pw.random.user('poster2');
const createdPoster2 = await adminClient.createUser(poster2, '', '');
await adminClient.addToTeam(team.id, createdPoster2.id);
await adminClient.addToChannel(createdPoster2.id, created.id);
const {client: posterClient2} = await pw.makeClient({
username: poster2.username,
password: poster2.password,
});
if (!posterClient2) {
throw new Error('Failed to create second poster client');
}
// Re-apply config immediately before posting so the server translates these messages.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
// Set source language for mock/real server before creating posts
// For mock: controls which language is detected; for real: auto-detection is used
// Post Spanish first so it gets the translation indicator (first message from posterClient)
await setMockSourceLanguage(translationUrl, 'es');
await posterClient.createPost({
channel_id: created.id,
message: 'Solo español',
user_id: createdPoster.id,
});
// English message won't be translated
await setMockSourceLanguage(translationUrl, 'en');
await posterClient.createPost({
channel_id: created.id,
message: 'English only',
user_id: createdPoster.id,
});
// Second user posts translated message (translation indicators only show with multiple users)
await posterClient2.createPost({
channel_id: created.id,
message: 'Hola desde segundo usuario',
user_id: createdPoster2.id,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify both posts appear
// Mock server produces "<original> [translated to en]", not real translations.
// Translation is async — use expect.poll to ride out mock-service latency in CI.
await expect(channelsPage.centerView.container.locator('[id^="post_"]').getByText('English only')).toBeVisible({
timeout: 15000,
});
const translatedSpanishLocator = channelsPage.centerView.container
.locator('[id^="post_"]')
.getByText('Solo español [translated to en]', {exact: false});
await expect
.poll(async () => translatedSpanishLocator.isVisible(), {
timeout: 45000,
intervals: [500, 1500, 3000, 5000],
})
.toBe(true);
// * Verify both messages are present
const spanishPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: 'Solo español [translated to en]'});
await expect(spanishPost).toBeVisible({timeout: 30000});
// * Verify English message is present and unchanged
const englishPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: 'English only'});
await expect(englishPost).toBeVisible();
},
);
test.fixme(
'message indicator only on actually translated message',
{
tag: ['@autotranslation'],
},
async ({pw}) => {
test.setTimeout(120000);
const {adminClient, user, userClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const translationUrl = process.env.TRANSLATION_SERVICE_URL || 'http://localhost:3010';
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
const channelName = `autotranslation-indicator-${pw.random.id()}`;
const created = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: 'Indicator Test',
type: 'O',
});
await enableChannelAutotranslation(adminClient, created.id);
await adminClient.addToChannel(user.id, created.id);
await setUserChannelAutotranslation(userClient, created.id, true);
const poster = await pw.random.user('poster');
const createdPoster = await adminClient.createUser(poster, '', '');
await adminClient.addToTeam(team.id, createdPoster.id);
await adminClient.addToChannel(createdPoster.id, created.id);
const {client: posterClient} = await pw.makeClient({
username: poster.username,
password: poster.password,
});
if (!posterClient) {
throw new Error('Failed to create poster client');
}
// Create a second poster to test translation indicators (only show with multiple users)
const poster2 = await pw.random.user('poster2');
const createdPoster2 = await adminClient.createUser(poster2, '', '');
await adminClient.addToTeam(team.id, createdPoster2.id);
await adminClient.addToChannel(createdPoster2.id, created.id);
const {client: posterClient2} = await pw.makeClient({
username: poster2.username,
password: poster2.password,
});
if (!posterClient2) {
throw new Error('Failed to create second poster client');
}
// Re-apply config immediately before posting so the server translates these messages.
await enableAutotranslationConfig(adminClient, {mockBaseUrl: translationUrl, targetLanguages: ['en', 'es']});
// Post the English-only message FIRST so the mock's source-language state is reset
// before any setMockSourceLanguage calls. The server processes translation
// asynchronously: if setMockSourceLanguage('en') is called right after a post the
// mock's detected language can be overridden before the server's detect request
// arrives, silently preventing translation of the Spanish message.
// By posting 'English only' first (no source override — mock auto-detects English
// and skips translation because source == target), we then safely set 'es' and
// post the Spanish message without any subsequent race-prone source switch.
await posterClient.createPost({
channel_id: created.id,
message: 'English only',
user_id: createdPoster.id,
});
// Set Spanish source so the mock translates the next post.
await setMockSourceLanguage(translationUrl, 'es');
await posterClient.createPost({
channel_id: created.id,
message: 'Solo español',
user_id: createdPoster.id,
});
// Second user posts translated message (translation indicators only show with multiple users)
await posterClient2.createPost({
channel_id: created.id,
message: 'Otro mensaje en español',
user_id: createdPoster2.id,
});
await enableAutotranslationConfig(adminClient, {
mockBaseUrl: translationUrl,
targetLanguages: ['en', 'es'],
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return (cfg as any).AutoTranslationSettings?.Enable === true;
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
await channelsPage.centerView.container.waitFor({state: 'visible', timeout: 30000});
// * Verify translated Spanish post is present
// Mock server produces "<original> [translated to en]"
const translatedPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: 'Solo español [translated to en]'});
await expect
.poll(async () => translatedPost.isVisible(), {
timeout: 90000,
intervals: [500, 1500, 3000, 5000],
})
.toBe(true);
// * Verify the English post is present and unchanged (not translated)
const notTranslatedPost = channelsPage.centerView.container
.locator('[id^="post_"]')
.filter({hasText: 'English only'})
.filter({hasNotText: '[translated to en]'});
await expect
.poll(async () => notTranslatedPost.isVisible(), {
timeout: 60000,
intervals: [500, 1500, 3000],
})
.toBe(true);
},
);

View File

@@ -0,0 +1,303 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page, Route} from '@playwright/test';
interface MockTranslateRequest {
q?: string;
text?: string;
source?: string;
target?: string;
}
interface MockTranslateResponse {
translatedText: string;
detectedLanguage: {language: string; confidence: number};
}
/**
* Tracks the source language for mock translation responses
* Used to simulate language detection behavior
*/
let mockSourceLanguage = 'es';
/**
* Simple language detection from text content
* Simulates how real LibreTranslate detects language from actual message text
*/
function detectLanguageFromText(text: string): string {
if (!text) {
return mockSourceLanguage;
}
const lowerText = text.toLowerCase();
// Spanish patterns
const spanishIndicators = [
'el ',
'la ',
'de ',
'que ',
'para ',
'con ',
'una ',
'este ',
'está',
'muy',
'en ',
'aquí',
'gracias',
'hola',
'adiós',
];
const spanishMatches = spanishIndicators.filter((indicator) => lowerText.includes(indicator)).length;
// English patterns
const englishIndicators = [
'the ',
'and ',
'is ',
'to ',
'of ',
'that ',
'this ',
'for ',
'with ',
'hello',
'thanks',
'please',
'translation',
];
const englishMatches = englishIndicators.filter((indicator) => lowerText.includes(indicator)).length;
// French patterns
const frenchIndicators = [
'le ',
'la ',
'de ',
'est ',
'que ',
'pour ',
'avec ',
'un ',
'une ',
'ça',
'bonjour',
'merci',
];
const frenchMatches = frenchIndicators.filter((indicator) => lowerText.includes(indicator)).length;
// Return the language with most matches
if (spanishMatches > englishMatches && spanishMatches > frenchMatches) {
return 'es';
}
if (englishMatches > frenchMatches) {
return 'en';
}
if (frenchMatches > 0) {
return 'fr';
}
// Default to configured mock language
return mockSourceLanguage;
}
/**
* Set the source language that the mock will detect
* Useful for testing language detection and filtering logic
*/
export function setMockSourceLanguage(language: string): void {
mockSourceLanguage = language;
}
/**
* Get the current mock source language
*/
export function getMockSourceLanguage(): string {
return mockSourceLanguage;
}
/**
* Reset mock source language to default
*/
export function resetMockSourceLanguage(): void {
mockSourceLanguage = 'es';
}
/**
* Mock the autotranslation API route using Playwright's route interception.
* This replaces the need for an external mock server.
*
* Returns a `Disposable` to remove the mock routes. Use with `await using`
* for automatic cleanup or call `.dispose()` manually:
*
* ```ts
* await using mock = await mockAutotranslationRoute(page);
* ```
*
* @param page - Playwright Page object
* @param options - Optional configuration
* @param options.sourceLanguage - Initial detected source language (default: 'es')
* @param options.supportedLanguages - List of supported target languages (default: ['en', 'es', 'fr', 'de'])
*/
export async function mockAutotranslationRoute(
page: Page,
options?: {
sourceLanguage?: string;
supportedLanguages?: string[];
},
): Promise<{dispose(): Promise<void>; [Symbol.asyncDispose](): Promise<void>}> {
// Reset mockSourceLanguage to avoid state leakage between tests
mockSourceLanguage = options?.sourceLanguage || 'es';
const supportedLanguages = options?.supportedLanguages || ['en', 'es', 'fr', 'de'];
// Named handler so it can be passed to page.unroute() for cleanup
const translateHandler = async (route: Route): Promise<void> => {
const request = route.request();
const method = request.method();
if (method === 'POST') {
let postData: MockTranslateRequest;
try {
postData = (await request.postDataJSON()) as MockTranslateRequest;
} catch {
// If POST data is not JSON, try to parse as form-encoded data
const postDataBuffer = request.postData();
if (!postDataBuffer) {
await route.abort('failed');
return;
}
// Parse form-encoded data (application/x-www-form-urlencoded)
const formData = new URLSearchParams(postDataBuffer.toString());
postData = {
q: formData.get('q') || undefined,
text: formData.get('text') || undefined,
source: formData.get('source') || undefined,
target: formData.get('target') || undefined,
};
}
const textToTranslate = postData.q || postData.text || '';
// When source is empty or "auto", detect language from text (simulates real LibreTranslate)
// Otherwise use the provided source language
const sourceLanguage =
postData.source && postData.source !== 'auto'
? postData.source
: detectLanguageFromText(textToTranslate);
const targetLanguage = postData.target || 'en';
// Validate target language is supported
if (!supportedLanguages.includes(targetLanguage)) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: `Target language '${targetLanguage}' is not supported`,
}),
});
return;
}
// Mock response: only "translate" if source differs from target
let translatedText = textToTranslate;
const shouldTranslate = sourceLanguage !== targetLanguage;
if (shouldTranslate) {
// Prepend language code to simulate translation
translatedText = `[${targetLanguage}] ${textToTranslate}`;
}
const response: MockTranslateResponse = {
translatedText,
detectedLanguage: {
language: sourceLanguage,
confidence: 0.95,
},
};
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
} else if (method === 'GET') {
// Handle GET requests (e.g., /translate?q=hello&source=es&target=en)
const url = new URL(request.url());
const textToTranslate = url.searchParams.get('q') || '';
const sourceParam = url.searchParams.get('source');
// When source is empty or "auto", detect language from text (simulates real LibreTranslate)
const sourceLanguage =
sourceParam && sourceParam !== 'auto' ? sourceParam : detectLanguageFromText(textToTranslate);
const targetLanguage = url.searchParams.get('target') || 'en';
if (!supportedLanguages.includes(targetLanguage)) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: `Target language '${targetLanguage}' is not supported`,
}),
});
return;
}
let translatedText = textToTranslate;
const shouldTranslate = sourceLanguage !== targetLanguage;
if (shouldTranslate) {
translatedText = `[${targetLanguage}] ${textToTranslate}`;
}
const response: MockTranslateResponse = {
translatedText,
detectedLanguage: {
language: sourceLanguage,
confidence: 0.95,
},
};
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
} else {
await route.abort('failed');
}
};
// Named handler so it can be passed to page.unroute() for cleanup
const detectHandler = async (route: Route): Promise<void> => {
// Language detection is mocked to always return the configured source language
// regardless of the input text
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
result: [
{
language: mockSourceLanguage,
confidence: 0.95,
},
],
detectedLanguage: {language: mockSourceLanguage, confidence: 0.95},
}),
});
};
// Mock LibreTranslate API endpoints
await page.route('**/api/translate', translateHandler);
await page.route('**/api/detect', detectHandler);
return {
async dispose() {
await page.unroute('**/api/translate', translateHandler);
await page.unroute('**/api/detect', detectHandler);
},
async [Symbol.asyncDispose]() {
await page.unroute('**/api/translate', translateHandler);
await page.unroute('**/api/detect', detectHandler);
},
};
}

View File

@@ -0,0 +1,408 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {BOR_TAG, setupBorTest, createSecondUser} from './support';
test.describe('Burn-on-Read in DMs and GMs', () => {
test('MM-66742_1 BoR toggle is available in DM channel', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user, team, adminClient} = await setupBorTest(pw);
// # Create second user for DM
const otherUser = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([user.id, otherUser.id]);
// # Login and navigate to DM
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
await channelsPage.goto(team.name, `@${otherUser.username}`);
// * Verify BoR toggle button is available
await expect(channelsPage.centerView.postCreate.burnOnReadButton).toBeAttached();
await expect(channelsPage.centerView.postCreate.burnOnReadButton).toBeEnabled();
// # Toggle BoR on
await channelsPage.centerView.postCreate.toggleBurnOnRead();
// * Verify BoR label appears indicating it's enabled
await expect(channelsPage.centerView.postCreate.burnOnReadLabel).toBeVisible();
});
test('MM-66742_2 complete BoR flow in DM between two users', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and navigate to DM
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name);
await senderPage.toBeVisible();
await senderPage.goto(team.name, `@${receiver.username}`);
// # Enable BoR and send message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `DM secret ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Get sender's view of the post
const senderPost = await senderPage.getLastPost();
// * Verify sender sees the message content (not concealed)
await expect(senderPost.body).toContainText(secretMessage);
// * Verify sender sees flame badge
await expect(senderPost.burnOnReadBadge.container).toBeVisible();
// # Login as receiver and navigate to DM
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name);
await receiverPage.toBeVisible();
await receiverPage.goto(team.name, `@${sender.username}`);
// # Get receiver's view of the post
const receiverPost = await receiverPage.getLastPost();
// * Verify receiver sees concealed placeholder
await expect(receiverPost.concealedPlaceholder.container).toBeVisible();
// * Verify receiver does NOT see the message content
await expect(receiverPost.body).not.toContainText(secretMessage);
// # Reveal the message
await receiverPost.concealedPlaceholder.clickToReveal();
await receiverPost.concealedPlaceholder.waitForReveal();
// * Verify receiver now sees the message content
await expect(receiverPost.body).toContainText(secretMessage);
// * Verify timer chip appears (wait for WebSocket update)
await expect(receiverPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
});
test(
'MM-66742_3 BoR message in group message with multiple recipients',
{tag: [BOR_TAG]},
async ({pw}, testInfo) => {
testInfo.setTimeout(120000);
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create two other users for the group
const recipient1 = await createSecondUser(pw, adminClient, team);
const recipient2 = await createSecondUser(pw, adminClient, team);
// # Create a private channel with exactly 3 members (sender + 2 recipients)
// This gives us control over the exact member count
const channelSuffix = Date.now().toString(36);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: `bor-gm-test-${channelSuffix}`,
displayName: `BoR GM Test ${channelSuffix}`,
type: 'P', // Private channel
}),
);
// Add all test users to the channel
await adminClient.addToChannel(sender.id, channel.id);
await adminClient.addToChannel(recipient1.id, channel.id);
await adminClient.addToChannel(recipient2.id, channel.id);
// Remove admin from channel (auto-added as creator)
const adminUser = await adminClient.getMe();
await adminClient.removeFromChannel(adminUser.id, channel.id);
// # Login as sender and navigate to the channel
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, channel.name);
await senderPage.toBeVisible();
// # Enable BoR and send message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `GM secret ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Get sender's view
const senderPost = await senderPage.getLastPost();
// * Verify sender sees flame badge
await expect(senderPost.burnOnReadBadge.container).toBeVisible();
// * Verify tooltip shows 0 of 2 recipients (channel has 3 members - 1 sender = 2 recipients)
const tooltipText = await senderPost.burnOnReadBadge.getTooltipText();
expect(tooltipText).toContain('Read by 0 of 2');
// # Login as first recipient and reveal
const {channelsPage: recipient1Page} = await pw.testBrowser.login(recipient1);
await recipient1Page.goto(team.name, channel.name);
await recipient1Page.toBeVisible();
const recipient1Post = await recipient1Page.getLastPost();
await recipient1Post.concealedPlaceholder.clickToReveal();
await recipient1Post.concealedPlaceholder.waitForReveal();
// * Verify first recipient sees message and timer (wait for WebSocket update)
await expect(recipient1Post.body).toContainText(secretMessage);
await expect(recipient1Post.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// # Refresh sender and verify updated count
await senderPage.page.reload();
await senderPage.toBeVisible();
const updatedSenderPost = await senderPage.getLastPost();
await updatedSenderPost.burnOnReadBadge.hover();
const updatedTooltip = await updatedSenderPost.burnOnReadBadge.getTooltipText();
expect(updatedTooltip).toContain('Read by 1 of 2');
// # Login as second recipient and reveal
const {channelsPage: recipient2Page} = await pw.testBrowser.login(recipient2);
await recipient2Page.goto(team.name, channel.name);
await recipient2Page.toBeVisible();
const recipient2Post = await recipient2Page.getLastPost();
await recipient2Post.concealedPlaceholder.clickToReveal();
await recipient2Post.concealedPlaceholder.waitForReveal();
// * Verify second recipient sees message and timer (wait for WebSocket update)
await expect(recipient2Post.body).toContainText(secretMessage);
await expect(recipient2Post.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
},
);
test('MM-66742_4 sender deletes BoR in DM and recipient cannot see it', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and navigate to DM
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name);
await senderPage.toBeVisible();
await senderPage.goto(team.name, `@${receiver.username}`);
// # Enable BoR and send message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `To be deleted ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Get the post ID
const senderPost = await senderPage.getLastPost();
const postId = await senderPost.getId();
// # Sender clicks flame badge to delete
await senderPost.burnOnReadBadge.click();
// * Verify confirmation modal appears
await expect(senderPage.burnOnReadConfirmationModal.container).toBeVisible();
// # Confirm deletion
await senderPage.burnOnReadConfirmationModal.confirm();
// * Verify post is removed from sender's view
const deletedPostSender = senderPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostSender).not.toBeVisible();
// # Login as receiver and verify message is not there
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name);
await receiverPage.toBeVisible();
await receiverPage.goto(team.name, `@${sender.username}`);
// * Verify the deleted message is not visible to receiver
const deletedPostReceiver = receiverPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostReceiver).not.toBeVisible();
// * Verify message content is not in the channel
const channelContent = await receiverPage.centerView.container.textContent();
expect(channelContent).not.toContain(secretMessage);
});
test('MM-66742_5 receiver burns revealed BoR in DM via timer chip', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and navigate to DM
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
// # Enable BoR and send message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `Receiver will burn ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Reveal the message
const receiverPost = await receiverPage.getLastPost();
const postId = await receiverPost.getId();
await receiverPost.concealedPlaceholder.clickToReveal();
await receiverPost.concealedPlaceholder.waitForReveal();
// * Verify message is revealed
await expect(receiverPost.body).toContainText(secretMessage);
// * Wait for timer chip to appear (WebSocket update)
await expect(receiverPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// # Click timer to burn
await receiverPost.burnOnReadTimerChip.click();
// * Verify confirmation modal
await expect(receiverPage.burnOnReadConfirmationModal.container).toBeVisible();
// # Confirm
await receiverPage.burnOnReadConfirmationModal.confirm();
// * Verify post is removed from receiver's view
const deletedPostReceiver = receiverPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostReceiver).not.toBeVisible({timeout: 15000});
// * Verify message content is not visible in the channel
await expect(receiverPage.centerView.container).not.toContainText(secretMessage);
});
test('MM-66742_6 DM shows correct recipient count of 1', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and navigate to DM
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name);
await senderPage.toBeVisible();
await senderPage.goto(team.name, `@${receiver.username}`);
// # Enable BoR and send message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `Count test ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Get sender's view
const senderPost = await senderPage.getLastPost();
// * Verify tooltip shows exactly 1 recipient
await senderPost.burnOnReadBadge.hover();
const recipientCount = await senderPost.burnOnReadBadge.getRecipientCount();
expect(recipientCount.total).toBe(1);
expect(recipientCount.revealed).toBe(0);
});
test('MM-66742_7 multiple BoR messages in same DM conversation', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
// # Send first BoR message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message1 = `First BoR ${pw.random.id()}`;
await senderPage.postMessage(message1);
// # Send second BoR message (toggle again to ensure BoR is on)
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message2 = `Second BoR ${pw.random.id()}`;
await senderPage.postMessage(message2);
// # Login as receiver and verify they can see concealed messages
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// * Verify concealed BoR placeholders are visible
await expect(receiverPage.centerView.container.getByTestId(/^burn-on-read-concealed-/).first()).toBeVisible();
// * Get all concealed placeholders
const concealedPlaceholders = receiverPage.centerView.container.locator('.BurnOnReadConcealedPlaceholder');
const count = await concealedPlaceholders.count();
expect(count).toBeGreaterThanOrEqual(1);
// # Reveal all concealed messages
for (let i = 0; i < count; i++) {
const placeholder = concealedPlaceholders.nth(i);
if (await placeholder.isVisible()) {
await placeholder.click();
// Wait for reveal animation
await receiverPage.page.waitForTimeout(500);
}
}
// * Verify at least one of the messages is visible after revealing
const pageContent = await receiverPage.centerView.container.textContent();
const hasMessage1 = pageContent?.includes(message1);
const hasMessage2 = pageContent?.includes(message2);
expect(hasMessage1 || hasMessage2).toBe(true);
});
test('MM-66742_8 BoR toggle resets after sending message', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM channel
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
// # Enable BoR toggle
await senderPage.centerView.postCreate.toggleBurnOnRead();
// * Verify BoR is enabled
const isEnabledBefore = await senderPage.centerView.postCreate.isBurnOnReadEnabled();
expect(isEnabledBefore).toBe(true);
// # Send a message
const message1 = `BoR message ${pw.random.id()}`;
await senderPage.postMessage(message1);
// * Verify BoR is disabled after sending (toggle resets)
const isEnabledAfter = await senderPage.centerView.postCreate.isBurnOnReadEnabled();
expect(isEnabledAfter).toBe(false);
// * Verify the sent message has BoR badge (was sent as BoR)
const lastPost = await senderPage.getLastPost();
await expect(lastPost.burnOnReadBadge.container).toBeVisible();
});
});

View File

@@ -0,0 +1,355 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {BOR_TAG, setupBorTest, createSecondUser} from './support';
test.describe('Burn-on-Read Receiver Flow', () => {
test('MM-66742_9 receiver sees concealed placeholder and reveals message', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create a DM channel between sender and receiver for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and navigate to DM
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
// # Post BoR message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const secretMessage = `Secret message ${pw.random.id()}`;
await senderPage.postMessage(secretMessage);
// # Login as receiver in new context
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Get the BoR post
const borPost = await receiverPage.getLastPost();
// * Verify post is concealed (placeholder shown)
await expect(borPost.concealedPlaceholder.container).toBeVisible();
// * Verify the actual message is NOT visible
await expect(borPost.body).not.toContainText(secretMessage);
// * Verify concealed placeholder has correct text
const placeholderText = await borPost.concealedPlaceholder.getText();
expect(placeholderText).toContain('View message');
// # Click to reveal
await borPost.concealedPlaceholder.clickToReveal();
// * Wait for reveal to complete
await borPost.concealedPlaceholder.waitForReveal();
// * Verify message is now visible
await expect(borPost.body).toContainText(secretMessage);
// * Verify concealed placeholder is no longer visible
await expect(borPost.concealedPlaceholder.container).not.toBeVisible();
});
test('MM-66742_10 receiver manually burns revealed message via timer chip', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `To be burned ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Get the BoR post and reveal it
const borPost = await receiverPage.getLastPost();
const postId = await borPost.getId();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// * Verify message is revealed
await expect(borPost.body).toContainText(message);
// * Wait for timer chip to appear (WebSocket update)
await expect(borPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// # Click timer chip to manually burn
await borPost.burnOnReadTimerChip.click();
// * Verify confirmation modal appears
await expect(receiverPage.burnOnReadConfirmationModal.container).toBeVisible();
// # Confirm deletion
await receiverPage.burnOnReadConfirmationModal.confirm();
// * Verify post is removed from receiver's view
const deletedPostLocator = receiverPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostLocator).not.toBeVisible();
});
test(
'MM-66742_11 receiver uses dont show again preference for burn confirmation',
{tag: [BOR_TAG]},
async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Test message ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Reveal the message
const borPost = await receiverPage.getLastPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// * Wait for timer chip to be visible
await expect(borPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// # Click timer to burn
await borPost.burnOnReadTimerChip.click();
// * Verify confirmation modal appears
await expect(receiverPage.burnOnReadConfirmationModal.container).toBeVisible();
// * Verify "don't show again" checkbox is available
await expect(receiverPage.burnOnReadConfirmationModal.dontShowAgainCheckbox).toBeVisible();
// # Check "don't show again" and confirm (combined action)
await receiverPage.burnOnReadConfirmationModal.confirmWithDontShowAgain();
// * Verify post is deleted
await expect(borPost.container).not.toBeVisible({timeout: 10000});
},
);
test('MM-66742_12 timer chip displays countdown after reveal', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with 60 second duration
const {
user: sender,
team,
adminClient,
} = await setupBorTest(pw, {
durationSeconds: 60,
});
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Timer test ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Get the BoR post and reveal it
const borPost = await receiverPage.getLastPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// * Verify timer chip is visible (wait for WebSocket update)
await expect(borPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// * Get initial time
const initialTime = await borPost.burnOnReadTimerChip.getTimeRemaining();
// * Verify time format is correct (MM:SS or M:SS)
expect(initialTime).toMatch(/^\d+:\d{2}$/);
// # Wait 2 seconds
await receiverPage.page.waitForTimeout(2000);
// * Get updated time
const updatedTime = await borPost.burnOnReadTimerChip.getTimeRemaining();
// * Verify time has decreased
expect(updatedTime).toMatch(/^\d+:\d{2}$/);
// Parse times to compare
const parseTime = (t: string) => {
const [m, s] = t.split(':').map(Number);
return m * 60 + s;
};
expect(parseTime(updatedTime)).toBeLessThan(parseTime(initialTime));
});
test('MM-66742_13 message auto-deletes after timer expires', {tag: [BOR_TAG]}, async ({pw}, testInfo) => {
testInfo.setTimeout(120000);
// # Initialize setup with very short duration (10 seconds)
const {
user: sender,
team,
adminClient,
} = await setupBorTest(pw, {
durationSeconds: 10,
maxTTLSeconds: 86400,
});
// # Verify the config was applied before proceeding (guard against state pollution)
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
});
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
// Re-apply guard: concurrent initSetup() may reset BurnOnReadDurationSeconds to 60
// (default) after the pw.waitUntil check above but before the message is posted.
await adminClient.patchConfig({
ServiceSettings: {
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 10,
BurnOnReadMaximumTimeToLiveSeconds: 86400,
},
});
// Confirm the re-apply actually took effect before the post is created.
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
});
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Auto-delete test ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
// # Get the BoR post and reveal it
const borPost = await receiverPage.getLastPost();
const postId = await borPost.getId();
// Re-apply guard: TTL is assigned by the server at reveal time, not post time.
// A concurrent initSetup() may have reset BurnOnReadDurationSeconds to its
// default (60 s) between the sender's post and the receiver's reveal click.
// Re-applying here ensures the server uses 10 s when it writes the TTL.
await adminClient.patchConfig({
ServiceSettings: {
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 10,
BurnOnReadMaximumTimeToLiveSeconds: 86400,
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 10;
});
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// * Verify message is visible and timer is running (wait for WebSocket update)
await expect(borPost.body).toContainText(message);
await expect(borPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
// # Wait for timer to expire (10 seconds + buffer)
// Use polling to check for post removal
await expect(async () => {
const postLocator = receiverPage.page.locator(`[id="post_${postId}"]`);
await expect(postLocator).not.toBeVisible();
}).toPass({
timeout: 30000,
intervals: [1000],
});
// * Verify message is no longer visible
const deletedPostLocator = receiverPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostLocator).not.toBeVisible();
// * Verify message text is not in the channel
const pageContent = await receiverPage.centerView.container.textContent();
expect(pageContent).not.toContain(message);
});
test('MM-66742_14 receiver sees flame badge on concealed message', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user: sender, team, adminClient} = await setupBorTest(pw);
// # Create receiver
const receiver = await createSecondUser(pw, adminClient, team);
// # Create DM for controlled environment
await adminClient.createDirectChannel([sender.id, receiver.id]);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name);
await senderPage.toBeVisible();
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Badge test ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name);
await receiverPage.toBeVisible();
await receiverPage.goto(team.name, `@${sender.username}`);
// # Get the BoR post (still concealed)
const borPost = await receiverPage.getLastPost();
// * Verify concealed placeholder is visible
await expect(borPost.concealedPlaceholder.container).toBeVisible();
// * Verify flame badge is also visible on concealed post
await expect(borPost.burnOnReadBadge.container).toBeVisible();
// * Verify badge tooltip shows appropriate message for receiver
const ariaLabel = await borPost.burnOnReadBadge.getAriaLabel();
expect(ariaLabel).toContain('Burn-on-read message');
});
});

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {BOR_TAG, setupBorTest, createSecondUser} from './support';
test.describe('Burn-on-Read Restrictions', () => {
test('MM-66742_19 no reply option in dot menu for BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No reply test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// # Open dot menu via page object
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Reply option is NOT present
await expect(receiverPage.postDotMenu.replyMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_20 no pin option in dot menu for BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No pin test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Pin option is NOT present
await expect(receiverPage.postDotMenu.pinToChannelMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_21 no edit option in dot menu for BoR post (sender)', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `No edit test ${pw.random.id()}`;
await senderPage.postMessage(message);
const borPost = await senderPage.centerView.getPostByText(message);
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Edit option is NOT present (even for sender)
await expect(senderPage.postDotMenu.editMenuItem).not.toBeVisible();
await senderPage.page.keyboard.press('Escape');
});
test('MM-66742_22 no forward option in dot menu for BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No forward test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Forward option is NOT present
await expect(receiverPage.postDotMenu.forwardMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_23 no copy text option in dot menu for BoR post (receiver)', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No copy test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Copy Text option is NOT present for receiver
await expect(receiverPage.postDotMenu.copyTextMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_24 no copy link option for receiver of BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No copy link test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Copy Link option is NOT present for receiver
await expect(receiverPage.postDotMenu.copyLinkMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_25 sender can copy link to own BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Sender copy link test ${pw.random.id()}`;
await senderPage.postMessage(message);
const borPost = await senderPage.centerView.getPostByText(message);
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Copy Link option IS present for sender
await expect(senderPage.postDotMenu.copyLinkMenuItem).toBeVisible();
await senderPage.page.keyboard.press('Escape');
});
test('MM-66742_26 no follow thread option for BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`No follow test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Follow Thread option is NOT present
await expect(receiverPage.postDotMenu.followMessageMenuItem).not.toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
test('MM-66742_27 keyboard shortcut Shift+UP does not open reply for BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Keyboard test ${pw.random.id()}`;
await senderPage.postMessage(message);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
// # Focus on the message input box and press Shift+UP
await receiverPage.centerView.postCreate.input.click();
await receiverPage.page.keyboard.press('Shift+ArrowUp');
// * Verify RHS does not open with the BoR message
await expect(receiverPage.sidebarRight.container)
.toBeHidden({timeout: 2000})
.catch(async () => {
await expect(receiverPage.sidebarRight.container).not.toContainText(message);
});
});
test('MM-66742_28 delete option available for revealed BoR post', {tag: [BOR_TAG]}, async ({pw}) => {
const {user: sender, team, adminClient} = await setupBorTest(pw);
const receiver = await createSecondUser(pw, adminClient, team);
await adminClient.createDirectChannel([sender.id, receiver.id]);
const {channelsPage: senderPage} = await pw.testBrowser.login(sender);
await senderPage.goto(team.name, `@${receiver.username}`);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
await senderPage.postMessage(`Delete test ${pw.random.id()}`);
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name, `@${sender.username}`);
await receiverPage.toBeVisible();
const borPost = await receiverPage.centerView.getConcealedBorPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Delete option IS present
await expect(receiverPage.postDotMenu.deleteMenuItem).toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
});

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {BOR_TAG, setupBorTest, createSecondUser} from './support';
test.describe('Burn-on-Read Sender Flow', () => {
test('MM-66742_15 sends BoR message and views sent status with recipient count', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user, team, adminClient} = await setupBorTest(pw);
// # Create second user as recipient (needed for BoR badge count)
await createSecondUser(pw, adminClient, team);
// # Login as sender
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Enable BoR toggle in composer
await channelsPage.centerView.postCreate.toggleBurnOnRead();
// * Verify BoR label appears
await expect(channelsPage.centerView.postCreate.burnOnReadLabel).toBeVisible();
// # Send BoR message
const message = `BoR Test ${pw.random.id()}`;
await channelsPage.postMessage(message);
// # Get the posted message
const lastPost = await channelsPage.getLastPost();
// * Verify post has BoR badge
await expect(lastPost.burnOnReadBadge.container).toBeVisible();
// * Verify flame icon is displayed
await expect(lastPost.burnOnReadBadge.flameIcon).toBeVisible();
// # Hover over badge to see tooltip
await lastPost.burnOnReadBadge.hover();
// * Verify tooltip shows recipient info
const tooltipText = await lastPost.burnOnReadBadge.getTooltipText();
expect(tooltipText).toContain('Read by 0 of');
expect(tooltipText).toContain('Click to delete');
});
test('MM-66742_16 sender sees read receipts in tooltip', {tag: [BOR_TAG]}, async ({pw}, testInfo) => {
testInfo.setTimeout(120000);
// # Initialize setup with BoR enabled
const {user, team, adminClient} = await setupBorTest(pw);
// # Create two recipients
const recipient1 = await createSecondUser(pw, adminClient, team);
const recipient2 = await createSecondUser(pw, adminClient, team);
// # Create a private channel with exactly these 3 users (sender + 2 recipients)
// Use timestamp to ensure unique channel name across test runs
const channelSuffix = Date.now().toString(36);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: `bor-test-${channelSuffix}`,
displayName: `BoR Test ${channelSuffix}`,
type: 'P', // Private channel
}),
);
// Add all test users to the channel
await adminClient.addToChannel(user.id, channel.id);
await adminClient.addToChannel(recipient1.id, channel.id);
await adminClient.addToChannel(recipient2.id, channel.id);
// Remove admin from channel (they were auto-added as creator)
// This ensures exactly 3 members: sender + 2 recipients
const adminUser = await adminClient.getMe();
await adminClient.removeFromChannel(adminUser.id, channel.id);
// # Login as sender and navigate to the new channel
const {channelsPage: senderPage} = await pw.testBrowser.login(user);
await senderPage.goto(team.name, channel.name);
await senderPage.toBeVisible();
// # Post BoR message
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Secret message ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Get sender's post
let senderPost = await senderPage.getLastPost();
// * Verify initial state shows 0 recipients revealed
await senderPost.burnOnReadBadge.hover();
let recipientCount = await senderPost.burnOnReadBadge.getRecipientCount();
expect(recipientCount.revealed).toBe(0);
expect(recipientCount.total).toBe(2); // Should be 2 recipients (channel has 3 members - 1 sender)
// # Login as first recipient and reveal the message
const {channelsPage: recipient1Page} = await pw.testBrowser.login(recipient1);
await recipient1Page.goto(team.name, channel.name);
await recipient1Page.toBeVisible();
const recipient1Post = await recipient1Page.getLastPost();
await recipient1Post.concealedPlaceholder.clickToReveal();
await recipient1Post.concealedPlaceholder.waitForReveal();
// # Refresh sender's view and check updated count
await senderPage.page.reload();
await senderPage.toBeVisible();
senderPost = await senderPage.getLastPost();
await expect(senderPost.burnOnReadBadge.container).toBeVisible({timeout: 15000});
await senderPost.burnOnReadBadge.hover();
recipientCount = await senderPost.burnOnReadBadge.getRecipientCount();
expect(recipientCount.revealed).toBe(1);
// # Login as second recipient and reveal the message
const {channelsPage: recipient2Page} = await pw.testBrowser.login(recipient2);
await recipient2Page.goto(team.name, channel.name);
await recipient2Page.toBeVisible();
const recipient2Post = await recipient2Page.getLastPost();
await recipient2Post.concealedPlaceholder.clickToReveal();
await recipient2Post.concealedPlaceholder.waitForReveal();
// # Refresh sender's view — all recipients revealed, sender timer should start
await senderPage.page.reload();
await senderPage.toBeVisible();
senderPost = await senderPage.getLastPost();
// * After all recipients reveal, badge transitions to timer chip
await expect(senderPost.burnOnReadTimerChip.container).toBeVisible({timeout: 15000});
});
test('MM-66742_17 sender manually deletes for all recipients', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with BoR enabled
const {user, team, adminClient} = await setupBorTest(pw);
// # Create recipient
const recipient = await createSecondUser(pw, adminClient, team);
// # Login as sender and post BoR message
const {channelsPage: senderPage} = await pw.testBrowser.login(user);
await senderPage.goto(team.name, 'town-square');
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `To be deleted ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Get the post and its ID for verification later
const senderPost = await senderPage.getLastPost();
const postId = await senderPost.getId();
// # Click flame badge to delete
await senderPost.burnOnReadBadge.click();
// * Verify confirmation modal appears
await expect(senderPage.burnOnReadConfirmationModal.container).toBeVisible();
// # Confirm deletion
await senderPage.burnOnReadConfirmationModal.confirm();
// * Verify the specific post is removed from sender's view
// Use attribute selector to handle special characters in post ID (colons, etc.)
const deletedPostLocator = senderPage.page.locator(`[id="post_${postId}"]`);
await expect(deletedPostLocator).not.toBeVisible();
// # Login as recipient and verify post is not visible
const {channelsPage: recipientPage} = await pw.testBrowser.login(recipient);
await recipientPage.goto(team.name, 'town-square');
// * Verify the deleted message is not in the channel
const posts = await recipientPage.centerView.container.locator('.post').all();
for (const post of posts) {
const text = await post.textContent();
expect(text).not.toContain(message);
}
});
test('MM-66742_18 sender sees timer after all recipients reveal', {tag: [BOR_TAG]}, async ({pw}) => {
// # Initialize setup with short duration
const {user, team, adminClient} = await setupBorTest(pw, {
durationSeconds: 60,
});
// # Create one recipient
const recipient = await createSecondUser(pw, adminClient, team);
// # Create a private channel with exactly 2 members (sender + recipient)
const channelSuffix = Date.now().toString(36);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: `bor-timer-test-${channelSuffix}`,
displayName: `BoR Timer Test ${channelSuffix}`,
type: 'P',
}),
);
// Add sender and recipient to the channel
await adminClient.addToChannel(user.id, channel.id);
await adminClient.addToChannel(recipient.id, channel.id);
// Remove admin from channel (they were auto-added as creator)
const adminUser = await adminClient.getMe();
await adminClient.removeFromChannel(adminUser.id, channel.id);
// # Login as sender and post BoR message in the controlled channel
const {channelsPage: senderPage} = await pw.testBrowser.login(user);
await senderPage.goto(team.name, channel.name);
await senderPage.toBeVisible();
await senderPage.centerView.postCreate.toggleBurnOnRead();
const message = `Timer test ${pw.random.id()}`;
await senderPage.postMessage(message);
// # Get sender's post
let senderPost = await senderPage.getLastPost();
// * Verify sender sees badge with exactly 1 recipient (not timer yet)
await expect(senderPost.burnOnReadBadge.container).toBeVisible();
await expect(senderPost.burnOnReadTimerChip.container).not.toBeVisible();
// * Verify recipient count is exactly 1 (our controlled channel)
const initialCount = await senderPost.burnOnReadBadge.getRecipientCount();
expect(initialCount.total).toBe(1);
expect(initialCount.revealed).toBe(0);
// # Login as recipient and reveal
const {channelsPage: recipientPage} = await pw.testBrowser.login(recipient);
await recipientPage.goto(team.name, channel.name);
await recipientPage.toBeVisible();
// Get the BoR post (last post in our controlled channel)
const recipientPost = await recipientPage.getLastPost();
await recipientPost.concealedPlaceholder.clickToReveal();
await recipientPost.concealedPlaceholder.waitForReveal();
// * Verify recipient sees timer chip (their countdown started)
await expect(recipientPost.burnOnReadTimerChip.container).toBeVisible();
// # Refresh sender's view
await senderPage.page.reload();
await senderPage.toBeVisible();
// Get the BoR post (last post in our controlled channel)
senderPost = await senderPage.getLastPost();
// * Verify sender now sees timer chip (all 1 recipient revealed, so sender timer starts)
await expect(senderPost.burnOnReadTimerChip.container).toBeVisible();
await expect(senderPost.burnOnReadBadge.container).not.toBeVisible();
// * Verify timer is counting down
const timeRemaining = await senderPost.burnOnReadTimerChip.getTimeRemaining();
expect(timeRemaining).toMatch(/\d+:\d+/);
});
});

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {PlaywrightClient4, PlaywrightExtended} from '@mattermost/playwright-lib';
export const BOR_TAG = '@burn_on_read';
export type BorSetup = {
adminClient: PlaywrightClient4;
adminUser: UserProfile;
user: UserProfile;
userClient: PlaywrightClient4;
team: Team;
offTopicUrl: string;
townSquareUrl: string;
};
/**
* Setup test environment with BoR feature enabled
* @param pw Playwright extended fixture
* @param options Configuration options
* @returns Setup data including users, team, and clients
*/
export async function setupBorTest(
pw: PlaywrightExtended,
options: {
durationSeconds?: number;
maxTTLSeconds?: number;
} = {},
): Promise<BorSetup> {
const {durationSeconds = 60, maxTTLSeconds = 300} = options;
// Ensure prerequisites
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Initialize setup
const setup = await pw.initSetup();
// Enable BoR via API - patch config instead of full update
const currentConfig = await setup.adminClient.getConfig();
await setup.adminClient.patchConfig({
ServiceSettings: {
...currentConfig.ServiceSettings,
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: durationSeconds,
BurnOnReadMaximumTimeToLiveSeconds: maxTTLSeconds,
},
});
return setup as BorSetup;
}
/**
* Create a second user and add to team
* @param pw Playwright extended fixture
* @param adminClient Admin client for user creation
* @param team Team to add user to
* @returns Created user with password
*/
export async function createSecondUser(
pw: PlaywrightExtended,
adminClient: PlaywrightClient4,
team: Team,
): Promise<UserProfile & {password: string}> {
const [user] = await adminClient.createUsers(team.id, 1);
return user as UserProfile & {password: string};
}
/**
* Parse recipient count from tooltip text
* @param tooltipText Tooltip text containing recipient info
* @returns Object with revealed and total counts
*/
export function parseRecipientCount(tooltipText: string): {revealed: number; total: number} {
const match = tooltipText.match(/Read by (\d+) of (\d+)/);
if (!match) {
throw new Error(`Could not parse recipient count from: ${tooltipText}`);
}
return {
revealed: parseInt(match[1], 10),
total: parseInt(match[2], 10),
};
}
/**
* Parse time remaining from timer text
* @param timerText Timer text (e.g., "0:45", "1:30")
* @returns Seconds remaining
*/
export function parseTimeRemaining(timerText: string): number {
const parts = timerText.split(':');
if (parts.length !== 2) {
throw new Error(`Invalid timer format: ${timerText}`);
}
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return minutes * 60 + seconds;
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
async function skipIfNoEnterpriseLicense(adminClient: any) {
const license = await adminClient.getClientLicenseOld();
test.skip(license.IsLicensed !== 'true', 'Skipping test - server does not have an enterprise license');
}
async function enableChannelCategorySorting(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableChannelCategorySorting: true,
},
});
}
async function disableChannelCategorySorting(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableChannelCategorySorting: false,
},
});
}
test.describe('Channel Category Sorting', () => {
/**
* @objective Verify that the default category selector is not visible in channel settings when channel category sorting is disabled.
*/
test(
'default category selector is not visible when channel category sorting is disabled',
{tag: '@channel_category_sorting'},
async ({pw}) => {
// # Initialize setup and disable channel category sorting
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await disableChannelCategorySorting(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and open channel settings
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify the default category selector is not rendered
const defaultCategorySelector = channelSettingsModal.container
.locator('.CategorySelector')
.filter({hasText: 'Choose a default category (optional)'});
await expect(defaultCategorySelector).toHaveCount(0);
await channelSettingsModal.close();
},
);
/**
* @objective Verify that a default category can be assigned when creating a new channel via the new channel modal.
*/
test(
'default category can be assigned when creating a new channel',
{tag: '@channel_category_sorting'},
async ({pw}) => {
// # Initialize setup and enable channel category sorting
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableChannelCategorySorting(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and open the new channel modal
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const newChannelModal = await channelsPage.openNewChannelModal();
const displayName = `New Default Cat ${Date.now()}`;
await newChannelModal.fillDisplayName(displayName);
// # Locate the default category selector and select a new category
const defaultCategorySection = newChannelModal.container.locator('.CategorySelector').first();
await expect(defaultCategorySection).toContainText('Choose a default category (optional)');
const defaultCategoryControl = defaultCategorySection.locator('.CategorySelector__control');
await defaultCategoryControl.click();
const input = defaultCategorySection.getByRole('combobox');
await input.fill('Flight Ops');
const createOption = page.getByRole('option', {name: 'Create new category: Flight Ops'});
await expect(createOption).toBeVisible();
await createOption.click();
await newChannelModal.create();
await channelsPage.toBeVisible();
await pw.wait(pw.duration.two_sec);
// * Verify the new category appears in the sidebar
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Flight Ops')).toBeVisible();
},
);
/**
* @objective Verify that a Channel Admin can assign a default category to an existing channel via the channel
* settings modal, and the category appears in the sidebar with the channel under it for the patching user.
*/
test('default category can be assigned via channel settings', {tag: '@channel_category_sorting'}, async ({pw}) => {
// # Initialize setup with admin user and enterprise license
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableChannelCategorySorting(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and navigate to town-square
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Create a new channel
const channelName = `default-assign-${Date.now()}`;
await channelsPage.newChannel(channelName, 'O');
await channelsPage.toBeVisible();
// # Open channel settings and navigate to info tab
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify the default category selector is visible
const defaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
await expect(defaultCategorySection).toContainText('Choose a default category (optional)');
// # Click the selector, type a new category name, and select "Create new category"
const defaultCategoryControl = defaultCategorySection.locator('.CategorySelector__control');
await defaultCategoryControl.click();
const input = defaultCategorySection.getByRole('combobox');
await input.fill('Operations');
const createOption = page.getByRole('option', {name: 'Create new category: Operations'});
await expect(createOption).toBeVisible();
await createOption.click();
// # Save and close
await channelSettingsModal.save();
await pw.wait(pw.duration.two_sec);
await channelSettingsModal.close();
// * Verify the default category appears in the sidebar with the channel under it
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Operations')).toBeVisible();
const operationsSection = sidebar.locator('.SidebarChannelGroup').filter({hasText: 'Operations'});
await expect(operationsSection.locator(`#sidebarItem_${channelName}`)).toBeVisible();
});
/**
* @objective Verify that a Channel Admin can remove a default category from a channel via the channel settings
* modal, and reopening the modal shows the field cleared.
*/
test('default category can be removed via channel settings', {tag: '@channel_category_sorting'}, async ({pw}) => {
// # Initialize setup and create a channel with a default category set
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableChannelCategorySorting(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `default-cat-remove-${Date.now()}`,
display_name: `Default remove ${Date.now()}`,
type: 'O',
});
await adminClient.patchChannel(channel.id, {default_category_name: 'Removable'});
await adminClient.addToChannel(adminUser.id, channel.id);
// # Log in and navigate to the channel
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Verify the default category is visible in the sidebar (admin was added after the patch set it)
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Removable')).toBeVisible();
// # Open channel settings and click the clear button on the default category selector
let channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
const defaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
await expect(defaultCategorySection).toContainText('Removable');
const clearButton = defaultCategorySection.locator('.CategorySelector__clear-indicator');
await expect(clearButton).toBeVisible();
await clearButton.click();
await pw.wait(pw.duration.half_sec);
// * Verify the clear button is gone (no value selected)
await expect(clearButton).not.toBeVisible();
// # Save and close
await channelSettingsModal.save();
await pw.wait(pw.duration.two_sec);
await channelSettingsModal.close();
// # Reopen channel settings to verify the cleared value persisted
channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify the default category selector shows the placeholder (value is cleared)
const reopenedDefaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
await expect(reopenedDefaultCategorySection).toContainText('Choose a default category (optional)');
await channelSettingsModal.close();
});
/**
* @objective Verify that the Channel category sorting setting is available in the System Console
* under Site Configuration > Users and Teams.
*/
test(
'Channel category sorting setting is available in System Console',
{tag: '@channel_category_sorting'},
async ({pw}) => {
// # Initialize setup
const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
// # Log in and navigate to the System Console
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Users and Teams
await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click();
await systemConsolePage.usersAndTeams.toBeVisible();
// * Verify the setting is visible
const setting = systemConsolePage.usersAndTeams.container.getByTestId(
'TeamSettings.EnableChannelCategorySortingtrue',
);
await expect(setting).toBeVisible();
},
);
});

View File

@@ -0,0 +1,725 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
async function skipIfNoEnterpriseLicense(adminClient: any) {
const license = await adminClient.getClientLicenseOld();
const enterpriseSkus = ['enterprise', 'advanced', 'entry'];
test.skip(
license.IsLicensed !== 'true' || !enterpriseSkus.includes(license.SkuShortName),
'Skipping test - server does not have an enterprise license',
);
}
async function enableManagedCategories(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableManagedChannelCategories: true,
},
});
}
async function disableManagedCategories(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableManagedChannelCategories: false,
},
});
}
/**
* Creates a uniquely-named team and user per test and adds the user to the team.
*/
async function setupManagedCategoriesTest(pw: any) {
const {adminClient, adminUser} = await pw.getAdminClient();
const suffix = getRandomId();
// Guard against UseAnonymousURLs=true left by anonymous_urls tests running on the same
// server shard. When active, newly created channels receive obfuscated slugs instead of
// human-readable names, breaking sidebar-item selectors (e.g. #sidebarItem_managed-assign-…).
await adminClient.patchConfig({PrivacySettings: {UseAnonymousURLs: false}});
const team = await adminClient.createTeam({
name: `mgd-${suffix}`,
display_name: `Managed ${suffix}`,
type: 'O',
});
const user = await pw.createNewUserProfile(adminClient, {
prefix: 'mgd-user',
disableTutorial: true,
disableOnboarding: true,
});
await adminClient.addToTeam(team.id, user.id);
return {adminClient, adminUser, team, user};
}
async function createChannelWithManagedCategory(
adminClient: any,
teamId: string,
categoryName: string,
channelSuffix: string,
) {
const channel = await adminClient.createChannel({
team_id: teamId,
name: `managed-cat-${channelSuffix}-${Date.now()}`,
display_name: `Managed ${channelSuffix} ${Date.now()}`,
type: 'O',
});
await adminClient.patchChannel(channel.id, {managed_category_name: categoryName});
return channel;
}
test.describe('Managed Channel Categories', () => {
/**
* @objective Verify that a Channel Admin can assign a managed category to a channel via the channel settings modal,
* and the category appears in the sidebar with the channel under it.
*/
test(
'Channel Admin can assign a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup with admin user and enterprise license
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and navigate to town-square
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Create a new channel
const channelName = `managed-assign-${Date.now()}`;
await channelsPage.newChannel(channelName, 'O');
await channelsPage.toBeVisible();
// # Open channel settings and navigate to info tab
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify managed category selector is visible
const managedSelector = channelSettingsModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).toBeVisible();
// # Click the selector, type a new category name, and select "Create new category"
await managedSelector.click();
const input = channelSettingsModal.container.getByRole('combobox');
await input.fill('Operations');
const createOption = page.getByRole('option', {name: 'Create new category: Operations'});
await expect(createOption).toBeVisible();
await createOption.click();
// # Save and close
await channelSettingsModal.save();
await pw.wait(pw.duration.two_sec);
await channelSettingsModal.close();
// * Verify the managed category appears in the sidebar with the channel under it
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Operations')).toBeVisible();
const operationsSection = sidebar.locator('.SidebarChannelGroup').filter({hasText: 'Operations'});
await expect(operationsSection.locator(`#sidebarItem_${channelName}`)).toBeVisible();
},
);
/**
* @objective Verify that a Channel Admin can remove a managed category from a channel via the channel settings modal,
* and the channel returns to the default CHANNELS section.
*/
test(
'Channel Admin can remove a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Removable', 'remove');
await adminClient.addToChannel(adminUser.id, channel.id);
// # Log in and navigate to the channel
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Verify the managed category is visible in the sidebar.
// Use waitUntil because fetchManagedCategories is async (two API calls:
// getPropertyFields then getManagedCategories) and may take a moment.
const sidebar = channelsPage.sidebarLeft.container;
await pw.waitUntil(
async () =>
sidebar
.getByText('Removable')
.isVisible()
.catch(() => false),
{timeout: 15000},
);
await expect(sidebar.getByText('Removable')).toBeVisible();
// # Open channel settings and click the clear button to remove the category
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
const clearButton = channelSettingsModal.container.locator('.ManagedCategory__clear-indicator');
await expect(clearButton).toBeVisible();
await clearButton.click();
await pw.wait(pw.duration.half_sec);
// * Verify the clear button is gone
await expect(clearButton).not.toBeVisible();
// # Save and close
await channelSettingsModal.save();
await pw.wait(pw.duration.two_sec);
await channelSettingsModal.close();
// * Verify the managed category is removed and the channel is back under CHANNELS
await expect(sidebar.getByText('Removable')).not.toBeVisible();
const channelsSection = sidebar.locator('.SidebarChannelGroup').filter({hasText: 'CHANNELS'});
await expect(channelsSection.locator(`#sidebarItem_${channel.name}`)).toBeVisible();
},
);
/**
* @objective Verify that the managed category selector is not visible in channel settings when the feature is disabled.
*/
test(
'managed category selector is not visible when feature is disabled',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', false);
// # Initialize setup and disable managed categories
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await disableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and open channel settings
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify managed category selector is not visible
const managedSelector = channelSettingsModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).not.toBeVisible();
await channelSettingsModal.close();
},
);
/**
* @objective Verify that a managed category can be assigned to a channel during creation via the new channel modal.
*/
test('managed category can be assigned when creating a new channel', {tag: '@managed_categories'}, async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and enable managed categories
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
// # Log in and open the new channel modal
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const newChannelModal = await channelsPage.openNewChannelModal();
const displayName = `New Managed ${Date.now()}`;
await newChannelModal.fillDisplayName(displayName);
// * Verify managed category selector is visible
const managedSelector = newChannelModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).toBeVisible();
// # Select a new managed category and create the channel
await managedSelector.click();
const input = newChannelModal.container.getByRole('combobox');
await input.fill('Flight Ops');
const createOption = page.getByRole('option', {name: 'Create new category: Flight Ops'});
await expect(createOption).toBeVisible();
await createOption.click();
await newChannelModal.create();
await channelsPage.toBeVisible();
await pw.wait(pw.duration.two_sec);
// * Verify the managed category appears in the sidebar
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Flight Ops')).toBeVisible();
});
/**
* @objective Verify that managed categories appear at the top of the sidebar above personal categories like CHANNELS.
*/
test(
'managed categories appear at the top of the sidebar above personal categories',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Alpha Priority', 'alpha');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category is visible and positioned above CHANNELS
const sidebar = channelsPage.sidebarLeft.container;
const managedCategory = sidebar.getByText('Alpha Priority');
await pw.waitUntil(async () => managedCategory.isVisible().catch(() => false), {timeout: 15000});
await expect(managedCategory).toBeVisible();
const channelsHeader = sidebar.getByText('CHANNELS', {exact: true});
await expect(channelsHeader).toBeVisible();
const managedBox = await managedCategory.boundingBox();
const channelsBox = await channelsHeader.boundingBox();
expect(managedBox).toBeTruthy();
expect(channelsBox).toBeTruthy();
expect(managedBox!.y).toBeLessThan(channelsBox!.y);
},
);
/**
* @objective Verify that a managed category is only visible to users who are members of at least one channel in it.
*/
test(
'managed category is only visible when user is a member of a channel in it',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category (without adding the user)
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
await createChannelWithManagedCategory(adminClient, team.id, 'Secret Ops', 'secret');
// # Log in as regular user who is not a member of the channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category is not visible
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Secret Ops')).not.toBeVisible();
},
);
/**
* @objective Verify that channels within a managed category are sorted alphabetically by display name.
*/
test('managed categories sort channels alphabetically', {tag: '@managed_categories'}, async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const suffix = Date.now();
const channelB = await adminClient.createChannel({
team_id: team.id,
name: `bravo-${suffix}`,
display_name: 'Bravo Channel',
type: 'O',
});
const channelA = await adminClient.createChannel({
team_id: team.id,
name: `alpha-${suffix}`,
display_name: 'Alpha Channel',
type: 'O',
});
// # Assign both to the same managed category and add user
await adminClient.patchChannel(channelB.id, {managed_category_name: 'Sorted Category'} as any);
await adminClient.patchChannel(channelA.id, {managed_category_name: 'Sorted Category'} as any);
await adminClient.addToChannel(user.id, channelA.id);
await adminClient.addToChannel(user.id, channelB.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify both channels are visible and Alpha appears before Bravo
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Sorted Category')).toBeVisible();
const alphaItem = sidebar.locator(`#sidebarItem_${channelA.name}`);
const bravoItem = sidebar.locator(`#sidebarItem_${channelB.name}`);
await expect(alphaItem).toBeVisible();
await expect(bravoItem).toBeVisible();
const alphaBox = await alphaItem.boundingBox();
const bravoBox = await bravoItem.boundingBox();
expect(alphaBox).toBeTruthy();
expect(bravoBox).toBeTruthy();
expect(alphaBox!.y).toBeLessThan(bravoBox!.y);
});
/**
* @objective Verify that the favorite button is disabled for channels in managed categories.
*/
test('channels in managed categories cannot be favorited', {tag: '@managed_categories'}, async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Favorites', 'nofav');
await adminClient.addToChannel(adminUser.id, channel.id);
// # Log in and navigate to the managed channel
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const sidebar = channelsPage.sidebarLeft.container;
await pw.waitUntil(
async () =>
sidebar
.getByText('No Favorites')
.isVisible()
.catch(() => false),
{timeout: 15000},
);
// * Verify the favorite button is visible but disabled
const favoriteButton = channelsPage.page.locator('#toggleFavorite');
await expect(favoriteButton).toBeVisible();
await expect(favoriteButton).toBeDisabled();
});
/**
* @objective Verify that managed category headers do not show a context menu on right-click.
*/
test('managed categories do not show a context menu', {tag: '@managed_categories'}, async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Menu', 'nomenu');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Right-click on the managed category header
const sidebar = channelsPage.sidebarLeft.container;
const categoryHeader = sidebar.getByText('No Menu');
await pw.waitUntil(async () => categoryHeader.isVisible().catch(() => false), {timeout: 15000});
await expect(categoryHeader).toBeVisible();
await categoryHeader.click({button: 'right'});
await pw.wait(pw.duration.one_sec);
// * Verify no context menu appears
const categoryMenu = channelsPage.page.locator('.SidebarCategoryMenu');
await expect(categoryMenu).not.toBeVisible();
});
/**
* @objective Verify that the Favorite menu item is disabled in the channel options menu for channels in managed categories.
*/
test(
'channel context menu shows favorite as disabled in managed category',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Context Menu', 'ctx');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the channel options menu via the three-dot button
const sidebar = channelsPage.sidebarLeft.container;
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
await expect(channelItem).toBeVisible();
// Wait for managed-category data to load before opening the menu so
// that isInManagedCategory is already true when the menu renders.
await pw.waitUntil(
async () =>
sidebar
.getByText('Context Menu')
.isVisible()
.catch(() => false),
{timeout: 15000},
);
await channelItem.hover();
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
await menuButton.click();
// * Verify the Favorite menu item is visible but disabled
const favoriteMenuItem = channelsPage.page.getByRole('menuitem', {name: /Favorite/i});
await expect(favoriteMenuItem).toBeVisible();
await expect(favoriteMenuItem).toHaveAttribute('aria-disabled', 'true');
},
);
/**
* @objective Verify that the Move To menu item is disabled for non-admin users on channels in managed categories.
*/
test(
'Move To is disabled for non-admin users on channels in managed categories',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Move', 'nomove');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the channel options menu via the three-dot button
const sidebar = channelsPage.sidebarLeft.container;
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
await expect(channelItem).toBeVisible();
await pw.waitUntil(
async () =>
sidebar
.getByText('No Move')
.isVisible()
.catch(() => false),
{timeout: 15000},
);
await channelItem.hover();
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
await menuButton.click();
// * Verify the Move To menu item is visible but disabled
const moveToMenuItem = channelsPage.page.getByRole('menuitem', {name: /Move to/i});
await expect(moveToMenuItem).toBeVisible();
await expect(moveToMenuItem).toHaveAttribute('aria-disabled', 'true');
},
);
/**
* @objective Verify that assigning the same managed category name to multiple channels groups them under a single
* category header in the sidebar.
*/
test(
'assigning the same category name to multiple channels groups them together',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const suffix = Date.now();
const channel1 = await createChannelWithManagedCategory(
adminClient,
team.id,
'Shared Category',
`shared1-${suffix}`,
);
const channel2 = await createChannelWithManagedCategory(
adminClient,
team.id,
'Shared Category',
`shared2-${suffix}`,
);
// # Add the user to both channels
await adminClient.addToChannel(user.id, channel1.id);
await adminClient.addToChannel(user.id, channel2.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify only one category header exists and both channels are under it
const sidebar = channelsPage.sidebarLeft.container;
const categories = sidebar.getByText('Shared Category');
await expect(categories).toHaveCount(1);
await expect(sidebar.locator(`#sidebarItem_${channel1.name}`)).toBeVisible();
await expect(sidebar.locator(`#sidebarItem_${channel2.name}`)).toBeVisible();
},
);
/**
* @objective Verify that when an admin assigns a managed category to a channel, the category appears in the
* sidebar of other users in real-time via websocket.
*/
test(
'managed category appears in real-time when admin assigns a channel to it',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel without a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `realtime-${Date.now()}`,
display_name: `Realtime Channel ${Date.now()}`,
type: 'O',
});
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category does not exist yet
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Realtime Ops')).not.toBeVisible();
// # Admin assigns a managed category to the channel via API
await adminClient.patchChannel(channel.id, {managed_category_name: 'Realtime Ops'} as any);
// * Verify the managed category appears in real-time
await pw.waitUntil(
async () => {
return sidebar
.getByText('Realtime Ops')
.isVisible()
.catch(() => false);
},
{timeout: 10000},
);
await expect(sidebar.getByText('Realtime Ops')).toBeVisible();
await expect(sidebar.locator(`#sidebarItem_${channel.name}`)).toBeVisible();
},
);
/**
* @objective Verify that the Enable Managed Channel Categories setting is available in the System Console
* under Site Configuration > Users and Teams.
*/
test(
'Enable Managed Channel Categories setting is available in System Console',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup
const {adminUser, adminClient} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
// # Log in and navigate to the System Console
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Users and Teams
await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click();
await systemConsolePage.usersAndTeams.toBeVisible();
// * Verify the setting is visible
const setting = systemConsolePage.usersAndTeams.container.getByTestId(
'TeamSettings.EnableManagedChannelCategoriestrue',
);
await expect(setting).toBeVisible();
},
);
/**
* @objective Verify that a non-channel-admin user sees the managed category selector as disabled in channel settings.
*/
test(
'non-channel-admin sees the managed category selector as disabled',
{tag: '@managed_categories'},
async ({pw}) => {
await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await setupManagedCategoriesTest(pw);
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Locked', 'locked');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user and open channel settings
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify the managed category selector is disabled
const disabledControl = channelSettingsModal.container.locator('.ManagedCategory__control--is-disabled');
await expect(disabledControl).toBeVisible();
await channelSettingsModal.close();
},
);
});

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect, test} from '@mattermost/playwright-lib';
test('should be able to scroll the post list with page up and down', async ({pw}) => {
const {user} = await pw.initSetup();
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
for (let i = 0; i < 10; i++) {
await channelsPage.centerView.postCreate.postMessage('a\n'.repeat(10));
}
let lastScrollTop = await getScrollTop(page, '#postListScrollContainer');
// # Press the page up key with the post textbox focused
channelsPage.centerView.postCreate.input.focus();
await page.keyboard.press('PageUp');
await page.waitForTimeout(200); // Wait for the browser's page up/down animation
// * Verify that the post list scrolled up by a page
let currentScrollTop = await getScrollTop(page, '#postListScrollContainer');
expect(currentScrollTop).toBeLessThan(lastScrollTop);
lastScrollTop = currentScrollTop;
// # Press the page up key with the post list focused
await page.keyboard.press('PageUp');
await page.waitForTimeout(200);
// * Verify that the post list scrolled up another page
currentScrollTop = await getScrollTop(page, '#postListScrollContainer');
expect(currentScrollTop).toBeLessThan(lastScrollTop);
lastScrollTop = currentScrollTop;
// # Press the page down key with the post list focused
await page.keyboard.press('PageDown');
await page.waitForTimeout(200);
// * Verify that the post list scrolled back down a page
currentScrollTop = await getScrollTop(page, '#postListScrollContainer');
expect(currentScrollTop).toBeGreaterThan(lastScrollTop);
lastScrollTop = currentScrollTop;
// # Press the page down key with the post textbox focused
channelsPage.centerView.postCreate.input.focus();
await page.keyboard.press('PageDown');
await page.waitForTimeout(200);
// * Verify that the post list scrolled back to the bottom
currentScrollTop = await getScrollTop(page, '#postListScrollContainer');
expect(currentScrollTop).toBeGreaterThan(lastScrollTop);
});
async function getScrollTop(page: Page, selector: string): Promise<number> {
const locator = await page.locator(selector);
return locator?.evaluate((element) => element.scrollTop);
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
deleteAttributes,
optionId,
purgeAttributes,
readChannelValues,
valueFor,
} from './helpers';
test.describe('Channel attribute assignment', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify every supported attribute control can be assigned while creating a channel.
*/
test('assigns select, multiselect, and text attribute values at channel creation', async ({pw}) => {
await pw.skipIfNoLicense();
// The Properties route gate is evaluated when the API router is built, so
// the flag has to be in the server config before boot.
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// Required, because the create dialog asks only for required attributes.
const program = await createAttribute(adminClient, attributeName('program', suffix), {
options: ['AURORA', 'BOREALIS'],
required: true,
});
const caveats = await createAttribute(adminClient, attributeName('caveats', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
required: true,
});
const note = await createAttribute(adminClient, attributeName('note', suffix), {
type: 'text',
required: true,
});
const userScoped = await createAttribute(adminClient, attributeName('clearance', suffix), {
objectType: 'user',
options: ['SECRET'],
required: true,
});
created.push(program, caveats, note, userScoped);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// Only the channel-scoped attributes belong here; a user attribute in
// the same group must not leak into channel creation.
await expect(page.getByTestId(`channelAttributeRow-${program.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${caveats.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${note.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${userScoped.name}`)).toHaveCount(0);
const displayName = `Attr Channel ${suffix}`;
await modal.fillDisplayName(displayName);
await page.getByTestId(`channelAttribute-${program.name}`).click();
await page.getByText('AURORA', {exact: true}).click();
// The menu closes on each pick, so a multiselect needs reopening to
// add the second value. Two values is what proves the array shape.
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('NOFORN', {exact: true}).click();
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('ORCON', {exact: true}).click();
await page.getByLabel(note.name, {exact: true}).fill('handle with care');
await modal.create();
// The values ride the create request, so the modal closing means the
// channel and its values both landed. The channel header is already
// visible from before, so waiting on it proves nothing.
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
const values = await readChannelValues(adminClient, channel.id);
expect(valueFor(values, program)).toBe(optionId(program, 'AURORA'));
expect(valueFor(values, caveats)).toEqual([optionId(caveats, 'NOFORN'), optionId(caveats, 'ORCON')]);
expect(valueFor(values, note)).toBe('handle with care');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify attribute assignment works for a private channel, not only a public one.
*/
test('assigns attribute values when creating a private channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('private_program', suffix), {
options: ['AURORA', 'BOREALIS'],
required: true,
});
created.push(program);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
const displayName = `Attr Private ${suffix}`;
await modal.fillDisplayName(displayName);
await modal.privateTypeButton.click();
await page.getByTestId(`channelAttribute-${program.name}`).click();
await page.getByText('BOREALIS', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
expect(channel.type).toBe('P');
const values = await readChannelValues(adminClient, channel.id);
expect(valueFor(values, program)).toBe(optionId(program, 'BOREALIS'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the server refuses a channel that is missing a required value.
*/
test('rejects a create that omits a required attribute', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('required_api', suffix), {
options: ['AURORA'],
required: true,
});
created.push(program);
// Straight at the API, which is the point: the requirement holds for
// callers that never see the create dialog.
const name = `attr-api-${suffix}`;
await expect(
adminClient.createChannel({
team_id: team.id,
name,
display_name: `Attr API ${suffix}`,
type: 'O',
}),
).rejects.toThrow();
await expect(adminClient.getChannelByName(team.id, name)).rejects.toThrow();
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,595 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
TEST_LEVELS,
deleteClassificationFieldsIfExist,
setupClassificationWithChannelField,
} from '../channel_classification/helpers';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
attributeToken,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
const BANNER_COLOR = '#1e325c';
test.describe('Channel attribute banner composition', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify an attribute token can be inserted from Channel Settings and previews its resolved value.
*/
test('inserts an attribute token from the Attributes menu and previews the resolved text', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('preview', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-preview-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The composer seeds itself with the banner already on screen, so clear it
// to author from a known template.
// # Insert the attribute
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
// * The token reads as the attribute's label, not its machine name, and
// * the preview resolves it to this channel's value
await expect(configuration.bannerTokenChip(marking.name)).toBeVisible();
await expect(configuration.bannerTextEditor).not.toContainText(attributeToken(marking.name));
await expect(configuration.bannerTokenPreview).toContainText('RESTRICTED');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify every banner-designated attribute shares one banner, and that
* Channel Settings shows them as chips the channel cannot remove.
*/
test('composes one banner from every designated attribute and locks their chips', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('multi_marking', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
sortOrder: 1,
});
const programme = await createAttribute(adminClient, attributeName('multi_programme', suffix), {
type: 'text',
actions: [DISPLAY_BANNER_TOP],
sortOrder: 2,
});
created.push(marking, programme);
const channel = await createChannelForAttributes(adminClient, team, `banner-multi-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
await setChannelValue(adminClient, channel.id, programme, 'AURORA');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Both designated attributes share the one banner, in sort order
await expect(channelsPage.page.getByTestId('channel_banner_text')).toHaveText('SECRET · AURORA');
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * The composer opens showing what the banner is made of
await expect(configuration.bannerTokenChip(marking.name)).toBeVisible();
await expect(configuration.bannerTokenChip(programme.name)).toBeVisible();
// * Designated attributes cannot be taken out of the banner
await expect(configuration.bannerTokenChipRemove(marking.name)).toHaveCount(0);
await expect(configuration.bannerTokenChipRemove(programme.name)).toHaveCount(0);
// * Seeding those chips is not an edit, so the tab opens clean
await expect(configuration.container.getByTestId('SaveChangesPanel__save-btn')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the banner section reads as enabled when an attribute drives the
* banner, even though banner_info stays disabled for that channel.
*/
test('shows the banner section as on when an attribute drives the banner', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('driven', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-driven-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * The channel shows a banner, so its settings must not claim it is off
await expect(channelsPage.page.getByTestId('channel_banner_text')).toContainText('SECRET');
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await expect(configuration.container.getByTestId('channelBannerToggle-button')).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(configuration.bannerTextEditor).toBeVisible();
// * Opening the tab changes nothing, so it must not offer to save
await expect(configuration.container.getByTestId('SaveChangesPanel__save-btn')).toHaveCount(0);
// * The preview still shows what members see, from the value alone
await expect(configuration.bannerTokenPreview).toContainText('SECRET');
// * banner_info itself stays disabled: the value is what renders the banner
const stored = await adminClient.getChannel(channel.id);
expect(stored.banner_info?.enabled ?? false).toBe(false);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner authored as custom text plus a token renders both in the channel.
*/
test('saves a banner mixing custom text with a token', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('mixed', suffix), {
options: ['NOFORN'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {NOFORN: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-mixed-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'NOFORN'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.typeBannerText('Handling: ');
await configuration.insertBannerToken(marking.name);
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
// * The channel banner shows the literal and the resolved token together
await channelsPage.centerView.assertChannelBanner('Handling: NOFORN', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a separator between two tokens is dropped when one of them has no value.
*/
test('tidies the separator when one of two tokens is unset', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('tidy_set', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
sortOrder: 0,
});
// Deliberately left without a value on this channel.
const program = await createAttribute(adminClient, attributeName('tidy_unset', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_INFO],
sortOrder: 1,
});
created.push(marking, program);
const channel = await createChannelForAttributes(adminClient, team, `banner-tidy-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
await configuration.typeBannerText(' · ');
await configuration.insertBannerToken(program.name);
// * The stranded separator is collapsed, not rendered next to nothing
await expect(configuration.bannerTokenPreview).toContainText('SECRET');
await expect(configuration.bannerTokenPreview).not.toContainText('·');
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
await channelsPage.centerView.assertChannelBanner('SECRET', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a template whose tokens are all unset says so rather than previewing a blank line.
*/
test('says so when every token in the template is unset', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('empty_preview', suffix), {
options: ['UNUSED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {UNUSED: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-empty-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.insertBannerToken(marking.name);
// * The preview names the empty result instead of rendering nothing
await expect(configuration.bannerTokenPreview).toContainText('no values are set');
await settings.close();
// * And no banner is rendered from a template that resolves to nothing
await channelsPage.centerView.assertChannelBannerNotVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner keeps resolving after the attribute's display name changes,
* because tokens key off the machine name.
*/
test('keeps resolving after the attribute display name changes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('renamed', suffix), {
options: ['ORCON'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {ORCON: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-rename-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'ORCON'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
// * Exactly what was authored is persisted, tokens unresolved
const saved = await adminClient.getChannel(channel.id);
expect(saved.banner_info?.text).toBe(attributeToken(marking.name));
await channelsPage.centerView.assertChannelBanner('ORCON', BANNER_COLOR);
// # Rename the attribute as an administrator would
await adminClient.patchPropertyField('access_control', 'channel', marking.id, {
attrs: {...marking.attrs, display_name: `Renamed ${suffix}`},
} as never);
// * The authored token still resolves, because it keys off the machine name
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await channelsPage.centerView.assertChannelBanner('ORCON', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify classification is composed as one attribute among many once channel
* attributes are on, rather than through its own dedicated controls.
*/
test('treats classification as one attribute among many', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const classification = await createAttribute(adminClient, attributeName('classification', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
});
created.push(classification);
const channel = await createChannelForAttributes(adminClient, team, `banner-class-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, classification, optionId(classification, 'SECRET'));
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * The dedicated classification controls are gone
await expect(page.locator('#channelClassificationToggle')).toHaveCount(0);
await expect(page.getByTestId('channelClassificationLevel')).toHaveCount(0);
// * The same marking is offered as an ordinary banner token instead
await configuration.enableChannelBanner();
await configuration.bannerTokenButton.click();
await expect(page.getByTestId(`bannerAttributeToken-${classification.name}`)).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify that a non-classification banner attribute leaves the color picker
* editable and the banner toggle unlocked.
*/
test('color picker is editable and toggle is unlocked when a non-classification attribute drives the banner', async ({
pw,
}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('colour_editable', suffix), {
options: ['ORCON'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {ORCON: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `colour-editable-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'ORCON'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * Color picker is editable — no classification in the banner
await expect(
configuration.container.locator('#channel_banner_banner_background_color_picker-inputColorValue'),
).toBeEnabled();
// * Toggle is also not locked
await expect(configuration.container.getByTestId('channelBannerToggle-button')).not.toBeDisabled();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify that when classification is banner-designated, Channel Settings
* locks the color picker to the selected level's color and the user cannot override it.
*/
test('color picker is locked to the classification level color when classification is banner-designated', async ({
pw,
}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
// Provision the classification template + channel-linked field ourselves:
// this test must not depend on state left behind by other spec files.
const {channelFieldId, levels} = await setupClassificationWithChannelField(adminClient, TEST_LEVELS);
const level = levels.find((l) => l.color);
if (!level) {
throw new Error('setupClassificationWithChannelField did not return a coloured level');
}
try {
// Designate classification for the banner
await adminClient.patchPropertyField('access_control', 'channel', channelFieldId, {
attrs: {actions: ['display_banner_top']},
} as never);
const channel = await createChannelForAttributes(adminClient, team, `class-banner-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelFieldId, value: level.id},
] as never);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
const colorInput = configuration.container.locator(
'#channel_banner_banner_background_color_picker-inputColorValue',
);
// * Color picker is disabled — the level colour is authoritative
await expect(colorInput).toBeDisabled();
// * It shows the classification level's colour
await expect(colorInput).toHaveValue(level.color.toUpperCase());
} finally {
await deleteClassificationFieldsIfExist(adminClient);
}
});
/**
* @objective Verify that when a required attribute is banner-designated, the banner
* toggle in Channel Settings is locked and cannot be turned off.
*/
test('banner toggle is disabled when a required attribute designates the banner', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marker = await createAttribute(adminClient, attributeName('req_banner', suffix), {
options: ['RESTRICTED'],
required: true,
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: BANNER_COLOR},
});
created.push(marker);
// Required attributes must be seeded at channel creation time
const channel = await createChannelForAttributes(adminClient, team, `req-banner-${suffix}`, undefined, [
{field_id: marker.id, value: optionId(marker, 'RESTRICTED')},
]);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * Toggle is locked — a required attribute mandates the banner
await expect(configuration.container.getByTestId('channelBannerToggle-button')).toBeDisabled();
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,595 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
test.describe('Channel attribute display and editing', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a designated attribute renders in both header slots, and that an
* undesignated one still reaches the Channel Info panel, read-only, for a channel member.
*/
test('shows a designated attribute in both header slots and Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('shown', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
optionColors: {AURORA: '#1e325c'},
});
// Undesignated: never a chip, but still reachable in Channel Info.
const hidden = await createAttribute(adminClient, attributeName('undesignated', suffix), {
options: ['QUIET'],
});
created.push(program, hidden);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-display-${suffix}`,
display_name: `Attr Display ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AURORA'));
await setChannelValue(adminClient, channel.id, hidden, optionId(hidden, 'QUIET'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// Visible to an ordinary member, not just whoever set it. Designated for
// both slots, so it renders in both.
const {attributes, infoAttributes} = channelsPage.centerView.header;
await expect(attributes.chip('AURORA')).toBeVisible();
await expect(infoAttributes.chip('AURORA')).toBeVisible();
await expect(attributes.chip('QUIET')).toHaveCount(0);
await expect(infoAttributes.chip('QUIET')).toHaveCount(0);
// Channel Info lists what the channel holds regardless of display
// designation -- it is the only surface a value can be edited from, so a
// display setting must not be able to strand one.
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId('channelInfoAttributes')).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeRow-${program.name}`)).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeRow-${hidden.name}`)).toBeVisible();
// A member reads, never writes.
await expect(page.getByTestId(`channelInfoAttributeEdit-${hidden.name}`)).toHaveCount(0);
await expect(page.getByTestId('channelInfoAddAttributeButton')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a channel admin can reach and fill an attribute that is designated
* for no display location at all.
*
* Channel Info is the only surface a value can be edited from, so gating it on the
* display locations would leave a required attribute permanently unset with no way to
* fix it. The panel therefore lists by role, not by designation.
*/
test('keeps an undesignated required attribute editable in Channel Info for a channel admin', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// The channel comes first: a required attribute is refused at create time
// unless the call carries a value for it. Marking an attribute required
// after the fact is what leaves an existing channel incomplete, which is
// the case that needs a way out.
const channel = await createChannelForAttributes(adminClient, team, `nodisplay-${suffix}`);
const channelAdmin = await pw.createNewUserProfile(adminClient, {prefix: 'chanadmin'});
await adminClient.addToTeam(team.id, channelAdmin.id);
await adminClient.addToChannel(channelAdmin.id, channel.id);
await adminClient.updateChannelMemberSchemeRoles(channel.id, channelAdmin.id, true, true);
await adminClient.addToChannel(user.id, channel.id);
const undesignated = await createAttribute(adminClient, attributeName('nodisplay', suffix), {
options: ['FILLED'],
actions: [],
required: true,
});
created.push(undesignated);
// # Look at the incomplete channel as an ordinary member
const asMember = await pw.testBrowser.login(user);
await asMember.channelsPage.goto(team.name, channel.name);
await asMember.channelsPage.toBeVisible();
// * The member is told nothing: an unset required attribute is not theirs
// to fix, and an empty row they cannot fill only reads as a broken channel
await asMember.channelsPage.openChannelInfo();
await expect(asMember.page.getByTestId(`channelInfoAttributeRow-${undesignated.name}`)).toHaveCount(0);
// # Look at the same channel as the channel admin
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * No chips on either header slot, because nothing was designated
await expect(channelsPage.centerView.header.attributes.container).toHaveCount(0);
await expect(channelsPage.centerView.header.infoAttributes.container).toHaveCount(0);
// * But the row is there, saying the channel is incomplete, and it can be filled
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.unset(undesignated.name)).toBeVisible();
await info.attributes.select(undesignated.name, 'FILLED');
await expect(info.attributes.chip(undesignated.name)).toHaveText('FILLED');
// * And once filled it reaches the member too, still read-only
const asMemberAgain = await pw.testBrowser.login(user);
await asMemberAgain.channelsPage.goto(team.name, channel.name);
await asMemberAgain.channelsPage.toBeVisible();
const memberInfo = await asMemberAgain.channelsPage.openChannelInfo();
await expect(memberInfo.attributes.chip(undesignated.name)).toHaveText('FILLED');
await expect(memberInfo.attributes.editButton(undesignated.name)).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify chips collapse into +N at a narrow viewport without displacing the header controls.
*/
test('collapses overflowing chips into +N without moving header controls', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-overflow-${suffix}`,
display_name: `Attr Overflow ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
for (let i = 0; i < 5; i++) {
// The inline slot: the one that shares its row with the header controls,
// so it is the only one where yielding space is an invariant.
const field = await createAttribute(adminClient, attributeName(`overflow${i}`, suffix), {
options: [`LONG_VALUE_NUMBER_${i}`],
actions: [DISPLAY_LABEL_INFO],
sortOrder: i,
});
created.push(field);
await setChannelValue(adminClient, channel.id, field, optionId(field, `LONG_VALUE_NUMBER_${i}`));
}
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const infoButton = page.locator('#channel-info-btn');
const row = channelsPage.centerView.header.infoAttributes.visibleRow;
await expect(page.getByTestId('attributeChip').first()).toBeVisible();
const wideX = (await infoButton.boundingBox())?.x ?? 0;
// The invariant, at every width: the row never scrolls. A chip clipped by
// overflow:hidden with no +N beside it is a marking silently hidden.
// Narrow, but still desktop: below 768px the header switches to the mobile
// layout and the icon row is not rendered at all.
await page.setViewportSize({width: 800, height: 800});
await expect(page.getByTestId('channelAttributeLabelsOverflow-info')).toBeVisible();
// Fewer chips shown than exist, and the remainder is reachable.
const shown = await page.getByTestId('attributeChip').count();
expect(shown).toBeGreaterThan(0);
expect(shown).toBeLessThan(5);
// The row yields space rather than claiming it, so the controls after it
// are not pushed further right as the window narrows.
const narrowX = (await infoButton.boundingBox())?.x ?? 0;
expect(narrowX).toBeLessThanOrEqual(wideX);
// The row is bounded by its container: whatever it cannot show goes to
// the popover rather than spilling across the header.
const spill = await row.evaluate((el: HTMLElement) => {
const parent = el.parentElement!.parentElement!;
return el.getBoundingClientRect().right - parent.getBoundingClientRect().right;
});
expect(spill).toBeLessThanOrEqual(1);
await page.getByTestId('channelAttributeLabelsOverflow-info').click();
await expect(page.getByTestId('channelAttributeLabelsPopover-info')).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a locked attribute renders read-only with its reason, and that the lock is enforced server-side.
*
* `editable: false` with no change_policy reads as "never", so the lock is a
* server-side invariant and not only a hidden pencil.
*/
test('renders a locked attribute read-only and validates the lock key server-side', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const locked = await createAttribute(adminClient, attributeName('locked', suffix), {
options: ['FIXED', 'OTHER'],
actions: [DISPLAY_LABEL_INFO],
editable: false,
});
created.push(locked);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-locked-${suffix}`,
display_name: `Attr Locked ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, locked, optionId(locked, 'FIXED'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
// Shown with the reason: hiding it looks like a missing marking.
await expect(page.getByTestId(`channelInfoAttributeRow-${locked.name}`)).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeEdit-${locked.name}`)).toHaveCount(0);
// The key is type-validated, so a truthy string cannot smuggle past it.
await expect(
adminClient.patchPropertyField('access_control', 'channel', locked.id, {
attrs: {...locked.attrs, editable: 'yes'},
} as never),
).rejects.toThrow();
// Refused for a system admin too: the lock is a property of the attribute,
// not a permission tier.
await expect(setChannelValue(adminClient, channel.id, locked, optionId(locked, 'OTHER'))).rejects.toThrow();
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.find((value) => value.field_id === locked.id)?.value).toBe(optionId(locked, 'FIXED'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify an optional attribute is absent from creation, addable from Channel Info, and produces no chip.
*/
test('adds an optional attribute from Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const optional = await createAttribute(adminClient, attributeName('optional', suffix), {
options: ['LATER'],
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
const displayName = `Attr Optional ${suffix}`;
await modal.fillDisplayName(displayName);
// Optional attributes are not asked for at creation.
await expect(page.getByTestId(`channelAttributeRow-${optional.name}`)).toHaveCount(0);
await modal.create();
await expect(modal.container).not.toBeVisible();
await page.locator('#channel-info-btn').click();
await page.getByTestId('channelInfoAddAttributeButton').click();
await page.getByText(optional.name, {exact: false}).last().click();
await page.getByTestId(`channelAttributeEdit-${optional.name}`).click();
await page.getByText('LATER', {exact: true}).click();
await expect(page.getByTestId(`channelInfoAttributeRow-${optional.name}`)).toContainText('LATER');
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
await expect
.poll(async () => {
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
return values?.find((value) => value.field_id === optional.id)?.value;
})
.toBe(optionId(optional, 'LATER'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner-designated attribute drives the channel banner.
*/
test('renders a banner from a banner-designated attribute', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('banner', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: '#c8102e'},
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-banner-${suffix}`,
display_name: `Attr Banner ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// Falls back to the value name, reproducing today's classification banner.
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a multiselect banner attribute set at channel creation banners the new channel immediately.
*/
test('banners a multiselect attribute filled while creating the channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// Required so the create dialog asks for it, which is how a channel gets
// a banner value before anyone has opened Channel Settings.
const caveats = await createAttribute(adminClient, attributeName('bannermulti', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
required: true,
actions: [DISPLAY_BANNER_TOP, DISPLAY_LABEL_HEADER],
});
created.push(caveats);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner Multi ${suffix}`);
// The menu closes on each pick, so the second value needs it reopened.
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('NOFORN', {exact: true}).click();
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('ORCON', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// No reload, no Channel Settings visit: creation alone has to banner it.
await expect(page.getByTestId('channel_banner_text')).toContainText('NOFORN, ORCON');
// Selections may each carry a different colour, so none of them wins and
// the banner falls back rather than rendering transparent.
await expect(page.getByTestId('channel_banner_container')).toHaveCSS(
'background-color',
'rgb(221, 221, 221)',
);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a text banner attribute banners the channel with the string it stores.
*/
test('banners a text attribute filled while creating the channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('bannertext', suffix), {
type: 'text',
required: true,
actions: [DISPLAY_BANNER_TOP],
});
created.push(note);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner Text ${suffix}`);
await page.getByLabel(note.name, {exact: true}).fill('HANDLE WITH CARE');
await modal.create();
await expect(modal.container).not.toBeVisible();
// A text attribute has no options, so the stored string is the banner.
await expect(page.getByTestId('channel_banner_text')).toContainText('HANDLE WITH CARE');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a user without the setter tier sees values but no editing affordance.
*/
test('hides editing from a user without the setter tier', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// admin tier resolves to manage_channel_roles, which a member lacks.
const adminOnly = await createAttribute(adminClient, attributeName('admin_tier', suffix), {
options: ['SET'],
actions: [DISPLAY_LABEL_INFO],
permissionValues: 'admin',
});
created.push(adminOnly);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-tier-${suffix}`,
display_name: `Attr Tier ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, adminOnly, optionId(adminOnly, 'SET'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId(`channelInfoAttributeRow-${adminOnly.name}`)).toContainText('SET');
await expect(page.getByTestId(`channelInfoAttributeEdit-${adminOnly.name}`)).toHaveCount(0);
// Asserted per attribute rather than on the button as a whole: the button
// is shared, so any other attribute this user *can* set would keep it on
// screen and say nothing about this one.
const addButton = page.getByTestId('channelInfoAddAttributeButton');
if (await addButton.count()) {
await addButton.click();
await expect(page.getByTestId(`channelInfoAddAttribute-${adminOnly.name}`)).toHaveCount(0);
}
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify every surface reverts when the feature flag is off.
*/
test('renders no attribute surfaces with the flag off', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', false);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
const marking = await createAttribute(adminClient, attributeName('flagoff', suffix), {
options: ['HIDDEN'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-flagoff-${suffix}`,
display_name: `Attr FlagOff ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'HIDDEN'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The value stays in the database; only the surfaces disappear.
await expect(page.getByTestId('channelAttributeLabels-header')).toHaveCount(0);
await expect(page.getByTestId('channelAttributeLabels-info')).toHaveCount(0);
await expect(page.getByText('HIDDEN')).toHaveCount(0);
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId('channelInfoAttributes')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,519 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
attributeName,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
readChannelValues,
setChannelValue,
valueFor,
} from './helpers';
// Editing in Channel Info is admin-only, whatever an attribute's own setter tier
// says: useIsChannelAttributeAdmin gates the pencil, canSet only narrows further.
async function promoteToChannelAdmin(
pw: PlaywrightExtended,
adminClient: Client4,
team: Team,
channelId: string,
prefix: string,
) {
const channelAdmin = await pw.createNewUserProfile(adminClient, {prefix});
await adminClient.addToTeam(team.id, channelAdmin.id);
await adminClient.addToChannel(channelAdmin.id, channelId);
await adminClient.updateChannelMemberSchemeRoles(channelId, channelAdmin.id, true, true);
return channelAdmin;
}
test.describe('Channel attribute editing', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a text value can be changed from Channel Info and commits on Enter.
*/
test('edits a text attribute inline and commits on Enter', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('note', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-text-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-note-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'first draft');
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Replace the existing value and commit with Enter
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(note.name)).toHaveText('first draft');
await info.attributes.setText(note.name, 'second draft', 'enter');
// * The new value replaces the old one, in the panel and in the store
await expect(info.attributes.chip(note.name)).toHaveText('second draft');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('second draft');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a text edit commits on blur but is abandoned on Escape.
*/
test('commits a text edit on blur and abandons it on Escape', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('commit', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-commit-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-commit-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'original');
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// # Type a new value and click away
await info.attributes.setText(note.name, 'blurred', 'blur');
await expect(info.attributes.chip(note.name)).toHaveText('blurred');
// # Type again, then abandon with Escape
await info.attributes.setText(note.name, 'discarded', 'escape');
// * Escape leaves the committed value untouched
await expect(info.attributes.chip(note.name)).toHaveText('blurred');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('blurred');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a multiselect value can gain and lose options after it is first set.
*/
test('adds and removes a multiselect option, and the header chips follow', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const caveats = await createAttribute(adminClient, attributeName('caveats', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(caveats);
const channel = await createChannelForAttributes(adminClient, team, `edit-multi-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-caveats-${suffix}`,
);
await setChannelValue(adminClient, channel.id, caveats, [optionId(caveats, 'NOFORN')]);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// # Add a second option to the existing one
await info.attributes.select(caveats.name, 'ORCON');
// * Both options are stored, in the order they were picked
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), caveats);
})
.toEqual([optionId(caveats, 'NOFORN'), optionId(caveats, 'ORCON')]);
// # Remove the first one from the still-open editor
await info.attributes.deselect(caveats.name, 'NOFORN');
// * Only the remaining option survives, and the header agrees
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), caveats);
})
.toEqual([optionId(caveats, 'ORCON')]);
await expect(channelsPage.centerView.header.attributes.chip('ORCON')).toBeVisible();
await expect(channelsPage.centerView.header.attributes.chip('NOFORN')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a failed value write is reported in the row and does not discard the stored value.
*/
test('surfaces an inline error when the value write fails and keeps the previous value', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('failing', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-fail-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-fail-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'kept');
const {page, channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.route('**/api/v4/properties/groups/access_control/channel/values/**', (route) => {
if (route.request().method() !== 'PATCH') {
return route.continue();
}
return route.fulfill({status: 500, body: '{"message":"forced failure"}'});
});
const info = await channelsPage.openChannelInfo();
await info.attributes.setText(note.name, 'never saved', 'enter');
// * The row says the write failed, and keeps the edit open so it can be
// * retried without retyping
await expect(info.attributes.error(note.name)).toBeVisible();
await expect(info.attributes.editor(note.name)).toHaveValue('never saved');
await page.unroute('**/api/v4/properties/groups/access_control/channel/values/**');
// * Nothing was stored, so the previous value stands
expect(valueFor(await readChannelValues(adminClient, channel.id), note)).toBe('kept');
// # Retry now that the write succeeds
await info.attributes.editor(note.name).press('Enter');
await expect(info.attributes.chip(note.name)).toHaveText('never saved');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('never saved');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a locked attribute can be filled once and is read-only afterwards.
*/
test('fills a locked attribute once, after which it is read-only', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
// Channel first: the server refuses a channel that misses a required
// attribute, so the only way to reach a required-but-unset value is for the
// attribute to become required after the channel exists. That is also how a
// real server gets there, when an admin marks an attribute required later.
const channel = await createChannelForAttributes(adminClient, team, `edit-lock-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-lock-${suffix}`,
);
// The lock bites only once a value exists, so an empty one is still fillable.
const marking = await createAttribute(adminClient, attributeName('locked_once', suffix), {
options: ['FINAL'],
actions: [DISPLAY_LABEL_INFO],
editable: false,
required: true,
});
created.push(marking);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * A required attribute with no value shows as unset and is still editable
await expect(info.attributes.unset(marking.name)).toBeVisible();
await expect(info.attributes.lock(marking.name)).toHaveCount(0);
// # Set it for the first time
await info.attributes.select(marking.name, 'FINAL');
await expect(info.attributes.chip(marking.name)).toHaveText('FINAL');
// * Once set, the row is locked: no pencil, and the lock icon appears
await expect(info.attributes.lock(marking.name)).toBeVisible();
await expect(info.attributes.editButton(marking.name)).toHaveCount(0);
expect(valueFor(await readChannelValues(adminClient, channel.id), marking)).toBe(
optionId(marking, 'FINAL'),
);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the admin setter tier admits a channel admin and excludes a plain member.
*/
test('lets a channel admin edit an admin-tier attribute that a member cannot', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const adminOnly = await createAttribute(adminClient, attributeName('admin_edit', suffix), {
options: ['SET'],
actions: [DISPLAY_LABEL_INFO],
permissionValues: 'admin',
});
created.push(adminOnly);
const channel = await createChannelForAttributes(adminClient, team, `edit-tier-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-tier-${suffix}`,
);
await adminClient.addToChannel(user.id, channel.id);
// # Look at it as the channel admin
const asAdmin = await pw.testBrowser.login(channelAdmin);
await asAdmin.channelsPage.goto(team.name, channel.name);
await asAdmin.channelsPage.toBeVisible();
const adminInfo = await asAdmin.channelsPage.openChannelInfo();
// Added rather than edited: an unset optional attribute has no row until
// it has a value.
// * The channel admin clears the admin tier and can set it
await adminInfo.attributes.add(adminOnly.name, 'SET');
await expect(adminInfo.attributes.chip(adminOnly.name)).toHaveText('SET');
// # Look at the same attribute as an ordinary member
const asMember = await pw.testBrowser.login(user);
await asMember.channelsPage.goto(team.name, channel.name);
await asMember.channelsPage.toBeVisible();
const memberInfo = await asMember.channelsPage.openChannelInfo();
// * The member sees the value but is offered no way to change it
await expect(memberInfo.attributes.chip(adminOnly.name)).toHaveText('SET');
await expect(memberInfo.attributes.editButton(adminOnly.name)).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify Channel Info management (unset required rows, Add Attribute,
* and the pencil) is available to a channel admin regardless of an attribute's
* own setter tier.
*/
test('shows a channel admin every attribute, including unset ones, and lets them add or edit', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
// Member-tier on purpose: management stays admin-only in Channel Info
// even for an attribute a plain member is otherwise allowed to set.
const required = await createAttribute(adminClient, attributeName('req', suffix), {
actions: [DISPLAY_LABEL_INFO],
options: ['DRAFT'],
required: true,
});
created.push(required);
const optional = await createAttribute(adminClient, attributeName('opt', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const channel = await createChannelForAttributes(
adminClient,
team,
`edit-admin-lists-${suffix}`,
undefined,
[{field_id: required.id, value: optionId(required, 'DRAFT')}],
);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-lists-${suffix}`,
);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * A required attribute that already has a value is listed and editable
await expect(info.attributes.chip(required.name)).toHaveText('DRAFT');
await expect(info.attributes.editButton(required.name)).toBeVisible();
// * An unset optional attribute is reachable through Add Attribute, and
// * becomes editable once added
await expect(info.attributes.row(optional.name)).toHaveCount(0);
await info.attributes.add(optional.name);
await info.attributes.editor(optional.name).fill('drafted');
await info.attributes.editor(optional.name).press('Enter');
await expect(info.attributes.chip(optional.name)).toHaveText('drafted');
await expect(info.attributes.editButton(optional.name)).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a plain member only sees attributes that already carry a
* value, always read-only, with no Add Attribute affordance.
*/
test('shows a plain member only what is already set, read-only, with no way to manage attributes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const required = await createAttribute(adminClient, attributeName('req', suffix), {
actions: [DISPLAY_LABEL_INFO],
options: ['DRAFT'],
required: true,
});
created.push(required);
const optional = await createAttribute(adminClient, attributeName('opt', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const channel = await createChannelForAttributes(
adminClient,
team,
`edit-member-hides-${suffix}`,
undefined,
[{field_id: required.id, value: optionId(required, 'DRAFT')}],
);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * The required attribute's value is visible, but there is no pencil
await expect(info.attributes.chip(required.name)).toHaveText('DRAFT');
await expect(info.attributes.editButton(required.name)).toHaveCount(0);
// * The unset optional attribute has no row, and there is no way to add one
await expect(info.attributes.row(optional.name)).toHaveCount(0);
await expect(info.attributes.addButton).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
test.describe('Channel attribute lifecycle', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a required attribute is asked for at creation and blocks it until filled.
*/
test('blocks channel creation until a required attribute is filled', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const required = await createAttribute(adminClient, attributeName('mandatory', suffix), {
options: ['ALPHA'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
required: true,
});
const optional = await createAttribute(adminClient, attributeName('discretionary', suffix), {
options: ['BETA'],
actions: [DISPLAY_LABEL_INFO],
});
created.push(required, optional);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// Required is asked for; optional is not.
await expect(page.getByTestId(`channelAttributeRow-${required.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${optional.name}`)).toHaveCount(0);
const displayName = `Attr Required ${suffix}`;
await modal.fillDisplayName(displayName);
// The dialog blocks submission so the user finds out here; the server
// refuses the same create independently.
await expect(modal.createButton).toBeDisabled();
await page.getByTestId(`channelAttribute-${required.name}`).click();
await page.getByText('ALPHA', {exact: true}).click();
await expect(modal.createButton).toBeEnabled();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.find((value) => value.field_id === required.id)?.value).toBe(optionId(required, 'ALPHA'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a value changed elsewhere reaches an open session without a reload.
*/
test('updates an open session when a value changes, and when it is cleared', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('live', suffix), {
options: ['BEFORE', 'AFTER'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(program);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-live-${suffix}`,
display_name: `Attr Live ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, program, optionId(program, 'BEFORE'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const chips = page.getByTestId('attributeChip');
await expect(chips.filter({hasText: 'BEFORE'}).first()).toBeVisible();
// Changed elsewhere, this session untouched.
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AFTER'));
await expect(chips.filter({hasText: 'AFTER'}).first()).toBeVisible();
await expect(chips.filter({hasText: 'BEFORE'})).toHaveCount(0);
// The shape most easily got wrong: a clear returns a null-valued row,
// not a delete event.
await setChannelValue(adminClient, channel.id, program, null);
await expect(page.getByTestId('attributeChip')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a required attribute left unset after creation is visible and recoverable from Channel Info.
*/
test('shows a required attribute as unset and lets it be filled from Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// The channel comes first, because creating one that misses a required
// attribute is refused now. What remains reachable is an attribute that
// becomes required after the fact, which is how an existing channel ends up
// short of its own requirement.
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-unset-${suffix}`,
display_name: `Attr Unset ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
// The unset-required row and its edit path are admin-only in Channel Info,
// so recovering it needs a channel admin, not a plain member.
await adminClient.updateChannelMemberSchemeRoles(channel.id, user.id, true, true);
const required = await createAttribute(adminClient, attributeName('unfilled', suffix), {
options: ['RECOVERED'],
actions: [DISPLAY_LABEL_INFO],
required: true,
});
created.push(required);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
// The empty row is both the signal and the retry path.
const row = page.getByTestId(`channelInfoAttributeRow-${required.name}`);
await expect(row).toBeVisible();
await expect(row).toContainText('Not set');
// No chip while unset.
await expect(page.getByTestId('attributeChip')).toHaveCount(0);
await page.getByTestId(`channelInfoAttributeEdit-${required.name}`).click();
await page.getByTestId(`channelAttributeEdit-${required.name}`).click();
await page.getByText('RECOVERED', {exact: true}).click();
await expect(row).toContainText('RECOVERED');
await expect
.poll(async () => {
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
return values?.find((value) => value.field_id === required.id)?.value;
})
.toBe(optionId(required, 'RECOVERED'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the banner renders attribute tokens, and that a manual banner text still wins.
*/
test('renders a token banner and honours a manual override', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('bannertoken', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: '#c8102e'},
sortOrder: 0,
});
const program = await createAttribute(adminClient, attributeName('bannerprogram', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_INFO],
sortOrder: 1,
});
created.push(marking, program);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-token-${suffix}`,
display_name: `Attr Token ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AURORA'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// No manual text: falls back to the value name.
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
// A two-attribute template resolves against this channel.
await adminClient.patchChannel(channel.id, {
banner_info: {
enabled: true,
text: `{{${marking.name}}} · {{${program.name}}}`,
background_color: '#c8102e',
},
} as never);
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED · AURORA');
// A literal passes through untouched.
await adminClient.patchChannel(channel.id, {
banner_info: {enabled: true, text: 'Handle with care', background_color: '#c8102e'},
} as never);
await expect(page.getByTestId('channel_banner_text')).toContainText('Handle with care');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a direct message shows no attribute chips even when values exist through the API.
*/
test('shows no chips on a direct message', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('dm', suffix), {
options: ['PRIVATE'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(marking);
const other = await pw.createNewUserProfile(adminClient);
await adminClient.addToTeam(team.id, other.id);
const dm = await adminClient.createDirectChannel([user.id, other.id]);
// Not blocked at the API level, but no DM surface displays them.
await setChannelValue(adminClient, dm.id, marking, optionId(marking, 'PRIVATE'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
await page.goto(`/${team.name}/messages/@${other.username}`);
await expect(page.getByTestId('channelAttributeLabels-header')).toHaveCount(0);
await expect(page.getByTestId('channelAttributeLabels-info')).toHaveCount(0);
await expect(page.getByText('PRIVATE')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the configured colour reaches the chip, since a marking's colour is part of how it is read.
*/
test('applies the configured colour to a chip', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('coloured', suffix), {
options: ['DARKBG'],
actions: [DISPLAY_LABEL_HEADER],
optionColors: {DARKBG: '#1e325c'},
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-colour-${suffix}`,
display_name: `Attr Colour ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'DARKBG'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const chip = page.getByTestId('attributeChip').filter({hasText: 'DARKBG'}).first();
await expect(chip).toBeVisible();
await expect(chip).toHaveCSS('background-color', 'rgb(30, 50, 92)');
// Contrast is derived, so a dark background must produce light text.
await expect(chip).toHaveCSS('color', 'rgb(255, 255, 255)');
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {Team} from '@mattermost/types/teams';
// Channel attributes share the access_control group with Classification Markings.
export const GROUP = 'access_control';
export const TARGET_TYPE = 'system';
// Prefix on every field these specs create, so cleanup never touches real ones.
export const FIELD_PREFIX = 'chanattr_e2e';
// Mirrors the allow-list in model/property_field_attrs_validation.go.
export const DISPLAY_LABEL_HEADER = 'display_label_header';
export const DISPLAY_LABEL_INFO = 'display_label_info';
export const DISPLAY_BANNER_TOP = 'display_banner_top';
type CreateOptions = {
objectType?: 'channel' | 'user';
type?: 'select' | 'multiselect' | 'text';
options?: string[];
required?: boolean;
// Omit to leave unset, which reads as editable. Pass false to lock.
editable?: boolean;
// Undesignated attributes are stored but never shown.
actions?: string[];
// Defaults to member, so an ordinary channel member can set a value.
permissionValues?: 'none' | 'sysadmin' | 'admin' | 'member';
sortOrder?: number;
optionColors?: Record<string, string>;
};
// The server validates field names as CEL identifiers, so anything that is not
// a word character has to be folded away before it reaches the API.
export function attributeName(suffix: string, uniqueId: string): string {
return `${FIELD_PREFIX}_${suffix}_${uniqueId}`.replace(/[^A-Za-z0-9_]/g, '_');
}
export async function createAttribute(
adminClient: Client4,
name: string,
{
objectType = 'channel',
type,
options = [],
required,
editable,
actions,
permissionValues = 'member',
sortOrder,
optionColors,
}: CreateOptions = {},
): Promise<PropertyField> {
const field: Record<string, unknown> = {
name,
type: type ?? (options.length ? 'select' : 'text'),
target_type: TARGET_TYPE,
target_id: '',
permission_field: 'admin',
permission_values: permissionValues,
permission_options: 'admin',
};
const attrs: Record<string, unknown> = {};
if (options.length) {
attrs.options = options.map((optionName) => ({
id: '',
name: optionName,
...(optionColors?.[optionName] ? {color: optionColors[optionName]} : {}),
}));
}
if (required !== undefined) {
attrs.required = required;
}
if (editable !== undefined) {
attrs.editable = editable;
}
if (actions) {
attrs.actions = actions;
}
if (sortOrder !== undefined) {
attrs.sort_order = sortOrder;
}
if (Object.keys(attrs).length) {
field.attrs = attrs;
}
return adminClient.createPropertyField(GROUP, objectType, field as Parameters<Client4['createPropertyField']>[2]);
}
// Sets up channel state without driving the UI.
export async function setChannelValue(
client: Client4,
channelId: string,
field: PropertyField,
value: unknown,
): Promise<void> {
await client.patchPropertyValues(GROUP, 'channel', channelId, [
{field_id: field.id, value} as Parameters<Client4['patchPropertyValues']>[3][number],
]);
}
export async function createChannelForAttributes(
adminClient: Client4,
team: Team,
suffix: string,
displayName = `Attr ${suffix}`,
propertyValues?: Array<{field_id: string; value: unknown}>,
): Promise<Channel> {
return adminClient.createChannel({
team_id: team.id,
name: `attr-${suffix}`.toLowerCase(),
display_name: displayName,
type: 'O',
// Required attributes are refused server-side, so a channel seeded for a
// spec has to carry them in the create call.
...(propertyValues?.length ? {property_values: propertyValues} : {}),
} as Channel);
}
export async function setBannerTemplate(
client: Client4,
channelId: string,
text: string,
backgroundColor = '#1E325C',
): Promise<void> {
await client.patchChannel(channelId, {
banner_info: {enabled: true, text, background_color: backgroundColor},
} as Partial<Channel>);
}
// Mirrors attributeToken() in components/channel_attributes/banner_template.ts.
export function attributeToken(name: string): string {
return `{{${name}}}`;
}
export function optionId(field: PropertyField, name: string): string {
const options = (field.attrs?.options ?? []) as Array<{id: string; name: string}>;
const match = options.find((option) => option.name === name);
if (!match) {
throw new Error(`option ${name} not found on ${field.name}`);
}
return match.id;
}
// Values are typed loosely because a multiselect stores an array where a select
// stores a single option id. The endpoint answers a bare null, not an empty
// array, when a channel has no values at all.
export async function readChannelValues(client: Client4, channelId: string): Promise<Array<PropertyValue<unknown>>> {
const values = await client.getPropertyValues<unknown>(GROUP, 'channel', channelId);
return values ?? [];
}
export function valueFor(values: Array<PropertyValue<unknown>>, field: PropertyField): unknown {
return values.find((value) => value.field_id === field.id)?.value;
}
export async function deleteAttributes(adminClient: Client4, fields: PropertyField[]): Promise<void> {
for (const field of fields) {
try {
await adminClient.deletePropertyField(GROUP, field.object_type, field.id);
} catch {} // eslint-disable-line no-empty
}
}
// Best-effort cleanup of fields left behind by an interrupted run.
export async function purgeAttributes(adminClient: Client4): Promise<void> {
for (const objectType of ['channel', 'user'] as const) {
try {
const fields = await adminClient.getPropertyFields(GROUP, objectType, TARGET_TYPE);
const stale = (fields ?? []).filter(
(field) => field.name.startsWith(FIELD_PREFIX) && field.delete_at === 0,
);
await deleteAttributes(adminClient, stale);
} catch {} // eslint-disable-line no-empty
}
}
/**
* Throws when the server carries a required channel attribute these specs did not
* create.
*
* A stray one breaks every fixture that creates a channel — as a 400 from the API,
* as a 30s timeout on a disabled Create button here — and neither failure names the
* cause. purgeAttributes cannot clear them: it is scoped to FIELD_PREFIX so it never
* deletes a field someone meant to keep.
*/
export async function assertNoForeignRequiredAttributes(adminClient: Client4): Promise<void> {
const fields = await adminClient.getPropertyFields(GROUP, 'channel', TARGET_TYPE, undefined, {perPage: 200});
const foreign = (fields ?? []).filter(
(field) => field.delete_at === 0 && field.attrs?.required === true && !field.name.startsWith(FIELD_PREFIX),
);
if (foreign.length) {
const names = foreign.map((field) => field.name).join(', ');
throw new Error(
`Required channel attributes not owned by these specs are present: ${names}. ` +
'Channel creation cannot complete until each has a value. Delete them, or turn off Required, and re-run.',
);
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
const EMOJI_SIZE = 16;
test('Should show channel banner when configured', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
let channelSettingsModal = await channelsPage.openChannelSettings();
let configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('Example channel banner text');
await configurationTab.setChannelBannerBackgroundColor('77DD88');
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBanner('Example channel banner text', '#77DD88');
// Now we'll disable the channel banner
channelSettingsModal = await channelsPage.openChannelSettings();
configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.disableChannelBanner();
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBannerNotVisible();
// re-enabling channel banner should already have
// the previously configured text and color
channelSettingsModal = await channelsPage.openChannelSettings();
configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBanner('Example channel banner text', '#77DD88');
});
test('Should show channel banner in thread view', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
// Configure the channel banner
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('Thread banner text');
await configurationTab.setChannelBannerBackgroundColor('AA33BB');
await configurationTab.save();
await channelSettingsModal.close();
// Post a message and open the thread
await channelsPage.centerView.postMessage('Message to create a thread');
const post = await channelsPage.centerView.getLastPost();
await post.reply();
await channelsPage.sidebarRight.toBeVisible();
await channelsPage.sidebarRight.assertChannelBanner('Thread banner text', '#AA33BB');
});
test('Should not show channel banner in thread view when disabled', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
await channelsPage.centerView.toBeVisible();
// Focus and type character-by-character to avoid React clearing a programmatic fill()
await channelsPage.centerView.postCreate.input.click();
await channelsPage.centerView.postCreate.input.pressSequentially('Message without banner');
await channelsPage.centerView.postCreate.sendMessage();
const post = await channelsPage.centerView.getLastPost();
await post.reply();
await channelsPage.sidebarRight.toBeVisible();
await channelsPage.sidebarRight.assertChannelBannerNotVisible();
});
test('Should render image emoticons without clipping', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// :dog: is in Mattermost's emoji map → renders as .emoticon (background-image).
// Unicode emojis that are also in the map (e.g. 🐶) follow the same path.
await configurationTab.setChannelBannerText('Hello :dog:');
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBannerImageEmojiSize(EMOJI_SIZE);
});
test('Should render unsupported unicode emoji without clipping', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// 🫠 (U+1FAE0, Unicode 14.0) is above Mattermost's emoji map ceiling (1FAD6)
// so it falls through to the .emoticon--unicode span path.
await configurationTab.setChannelBannerText('Hello 🫠');
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBannerUnicodeEmojiSize(EMOJI_SIZE);
});
test('Should render text with descenders without clipping', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// Characters with descenders (parts that extend below the baseline).
// Previously clipped because line-height equalled font-size (13px), leaving
// no room below the baseline for g, j, p, q, y etc.
await configurationTab.setChannelBannerText('YyGgQqJj');
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBannerTextNotClipped();
});
test('Should render markdown', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have Enterprise Advanced license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(pw.random.id(), 'O');
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('**bold** *italic* ~~strikethrough~~');
await configurationTab.setChannelBannerBackgroundColor('77DD88');
await configurationTab.save();
await channelSettingsModal.close();
await channelsPage.centerView.assertChannelBannerHasBoldText('bold');
await channelsPage.centerView.assertChannelBannerHasItalicText('italic');
await channelsPage.centerView.assertChannelBannerHasStrikethroughText('strikethrough');
});

View File

@@ -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 channelsPage.channelBookmarksBar.toBeVisible();
await expect(channelsPage.channelBookmarksBar.getBookmark(/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 channelsPage.channelBookmarksBar.toBeVisible();
await expect(channelsPage.channelBookmarksBar.getBookmark(/mattermost-icon/i)).toBeVisible();
});

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, testConfig} from '@mattermost/playwright-lib';
import {createLinkBookmark, createLinkBookmarks} from './support';
/**
* @objective Verify the bookmarks bar remains visible and usable with an announcement banner present.
*/
test('MM-T5609 displays the bookmarks bar correctly with an announcement banner', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Banner ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'Mattermost Community', 'https://community.mattermost.com');
const bannerText = `Announcement ${pw.random.id()}`;
// # Enable an announcement banner above a channel with a bookmark
await adminClient.patchConfig({
AnnouncementSettings: {
EnableBanner: true,
BannerText: bannerText,
},
} as any);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
// * Verify both the announcement and bookmark display correctly
await expect(page.getByText(bannerText, {exact: true})).toBeVisible();
await channelsPage.channelBookmarksBar.toBeVisible();
await expect(channelsPage.channelBookmarksBar.getBookmark('Mattermost Community')).toBeVisible();
});
/**
* @objective Verify external bookmarks open in a new tab and internal bookmarks navigate within Mattermost.
*/
test('MM-T5611 opens external and internal bookmark links', {tag: '@channels'}, async ({pw}) => {
// # Create external and internal link bookmarks
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Open ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage, page} = await pw.testBrowser.login(user);
await createLinkBookmark(userClient, channel.id, 'External site', 'https://example.com');
await createLinkBookmark(
userClient,
channel.id,
'Town Square',
new URL(`/${team.name}/channels/town-square`, testConfig.baseURL).toString(),
);
await channelsPage.goto(team.name, channel.name);
// # Open the external bookmark
const popupPromise = page.waitForEvent('popup');
await channelsPage.channelBookmarksBar.getBookmark('External site').click();
const popup = await popupPromise;
// * Verify it opens externally in a new tab
await expect.poll(() => popup.url()).toContain('example.com');
await popup.close();
// # Open the internal bookmark
await channelsPage.channelBookmarksBar.getBookmark('Town Square').click();
// * Verify it navigates to Town Square in Mattermost
await expect.poll(() => page.url()).toContain(`/${team.name}/channels/town-square`);
});
/**
* @objective Verify bookmarks beyond the visible bar remain accessible through the overflow menu.
*/
test(
'MM-T5612 provides overflow access when the bookmarks bar reaches its visible limit',
{tag: '@channels'},
async ({pw}) => {
// # Create enough bookmarks to overflow the visible bar
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Overflow ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmarks(userClient, channel.id, 12);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.channelBookmarksBar.toBeVisible();
// * Verify the overflow control exposes the last bookmark
const overflowButton = channelsPage.channelBookmarksBar.getOverflowButton();
await expect(overflowButton).toBeVisible();
await overflowButton.click();
await expect(page.getByRole('menuitem', {name: /Bookmark 12/})).toBeVisible();
},
);

View File

@@ -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 {createLinkBookmark} from './support';
/**
* @objective Verify a bookmark's URL and title can be edited and saved.
*/
test('MM-T5610 edits a bookmark URL and title', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Edit ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'Community Server', 'https://community.mattermost.com');
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
const editModal = channelsPage.getBookmarkEditModal();
// # Open the bookmark editor and change its URL and title
await channelsPage.channelBookmarksBar.openBookmarkMenu('Community Server');
await channelsPage.channelBookmarksBar.editMenuItem.click();
await editModal.toBeVisible();
await editModal.linkInput.fill('https://hub.mattermost.com');
await expect(editModal.titleInput).toHaveValue('hub.mattermost.com');
await expect(editModal.saveButton).toBeEnabled();
await editModal.titleInput.fill('Hub Server');
await editModal.saveButton.click();
// * Verify the updated bookmark title and link
await expect(channelsPage.channelBookmarksBar.getBookmark('Hub Server')).toBeVisible();
await expect(channelsPage.channelBookmarksBar.getBookmark('Hub Server')).toHaveAttribute(
'href',
/^https:\/\/hub\.mattermost\.com/,
);
});
/**
* @objective Verify users without bookmark-management permissions can only open or copy bookmarks.
*/
test('MM-T5615 restricts bookmark management to users with permission', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Permissions ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'First link', 'https://example.com/first');
await createLinkBookmark(userClient, channel.id, 'Second link', 'https://example.com/second');
const scheme = await adminClient.createScheme({
display_name: `Bookmark permissions ${pw.random.id()}`,
scope: 'team',
});
const channelUserRole = await adminClient.getRoleByName(scheme.default_channel_user_role);
const restrictedPermissions = channelUserRole.permissions.filter((permission) => !permission.includes('bookmark'));
// # Assign an isolated team scheme without bookmark-management permissions
await adminClient.patchRole(channelUserRole.id, {permissions: restrictedPermissions});
await adminClient.updateTeamScheme(team.id, scheme.id);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.channelBookmarksBar.toBeVisible();
// * Verify add, edit, and delete controls are unavailable
await expect(channelsPage.channelBookmarksBar.addBookmarkButton).not.toBeVisible();
await channelsPage.channelBookmarksBar.openBookmarkMenu('First link');
await expect(channelsPage.channelBookmarksBar.openMenuItem).toBeVisible();
await expect(channelsPage.channelBookmarksBar.copyLinkMenuItem).toBeVisible();
await expect(channelsPage.channelBookmarksBar.editMenuItem).not.toBeVisible();
await expect(channelsPage.channelBookmarksBar.deleteMenuItem).not.toBeVisible();
await page.keyboard.press('Escape');
// * Verify bookmarks render in their original order
const bookmarkLinks = channelsPage.channelBookmarksBar.getBookmarkLinks();
await expect(bookmarkLinks).toHaveText(['First link', 'Second link']);
const firstLink = channelsPage.channelBookmarksBar.getBookmark('First link');
await firstLink.focus();
await firstLink.press('Space');
await firstLink.press('ArrowRight');
await firstLink.press('Space');
// * Verify the keyboard reorder attempt did not change the bookmark order or link
await expect(bookmarkLinks).toHaveText(['First link', 'Second link']);
await expect(channelsPage.channelBookmarksBar.getBookmark('First link')).toHaveAttribute(
'href',
/^https:\/\/example\.com\/first/,
);
});
/**
* @objective Verify archived channels allow opening/copying bookmarks but prevent add, edit, delete, and reorder.
*/
test('MM-T5725 prevents bookmark management in an archived channel', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Archived ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'First link', 'https://example.com/first');
await createLinkBookmark(userClient, channel.id, 'Second link', 'https://example.com/second');
// # Archive a channel containing two bookmarks
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.archiveChannel();
// * Verify bookmark management controls are unavailable
await expect(channelsPage.channelBookmarksBar.addBookmarkButton).not.toBeVisible();
await channelsPage.channelBookmarksBar.openBookmarkMenu('First link');
await expect(channelsPage.channelBookmarksBar.openMenuItem).toBeVisible();
await expect(channelsPage.channelBookmarksBar.copyLinkMenuItem).toBeVisible();
await expect(channelsPage.channelBookmarksBar.editMenuItem).not.toBeVisible();
await expect(channelsPage.channelBookmarksBar.deleteMenuItem).not.toBeVisible();
await page.keyboard.press('Escape');
// * Verify bookmarks render in their original order
const bookmarkLinks = channelsPage.channelBookmarksBar.getBookmarkLinks();
await expect(bookmarkLinks).toHaveText(['First link', 'Second link']);
const firstLink = channelsPage.channelBookmarksBar.getBookmark('First link');
await firstLink.focus();
await firstLink.press('Space');
await firstLink.press('ArrowRight');
await firstLink.press('Space');
// * Verify the keyboard reorder attempt did not change the bookmark order or link
await expect(bookmarkLinks).toHaveText(['First link', 'Second link']);
await expect(channelsPage.channelBookmarksBar.getBookmark('First link')).toHaveAttribute(
'href',
/^https:\/\/example\.com\/first/,
);
});

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, type ChannelsPage} from '@mattermost/playwright-lib';
import {addFileBookmark} from './support';
const fileName = 'sample_text_file.txt';
async function searchFiles(channelsPage: ChannelsPage) {
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.toBeVisible();
await channelsPage.searchBox.searchInput.fill(fileName);
await channelsPage.searchBox.searchInput.press('Enter');
await channelsPage.searchResultsPanel.toBeVisible();
await channelsPage.searchResultsPanel.filesTab.click();
}
/**
* @objective Verify a file attached as a bookmark appears in Files search results.
*/
test('MM-T5613 finds a file bookmark in Files search results', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Search ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
// # Add a file bookmark and search for its filename
await addFileBookmark(page, channelsPage);
await searchFiles(channelsPage);
// * Verify the file bookmark appears in Files results
await expect(channelsPage.searchResultsPanel.container.getByText(fileName, {exact: true})).toBeVisible();
});
/**
* @objective Verify deleting a file bookmark removes its file from Files search results.
*/
test('MM-T5614 removes a deleted file bookmark from Files search results', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Delete ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
// # Add a file bookmark and confirm it is searchable
await addFileBookmark(page, channelsPage);
await searchFiles(channelsPage);
await expect(channelsPage.searchResultsPanel.container.getByText(fileName, {exact: true})).toBeVisible();
await page.keyboard.press('ControlOrMeta+.');
await expect(channelsPage.searchResultsPanel.container).not.toBeVisible();
// # Delete the file bookmark
await channelsPage.channelBookmarksBar.openBookmarkMenu(fileName);
await channelsPage.channelBookmarksBar.deleteMenuItem.click();
await expect(channelsPage.channelBookmarksBar.deleteDialog).toBeVisible();
await channelsPage.channelBookmarksBar.confirmDeleteButton.click();
// * Verify the deleted file no longer appears in Files results
await searchFiles(channelsPage);
await expect(channelsPage.searchResultsPanel.container.getByText(fileName, {exact: true})).not.toBeVisible();
});

View File

@@ -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';
import {createLinkBookmark} from './support';
/**
* @objective Verify a bookmark without favicon metadata displays the fallback link icon.
*/
test('MM-T5605 uses a fallback icon when a link has no favicon', {tag: '@channels'}, async ({pw}) => {
// # Create a link bookmark without favicon metadata
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Fallback ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'No favicon', 'https://invalid.example.test');
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.channelBookmarksBar.toBeVisible();
// * Verify the fallback link icon is used
await expect(channelsPage.channelBookmarksBar.getBookmarkIcon('No favicon')).toHaveAttribute(
'data-icon-kind',
'link-fallback',
);
});
/**
* @objective Verify a bookmark favicon can be changed to an emoji and reverted to its fallback icon.
*/
test(
'MM-T5606 MM-T5607 changes a bookmark favicon to an emoji and restores the fallback',
{tag: '@channels'},
async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Icon ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
await createLinkBookmark(userClient, channel.id, 'Editable icon', 'https://invalid.example.test');
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.channelBookmarksBar.toBeVisible();
const editModal = channelsPage.getBookmarkEditModal();
// # Edit the bookmark and choose an emoji icon
await channelsPage.channelBookmarksBar.openBookmarkMenu('Editable icon');
await channelsPage.channelBookmarksBar.editMenuItem.click();
await editModal.toBeVisible();
await editModal.selectEmoji('smile');
await editModal.saveButton.click();
// * Verify the bookmark now uses an emoji icon
await expect(channelsPage.channelBookmarksBar.getBookmarkIcon('Editable icon')).toHaveAttribute(
'data-icon-kind',
'emoji',
);
// # Edit again and remove the emoji
await channelsPage.channelBookmarksBar.openBookmarkMenu('Editable icon');
await channelsPage.channelBookmarksBar.editMenuItem.click();
await editModal.toBeVisible();
await editModal.removeEmojiButton.click();
await editModal.saveButton.click();
// * Verify the fallback icon is restored
await expect(channelsPage.channelBookmarksBar.getBookmarkIcon('Editable icon')).toHaveAttribute(
'data-icon-kind',
'link-fallback',
);
},
);
/**
* @objective Verify an invalid URL shows an error and cannot be saved as a bookmark.
*/
test('MM-T5608 rejects an invalid bookmark URL', {tag: '@channels'}, async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Invalid ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
// # Open Add bookmark and enter invalid URL text
const channelMenu = await channelsPage.openChannelMenu();
await channelMenu.openBookmarksSubmenu();
await channelMenu.addBookmarkLink.click();
await channelsPage.bookmarkCreateModal.toBeVisible();
await channelsPage.bookmarkCreateModal.linkInput.fill('this is not a URL');
// * Verify the validation error appears and saving is disabled
await expect(channelsPage.bookmarkCreateModal.invalidLinkMessage).toBeVisible();
await expect(channelsPage.bookmarkCreateModal.addButton).toBeDisabled();
});

View File

@@ -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();
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import type {Client4} from '@mattermost/client';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {Page} from '@playwright/test';
import type {ChannelsPage} from '@mattermost/playwright-lib';
export async function createLinkBookmark(
client: Client4,
channelId: string,
displayName: string,
linkUrl: string,
options: {emoji?: string; imageUrl?: string} = {},
): Promise<ChannelBookmark> {
return client.createChannelBookmark(
channelId,
{
display_name: displayName,
link_url: linkUrl,
type: 'link',
emoji: options.emoji,
image_url: options.imageUrl,
},
'',
);
}
export async function createLinkBookmarks(client: Client4, channelId: string, count: number) {
const bookmarks: ChannelBookmark[] = [];
for (let index = 0; index < count; index++) {
bookmarks.push(
await createLinkBookmark(
client,
channelId,
`Bookmark ${index + 1}`,
`https://example.com/bookmark-${index + 1}`,
),
);
}
return bookmarks;
}
export async function addFileBookmark(page: Page, channelsPage: ChannelsPage, fileName = 'sample_text_file.txt') {
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', fileName));
await channelsPage.bookmarkCreateModal.toBeVisible();
await channelsPage.bookmarkCreateModal.addButton.click();
}

View File

@@ -0,0 +1,434 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Channel Classification E2E tests.
* Tests the classification level assignment feature on both new and existing channels.
*
* Prerequisites: Enterprise-tier license + ClassificationMarkings feature flag enabled.
*/
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
import {
TEST_LEVELS,
setClassificationMarkingsFeatureFlag,
setupClassificationWithChannelField,
deleteClassificationFieldsIfExist,
} from './helpers';
import type {ClassificationLevel} from './helpers';
let classificationLevels: ClassificationLevel[] = [];
let setupComplete = false;
// Teams created by pw.initSetup() in each test are tracked here and deleted in
// afterEach so local environments don't accumulate stale teams across runs.
const createdTeamIds: string[] = [];
async function initSetupTracked(pw: PlaywrightExtended) {
const setup = await pw.initSetup();
createdTeamIds.push(setup.team.id);
return setup;
}
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
if (licenseTier(license.SkuShortName) < 20) {
return;
}
await setClassificationMarkingsFeatureFlag(adminClient, true);
const setup = await setupClassificationWithChannelField(adminClient);
classificationLevels = setup.levels;
setupComplete = true;
});
test.afterAll(async () => {
if (!setupComplete) {
return;
}
const {adminClient} = await getAdminClient();
try {
await deleteClassificationFieldsIfExist(adminClient);
} catch {
// Best-effort cleanup
}
});
test.beforeEach(async () => {
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(licenseTier(license.SkuShortName) < 20, 'Channel classification requires Enterprise-tier license');
test.skip(!setupComplete, 'Classification levels were not set up');
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags.ClassificationMarkings !== true,
'ClassificationMarkings feature flag could not be enabled',
);
});
test.afterEach(async () => {
if (createdTeamIds.length === 0) {
return;
}
const ids = createdTeamIds.splice(0);
try {
const {adminClient} = await getAdminClient({skipLog: true});
await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
} catch {
// Best-effort cleanup
}
});
test.describe('Channel Classification - New channel creation', () => {
test('Enabling classification toggle without selecting values prevents channel creation', async ({pw}) => {
const {adminUser, team} = await initSetupTracked(pw);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const newChannelModal = await channelsPage.openNewChannelModal();
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
await newChannelModal.publicTypeButton.click();
// Create button should be enabled before toggling classification
await expect(newChannelModal.createButton).toBeEnabled();
// Enable classification toggle
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
// Create button should be disabled (no classification level selected, no banner text)
await expect(newChannelModal.createButton).toBeDisabled();
});
test('Classification dropdown displays the correct levels from the template', async ({pw}) => {
const {adminUser, team} = await initSetupTracked(pw);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const newChannelModal = await channelsPage.openNewChannelModal();
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
// Enable classification toggle
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
// Open the classification dropdown
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
// Verify all test levels are present in the dropdown menu
const menu = channelsPage.page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
for (const level of TEST_LEVELS) {
await expect(menu.getByText(level.name, {exact: true})).toBeVisible();
}
});
test('User can append text to the Banner Text field after selecting a classification', async ({pw}) => {
const {adminUser, team} = await initSetupTracked(pw);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
expect(selectedLevel).toBeDefined();
const newChannelModal = await channelsPage.openNewChannelModal();
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
// Enable classification toggle
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
// Select a classification level
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const menu = channelsPage.page.locator('.DropDown__menu');
await menu.getByText(selectedLevel!.name, {exact: true}).click();
// Banner text should be auto-populated with the bold level name
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
await expect(bannerTextbox).toBeVisible();
const currentValue = await bannerTextbox.inputValue();
expect(currentValue).toContain(selectedLevel!.name);
// Append custom text to the banner
await bannerTextbox.click();
await bannerTextbox.press('End');
await bannerTextbox.pressSequentially(' - Custom suffix');
const updatedValue = await bannerTextbox.inputValue();
expect(updatedValue).toContain('Custom suffix');
});
test('Creating channel with classification shows banner with correct color', async ({pw}) => {
const {adminUser, team} = await initSetupTracked(pw);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
expect(selectedLevel).toBeDefined();
const newChannelModal = await channelsPage.openNewChannelModal();
await newChannelModal.fillDisplayName(`classified-${pw.random.id()}`);
await newChannelModal.publicTypeButton.click();
// Enable classification toggle
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
// Select the classification level
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const menu = channelsPage.page.locator('.DropDown__menu');
await menu.getByText(selectedLevel!.name, {exact: true}).click();
// Wait for banner text to auto-populate, then create the channel
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
await expect(bannerTextbox).toBeVisible();
await expect(bannerTextbox).not.toHaveValue('');
await newChannelModal.create();
// Should be redirected to the new channel and center view loads
await expect(channelsPage.page).toHaveURL(/\/channels\//, {timeout: 30000});
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 30000});
// Channel banner should be visible (allow extra time for property value fetch)
const banner = channelsPage.page.getByTestId('channel_banner_container');
await expect(banner).toBeVisible({timeout: 30000});
// Verify the banner has the correct background color
const actualBackgroundColor = await banner.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-color');
});
const expectedRgb = hexToRgb(selectedLevel!.color);
expect(actualBackgroundColor).toBe(expectedRgb);
// Verify the banner contains the classification level name (rendered from **SECRET** markdown)
await expect(banner).toContainText(selectedLevel!.name);
});
});
test.describe('Channel Classification - Existing channel settings', () => {
test('Classification toggle can be enabled from channel settings', async ({pw}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openConfigurationTab();
// The classification toggle should be available
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await expect(classificationToggle).toBeVisible();
// Toggle it on
const classes = await classificationToggle.getAttribute('class');
if (!classes?.includes('active')) {
await classificationToggle.click();
}
// Toggle should now be active
await expect(classificationToggle).toHaveClass(/active/);
});
test('Classification level can be set once toggle is enabled', async ({pw}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openConfigurationTab();
// Enable classification toggle
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
// Classification level dropdown should be visible
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await expect(dropdownContainer).toBeVisible();
// Open dropdown and select a level
await dropdownContainer.click();
const menu = channelsPage.page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
const selectedLevel = classificationLevels.find((l) => l.name === 'CONFIDENTIAL');
expect(selectedLevel).toBeDefined();
await menu.getByText(selectedLevel!.name, {exact: true}).click();
// The dropdown should now show the selected value
await expect(dropdownContainer.getByText(selectedLevel!.name, {exact: true})).toBeVisible();
});
test('Selecting classification locks banner toggle active and disabled, with matching color', async ({pw}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openConfigurationTab();
// Enable classification and select a level
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
expect(selectedLevel).toBeDefined();
const menu = channelsPage.page.locator('.DropDown__menu');
await menu.getByText(selectedLevel!.name, {exact: true}).click();
// The channel banner toggle should now be forced active and disabled
const bannerToggle = channelsPage.page.getByTestId('channelBannerToggle-button');
await expect(bannerToggle).toBeVisible();
await expect(bannerToggle).toHaveClass(/active/);
await expect(bannerToggle).toBeDisabled();
// Banner color input should be locked to the classification color
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
await expect(colorInput).toBeVisible();
const colorValue = await colorInput.inputValue();
expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', ''));
});
test('Disabling classification with a custom banner color preserves it server-side, and re-enabling restores the Save button', async ({
pw,
}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
// Step 1: enable classification, select a level, save
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET')!;
await channelsPage.page.locator('.DropDown__menu').getByText(selectedLevel.name, {exact: true}).click();
await configurationTab.save();
// Step 2: in the same open modal, disable classification, enable a manual banner,
// set a custom color, save. The manual banner toggle is needed because master no
// longer auto-enables banner_info.enabled when classification is on, so the banner
// section body (and its color input) is hidden after toggling classification off
// until a manual banner is enabled.
await classificationToggle.click();
await configurationTab.enableChannelBanner();
const customColor = 'aa00aa';
await configurationTab.setChannelBannerBackgroundColor(customColor);
await configurationTab.save();
// Symptom 1 guard: color input reflects what we typed and the server persisted the custom color
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
await expect(colorInput).toHaveValue(`#${customColor}`);
const persisted = await adminClient.getChannel(channel.id);
expect(persisted.banner_info?.background_color?.toLowerCase().replace('#', '')).toBe(customColor);
// Symptom 2 guard: re-enable classification → Save button reappears (panel transitions out of 'saved')
await classificationToggle.click();
const saveButton = channelsPage.page.getByTestId('SaveChangesPanel__save-btn');
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeEnabled();
await channelSettingsModal.close();
});
test('Editing banner text and saving updates the banner in real time', async ({pw}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();
// Enable classification and select a level
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const selectedLevel = classificationLevels.find((l) => l.name === 'TOP SECRET');
expect(selectedLevel).toBeDefined();
const menu = channelsPage.page.locator('.DropDown__menu');
await menu.getByText(selectedLevel!.name, {exact: true}).click();
// Edit the banner text to a custom value
const customBannerText = 'TOP SECRET - Handle via COMINT channels only';
const bannerTextbox = channelsPage.page.locator('#channel_banner_banner_text_textbox');
await expect(bannerTextbox).toBeVisible();
await bannerTextbox.fill(customBannerText);
// Save the changes
await configurationTab.save();
await channelSettingsModal.close();
// The channel banner should now show the custom text with the classification color
const banner = channelsPage.page.getByTestId('channel_banner_container');
await expect(banner).toBeVisible({timeout: 30000});
await expect(banner).toContainText(customBannerText);
const actualBackgroundColor = await banner.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-color');
});
expect(actualBackgroundColor).toBe(hexToRgb(selectedLevel!.color));
});
});
function hexToRgb(hex: string): string {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) {
return hex;
}
return `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})`;
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
const PROPERTY_GROUP = 'access_control';
const TEMPLATE_OBJECT_TYPE = 'template';
const CHANNEL_OBJECT_TYPE = 'channel';
const TARGET_TYPE = 'system';
const CLASSIFICATION_FIELD_NAME = 'classification';
const CHANNEL_LINKED_FIELD_NAME = 'classification';
export const TEST_LEVELS = [
{name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{name: 'CONFIDENTIAL', color: '#0033A0', rank: 2},
{name: 'SECRET', color: '#C8102E', rank: 3},
{name: 'TOP SECRET', color: '#FF8C00', rank: 4},
];
/**
* Sets the ClassificationMarkings feature flag via server config.
*/
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
const config = await adminClient.getConfig();
await adminClient.updateConfig({
...config,
FeatureFlags: {
...config.FeatureFlags,
ClassificationMarkings: enabled,
},
} as Awaited<ReturnType<Client4['getConfig']>>);
}
/**
* Deletes existing classification fields (channel linked, system linked, and template)
* to provide a clean slate.
*/
export async function deleteClassificationFieldsIfExist(adminClient: Client4) {
// Delete channel linked fields first
try {
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, TARGET_TYPE, '');
for (const f of channelFields.filter((f) => f.name === CHANNEL_LINKED_FIELD_NAME && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, f.id);
}
} catch {
// May not exist
}
// Delete system linked fields
for (const objectType of ['system', 'user'] as const) {
try {
const linkedFields = await adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, '');
for (const f of linkedFields.filter(
(f) => f.name === 'classification' && f.delete_at === 0 && f.linked_field_id,
)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
}
} catch {
// May not exist
}
}
// Delete template fields
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, TARGET_TYPE);
for (const f of fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, f.id);
}
} catch {
// May not exist
}
}
export type ClassificationLevel = {
id: string;
name: string;
color: string;
rank: number;
};
export type SetupResult = {
templateFieldId: string;
channelFieldId: string;
levels: ClassificationLevel[];
};
/**
* Creates the full classification setup: template field + channel linked field.
* Returns the created fields and the resolved levels (with server-assigned IDs).
*/
export async function setupClassificationWithChannelField(
adminClient: Client4,
levels: Array<{name: string; color: string; rank: number}> = TEST_LEVELS,
): Promise<SetupResult> {
await deleteClassificationFieldsIfExist(adminClient);
// Create template field
const templateField = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
name: CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: '',
attrs: {
options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})),
},
permission_field: 'admin',
permission_values: 'admin',
permission_options: 'admin',
} as Parameters<Client4['createPropertyField']>[2]);
// Create channel linked field. permission_values must be set (matching how
// the admin console creates it) or the server refuses to let anyone,
// including the creator, set a value on it.
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name: CHANNEL_LINKED_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: '',
linked_field_id: templateField.id,
permission_field: 'admin',
permission_values: 'admin',
} as Parameters<Client4['createPropertyField']>[2]);
// Resolve levels with server-assigned IDs
const options = (templateField.attrs?.options ?? []) as ClassificationLevel[];
const resolvedLevels = options.sort((a, b) => a.rank - b.rank);
return {templateFieldId: templateField.id, channelFieldId: channelField.id, levels: resolvedLevels};
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
// Teams created by pw.initSetup() in each test are tracked here and deleted in
// afterEach so local environments don't accumulate stale teams across runs.
const createdTeamIds: string[] = [];
async function initSetupTracked(pw: PlaywrightExtended) {
const setup = await pw.initSetup();
createdTeamIds.push(setup.team.id);
return setup;
}
test.afterEach(async () => {
if (createdTeamIds.length === 0) {
return;
}
const ids = createdTeamIds.splice(0);
try {
const {adminClient} = await getAdminClient({skipLog: true});
await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
} catch {
// Best-effort cleanup
}
});
test(
'sidebar icon updates from globe to lock when channel converted to private via API',
{tag: ['@channels', '@channel_privacy']},
async ({pw}) => {
// # Initialize setup
const {adminClient, user, team} = await initSetupTracked(pw);
// # Create a public channel
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'privacy-ws-test',
displayName: 'Privacy WS Test',
type: 'O',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
// # Log in user and navigate to the channel
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Verify sidebar shows globe icon (public channel)
const sidebarItem = page.locator(`#sidebarItem_${channel.name}`);
await expect(sidebarItem).toBeVisible();
await expect(sidebarItem.locator('.icon-globe')).toBeVisible();
await expect(sidebarItem.locator('.icon-lock-outline')).not.toBeVisible();
// # Convert channel to private via admin API (simulates mmctl)
await adminClient.updateChannelPrivacy(channel.id, 'P');
// * Verify sidebar icon updates to lock without page refresh
await expect(sidebarItem.locator('.icon-lock-outline')).toBeVisible({timeout: 10000});
await expect(sidebarItem.locator('.icon-globe')).not.toBeVisible();
},
);
test(
'sidebar icon updates from lock to globe when channel converted to public via API',
{tag: ['@channels', '@channel_privacy']},
async ({pw}) => {
// # Initialize setup
const {adminClient, user, team} = await initSetupTracked(pw);
// # Create a private channel
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'privacy-ws-test-p2o',
displayName: 'Privacy WS Test P2O',
type: 'P',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
// # Log in user and navigate to the channel
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Verify sidebar shows lock icon (private channel)
const sidebarItem = page.locator(`#sidebarItem_${channel.name}`);
await expect(sidebarItem).toBeVisible();
await expect(sidebarItem.locator('.icon-lock-outline')).toBeVisible();
await expect(sidebarItem.locator('.icon-globe')).not.toBeVisible();
// # Convert channel to public via admin API (simulates mmctl)
await adminClient.updateChannelPrivacy(channel.id, 'O');
// * Verify sidebar icon updates to globe without page refresh
await expect(sidebarItem.locator('.icon-globe')).toBeVisible({timeout: 10000});
await expect(sidebarItem.locator('.icon-lock-outline')).not.toBeVisible();
},
);

View File

@@ -0,0 +1,241 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective E2E coverage for public-channel ABAC behavior:
* - Default channels are not eligible for ABAC policies (channel-settings + team-settings paths)
* - A policy on a public channel surfaces matching users a "Recommended" channel entry
*
* @reference Public-channel ABAC feature
*/
import {ChannelsPage, expect, getAdminClient, test} from '@mattermost/playwright-lib';
import {enableABACConfig, enableAPIUserDeletion} from '../team_settings/helpers';
import {
CleanupLedger,
createPolicyAssignedToChannels,
createTrackedAttribute,
createTrackedPublicChannel,
createTrackedTeamMember,
runAccessControlSyncJob,
setChannelPolicyActive,
waitForJobCompletion,
} from './helpers';
test.describe('ABAC - Public channels', () => {
let ledger: CleanupLedger;
// Enable the permanent-delete API once for the whole describe so the
// CleanupLedger's permanentDeleteUser cleanup tasks succeed instead of
// spamming "Permanent user deletion feature is not enabled" on teardown.
// `pw` is test-scoped, so we use the worker-scoped getAdminClient here.
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
if (!adminClient) {
return;
}
await enableAPIUserDeletion(adminClient);
});
test.beforeEach(() => {
ledger = new CleanupLedger();
});
test.afterEach(async () => {
await ledger.drain();
});
test('Default channel hides Membership Policy tab in Channel Settings', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Open Channel Settings on the default (town-square) channel
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// * Membership Policy tab is NOT rendered for the default channel —
// ValidateChannelEligibilityForAccessControl rejects default channels
// server-side, so the tab would only let a user assemble unsaveable rules.
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
await channelSettings.close();
});
test('Default channel is excluded from Team Settings policy editor channel selector', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Navigate to Team Settings → Membership Policies → New policy editor
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessPoliciesTab();
await teamSettings.container.getByRole('button', {name: 'Add policy'}).click();
// # Open the channel selector modal
await teamSettings.container.getByRole('button', {name: /Add channels/i}).click();
const channelSelector = page.locator('.channel-selector-modal');
await channelSelector.waitFor();
// * Both default channels (town-square, off-topic) are absent from the list.
// The component contract is `excludeDefaultChannels={true}` — assert the
// canonical channel rows by their stable test ids rather than display text.
await expect(channelSelector.locator('[data-testid="ChannelRow-town-square"]')).toHaveCount(0);
await expect(channelSelector.locator('[data-testid="ChannelRow-off-topic"]')).toHaveCount(0);
await teamSettings.close();
});
test('Public channel with matching policy surfaces as Recommended for matching user (auto-add off)', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Per-test custom profile attribute. We don't reuse a shared "Department"
// field — the server caps CPA fields at 20, and accumulating shared
// state across tests has historically saturated that limit and broken
// subsequent runs. This field gets cleaned up via the ledger.
const attribute = await createTrackedAttribute(adminClient, 'PubAbacDept', ledger);
// # Public channel + parent policy with rule "<attr> == 'engineering'",
// policy assigned to that channel. Auto-add stays OFF (server default
// on a freshly-created child policy).
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
// CEL references the attribute by its field name (lowercased to keep
// the identifier characters CEL-safe — uppercase chars in identifiers
// are technically valid but the table-editor parser used in the webapp
// round-trips through lowercase, so this matches reality).
const expression = `user.attributes.${attribute.name} == 'engineering'`;
await createPolicyAssignedToChannels(
adminClient,
`Public Recommend ${Date.now()}`,
expression,
[publicChannel.id],
ledger,
);
// # Matching user — value matches the rule, member of the team but NOT
// of the channel. Auto-add is off, so they must NOT be auto-joined;
// the channel should instead appear in their Recommended list.
const matchingUser = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'engineering'},
ledger,
);
// * Auto-add OFF contract: the matching user must NOT have been silently
// joined to the channel. Verified server-side before any UI flow so a
// regression that auto-adds them would fail here, regardless of UI
// behavior. The Browse-Channels assertion below proves the channel
// *appears as a recommendation* — but only this membership check
// distinguishes "recommended (advisory)" from "auto-added".
const preMembers = await adminClient.getChannelMembers(publicChannel.id);
expect(
preMembers.map((m: any) => m.user_id),
'matching user must not be auto-joined when auto-add is OFF (advisory recommendation only)',
).not.toContain(matchingUser.id);
const {page} = await pw.testBrowser.login(matchingUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
// # Open Browse Channels and switch the type filter to "Recommended channels"
const browse = await channelsPage.openBrowseChannelsModal();
await browse.toBeDoneLoading();
// The recommended menu item is keyed by `id="channelsMoreDropdownRecommended"`,
// gated by the `showRecommendedFilter` prop which is set whenever ABAC is
// enabled in client config. Using the stable id avoids brittleness from
// the dropdown's localized label.
await browse.container.locator('#menuWrapper').click();
await page.locator('#channelsMoreDropdownRecommended').click();
// * The public channel appears in the Recommended results. The row id
// is generated by the SearchableChannelList component as
// `ChannelRow-${channel.name}` — assert against that, not the display
// text, which can be locale-dependent.
const channelRow = browse.container.locator(`[data-testid="ChannelRow-${publicChannel.name}"]`);
await expect(channelRow).toBeVisible({timeout: 15000});
});
test('Public channel with auto-add ON adds matching users; non-matching members are NOT removed', async ({pw}) => {
// The sync job is queued and processed asynchronously — give the test
// enough headroom for the queue + evaluation + member writes. Empirically
// it finishes in <10s on a quiet dev server, but CI workers are slower.
test.setTimeout(120_000);
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const attribute = await createTrackedAttribute(adminClient, 'PubAbacAuto', ledger);
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
const expression = `user.attributes.${attribute.name} == 'engineering'`;
await createPolicyAssignedToChannels(
adminClient,
`Public Auto-Add ${Date.now()}`,
expression,
[publicChannel.id],
ledger,
);
// # Auto-add ON: flip the channel-scope (child) policy's Active flag.
// The parent default is inactive, and children inherit Active=false at
// assign time, so we activate the child directly here. The sync job
// only auto-adds members for Active policies.
await setChannelPolicyActive(adminClient, publicChannel.id, true);
// # Two users:
// - matching: Department=engineering, in team but NOT in channel.
// Sync should auto-add them.
// - nonMatching: Department=sales, already a member of the channel.
// For PUBLIC channels, ABAC is advisory: sync must NOT
// remove them. (For private channels, sync would.)
const matching = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'engineering'},
ledger,
);
const nonMatching = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'sales'},
ledger,
);
await adminClient.addToChannel(nonMatching.id, publicChannel.id);
// # Trigger sync against this channel-scope policy and wait for it to
// reach a terminal state. We pass `publicChannel.id` (the child
// policy ID) rather than the parent ID so the sync stays scoped to
// this test and ignores any other policies on the dev server.
const job = await runAccessControlSyncJob(adminClient, publicChannel.id);
const finished = await waitForJobCompletion(adminClient, job.id);
expect(finished.status, `sync job did not succeed: ${JSON.stringify(finished)}`).toBe('success');
// * Membership state via API — no UI flake.
const members = await adminClient.getChannelMembers(publicChannel.id);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds, 'matching user must be auto-added by sync').toContain(matching.id);
expect(
memberIds,
'non-matching member must NOT be removed (public channels are advisory under ABAC)',
).toContain(nonMatching.id);
});
});

View File

@@ -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();
},
);

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Channel Settings → Access Control regression test for the
* "Test access rule" results for top-level OR rules.
* @reference MM-68538
*/
import type {Client4} from '@mattermost/client';
import type {Page} from '@playwright/test';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import type {UserProfile} from '@mattermost/types/users';
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
const PROGRAM_OPTIONS = [
{name: 'Artemis', color: '#FF8800'},
{name: 'Helios', color: '#00AAFF'},
];
// CEL the table editor emits for `<field> has any of [Artemis, Helios]`.
// See rowToCEL() in webapp/.../table_editor/table_editor.tsx.
function buildTwoValueOrExpression(attributeName: string): string {
return `("Artemis" in user.attributes.${attributeName} || "Helios" in user.attributes.${attributeName})`;
}
function buildAliceExcludingExpression(attributeName: string): string {
return `"Helios" in user.attributes.${attributeName}`;
}
type ProgramField = {
id: string;
optionIdByName: Record<string, string>;
};
// Create a multiselect CPA field directly via the REST API.
async function ensureProgramMultiselectField(adminClient: Client4, fieldName: string): Promise<ProgramField> {
const baseRoute = `${adminClient.getBaseRoute()}/custom_profile_attributes/fields`;
const created: UserPropertyField = await (adminClient as any).doFetch(baseRoute, {
method: 'POST',
body: JSON.stringify({
name: fieldName,
type: 'multiselect',
attrs: {
managed: 'admin',
visibility: 'when_set',
options: PROGRAM_OPTIONS,
},
}),
});
const options: Array<{id: string; name: string}> =
((created as any)?.attrs?.options as Array<{id: string; name: string}>) ?? [];
const optionIdByName: Record<string, string> = {};
for (const opt of options) {
optionIdByName[opt.name] = opt.id;
}
for (const expected of PROGRAM_OPTIONS) {
if (!optionIdByName[expected.name]) {
throw new Error(
`Multiselect field "${fieldName}" was created without an id for option "${expected.name}"; ` +
`got: ${JSON.stringify(options)}`,
);
}
}
return {id: created.id, optionIdByName};
}
async function createUserWithProgram(
adminClient: Client4,
programField: ProgramField,
program: string[],
teamId: string,
prefix: string,
): Promise<UserProfile> {
const password = newTestPassword();
const id = Math.random().toString(36).substring(2, 9);
const user = await adminClient.createUser(
{
email: `${prefix}-${id}@sample.mattermost.com`,
username: `${prefix}${id}`,
password,
} as UserProfile,
'',
'',
);
user.password = password;
// Multiselect values must be sent as option IDs not names.
const optionIds = program.map((name) => {
const optionId = programField.optionIdByName[name];
if (!optionId) {
throw new Error(`Program option "${name}" not found in field options`);
}
return optionId;
});
await adminClient.updateUserCustomProfileAttributesValues(user.id, {[programField.id]: optionIds});
// Suppress tutorials/onboarding so UI navigation is stable.
await adminClient.savePreferences(user.id, [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
]);
await adminClient.addToTeam(teamId, user.id);
return user;
}
async function createChannelAccessRule(
adminClient: Client4,
channel: {id: string; display_name: string},
expression: string,
) {
await (adminClient as any).doFetch(`${adminClient.getBaseRoute()}/access_control_policies`, {
method: 'PUT',
body: JSON.stringify({
id: channel.id,
name: channel.display_name,
type: 'channel',
active: false,
revision: 1,
created_at: Date.now(),
rules: [{actions: ['membership'], expression}],
imports: [],
}),
});
}
async function openAccessControlSettings(channelsPage: ChannelsPage) {
const channelSettings = await channelsPage.openChannelSettings();
const accessControlTab = channelSettings.container.getByRole('tab', {name: /Membership Policy/i});
await expect(accessControlTab).toBeVisible({timeout: 10000});
await accessControlTab.click();
return channelSettings;
}
async function verifyTestAccessRuleDisabled(page: Page) {
await expect(page.getByRole('button', {name: /Test access rule/i})).toBeDisabled({timeout: 10000});
}
async function testAccessRuleAndVerifyUser(page: Page, username: string) {
const testButton = page.getByRole('button', {name: /Test access rule/i});
await expect(testButton).toBeEnabled({timeout: 10000});
await testButton.click();
const modal = page.locator('.TestResultsModal').filter({hasText: 'Access Rule Test Results'});
await expect(modal).toBeVisible({timeout: 10000});
await modal.getByRole('textbox', {name: /Search users/i}).fill(username);
await expect(modal.getByText(`@${username}`)).toBeVisible({timeout: 10000});
}
test.describe('Channel Settings → Membership Policy', () => {
test('MM-68538 channel admin can test access rule for multiselect "has any of" with multiple values', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
// Enable ABAC + user-managed attributes so the Membership Policy tab and
// the test access rule flow are available.
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
});
// CPA name must be a valid CEL identifier segment; keep alphanumeric + underscore.
const programFieldName = `Program_mm68538_${Math.random().toString(36).substring(2, 14)}`;
const programField = await ensureProgramMultiselectField(adminClient, programFieldName);
const aliceExcludingExpression = buildAliceExcludingExpression(programFieldName);
const twoValueOrExpression = buildTwoValueOrExpression(programFieldName);
// Several users matching the OR. Without the fix, SearchUsers ordered
// by Users.Id ASC would return whichever of the matching users has the
// lowest Id, so populating multiple matches makes the assertion robust
// regardless of which Id alice ends up with.
const alice = await createUserWithProgram(adminClient, programField, ['Artemis'], team.id, 'alice');
await createUserWithProgram(adminClient, programField, ['Helios'], team.id, 'bob');
await createUserWithProgram(adminClient, programField, ['Artemis'], team.id, 'carol');
// Private channel where alice is the channel admin and the only one
// with the manage_channel_access_rules permission. Bob/carol exist
// purely to populate the OR-matching candidate set.
const channel = await adminClient.createChannel({
team_id: team.id,
name: `mm68538-${Math.random().toString(36).substring(2, 8)}`,
display_name: `MM-68538 ${Math.random().toString(36).substring(2, 6)}`,
type: 'P',
} as any);
await adminClient.addToChannel(alice.id, channel.id);
await adminClient.updateChannelMemberSchemeRoles(channel.id, alice.id, true, true);
await createChannelAccessRule(adminClient, channel, aliceExcludingExpression);
// Alice can open Channel Settings → Membership Policy as channel admin
// and test the existing rule through the same UI users exercise.
const {page} = await pw.testBrowser.login(alice);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const excludingRuleSettings = await openAccessControlSettings(channelsPage);
await verifyTestAccessRuleDisabled(page);
await excludingRuleSettings.close();
await createChannelAccessRule(adminClient, channel, twoValueOrExpression);
const includingRuleSettings = await openAccessControlSettings(channelsPage);
await testAccessRuleAndVerifyUser(page, alice.username);
await includingRuleSettings.close();
});
});

View File

@@ -0,0 +1,91 @@
// 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 multi-line channel header is truncated in the header bar, that hovering it opens a
* popover with the full header, that a markdown link in the header points to the correct URL, and that the
* popover closes.
*/
test(
'MM-T879 opens and closes the channel header popover with a working link',
{tag: '@channel_settings'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const channel = await adminClient.createPublicChannel(team.id, `Header ${pw.random.id()}`);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Set a multi-line channel header that contains a markdown link, via Channel Settings
const header = ['Header text line one', '', '- Two', '- [Google](https://google.com/)', '- Four'].join('\n');
const channelSettings = await channelsPage.openChannelSettings();
const infoSettings = await channelSettings.openInfoTab();
await infoSettings.updateHeader(header);
await channelSettings.save();
await channelSettings.close();
// * Verify the header bar shows the first line of the header
const headerText = channelsPage.centerView.header.container.getByText('Header text line one');
await expect(headerText).toBeVisible();
// # Hover the header text to open the popover. Force the hover because opening the popover mounts a
// full-screen floating overlay that would otherwise be reported as intercepting the pointer.
await headerText.hover({force: true});
// * Verify the popover opens with the full header and a link to the correct URL
const popover = page.getByRole('tooltip');
await expect(popover).toBeVisible();
await expect(popover.getByText('Four')).toBeVisible();
const link = popover.getByRole('link', {name: 'Google'});
await expect(link).toHaveAttribute('href', 'https://google.com/');
// # Move the pointer away from the header to dismiss the popover
await page.mouse.move(400, 500);
await channelsPage.centerView.postCreate.input.hover({force: true});
// * Verify the popover closes
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();
});

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective E2E coverage for the v0.4 Permissions Policy tab in Channel Settings:
* - Tab visibility is gated by ABAC + license + the PermissionPolicies
* umbrella + the ChannelPermissionPolicies sub-flag.
* - The list view exposes Add rule, search, and a paginated rules table.
* - Adding a permission rule (name, role, actions) and committing returns to the list.
* - Per-rule expression uses the same TableEditor as Membership Policy, but
* re-labelled "Simulate rules" — the button opens the dual-lane
* SimulateAccessModal instead of the legacy expression-only one (additionally
* gated by the PolicySimulation feature flag).
* - Duplicate rule names surface a save-time error.
*
* @reference Channel-scoped permission policies (v0.4)
*
* These tests skip themselves at runtime when the PermissionPolicies umbrella
* OR the ChannelPermissionPolicies sub-flag is not enabled on the server —
* the tab is invisible in either case and the workflow is not exercised.
* Run with `MM_FEATUREFLAGS_PERMISSIONPOLICIES=true` AND
* `MM_FEATUREFLAGS_CHANNELPERMISSIONPOLICIES=true`. The "Simulate rules"
* button additionally requires `MM_FEATUREFLAGS_POLICYSIMULATION=true`.
*/
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
import {enableABACConfig, ensureDepartmentAttribute, createPrivateChannel} from '../team_settings/helpers';
test.describe('Channel Settings Modal - Permissions Policy tab (v0.4)', () => {
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
// Skip the suite when either flag is OFF on the server rather
// than relying on the tab's UI presence as a proxy — a
// UI-based guard would silently mask a regression in the tab
// visibility logic itself. Both flags must be on for the tab
// to render (the channel-scope sub-flag depends on the
// umbrella, mirroring `IsChannelPermissionPoliciesEnabled` on
// the server).
await pw.skipIfFeatureFlagNotSet('PermissionPolicies', true);
await pw.skipIfFeatureFlagNotSet('ChannelPermissionPolicies', true);
});
test('MM-PP_v0_4_c1 Permissions Policy tab visible on private channel when feature flag enabled', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
// Suite-level feature-flag guard already covers PermissionPolicies;
// assert visibility unconditionally so a regression in the tab's
// render gate would surface as a test failure.
await expect(permissionsTab).toBeVisible();
// # Open Permissions Policy
await permissionsTab.click();
// * The list view renders: header, search, table, Add rule button.
await expect(channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab')).toBeVisible({
timeout: 10000,
});
await expect(channelSettings.container.getByTestId('permissions-policy-add-rule')).toBeVisible();
await expect(channelSettings.container.getByTestId('permissions-policy-search')).toBeVisible();
await expect(channelSettings.container.getByTestId('permissions-policy-rules-table')).toBeVisible();
await channelSettings.close();
});
test('MM-PP_v0_4_c2 Add rule opens the editor with sections in the expected order, Cancel returns to the list', async ({
pw,
}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
await expect(permissionsTab).toBeVisible();
await permissionsTab.click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Click "Add rule" → editor view appears
await tab.getByTestId('permissions-policy-add-rule').click();
const editor = channelSettings.container.getByTestId('permissions-policy-editor');
await expect(editor).toBeVisible({timeout: 5000});
// * Mirror system-console layout: name → role → user-attribute conditions → permissions list.
const expressionSection = editor.getByTestId('permissions-policy-editor-expression-section');
const permissionsSection = editor.getByTestId('permissions-policy-editor-permissions-section');
await expect(expressionSection).toBeVisible();
await expect(permissionsSection).toBeVisible();
// Permissions list must render BELOW the user-attribute conditions
// (TableEditor) so we match the System Console policy editor ordering.
const expressionBox = await expressionSection.boundingBox();
const permissionsBox = await permissionsSection.boundingBox();
expect(expressionBox).not.toBeNull();
expect(permissionsBox).not.toBeNull();
expect(permissionsBox?.y ?? 0).toBeGreaterThan((expressionBox?.y ?? 0) + (expressionBox?.height ?? 0) - 1);
// # Cancel → back to list view
await channelSettings.container.getByTestId('permissions-policy-editor-cancel').click();
await expect(tab).toBeVisible({timeout: 5000});
await expect(channelSettings.container.getByTestId('permissions-policy-editor')).not.toBeVisible();
await channelSettings.close();
});
test('MM-PP_v0_4_c3 Editor validates: missing name surfaces an inline error', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
await expect(permissionsTab).toBeVisible();
await permissionsTab.click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Open the editor without filling the name
await tab.getByTestId('permissions-policy-add-rule').click();
await channelSettings.container.getByTestId('permissions-policy-editor-save').click();
// * Inline error mentions name uniqueness/required.
await expect(channelSettings.container.getByTestId('permissions-policy-editor-error')).toBeVisible({
timeout: 5000,
});
await channelSettings.close();
});
});

View File

@@ -0,0 +1,669 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective E2E tests for the Membership Policy tab (access_rules) in Channel Settings Modal
* @reference MM-67326 — public and private channels can carry ABAC membership policies
*/
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
import {
enableABACConfig,
ensureDepartmentAttribute,
createParentPolicy,
assignChannelsToPolicy,
createPrivateChannel,
createGroupConstrainedPrivateChannel,
setUserAttribute,
addAttributeRule,
createTeamAdmin,
waitForAttributeViewToInclude,
} from '../team_settings/helpers';
import {waitForJobCompletion} from './helpers';
/** Unique CPA value so only users this test sets match the rule (avoids clashing with leftover Engineering users on the server). */
function uniqueDepartmentValue(testId: string): string {
return `E2E-${testId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
test.describe('Channel Settings Modal - Access Control Tab', () => {
test('MM-67326_c1 Access Control tab visible for admin on private channel with ABAC enabled', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// * Access Control tab is visible
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).toBeVisible();
await channelSettings.close();
});
test('MM-67326_c2 Access Control tab hidden when ABAC disabled', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Explicitly disable ABAC. initSetup() resets to the default config which has
// EnableAttributeBasedAccessControl:true (required by the ABAC test suite baseline),
// so we must patch it off. Concurrent tests in other files also call enableABACConfig()
// and may race to re-enable it before this modal opens.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
});
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// Disable ABAC once more right before the modal opens to shrink the race window.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
});
const channelSettings = await channelsPage.openChannelSettings();
// * Access Control tab is NOT visible
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
await channelSettings.close();
});
test('MM-67326_c3 Membership Policy tab visible for admin on public channel with ABAC enabled', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const channel = await adminClient.createPublicChannel(team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// * Membership Policy tab is visible on public channels when ABAC is enabled (not group-constrained / not default)
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).toBeVisible();
await channelSettings.close();
});
test('MM-67326_c4 Access Control tab hidden for group-constrained channel', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const channel = await createGroupConstrainedPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// * Access Control tab is NOT visible for group-constrained channel
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
await channelSettings.close();
});
test('MM-67326_c5 Auto-add checkbox disabled when no rules defined', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// * Auto-add checkbox is disabled — no rules or system policies defined
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
await expect(autoAddCheckbox).toBeDisabled();
await channelSettings.close();
});
test('MM-67326_c6 System policy banner visible when parent policy is applied to channel', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
// # Create a parent (team-level) policy and assign this channel to it
const policy = await createParentPolicy(adminClient, `Parent Policy ${Date.now()}`);
await assignChannelsToPolicy(adminClient, policy.id, [channel.id]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// * System policy banner is visible
await expect(tab.locator('.ChannelSettingsModal__systemPolicies')).toBeVisible({timeout: 10000});
await channelSettings.close();
});
test('MM-67326_c7 Add attribute rule and save without auto-add', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Admin's Department must satisfy the rule (self-exclusion check)
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add an attribute rule: Department == Engineering
await addAttributeRule(tab, page, 'Engineering');
// # Save (auto-add remains off — no confirmation modal for first-time rules with no member changes)
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
// * SaveChangesPanel disappears — rules were saved
await expect(saveBtn).not.toBeVisible({timeout: 15000});
// The dialog may auto-close after save or the Close button may take a moment to stabilise
// after the panel removal re-render. Only close if the dialog is still open.
const isOpen = await channelSettings.container.isVisible({timeout: 2000}).catch(() => false);
if (isOpen) {
await channelSettings.close();
}
});
test('MM-67326_c8 Auto-add checkbox becomes enabled after adding an attribute rule', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// * Checkbox starts disabled — no rules or system policies
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
await expect(autoAddCheckbox).toBeDisabled();
// # Add an attribute rule
await addAttributeRule(tab, page, 'Engineering');
// * Checkbox is now enabled
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
await channelSettings.close();
});
test('MM-67326_c9 Auto-add members: user matching rule is added to channel via sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const departmentValue = uniqueDepartmentValue('c9');
// # Admin's Department satisfies the rule (self-exclusion check passes)
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
// # Private channel — admin is the creator and only member
const channel = await createPrivateChannel(adminClient, team.id);
// # Target user: in the team, same Department, NOT yet in the channel
const targetUser = await createTeamAdmin(adminClient, team.id);
await setUserAttribute(adminClient, targetUser.id, 'Department', departmentValue);
// Save will run validateExpressionAgainstRequester and calculateMembershipChanges,
// both of which query the Postgres materialized AttributeView. The enterprise
// access-control service gates view refreshes to once per ~30s, so the brand-new
// unique CPA value above is not yet visible to CEL queries. Without this wait,
// Save hits the self-exclusion modal (admin appears unmatched against the rule
// they just satisfied via the API) and the confirmation modal never opens.
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
adminUser.id,
targetUser.id,
]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add attribute rule (unique value → preview lists only targetUser to add)
await addAttributeRule(tab, page, departmentValue);
// * Unsaved changes must be committed before save; otherwise handleSave can skip the confirmation path
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
// # Enable auto-add members
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
await autoAddCheckbox.click();
await expect(autoAddCheckbox).toBeChecked();
// # Save — confirmation modal appears because targetUser will be added
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
// # Confirm in the membership changes modal
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
await confirmModal.waitFor({state: 'visible', timeout: 30000});
await confirmModal.getByRole('button', {name: /Save and apply/}).click();
await confirmModal.waitFor({state: 'hidden', timeout: 15000});
// * Poll until target user appears as a channel member (sync job runs asynchronously)
let isMember = false;
for (let i = 0; i < 30; i++) {
try {
await adminClient.getChannelMember(channel.id, targetUser.id);
isMember = true;
break;
} catch {
await page.waitForTimeout(1000);
}
}
expect(isMember).toBe(true);
await channelSettings.close();
});
test('MM-67326_c10 Self-exclusion validation prevents saving rule that excludes current user', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Admin is in Engineering — rule requires Sales, so admin would be excluded
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add a rule that excludes the admin: Department == Sales
await addAttributeRule(tab, page, 'Sales');
// # Attempt to save
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
// * Self-exclusion error modal appears
const selfExclusionModal = page.locator('#confirmModal').filter({hasText: 'Cannot save access rules'});
await expect(selfExclusionModal).toBeVisible({timeout: 15000});
// # Dismiss the modal to go back to editing
await selfExclusionModal.getByRole('button', {name: 'Back to editing'}).click();
await expect(selfExclusionModal).not.toBeVisible();
await channelSettings.close();
});
test('MM-67326_c11 Unsaved changes — closing modal without saving keeps it open on first click', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const channel = await createPrivateChannel(adminClient, team.id);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add a rule to create unsaved changes
await addAttributeRule(tab, page, 'Engineering');
// * SaveChangesPanel is visible — there are unsaved changes
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 5000});
// # First close click — modal stays open (unsaved-changes two-step close)
await channelSettings.closeButton.click();
await expect(channelSettings.container).toBeVisible({timeout: 15000});
// # Second click — modal closes
await channelSettings.closeButton.click();
await expect(channelSettings.container).not.toBeVisible({timeout: 30000});
});
test('MM-67326_c12 View users — Restricted tab shows member count and user when rule removes a channel member', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Admin satisfies the rule
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
const channel = await createPrivateChannel(adminClient, team.id);
// # Add a second member who will NOT satisfy the rule (no Department attribute)
const memberToRemove = await createTeamAdmin(adminClient, team.id);
await adminClient.addToChannel(memberToRemove.id, channel.id);
// memberToRemove has no Department set → will not match "Department == Engineering"
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add rule: Department == Engineering (memberToRemove doesn't match → will be removed)
await addAttributeRule(tab, page, 'Engineering');
// # Click Save — confirmation modal appears because memberToRemove will be removed
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
await confirmModal.waitFor({state: 'visible', timeout: 30000});
// * Summary message shows 0 users added and 1 member removed
await expect(confirmModal).toContainText('remove 1 current channel member');
// # Click "View users" to open the detailed user list
await confirmModal.getByRole('button', {name: 'View users'}).click();
// * "Restricted (1)" tab is visible — one member will be removed
await expect(confirmModal.getByRole('button', {name: /Restricted \(1\)/})).toBeVisible({timeout: 5000});
// # Switch to the Restricted tab
await confirmModal.getByRole('button', {name: /Restricted \(1\)/}).click();
// * memberToRemove's username appears in the restricted list
await expect(confirmModal).toContainText(memberToRemove.username, {timeout: 5000});
// # Cancel — don't actually apply
await confirmModal.getByRole('button', {name: 'Cancel'}).click();
await confirmModal.waitFor({state: 'hidden', timeout: 10000});
await channelSettings.close();
});
test('MM-67326_c13 View users — Allowed tab shows member count and user when auto-add brings new members', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const departmentValue = uniqueDepartmentValue('c13');
// # Admin satisfies the rule
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
// # Private channel — admin is the only member
const channel = await createPrivateChannel(adminClient, team.id);
// # Target user: in the team, same Department, NOT yet in the channel
const memberToAdd = await createTeamAdmin(adminClient, team.id);
await setUserAttribute(adminClient, memberToAdd.id, 'Department', departmentValue);
// See c9: wait for the materialized AttributeView to surface admin and
// memberToAdd as matching the freshly-written CPA value before clicking Save.
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
adminUser.id,
memberToAdd.id,
]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add rule (unique value → only memberToAdd appears in "to add" with this server data)
await addAttributeRule(tab, page, departmentValue);
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
// # Enable auto-add so memberToAdd appears in the "to add" list
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
await autoAddCheckbox.click();
await expect(autoAddCheckbox).toBeChecked();
// # Click Save — confirmation modal appears because memberToAdd will be added
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
await confirmModal.waitFor({state: 'visible', timeout: 30000});
// * Summary shows 0 members removed (no one in this channel lacks the attribute)
await expect(confirmModal).toContainText('remove 0 current channel members');
// # Click "View users" to open the detailed user list
await confirmModal.getByRole('button', {name: 'View users'}).click();
// * Allowed tab is visible with at least one user (memberToAdd)
await expect(confirmModal.locator('.ChannelAccessRulesConfirmModal__tab', {hasText: /Allowed/})).toBeVisible({
timeout: 5000,
});
// # The Allowed tab is active by default — verify memberToAdd's username is shown (unique Dept avoids other matches)
await expect(confirmModal).toContainText(memberToAdd.username, {timeout: 5000});
// # Cancel — don't actually apply
await confirmModal.getByRole('button', {name: 'Cancel'}).click();
await confirmModal.waitFor({state: 'hidden', timeout: 10000});
await channelSettings.close();
});
test('MM-67326_c14 Applying access rules removes non-matching member from channel and RHS members', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const departmentValue = uniqueDepartmentValue('c14');
// # Admin satisfies the rule
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
const channel = await createPrivateChannel(adminClient, team.id);
// # Existing member does not satisfy the rule and should be removed when it is applied
const memberToRemove = await createTeamAdmin(adminClient, team.id);
await setUserAttribute(adminClient, memberToRemove.id, 'Department', `${departmentValue}-other`);
await adminClient.addToChannel(memberToRemove.id, channel.id);
// See c9: wait for the materialized AttributeView to surface the admin's
// freshly-written CPA value before clicking Save.
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
adminUser.id,
]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// # Navigate to Access Control tab
await channelSettings.container.getByTestId('access_rules-tab-button').click();
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add rule (unique value -> only admin remains allowed in the private channel)
await addAttributeRule(tab, page, departmentValue);
// # Click Save - confirmation modal appears because memberToRemove will be removed
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
await expect(saveBtn).toBeEnabled({timeout: 10000});
await saveBtn.click();
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
await confirmModal.waitFor({state: 'visible', timeout: 30000});
// * Summary message shows 0 users added and 1 member removed
await expect(confirmModal).toContainText('remove 1 current channel member');
// # Confirm and wait for the access-control sync job that applies the removal
const [syncJobResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/api/v4/jobs') && response.request().method() === 'POST',
{timeout: 10000},
),
confirmModal.getByRole('button', {name: 'Save'}).click(),
]);
await confirmModal.waitFor({state: 'hidden', timeout: 15000});
if (!syncJobResponse.ok()) {
throw new Error(`Failed to create access-control sync job: ${syncJobResponse.status()}`);
}
const syncJob = await syncJobResponse.json();
const syncJobId = syncJob.id as string;
const finished = await waitForJobCompletion(adminClient, syncJobId, {timeoutMs: 90_000});
expect(finished.status, `sync job did not succeed: ${JSON.stringify(finished)}`).toBe('success');
// * Poll until memberToRemove is no longer a channel member
await expect
.poll(
async () => {
try {
await adminClient.getChannelMember(channel.id, memberToRemove.id);
return true;
} catch {
return false;
}
},
{
timeout: 15000,
intervals: [500, 1000, 1000, 2000],
message: `${memberToRemove.username} should be removed from the channel`,
},
)
.toBe(false);
await channelSettings.close();
// * The ABAC removal system message is the last visible post in the channel
await channelsPage.centerView.waitUntilLastPostContains('was removed from the channel', 30000);
const lastPost = await channelsPage.getLastPost();
await expect(lastPost.container).toContainText(memberToRemove.username);
await expect(lastPost.container).toContainText('was removed from the channel');
// # Open channel members RHS from the affected channel
await channelsPage.centerView.header.openChannelMenu();
await page.locator('#channelMembers').click();
await channelsPage.sidebarRight.toBeVisible();
// * Admin remains in the RHS members list, and removed member is absent
await expect(page.getByTestId(`memberline-${adminUser.id}`)).toBeVisible({timeout: 10000});
await expect(page.getByTestId(`memberline-${memberToRemove.id}`)).not.toBeVisible();
});
});

View File

@@ -0,0 +1,128 @@
// 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();
});
/**
* @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.
*/
test(
'MM-T892 leaving a channel syncs to another session viewing a different channel',
{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);
// * Verify the new channel appears in the second session's sidebar
await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible();
// # Move the second session to a different channel
await secondSession.sidebarLeft.goToItem('off-topic');
await secondSession.centerView.header.toHaveTitle('Off-Topic');
// # Leave the channel from the first session while still viewing it
const channelMenu = await firstSession.openChannelMenu();
await channelMenu.leaveChannel.click();
// * Verify the first session is redirected to the default channel 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 stays on its current channel and the left channel is removed from its sidebar
await secondSession.centerView.header.toHaveTitle('Off-Topic');
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();
},
);

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
const DEFAULT_CHANNEL_URL_LOCKED_TEXT = 'The URL of the default channel cannot be changed.';
/**
* @objective Verify that Channel Settings does not offer a URL edit for the default channel, whose URL the
* server always rejects, while its display name can still be saved.
* @reference MM-67612
*/
test(
'does not offer a URL edit for the default channel but still saves its display name',
{tag: '@channel_settings'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the Channel Settings modal on the Info tab
const channelSettings = await channelsPage.openChannelSettings();
const infoSettings = await channelSettings.openInfoTab();
// * Verify the URL is shown read-only, with no way to edit it and an explanation why
await expect(infoSettings.urlLabel).toContainText('town-square');
await expect(infoSettings.urlEditButton).not.toBeVisible();
await expect(infoSettings.urlInput).not.toBeVisible();
await expect(infoSettings.container.getByText(DEFAULT_CHANNEL_URL_LOCKED_TEXT)).toBeVisible();
// # Rename the display name, which the server does allow on the default channel
await infoSettings.updateName('Company Wide');
await channelSettings.save();
// * Verify the save succeeded: the save panel disappears rather than showing a form error
await expect(channelSettings.saveButton).not.toBeVisible();
await channelSettings.close();
await channelsPage.centerView.header.toHaveTitle('Company Wide');
// * Verify the server kept the default channel URL and applied the new display name
const channel = await adminClient.getChannelByName(team.id, 'town-square');
expect(channel.name).toBe('town-square');
expect(channel.display_name).toBe('Company Wide');
},
);
/**
* @objective Verify that an ordinary channel is unaffected by the default-channel restriction and its URL
* can still be changed from Channel Settings.
* @reference MM-67612
*/
test('allows the URL of an ordinary channel to be changed', {tag: '@channel_settings'}, async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const createdChannel = await adminClient.createPublicChannel(team.id, 'Ordinary Channel');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, createdChannel.name);
await channelsPage.toBeVisible();
// # Open the Channel Settings modal on the Info tab
const channelSettings = await channelsPage.openChannelSettings();
const infoSettings = await channelSettings.openInfoTab();
// * Verify an ordinary channel is not affected by the default-channel restriction
await expect(infoSettings.container.getByText(DEFAULT_CHANNEL_URL_LOCKED_TEXT)).not.toBeVisible();
await expect(infoSettings.urlEditButton).toBeVisible();
// # Change the channel URL and save
const updatedUrl = `renamed-${pw.random.id()}`;
await infoSettings.updateUrl(updatedUrl);
await channelSettings.save();
// * Verify the save succeeded: the save panel disappears rather than showing a form error
await expect(channelSettings.saveButton).not.toBeVisible();
await channelSettings.close();
const channel = await adminClient.getChannel(createdChannel.id);
expect(channel.name).toBe(updatedUrl);
});

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Lean helpers for the public-channel ABAC E2E specs.
*
* Design rules:
* - Reuse the existing primitives in `../team_settings/helpers.ts` for
* channel/policy/user creation and configuration. This file only adds
* thin wrappers that register cleanup as resources are created.
* - All test-created resources are tracked in a `CleanupLedger`. The spec's
* `test.afterEach` drains the ledger so resources are removed whether the
* test passed, failed, or threw mid-flight.
* - Cleanup is best-effort: individual failures are swallowed so one stale
* resource cannot block deletion of the others or mask the test result.
* - Custom profile attributes are scoped per test (unique names + cleanup).
* Tests must not lean on a shared `Department` field — the server caps CPA
* fields at 20, and accumulating shared state has historically saturated
* that limit and broken every subsequent run.
*/
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {newTestPassword, getRandomId, type PlaywrightClient4} from '@mattermost/playwright-lib';
import {assignChannelsToPolicy, deletePolicy, unassignChannelsFromPolicy} from '../team_settings/helpers';
export type CleanupTask = () => Promise<unknown>;
export class CleanupLedger {
private tasks: CleanupTask[] = [];
add(task: CleanupTask): void {
// LIFO: latest registrations run first so deletions respect dependency
// order (e.g. unassign channels from a policy before deleting the policy).
this.tasks.unshift(task);
}
async drain(): Promise<void> {
const tasks = this.tasks;
this.tasks = [];
for (let i = 0; i < tasks.length; i++) {
try {
await tasks[i]();
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? e.stack : undefined;
// eslint-disable-next-line no-console
console.error(`CleanupLedger.drain: cleanup task ${i} failed`, message, stack ?? '');
// Swallow — cleanup is best-effort and must not mask the test
// result or stop subsequent cleanups from running.
}
}
}
}
/**
* Issue a server request and surface the response body on non-2xx so the test
* report shows *what* the server rejected, not just "failed". The shipped
* helpers in `team_settings/helpers.ts` swallow these — useful in steady
* state, brutal when something legitimately broke (e.g. CPA field-limit
* exhaustion silently masquerading as a "field not found" error).
*/
async function doFetchOrThrow(client: Client4, url: string, init: RequestInit & {body?: string}): Promise<any> {
const response = await fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${client.getToken()}`,
...(init.headers || {}),
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`${init.method} ${url} -> ${response.status}: ${text}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
/**
* Permanently delete a user via the admin API.
* Permanent deletion requires `ServiceSettings.EnableAPIUserDeletion` to be on
* server-side; on test deployments where it is off, the call returns 4xx and
* the cleanup ledger silently swallows it.
*/
export async function permanentDeleteUser(client: Client4, userId: string): Promise<void> {
await (client as any).doFetch(`${client.getBaseRoute()}/users/${userId}?permanent=true`, {
method: 'DELETE',
});
}
/**
* Create a per-test custom profile attribute (text type) with a unique name and
* register cleanup. Returns the field including its server-assigned `id`,
* which callers can plug straight into a CEL expression via
* `user.attributes.id_<id>`.
*
* Avoids the shared `Department` slot used by other ABAC tests: those compete
* for one of 20 server-cap'd CPA slots and any leak (a failed test, an
* abandoned run) cascades into "field not found" or "limit reached" errors
* across the whole suite.
*/
export async function createTrackedAttribute(
client: Client4,
baseName: string,
ledger: CleanupLedger,
): Promise<{id: string; name: string}> {
const name = `${baseName}_${getRandomId()}`;
const field = await doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields`, {
method: 'POST',
body: JSON.stringify({name, type: 'text', attrs: {visibility: 'when_set'}}),
});
ledger.add(() =>
doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields/${field.id}`, {
method: 'DELETE',
}),
);
return {id: field.id, name: field.name};
}
/**
* Set a CPA value on a user, addressing the field by its ID. Avoids the
* field-name-to-ID lookup in the shared `setUserAttribute` helper, which is
* unnecessary indirection now that callers hold the field handle directly.
*/
export async function setUserAttributeById(client: Client4, userId: string, fieldId: string, value: string) {
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
}
/**
* Create a public channel and register cleanup for it.
*/
export async function createTrackedPublicChannel(client: PlaywrightClient4, teamId: string, ledger: CleanupLedger) {
const channel = await client.createPublicChannel(teamId);
ledger.add(() => client.deleteChannel(channel.id));
return channel;
}
/**
* Create a parent ABAC policy with the given CEL rule and assign the given
* channels in one shot. Registers cleanup so the policy (and its inherited
* channel-scope children) gets removed even if the test bails partway.
*
* Why we don't reuse `createParentPolicy` from team_settings/helpers.ts and
* patch afterwards: that helper hardcodes `expression: 'true'`, and a follow-up
* PUT to override the rule fails the server's policy-validation pipeline.
* Stamping the desired expression on the initial create is both simpler and
* matches what the system console actually does.
*/
export async function createPolicyAssignedToChannels(
client: Client4,
name: string,
expression: string,
channelIds: string[],
ledger: CleanupLedger,
) {
const policy: any = await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies`, {
method: 'PUT',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.2',
revision: 0,
rules: [{expression, actions: ['*']}],
}),
});
// Cleanup is self-sufficient: unassign first, then delete. The LIFO drain
// order in the spec runs this BEFORE the channel deletes, so the parent
// still has live child-scope policy rows referencing it — DeleteAccessControlPolicy
// would silently fail and leak the parent. Unassigning explicitly drops
// those children regardless of channel cleanup order.
ledger.add(async () => {
if (channelIds.length > 0) {
try {
await unassignChannelsFromPolicy(client, policy.id, channelIds);
} catch {
// Channels may already be gone (their cleanup tasks may have run
// first), in which case the child policies were auto-removed by
// cleanupChannelAccessControlPolicy. Either way, best-effort.
}
}
await deletePolicy(client, policy.id);
});
if (channelIds.length > 0) {
await assignChannelsToPolicy(client, policy.id, channelIds);
}
return policy;
}
/**
* Flip the auto-add (Active) flag on a channel-scope ABAC policy. Channel-scope
* policies share the channel's ID, so this is keyed by channelId. Active=true
* is what makes the access-control sync job auto-add matching users.
*
* Children inherit the parent's Active flag at assign time, but the parent
* default on a fresh `createPolicyAssignedToChannels` is false — flip the
* child here when the test needs auto-add ON.
*/
export async function setChannelPolicyActive(client: Client4, channelId: string, active: boolean): Promise<void> {
await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies/activate`, {
method: 'PUT',
body: JSON.stringify({entries: [{id: channelId, active}]}),
});
}
/**
* Trigger an access-control sync job for a single policy and return the job
* record. The job is queued — call `waitForJobCompletion` to block until it
* resolves.
*/
export async function runAccessControlSyncJob(client: Client4, policyId: string): Promise<any> {
return client.createJob({
type: 'access_control_sync' as any,
data: {policy_id: policyId},
});
}
/**
* Poll a job until it reaches a terminal status (success / error / canceled).
* Sync jobs are queued and processed asynchronously; in CI the queue can be
* idle for several seconds before pickup. The default 60s ceiling matches what
* we've seen empirically; callers can extend via `timeoutMs` when running on
* slow servers.
*/
export async function waitForJobCompletion(
client: Client4,
jobId: string,
opts: {timeoutMs?: number; pollIntervalMs?: number} = {},
): Promise<any> {
const timeoutMs = opts.timeoutMs ?? 60_000;
const pollIntervalMs = opts.pollIntervalMs ?? 1_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const job: any = await client.getJob(jobId);
if (job.status === 'success') {
return job;
}
if (job.status === 'error' || job.status === 'canceled') {
const detail = job?.data?.message ?? job?.message ?? job?.error ?? JSON.stringify(job?.data ?? job);
throw new Error(`Job ${jobId} finished with status ${job.status}: ${detail}`);
}
await new Promise((res) => setTimeout(res, pollIntervalMs));
}
throw new Error(`Job ${jobId} did not reach a terminal status within ${timeoutMs}ms`);
}
/**
* Create a user, add them to a team, optionally set a single attribute by ID,
* and bypass the tutorial / onboarding so tests can `pw.testBrowser.login()`
* straight into the channels view. Cleanup is registered for the user.
*/
export async function createTrackedTeamMember(
adminClient: Client4,
teamId: string,
attribute: {fieldId: string; value: string} | undefined,
ledger: CleanupLedger,
): Promise<UserProfile & {password: string}> {
const id = getRandomId();
const username = `pub${id}`.toLowerCase();
const password = newTestPassword();
const user = await adminClient.createUser(
{email: `${username}@sample.mattermost.com`, username, password} as UserProfile & {password: string},
'',
'',
);
ledger.add(() => permanentDeleteUser(adminClient, user.id));
await adminClient.savePreferences(user.id, [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'onboarding', name: 'complete', value: 'true'},
]);
await adminClient.addToTeam(teamId, user.id);
if (attribute) {
await setUserAttributeById(adminClient, user.id, attribute.fieldId, attribute.value);
}
// Attach the password back to the user object so pw.testBrowser.login() can
// authenticate — the API response does not include it.
return {...user, password};
}

View File

@@ -0,0 +1,295 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Verify per-channel suppression of join/leave system messages via
* the Configuration tab toggle in Channel Settings.
*
* The toggle controls `DisableJoinLeaveMessages` on the channel:
* - Toggle ON (active class) → messages shown (DisableJoinLeaveMessages = false)
* - Toggle OFF (no active class) → messages hidden (DisableJoinLeaveMessages = true)
*
* Join system posts are generated server-side when a user is added to a
* channel, so tests use `adminClient.addToChannel()` to produce them.
*/
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
test.describe('Channel Settings Modal - Join/Leave System Messages', () => {
/**
* @objective Verify the join/leave messages toggle is visible in Channel Settings
* Configuration tab and defaults to ON (messages shown).
*/
test('toggle is visible and defaults to ON (messages shown)', {tag: '@channel_settings'}, async ({pw}) => {
// # Initialize test setup
const {adminUser, adminClient, team} = await pw.initSetup();
// # Create a public channel via API
const channel = await adminClient.createChannel({
team_id: team.id,
name: `jl-toggle-default-${Date.now()}`,
display_name: 'JL Toggle Default',
type: 'O',
} as any);
// # Login as admin and navigate to the channel
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open Channel Settings and navigate to Configuration tab
const channelSettings = await channelsPage.openChannelSettings();
const configSettings = await channelSettings.openConfigurationTab();
// * Toggle is visible
await expect(configSettings.joinLeaveMessagesToggle).toBeVisible();
// * Toggle defaults to ON (active class present = messages shown)
const classes = await configSettings.joinLeaveMessagesToggle.getAttribute('class');
expect(classes).toContain('active');
await channelSettings.close();
});
/**
* @objective Verify that disabling the join/leave messages toggle hides join system
* posts from the channel timeline while keeping regular messages visible.
*/
test(
'disabling the toggle hides join system posts from the channel timeline',
{tag: '@channel_settings'},
async ({pw}) => {
// # Initialize test setup
const {adminUser, adminClient, team} = await pw.initSetup();
// # Create a public channel via API
const channel = await adminClient.createChannel({
team_id: team.id,
name: `jl-disable-${Date.now()}`,
display_name: 'JL Disable Test',
type: 'O',
} as any);
// # Create a second user, add to team and channel to produce a join system post
const secondUser = await pw.createNewUserProfile(adminClient, {
prefix: 'jl2',
disableTutorial: true,
disableOnboarding: true,
});
await adminClient.addToTeam(team.id, secondUser.id);
// # Post a normal message to verify it stays visible when system posts are hidden
await adminClient.createPost({
channel_id: channel.id,
message: 'This is a regular message that should remain visible',
} as any);
// # Add the second user to the channel — this triggers a server-side join system post
await adminClient.addToChannel(secondUser.id, channel.id);
// # Login as admin and navigate to the channel
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * The join system post is visible in the timeline before disabling
await expect(
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
).toBeVisible({timeout: pw.duration.ten_sec});
// # Open Channel Settings → Configuration tab and disable join/leave messages
const channelSettings = await channelsPage.openChannelSettings();
const configSettings = await channelSettings.openConfigurationTab();
await configSettings.disableJoinLeaveMessages();
await configSettings.save();
await channelSettings.close();
// * The join system post is no longer visible — wait for post-list re-fetch after ETag bust
await expect(
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
).not.toBeVisible({timeout: pw.duration.ten_sec});
// * Normal messages are still visible
await expect(
channelsPage.centerView.container
.getByTestId('postContent')
.getByText('This is a regular message that should remain visible'),
).toBeVisible();
},
);
/**
* @objective Verify that a user added to a channel with DisableJoinLeaveMessages=true
* receives a sidebar mention count badge (blue dot) even though the add-to-channel
* system post is suppressed from the timeline.
*/
test(
'added user receives sidebar mention badge when join/leave messages are disabled',
{tag: '@channel_settings'},
async ({pw}) => {
// # Initialize test setup
const {adminClient, team} = await pw.initSetup();
// # Create a public channel and immediately disable join/leave messages
const channel = await adminClient.createChannel({
team_id: team.id,
name: `jl-added-badge-${Date.now()}`,
display_name: 'JL Added Badge Test',
type: 'O',
} as any);
await adminClient.patchChannel(channel.id, {disable_join_leave_messages: true} as any);
// # Create a second user and add them to the team (not the channel yet)
const secondUser = await pw.createNewUserProfile(adminClient, {
prefix: 'jl-badge',
disableTutorial: true,
disableOnboarding: true,
});
await adminClient.addToTeam(team.id, secondUser.id);
// # Login as secondUser and navigate to a channel so the app fully loads and
// WS connection is established before the add-to-channel event fires
const {page: secondPage} = await pw.testBrowser.login(secondUser);
const secondChannelsPage = new ChannelsPage(secondPage);
await secondChannelsPage.goto(team.name, 'town-square');
await secondChannelsPage.toBeVisible();
// # Admin adds secondUser to the channel — this triggers a suppressed add-to-channel
// system post; the server sends a targeted posted WS event only to secondUser so
// they receive a desktop notification and sidebar badge without the post appearing
// in the channel timeline for any member.
await adminClient.addToChannel(secondUser.id, channel.id);
// * secondUser's sidebar should show the new channel with a mention count badge
const badge = secondChannelsPage.sidebarLeft.unreadMentionsBadge(channel.name);
await expect(badge).toBeVisible({timeout: pw.duration.ten_sec});
// * The channel timeline should NOT contain an add-to-channel system post
await secondChannelsPage.sidebarLeft.goToItem(channel.name);
await expect(secondChannelsPage.centerView.container.getByText('added to the channel')).not.toBeVisible({
timeout: pw.duration.ten_sec,
});
},
);
/**
* @objective Verify that toggling DisableJoinLeaveMessages live-reloads the channel
* post list for all users currently viewing the channel (Option 7 / WS reload).
* A second user already viewing the channel should see join system posts appear or
* disappear in real time when the admin changes the setting.
*/
test(
'toggling join/leave messages live-reloads the channel for other viewers',
{tag: '@channel_settings'},
async ({pw}) => {
// # Initialize test setup
const {adminUser, adminClient, team} = await pw.initSetup();
// # Create a public channel and add a second user to produce a join system post
const channel = await adminClient.createChannel({
team_id: team.id,
name: `jl-ws-reload-${Date.now()}`,
display_name: 'JL WS Reload Test',
type: 'O',
} as any);
const secondUser = await pw.createNewUserProfile(adminClient, {
prefix: 'jl-ws',
disableTutorial: true,
disableOnboarding: true,
});
await adminClient.addToTeam(team.id, secondUser.id);
await adminClient.addToChannel(secondUser.id, channel.id);
// # Login as secondUser and navigate to the channel
const {page: secondPage} = await pw.testBrowser.login(secondUser);
const secondChannelsPage = new ChannelsPage(secondPage);
await secondChannelsPage.goto(team.name, channel.name);
await secondChannelsPage.toBeVisible();
// * secondUser sees the join system post in the channel
await expect(secondChannelsPage.centerView.container.getByText('joined the channel')).toBeVisible({
timeout: pw.duration.ten_sec,
});
// # Admin logs in and disables join/leave messages from their browser
const {page: adminPage} = await pw.testBrowser.login(adminUser);
const adminChannelsPage = new ChannelsPage(adminPage);
await adminChannelsPage.goto(team.name, channel.name);
await adminChannelsPage.toBeVisible();
const channelSettings = await adminChannelsPage.openChannelSettings();
const configSettings = await channelSettings.openConfigurationTab();
await configSettings.disableJoinLeaveMessages();
await configSettings.save();
await channelSettings.close();
// * secondUser's view updates in real time — join post disappears without a page reload
await expect(secondChannelsPage.centerView.container.getByText('joined the channel')).not.toBeVisible({
timeout: pw.duration.ten_sec,
});
},
);
/**
* @objective Verify that re-enabling the join/leave messages toggle restores
* previously hidden join system posts (two-way door behavior).
*/
test(
're-enabling the toggle restores hidden join system posts (two-way door)',
{tag: '@channel_settings'},
async ({pw}) => {
// # Initialize test setup
const {adminUser, adminClient, team} = await pw.initSetup();
// # Create a public channel via API
const channel = await adminClient.createChannel({
team_id: team.id,
name: `jl-reenable-${Date.now()}`,
display_name: 'JL Re-enable Test',
type: 'O',
} as any);
// # Create a second user, add to team and channel to produce a join system post
const secondUser = await pw.createNewUserProfile(adminClient, {
prefix: 'jl3',
disableTutorial: true,
disableOnboarding: true,
});
await adminClient.addToTeam(team.id, secondUser.id);
await adminClient.addToChannel(secondUser.id, channel.id);
// # Login as admin and navigate to the channel
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Disable join/leave messages and save
let channelSettings = await channelsPage.openChannelSettings();
let configSettings = await channelSettings.openConfigurationTab();
await configSettings.disableJoinLeaveMessages();
await configSettings.save();
await channelSettings.close();
// * Join system post is hidden after disabling — wait for post-list re-fetch
await expect(
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
).not.toBeVisible({timeout: pw.duration.ten_sec});
// # Re-open Channel Settings → Configuration tab and re-enable join/leave messages
channelSettings = await channelsPage.openChannelSettings();
configSettings = await channelSettings.openConfigurationTab();
await configSettings.enableJoinLeaveMessages();
await configSettings.save();
await channelSettings.close();
// * Join system post is visible again after re-enabling
await expect(
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
).toBeVisible({timeout: pw.duration.ten_sec});
},
);
});

View File

@@ -0,0 +1,86 @@
// 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});
},
);
/**
* @objective Verify that users added to a public channel in one batch are combined into a single system
* message, while a user added after an intervening post produces a separate system message.
*/
test(
'MM-T858 combines consecutive join messages and separates them across posts',
{tag: '@channel_settings'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
const [member1, member2, member3] = await adminClient.createUsers(team.id, 3, 'join-leave');
const channel = await adminClient.createPublicChannel(team.id, `Messages ${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();
// # Post a message to seal the setup system messages into their own group
await channelsPage.postMessage(`setup ${pw.random.id()}`);
// # Add two members to the channel in quick succession
await adminClient.addToChannel(member1.id, channel.id);
await adminClient.addToChannel(member2.id, channel.id);
// * Verify both members are combined into a single "added to the channel" system message
await channelsPage.centerView.waitUntilLastPostContains('added to the channel');
const combinedPost = await channelsPage.getLastPost();
await combinedPost.toContainText(member1.username);
await combinedPost.toContainText(member2.username);
// # Post a message, then add a third member after it
await channelsPage.postMessage(`divider ${pw.random.id()}`);
await adminClient.addToChannel(member3.id, channel.id);
// * Verify the third member produces a separate system message that does not include the earlier members
await channelsPage.centerView.waitUntilLastPostContains('added to the channel');
const separatePost = await channelsPage.getLastPost();
await separatePost.toContainText(member3.username);
await separatePost.toNotContainText(member1.username);
},
);

View File

@@ -0,0 +1,81 @@
// 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();
});
/**
* @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.
*/
test(
'MM-T861 cancelling the create channel modal does not join a channel',
{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 via the modal
const createdChannel = `Random ${pw.random.id()}`;
await channelsPage.newChannel(createdChannel, 'O');
// * Verify the new channel is opened
await channelsPage.centerView.header.toHaveTitle(createdChannel);
// # Leave the channel via the channel header menu
const channelMenu = await channelsPage.openChannelMenu();
await channelMenu.leaveChannel.click();
// * Verify the user is redirected to the default channel and the channel is no longer in the sidebar
await channelsPage.centerView.header.toHaveTitle('Town Square');
await expect(channelsPage.sidebarLeft.container.getByText(createdChannel)).not.toBeVisible();
// # Open the create-channel modal, enter a name, and cancel without creating
const cancelledChannel = `AB ${pw.random.id()}`;
const newChannelModal = await channelsPage.openNewChannelModal();
await newChannelModal.displayNameInput.fill(cancelledChannel);
await newChannelModal.cancel();
// * Verify the modal is closed and the cancelled channel was never created or joined
await expect(newChannelModal.container).not.toBeVisible();
await expect(channelsPage.sidebarLeft.container.getByText(cancelledChannel)).not.toBeVisible();
// * Verify the previously left channel remains absent from the sidebar
await expect(channelsPage.sidebarLeft.container.getByText(createdChannel)).not.toBeVisible();
},
);

View File

@@ -0,0 +1,97 @@
// 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 Settings opens with no unsaved-changes warning and no channel name
* validation error, and closes on a single click, for a channel whose stored purpose and header carry
* leading or trailing whitespace. The server keeps that whitespace verbatim, so channels populated
* outside this form reach it untidy.
*/
test(
'opens Channel Settings cleanly for a channel whose stored text has surrounding whitespace',
{tag: '@channel_settings'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
// # Create a channel whose stored purpose and header have surrounding whitespace
const channel = await adminClient.createPublicChannel(team.id, `Untidy ${pw.random.id()}`);
const paddedPurpose = ' Small annoyances that add up ';
const paddedHeader = 'Runbook: [Ops guide](https://example.com/runbook)\nEscalate to the on-call\n';
await adminClient.patchChannel(channel.id, {purpose: paddedPurpose, header: paddedHeader});
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open Channel Settings without touching any field
const channelSettings = await channelsPage.openChannelSettings();
const infoSettings = await channelSettings.openInfoTab();
// * Verify the stored text is shown as-is and the form does not report unsaved changes
await expect(infoSettings.purposeInput).toHaveValue(paddedPurpose);
await expect(infoSettings.headerInput).toHaveValue(paddedHeader);
await expect(infoSettings.saveChangesPanel).not.toBeVisible();
// * Verify no channel name validation error is shown for the populated name field
await expect(infoSettings.nameInput).toHaveValue(channel.display_name);
await expect(
channelSettings.container.getByText('Channel names must have at least 1 character.'),
).not.toBeVisible();
// # Close the modal with a single click
await channelSettings.closeButton.click();
// * Verify the modal closes without the second click that unsaved changes would demand
await expect(channelSettings.container).not.toBeVisible();
},
);
/**
* @objective Verify that editing a channel whose stored text has surrounding whitespace still surfaces the
* unsaved-changes panel and saves the edit.
*/
test(
'saves an edit made on a channel whose stored text has surrounding whitespace',
{tag: '@channel_settings'},
async ({pw}) => {
const {adminClient, team, user} = await pw.initSetup();
// # Create a channel whose stored header ends in a newline
const channel = await adminClient.createPublicChannel(team.id, `Untidy ${pw.random.id()}`);
const paddedHeader = 'padded header\n';
await adminClient.patchChannel(channel.id, {purpose: ' padded purpose ', header: paddedHeader});
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Open Channel Settings and edit the purpose
const channelSettings = await channelsPage.openChannelSettings();
const infoSettings = await channelSettings.openInfoTab();
await infoSettings.updatePurpose('A brand new purpose');
// * Verify the edit is recognised as an unsaved change
await expect(channelSettings.container.getByText('You have unsaved changes')).toBeVisible();
// # Save the edit
await channelSettings.save();
// * Verify the save completes and the panel leaves the unsaved state
await expect(channelSettings.saveButton).not.toBeVisible();
// # Close and reopen Channel Settings
await channelSettings.close();
const reopenedSettings = await channelsPage.openChannelSettings();
const reopenedInfo = await reopenedSettings.openInfoTab();
// * Verify the saved purpose persisted, the untouched header was left exactly as stored, and the
// * reopened form is clean
await expect(reopenedInfo.purposeInput).toHaveValue('A brand new purpose');
await expect(reopenedInfo.headerInput).toHaveValue(paddedHeader);
await expect(reopenedInfo.saveChangesPanel).not.toBeVisible();
},
);

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost} from './../support';
/**
* @objective Verify that a deletion report summary is posted to the reviewer's content review thread after removing a flagged post
* @testcase
* 1. Create two users and set one as reviewer
* 2. Setup content flagging
* 3. Create a post, flag it, and remove it via reviewer
* 4. Login as the reviewer and navigate to the content review DM
* 5. Verify the deletion report summary table is posted in the reviewer's thread
*/
test('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
const {adminClient, team, user: reviewerUser} = await pw.initSetup();
// # Create author user
const authorUser = await pw.random.user('author');
const {id: authorUserID} = await adminClient.createUser(authorUser, '', '');
await adminClient.addToTeam(team.id, authorUserID);
const {client: authorUserClient} = await pw.makeClient(authorUser);
await setupContentFlagging(adminClient, [reviewerUser.id]);
const message = `Sensitive 2 post by @${authorUser.username} to be removed`;
const {post} = await createPost(adminClient, authorUserClient, team, authorUser, message);
const expectedFileName = `deletion_report_${post.id}.md`;
// # Flag the post
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message contains sensitive data');
// # Login as reviewer and navigate to content review DM
const {channelsPage, contentReviewPage} = await pw.testBrowser.login(reviewerUser);
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
const flaggedPost = await channelsPage.centerView.getLastPost();
await flaggedPost.toContainText(message);
// # Remove the post via the UI. The remove flow still routes through the
// skip-confirm step (destructive action), unlike the keep flow which now
// bypasses it.
await contentReviewPage.setReportCardByPostID(post.id);
await contentReviewPage.openViewDetails();
await contentReviewPage.waitForRHSVisible();
await contentReviewPage.openViewDetails();
await contentReviewPage.clickRemoveMessage();
await contentReviewPage.enterConfirmationComment('Removing: data spillage confirmed');
await contentReviewPage.confirmRemoveWithoutReport();
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
// * Verify the removed stub is in the content-review thread
const removedPost = await channelsPage.centerView.getPostByText(
'Content deleted as part of Content Flagging review process',
);
await removedPost.toBeVisible();
// # Open the thread — the deletion report is posted as a reply, not on the
// view-details property card.
await expect(removedPost.threadFooter.container).toBeVisible();
await removedPost.threadFooter.reply();
await channelsPage.sidebarRight.toBeVisible();
// * Verify the async deletion-report file is in the thread before table headers
await channelsPage.sidebarRight.toContainText(expectedFileName);
// * Verify the summary table headers are present (rendered as markdown table)
await channelsPage.sidebarRight.toContainText('Step');
await channelsPage.sidebarRight.toContainText('Status');
await channelsPage.sidebarRight.toContainText('Detail');
await channelsPage.sidebarRight.toContainText('File attachments');
await channelsPage.sidebarRight.toContainText('File attachment records');
await channelsPage.sidebarRight.toContainText('Edit history');
await channelsPage.sidebarRight.toContainText('Priority metadata');
await channelsPage.sidebarRight.toContainText('Persistent notifications');
await channelsPage.sidebarRight.toContainText('Acknowledgements');
await channelsPage.sidebarRight.toContainText('Reminders');
await channelsPage.sidebarRight.toContainText('Thread, replies, and reactions');
await channelsPage.sidebarRight.toContainText('Post record');
});

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {createPost, verifyAuthorNotification, setupContentFlagging} from './../support';
/**
* @objective Verify that when the author deletes a flagged message before review,
* the flag status is updated to "Removed" and the report reflects the deletion.
*/
// TODO: Fix defect https://mattermost.atlassian.net/browse/MM-66342
test.skip('should not be able to restore flagged messages when author deletes message', async ({pw}) => {
const {adminClient, team, user: reviewerUser} = await pw.initSetup();
// Create second user and add to team
const reporterUser = await pw.random.user('second');
const {id: reporterUserID} = await adminClient.createUser(reporterUser, '', '');
await adminClient.addToTeam(team.id, reporterUserID);
// Create third user and add to team
const postFromThirdUser = await pw.random.user('third');
const {id: postFromThirdUserID} = await adminClient.createUser(postFromThirdUser, '', '');
await adminClient.addToTeam(team.id, postFromThirdUserID);
const {client: thirdUserClient} = await pw.makeClient(postFromThirdUser);
const {client: reporterUserClient} = await pw.makeClient(reporterUser);
await setupContentFlagging(adminClient, [reviewerUser.id], true, false);
const message = `Post by @${reviewerUser.username}, is flagged once`;
const {post} = await createPost(adminClient, thirdUserClient, team, postFromThirdUser, message);
await reporterUserClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
// delete the post as the author
await thirdUserClient.deletePost(post.id);
// verify the flag status is updated to "Removed"
const flagReport = await adminClient.getFlaggedPost(post.id);
// Verify the delete_at timestamp is set (indicating deletion)
expect(flagReport.delete_at).not.toBe(0);
const {channelsPage, contentReviewPage} = await pw.testBrowser.login(reviewerUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await verifyAuthorNotification(post.id, channelsPage, contentReviewPage, team.name, message, 'Removed');
});

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {createPost, verifyAuthorNotification, setupContentFlagging} from './../support';
/** @objective Verify Post message is updated for the reviewer, if author updates the post before reviewer's action
* @testcase
* 1. Setup Content Flagging with reviewers
* 2. Create a post by User A
* 3. Flag the post by User B
* 4. Edit the post by User A before reviewer's action
* 5. Login as Reviewer and verify the updated message in Content Review page
*/
test("Verify Post message is updated for the reviewer, if author updates the post before reviewer's action ", async ({
pw,
}) => {
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
// Create second user and add to team
const secondUser = await pw.random.user('reviewer');
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
// Promote to system_admin so SystemAdminsAsReviewers:true (the test-suite default)
// keeps them as a reviewer even if a concurrent initSetup() resets CommonReviewerIds:[].
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
// Setup content flagging with explicit reviewer list and HideFlaggedContent=false.
// The default test config sets EnableContentFlagging=true and SystemAdminsAsReviewers=true,
// so concurrent initSetup() resets from other workers cannot silently disable this test.
await setupContentFlagging(adminClient, [adminUser.id, secondUserID], true, false);
const message = `Post by @${user.username}, is flagged once`;
const {post} = await createPost(adminClient, userClient, team, user, message);
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
let updatedMessage = `${message} - Edited during review`;
await userClient.updatePost({
id: post.id,
create_at: post.create_at,
update_at: Date.now(),
edit_at: 0,
delete_at: 0,
is_pinned: false,
user_id: post.user_id,
channel_id: post.channel_id,
root_id: '',
original_id: '',
message: updatedMessage,
type: '',
props: {},
hashtags: '',
file_ids: [],
pending_post_id: '',
remote_id: '',
reply_count: 0,
last_reply_at: 0,
participants: null,
metadata: post.metadata,
});
const {channelsPage: secondChannelsPage, contentReviewPage: secondContentReviewPage} =
await pw.testBrowser.login(secondUser);
// The edited post will have Edited indicator automatically added by the system
updatedMessage = `${updatedMessage} Edited`;
await verifyAuthorNotification(
post.id,
secondChannelsPage,
secondContentReviewPage,
team.name,
updatedMessage,
'Pending',
);
});

View File

@@ -0,0 +1,534 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
// NOTE: No global afterAll disabling content flagging here. A global afterAll
// that writes shared server config races with reviewer-* tests running in a
// parallel worker on the same shard. Each test that needs content flagging
// disabled sets it explicitly at the start (see the "feature is disabled" test
// below). Tests that need it enabled do the same via patchConfig/setupContentFlagging.
// Constants for repeated strings
const FLAG_REASON_CLASSIFICATION_MISMATCH: string = 'Classification mismatch';
// Sent by every patchConfig below: the reason list is server-wide and replaced as a whole, so a
// test cannot rely on the one another test left behind.
const FLAG_REASONS: string[] = ['Spam', FLAG_REASON_CLASSIFICATION_MISMATCH, 'Harassment', 'Hate Speech', 'Other'];
const FLAG_COMMENT: string = 'This message contains misclassified data';
const systemMessageForUser = (username: string): string =>
`The message from @${username} has been quarantined for review. You will be notified once it is reviewed by a Reviewer.`;
// Helper to login and navigate to channel
async function loginAndNavigate(pw: any, user: any, teamName?: string, channelName?: string): Promise<any> {
const {channelsPage} = await pw.testBrowser.login(user);
if (teamName && channelName) {
await channelsPage.goto(teamName, channelName);
} else {
await channelsPage.goto();
}
await channelsPage.toBeVisible();
return channelsPage;
}
// Helper to post a message and get post info
async function postMessage(channelsPage: any, message: string): Promise<{post: any; postId: any}> {
await channelsPage.postMessage(message);
const post = await channelsPage.getLastPost();
const postId = await channelsPage.centerView.getLastPostID();
return {post, postId};
}
// Helper to flag a post
async function flagPostFlow(
post: any,
channelsPage: any,
message: string,
reason: string = FLAG_REASON_CLASSIFICATION_MISMATCH,
comment: string = FLAG_COMMENT,
): Promise<void> {
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(reason);
await channelsPage.centerView.flagPostConfirmationDialog.fillFlagComment(comment);
await channelsPage.centerView.flagPostConfirmationDialog.submitButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.notToBeVisible();
}
// Helper to open the dot menu for a given post
async function openPostDotMenu(post: any, channelsPage: any): Promise<void> {
await post.hover();
await post.postMenu.toBeVisible();
await post.postMenu.dotMenuButton.click();
await channelsPage.postDotMenu.toBeVisible();
}
/**
* @objective: Test the basic flow of flagging a message and verify flagged message is hidden
*
* @testcase
* 1. Login as a user
* 2. Post a message
* 3. Flag the message
* 4. Verify the message is hidden and a system message is shown
*/
test('Verify flagged message is hidden by default', async ({pw}) => {
const {user, adminClient} = await pw.initSetup();
// Explicitly set HideFlaggedContent: true — a parallel worker may have set it
// to false (e.g. author-deletes-message-before-review.spec.ts). Without an
// explicit value this test would fail intermittently under PW_WORKERS=2.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
HideFlaggedContent: true,
Reasons: FLAG_REASONS,
},
},
});
const channelsPage = await loginAndNavigate(pw, user);
const message = 'This is a test message to be flagged';
const {post, postId} = await postMessage(channelsPage, message);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {HideFlaggedContent: true, Reasons: FLAG_REASONS},
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
// Cancel flagging the message
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.cancelButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.notToBeVisible();
// Flag the message
await flagPostFlow(post, channelsPage, message, FLAG_REASON_CLASSIFICATION_MISMATCH);
// Verify the message is flagged
const flaggedPost = await channelsPage.centerView.getPostById(postId);
await flaggedPost.toContainText('(message deleted)');
const systemMessage = await channelsPage.getLastPost();
await expect(systemMessage.body).toContainText(systemMessageForUser(user.username));
});
/**
* @objective: Verify Post is not hidden after flagging if HideFlaggedContent is false
*
* @testcase
* 1. Login as a user
* 2. Post a message
* 3. Flag the message
* 4. Verify the message is not hidden
*/
test('Verify Post is not hidden after flagging if HideFlaggedContent is false', async ({pw}) => {
const {user, adminClient} = await pw.initSetup();
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
HideFlaggedContent: false,
Reasons: FLAG_REASONS,
},
},
});
const channelsPage = await loginAndNavigate(pw, user);
const message = 'This is a test message to be flagged';
const {post, postId} = await postMessage(channelsPage, message);
await post.toBeVisible();
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {HideFlaggedContent: false, Reasons: FLAG_REASONS},
},
});
// Cancel flagging the message
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.cancelButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.notToBeVisible();
// Flag the message
await flagPostFlow(post, channelsPage, message);
// Verify the message is flagged
const originaltext = await channelsPage.centerView.getPostById(postId);
await expect(originaltext.body).toContainText(message);
const systemMessage = await channelsPage.getLastPost();
await expect(systemMessage.body).toContainText(systemMessageForUser(user.username));
});
/**
* @objective: Test that another user cannot flag an already flagged message
*
* @testcase
* 1. Login as a user
* 2. Post a message
* 3. Flag the message
* 4. Login as another user
* 5. Attempt to flag the already flagged message
* 6. Verify that the message cannot be flagged again
*/
test('Verify user cannot flag already flagged message', async ({pw}) => {
const {user, adminClient, team} = await pw.initSetup();
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
HideFlaggedContent: false,
Reasons: FLAG_REASONS,
},
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers'],
removed: ['author', 'reporter', 'reviewers'],
},
},
},
});
const secondUser = await pw.random.user('mentioned');
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
const channels = await adminClient.getMyChannels(team.id);
const townSquare = channels.find((channel) => channel.name === 'town-square');
if (!townSquare) {
throw new Error('Town Square channel not found');
}
const message = `Post by @${user.username}, is flagged once`;
const postToBeflagged = await adminClient.createPost({
channel_id: townSquare.id,
message,
user_id: user.id,
});
await adminClient.flagPost(postToBeflagged.id, FLAG_REASON_CLASSIFICATION_MISMATCH, FLAG_COMMENT);
// Login as the second user
const channelsPage = await loginAndNavigate(pw, secondUser, team.name, 'town-square');
// Town Square may show join/system posts above the target — select by post id.
const post = await channelsPage.centerView.getPostById(postToBeflagged.id);
// Re-apply guard immediately before dot-menu: login takes 3-5 s during which a
// concurrent initSetup() can reset EnableContentFlagging to false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {HideFlaggedContent: false, Reasons: FLAG_REASONS},
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
// Try to flag already flagged post
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(FLAG_REASON_CLASSIFICATION_MISMATCH);
await channelsPage.centerView.flagPostConfirmationDialog.fillFlagComment(FLAG_COMMENT);
await channelsPage.centerView.flagPostConfirmationDialog.submitButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.cannotFlagAlreadyFlaggedPostToBeVisible();
});
/**
* @objective: Test that user cannot flag a message that was previously retained.
*
* @testcase
* 1. Login as a user
* 2. Post a message
* 3. Flag the message
* 4. Retain the message as a reviewer
* 5. Attempt to flag the retained message again
* 6. Verify that the message cannot be flagged again
*/
test('Verify user cannot flag a message that was previously retained', async ({pw}) => {
const {user, adminClient, team} = await pw.initSetup();
const secondUser = await pw.random.user('mentioned-');
const {id: secondUserID, username: secondUsername} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
const channels = await adminClient.getMyChannels(team.id);
const townSquare = channels.find((channel) => channel.name === 'town-square');
if (!townSquare) {
throw new Error('Town Square channel not found');
}
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
HideFlaggedContent: false,
Reasons: FLAG_REASONS,
},
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers'],
removed: ['author', 'reporter', 'reviewers'],
},
},
ReviewerSettings: {
CommonReviewers: true,
SystemAdminsAsReviewers: true,
TeamAdminsAsReviewers: true,
CommonReviewerIds: [user.id, secondUserID],
},
},
});
const message = `Post by @${secondUsername}, is flagged once`;
const postToBeflagged = await adminClient.createPost({
channel_id: townSquare.id,
message,
user_id: secondUserID,
});
await adminClient.flagPost(postToBeflagged.id, FLAG_REASON_CLASSIFICATION_MISMATCH, FLAG_COMMENT);
await adminClient.keepFlaggedPost(postToBeflagged.id, 'Retaining this post after review');
// Re-apply guard before UI interaction: a concurrent initSetup() may have reset
// EnableContentFlagging or reviewer settings between the initial patchConfig and here.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {HideFlaggedContent: false, Reasons: FLAG_REASONS},
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers'],
removed: ['author', 'reporter', 'reviewers'],
},
},
ReviewerSettings: {
CommonReviewers: true,
SystemAdminsAsReviewers: true,
TeamAdminsAsReviewers: true,
CommonReviewerIds: [user.id, secondUserID],
},
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
// Login as the second user
const channelsPage = await loginAndNavigate(pw, secondUser, team.name, 'town-square');
const post = await channelsPage.centerView.getPostById(postToBeflagged.id);
// Try to flag previously retained post
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(FLAG_REASON_CLASSIFICATION_MISMATCH);
await channelsPage.centerView.flagPostConfirmationDialog.fillFlagComment(FLAG_COMMENT);
await channelsPage.centerView.flagPostConfirmationDialog.submitButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.cannotFlagPreviouslyRetainedPostToBeVisible();
});
/**
* @objective: Test that flag message option is not available when Content Flagging feature is disabled
* * @testcase
* 1. Login as a user
* 2. Post a message
* 3. Verify that flag message option is not available in the post menu
*/
test('Verify the Quarantine for Review option is not available when feature is disabled', async ({pw}) => {
const {user, adminClient} = await pw.initSetup();
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: false,
},
});
const channelsPage = await loginAndNavigate(pw, user);
const message = 'This is a test message to be flagged';
const {post} = await postMessage(channelsPage, message);
// Re-apply guard: parallel tests often turn flagging back on; the menu item only hides when false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: false,
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === false;
});
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItemNotToBeVisible();
});
/**
* @objective: Verify Flagging reason dropdown options
* * @testcase
* 1. Login as a user
* 2. Post a message
* 3. Open flag message dialog
* 4. Verify the flagging reason dropdown options
*/
test('Verify Flagging reason dropdown', async ({pw}) => {
const {user, adminClient, team} = await pw.initSetup();
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
Reasons: FLAG_REASONS,
},
},
});
const channelsPage = await loginAndNavigate(pw, user, team.name, 'town-square');
const message = 'This is a test message to be flagged';
const {post} = await postMessage(channelsPage, message);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
Reasons: FLAG_REASONS,
},
},
});
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(FLAG_REASON_CLASSIFICATION_MISMATCH);
});
/**
* @objective: Verify Comments are required for Flagging
* * @testcase
* 1. Login as a user
* 2. Post a message
* 3. Open flag message dialog
* 4. Verify that comments are required for flagging
*/
test('Verify Comments are required for Flagging', async ({pw}) => {
const {user, adminClient, team} = await pw.initSetup();
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
Reasons: FLAG_REASONS,
ReporterCommentRequired: true,
},
},
});
const channelsPage = await loginAndNavigate(pw, user, team.name, 'town-square');
const message = 'This is a test message to be flagged';
const {post} = await postMessage(channelsPage, message);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
AdditionalSettings: {
Reasons: FLAG_REASONS,
ReporterCommentRequired: true,
},
},
});
await openPostDotMenu(post, channelsPage);
await channelsPage.postDotMenu.flagMessageMenuItem.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.toContainPostText(message);
await channelsPage.centerView.flagPostConfirmationDialog.selectFlagReason(FLAG_REASON_CLASSIFICATION_MISMATCH);
await channelsPage.centerView.flagPostConfirmationDialog.submitButton.click();
await channelsPage.centerView.flagPostConfirmationDialog.toBeVisible();
await channelsPage.centerView.flagPostConfirmationDialog.requireCommentsForFlaggingPost();
});
/**
* @objective: Verify message is removed from channel if the reviewer removed the message
*
* @testcase
* 1. Login as a user
* 2. Post a message
* 3. Flag the message
* 4. Login as a reviewer and remove the message
* 5. Verify the message is removed from the channel
*/
test('Verify message is removed from channel if the reviewer removed the message', async ({pw}) => {
const {user, adminClient, team} = await pw.initSetup();
// Only set the fields this test actually needs. Omitting ReviewerSettings.CommonReviewerIds
// prevents racing with reviewer-* tests that set their own reviewer list — a patchConfig
// that includes CommonReviewerIds replaces the array for ALL concurrent tests on the same
// server, causing reviewer-actions.spec.ts to lose its notification recipients.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
ReviewerSettings: {
SystemAdminsAsReviewers: true,
},
AdditionalSettings: {
HideFlaggedContent: false,
Reasons: FLAG_REASONS,
},
},
});
const channels = await adminClient.getMyChannels(team.id);
const townSquare = channels.find((channel) => channel.name === 'town-square');
if (!townSquare) {
throw new Error('Town Square channel not found');
}
const message = `Post by @${user.username}, is flagged once`;
const postToBeflagged = await adminClient.createPost({
channel_id: townSquare.id,
message,
user_id: user.id,
});
await adminClient.flagPost(postToBeflagged.id, FLAG_REASON_CLASSIFICATION_MISMATCH, FLAG_COMMENT);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false or
// SystemAdminsAsReviewers: false between the initial patchConfig and the remove call.
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: true,
ReviewerSettings: {
SystemAdminsAsReviewers: true,
},
AdditionalSettings: {
HideFlaggedContent: false,
Reasons: FLAG_REASONS,
},
},
});
await adminClient.removeFlaggedPost(postToBeflagged.id, 'Removing this post after review');
// Login as the user
const channelsPage = await loginAndNavigate(pw, user, team.name, 'town-square');
const lastPostId = await channelsPage.centerView.getLastPostID();
expect(lastPostId).not.toBe(postToBeflagged.id);
});

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {createPost} from './../support';
async function setupContentFlagging(adminClient: any, userIds: string[], enable = true) {
await adminClient.patchConfig({
ContentFlaggingSettings: {
EnableContentFlagging: enable,
ReviewerSettings: {
CommonReviewers: true,
SystemAdminsAsReviewers: true,
TeamAdminsAsReviewers: true,
CommonReviewerIds: userIds,
},
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers', 'author'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers', 'author'],
removed: ['author', 'reporter', 'reviewers'],
},
},
AdditionalSettings: {
HideFlaggedContent: true,
},
},
});
return adminClient;
}
async function verifyAuthorNotification(channelsPage: any, teamName: string, expectedMessage: string) {
await channelsPage.goto(teamName, '@content-review');
await channelsPage.toBeVisible();
const lastPostId = await channelsPage.centerView.getLastPostID();
const post = await channelsPage.centerView.getPostById(lastPostId);
await expect(post.body).toContainText(expectedMessage);
}
/**
* @objective Verify Author is notified if the post is flagged in a channel
*/
test('Verify Author is notified if the post is flagged in a channel', async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
await setupContentFlagging(adminClient, [user.id]);
const message = `Post by @${user.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, userClient, team, user, message);
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
const {channelsPage} = await pw.testBrowser.login(user);
const expected = `Your post having ID ${post.id} in the channel ${townSquare.display_name} has been quarantined for review.`;
await verifyAuthorNotification(channelsPage, team.name, expected);
});
/**
* @objective Verify Author is notified if flagged post is Retained by the reviewer
*/
test('Verify Author is notified if flagged post is Retained in a channel', async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
await setupContentFlagging(adminClient, [user.id]);
const message = `Post by @${user.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, userClient, team, user, message);
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
await adminClient.keepFlaggedPost(post.id, 'Retaining this post after review');
const {channelsPage} = await pw.testBrowser.login(user);
const expected = `Your post having ID ${post.id} in the channel ${townSquare.display_name} which was quarantined for review has been restored by a reviewer.`;
await verifyAuthorNotification(channelsPage, team.name, expected);
});
/**
* @objective Verify Author is notified if flagged post is Removed by the reviewer
*/
test('Verify Author is notified if flagged post is Removed from a channel', async ({pw}) => {
const {adminClient, team, user, userClient} = await pw.initSetup();
await setupContentFlagging(adminClient, [user.id]);
const message = `Post by @${user.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, userClient, team, user, message);
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
await adminClient.removeFlaggedPost(post.id, 'Removing this post after review');
const {channelsPage} = await pw.testBrowser.login(user);
const expected = `Your post having ID ${post.id} in the channel ${townSquare.display_name} which was quarantined for review has been permanently removed by a reviewer.`;
await verifyAuthorNotification(channelsPage, team.name, expected);
});

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost, verifyReporterNotification} from './../support';
/**
* @objective Verify Reporter is notified if the flagged post is Retained by the reviewer
*/
test('Verify Reporter is notified if flagged post is Retained in a channel', async ({pw}) => {
const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
// Create second user and add to team
const reporterUser = await pw.random.user('second');
const {id: reporterUserID} = await adminClient.createUser(reporterUser, '', '');
await adminClient.addToTeam(team.id, reporterUserID);
// Create third user and add to team
const postFromThirdUser = await pw.random.user('third');
const {id: postFromThirdUserID} = await adminClient.createUser(postFromThirdUser, '', '');
await adminClient.addToTeam(team.id, postFromThirdUserID);
const {client: thirdUserClient} = await pw.makeClient(postFromThirdUser);
const {client: reporterUserClient} = await pw.makeClient(reporterUser);
await setupContentFlagging(adminClient, [reviewerUser.id]);
await expect
.poll(
async () => {
const cfg = await adminClient.getAdminContentFlaggingConfig();
return cfg.ReviewerSettings?.CommonReviewerIds?.includes(reviewerUser.id) ?? false;
},
{timeout: 30_000, intervals: [500, 1500, 3000]},
)
.toBe(true);
const message = `Post by @${reviewerUser.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, thirdUserClient, team, postFromThirdUser, message);
await reporterUserClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
await reviewerUserClient.keepFlaggedPost(post.id, 'Retaining this post after review');
const {channelsPage} = await pw.testBrowser.login(reporterUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const expected = `The post having ID ${post.id} in the channel ${townSquare.display_name} which you quarantined for review has been restored by a reviewer.`;
await verifyReporterNotification(channelsPage, team.name, expected);
});
/**
* @objective Verify Reporter is notified if flagged post is Removed from a channel
*/
test('Verify Reporter is notified if flagged post is Removed from a channel', async ({pw}) => {
const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
// Create second user and add to team
const reporterUser = await pw.random.user('second');
const {id: reporterUserID} = await adminClient.createUser(reporterUser, '', '');
await adminClient.addToTeam(team.id, reporterUserID);
// Create third user and add to team
const postFromThirdUser = await pw.random.user('third');
const {id: postFromThirdUserID} = await adminClient.createUser(postFromThirdUser, '', '');
await adminClient.addToTeam(team.id, postFromThirdUserID);
const {client: thirdUserClient} = await pw.makeClient(postFromThirdUser);
const {client: reporterUserClient} = await pw.makeClient(reporterUser);
await setupContentFlagging(adminClient, [reviewerUser.id]);
await expect
.poll(
async () => {
const cfg = await adminClient.getAdminContentFlaggingConfig();
return cfg.ReviewerSettings?.CommonReviewerIds?.includes(reviewerUser.id) ?? false;
},
{timeout: 30_000, intervals: [500, 1500, 3000]},
)
.toBe(true);
const message = `Post by @${reviewerUser.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, thirdUserClient, team, postFromThirdUser, message);
await reporterUserClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
await reviewerUserClient.removeFlaggedPost(post.id, 'Retaining this post after review');
const {channelsPage} = await pw.testBrowser.login(reporterUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const expected = `The post having ID ${post.id} in the channel ${townSquare.display_name} which you quarantined for review has been permanently removed by a reviewer.`;
await verifyReporterNotification(channelsPage, team.name, expected);
});

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost, verifyAuthorNotification} from './../support';
/** @objective Verify Removed Flagged posts show appropriate status and do not show the post message
* @testcase
* 1. Create three users and add them as reviewers to a team
* 2. Setup content flagging with the three users as reviewers
* 3. Create a post and flag it
* 4. As Reviewer 1, walk through the multi-step removal flow (form → report generated → remove permanently)
* 5. As Reviewer 2, verify the flagged post status is 'Removed' and the message has been replaced
*/
test('Verify Removed Flagged posts show appropriate status and do not show the post message', async ({pw}) => {
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
// Create second user and add to team
const secondUser = await pw.random.user('reviewer');
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
// Make system_admin so SystemAdminsAsReviewers: true covers them even if
// CommonReviewerIds is reset to [] by a concurrent initSetup() call.
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
// Create third user and add to team
const thirdUser = await pw.random.user('reviewer');
const {id: thirdUserID} = await adminClient.createUser(thirdUser, '', '');
await adminClient.addToTeam(team.id, thirdUserID);
await adminClient.updateUserRoles(thirdUserID, 'system_user system_admin');
// Setup content flagging *after* roles are set
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
const message = `Post by @${user.username}, is flagged once`;
const {post} = await createPost(adminClient, userClient, team, user, message);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false
// between the initial setupContentFlagging call and the flagPost call.
// pw.waitUntil confirms the config is actually true before proceeding — this
// closes the race window to < 100 ms (time between final poll and flagPost).
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
// Re-apply guard: concurrent initSetup() may have reset config between flagPost and login
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
const {channelsPage: secondChannelsPage, contentReviewPage: secondContentReviewPage} =
await pw.testBrowser.login(secondUser);
await verifyAuthorNotification(post.id, secondChannelsPage, secondContentReviewPage, team.name, message, 'Pending');
const commentRemove = 'Removing this message as it violates the guidelines.';
const contentModerationMessage = 'Content deleted as part of Content Flagging review process';
await secondContentReviewPage.setReportCardByPostID(post.id);
await secondContentReviewPage.openViewDetails();
await secondContentReviewPage.waitForRHSVisible();
await secondContentReviewPage.openViewDetails();
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
await secondContentReviewPage.clickRemoveMessage();
await secondContentReviewPage.enterConfirmationComment(commentRemove);
// New multi-step flow: Continue → wait for report to generate → Remove permanently
await secondContentReviewPage.submitFormAndWaitForReport();
await secondContentReviewPage.confirmRemovePermanently();
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
// After the remove action succeeds, the reviewer's own view of the flagged
// post should be replaced with the moderation placeholder rather than the
// original message that is now cleared from the redux store (MM-69043).
// The placeholder appears both in the RHS detail view and in the report
// card shown in the @content-review center channel — assert each scope
// separately so a regression in either location is caught.
await secondContentReviewPage.verifyFlaggedPostMessageInRHS(contentModerationMessage);
await secondContentReviewPage.verifyFlaggedPostMessageInCenter(post.id, contentModerationMessage);
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
await pw.testBrowser.login(thirdUser);
await verifyAuthorNotification(
post.id,
channelsPageThird,
contentReviewPageThird,
team.name,
contentModerationMessage,
'Removed',
);
});

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {createPost, verifyFlaggedPostCardDetails, verifyRHSFlaggedPostDetails} from './../support';
/**
* @objective Verify a reviewer from other team can receive a review request for a flagged post
* @testcase
* 1. Create two teams and users
* 2. Setup content flagging with reviewers from both teams
* 3. Create a post in team A and flag it
* 4. Verify that a reviewer from team B receives a review request in Content Review channel
* 5. Verify the flagged post details in the reviewer's Content Review DM and RHS
*/
test('Verify reviewer from another team can receive a review request for a flagged post', async ({pw}) => {
const reasonToFlag = 'Classification mismatch';
const flagPostReviewStatus = 'Pending';
const flagPostComment = 'This message is inappropriate';
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
const secondTeam = await userClient.createTeam(await pw.random.team('team', 'Team', 'O', true));
const secondUser = await pw.random.user('mentioned');
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
await adminClient.addToTeam(secondTeam.id, secondUserID);
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
// Configure content flagging
await adminClient.saveContentFlaggingConfig({
EnableContentFlagging: true,
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers', 'author'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers', 'author'],
removed: ['author', 'reporter', 'reviewers'],
},
},
ReviewerSettings: {
CommonReviewers: true,
CommonReviewerIds: [user.id, adminUser.id, secondUserID],
TeamReviewersSetting: {},
SystemAdminsAsReviewers: true,
TeamAdminsAsReviewers: true,
},
AdditionalSettings: {
Reasons: ['Classification mismatch', 'Need-to-know violation', 'Unauthorized disclosure', 'Other'],
ReporterCommentRequired: true,
ReviewerCommentRequired: true,
HideFlaggedContent: true,
},
});
// Create and flag post
const message = `Post by @${user.username}, is flagged once`;
const {post, townSquare} = await createPost(adminClient, userClient, team, user, message);
await adminClient.flagPost(post.id, reasonToFlag, flagPostComment);
// Reviewer logs in and verifies flagged post in content review
const {channelsPage, contentReviewPage} = await pw.testBrowser.login(secondUser);
await verifyFlaggedPostCardDetails(post.id, channelsPage, contentReviewPage, secondTeam, message);
await verifyRHSFlaggedPostDetails(
post.id,
contentReviewPage,
user.username,
adminUser.username,
message,
reasonToFlag,
flagPostReviewStatus,
townSquare.display_name,
);
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost, verifyAuthorNotification} from './../support';
/** @objective Verify that multiple reviewers receive the same flag notification
* @testcase
* 1. Create three users and add them as reviewers to a team
* 2. Setup content flagging with the three users as reviewers
* 3. Create a post and flag it
* 4. Verify that all three reviewers receive a review request in their Content Review channel
* 5. Verify the flagged post details in each reviewer's Content Review DM and RHS
*
*/
test('Verify multiple reviewers receive same flagged post', async ({pw}) => {
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
// Create second user and add to team
const secondUser = await pw.random.user('reviewer');
const {id: secondUserID} = await adminClient.createUser(secondUser, '', '');
await adminClient.addToTeam(team.id, secondUserID);
await adminClient.updateUserRoles(secondUserID, 'system_user system_admin');
// Create third user and add to team
const thirdUser = await pw.random.user('reviewer');
const {id: thirdUserID} = await adminClient.createUser(thirdUser, '', '');
await adminClient.addToTeam(team.id, thirdUserID);
await adminClient.updateUserRoles(thirdUserID, 'system_user system_admin');
// Setup content flagging *after* roles are set
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
const message = `Post by @${user.username}, is flagged once`;
const {post} = await createPost(adminClient, userClient, team, user, message);
// Re-apply guard: concurrent initSetup() may reset EnableContentFlagging: false
// between the initial setupContentFlagging call and the flagPost call.
// pw.waitUntil confirms the config is actually true before proceeding — closes
// the race window to < 100 ms (time between final poll and flagPost).
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ContentFlaggingSettings?.EnableContentFlagging === true;
});
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message is inappropriate');
const {channelsPage: secondChannelsPage, contentReviewPage: secondContentReviewPage} =
await pw.testBrowser.login(secondUser);
await verifyAuthorNotification(post.id, secondChannelsPage, secondContentReviewPage, team.name, message, 'Pending');
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
await pw.testBrowser.login(thirdUser);
await verifyAuthorNotification(post.id, channelsPageThird, contentReviewPageThird, team.name, message, 'Pending');
});

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect} from '@mattermost/playwright-lib';
export async function setupContentFlagging(
adminClient: any,
userIds: string[],
enable = true,
hideFlaggedContent = true,
) {
// Configure content flagging
await adminClient.saveContentFlaggingConfig({
EnableContentFlagging: enable,
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers', 'author'],
dismissed: ['reporter', 'author', 'reviewers'],
flagged: ['reviewers', 'author'],
removed: ['author', 'reporter', 'reviewers'],
},
},
ReviewerSettings: {
CommonReviewers: true,
CommonReviewerIds: userIds,
TeamReviewersSetting: {},
SystemAdminsAsReviewers: true,
TeamAdminsAsReviewers: true,
},
AdditionalSettings: {
Reasons: ['Classification mismatch', 'Need-to-know violation', 'Unauthorized disclosure', 'Other'],
ReporterCommentRequired: true,
ReviewerCommentRequired: true,
HideFlaggedContent: hideFlaggedContent,
},
});
return adminClient;
}
export async function createPost(adminClient: any, userClient: any, team: any, user: any, message: string) {
const channels = await adminClient.getMyChannels(team.id);
const townSquare = channels.find((ch: any) => ch.name === 'town-square');
if (!townSquare) {
throw new Error('Town Square channel not found');
}
const post = await userClient.createPost({
channel_id: townSquare.id,
message,
user_id: user.id,
});
return {post, message, townSquare};
}
export async function verifyAuthorNotification(
postID: string,
channelsPage: any,
contentReviewPage: any,
teamName: string,
expectedMessage: string,
postStatus: string,
) {
await channelsPage.goto(teamName, '@content-review');
await channelsPage.toBeVisible();
await contentReviewPage.setReportCardByPostID(postID);
await contentReviewPage.waitForPageLoaded();
await contentReviewPage.verifyFlaggedPostStatus(postStatus);
await contentReviewPage.verifyFlaggedPostReason('Classification mismatch');
await contentReviewPage.verifyFlaggedPostMessage(expectedMessage);
}
/**
* Verify flagged post details inside the RHS view
*/
export async function verifyRHSFlaggedPostDetails(
postID: string,
contentReviewPage: any,
postedByUsername: string,
flaggedByUsername: string,
postMessageFlagged: string,
reasonToFlag: string,
flagPostReviewStatus: string,
postFlaggedInChannel: string,
) {
await contentReviewPage.setReportCardByPostID(postID);
await contentReviewPage.openViewDetails();
await contentReviewPage.waitForRHSVisible();
await contentReviewPage.expectSelectProperty('Status', flagPostReviewStatus);
await contentReviewPage.expectSelectProperty('Reason', reasonToFlag);
await contentReviewPage.expectMessageContains(postMessageFlagged);
await contentReviewPage.expectUser('Quarantined by', flaggedByUsername);
await contentReviewPage.expectUser('Posted by', postedByUsername);
await contentReviewPage.expectChannel(postFlaggedInChannel);
}
/**
* Verify flagged post card details in the Content Review DM
*/
export async function verifyFlaggedPostCardDetails(
postID: string,
channelsPage: any,
contentReviewPage: any,
team: any,
expectedMessage: string,
) {
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
await contentReviewPage.setReportCardByPostID(postID);
await contentReviewPage.waitForPageLoaded();
await contentReviewPage.verifyFlaggedPostStatus('Pending');
await contentReviewPage.verifyFlaggedPostReason('Classification mismatch');
await contentReviewPage.verifyFlaggedPostMessage(expectedMessage);
}
export async function verifyReporterNotification(channelsPage: any, teamName: string, expectedMessage: string) {
await channelsPage.goto(teamName, '@content-review');
await channelsPage.toBeVisible();
const lastPostId = await channelsPage.centerView.getLastPostID();
const post = await channelsPage.centerView.getPostById(lastPostId);
await expect(post.body).toContainText(expectedMessage);
}

View File

@@ -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);
});

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {
type CustomProfileAttribute,
deleteCustomProfileAttributes,
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValues,
} from './helpers';
function matchingOrder(actual: string[], expected: string[]) {
return actual.filter((value) => expected.includes(value));
}
/**
* @objective Verify custom attributes preserve their configured order in profile settings and profile popovers.
*/
test(
'MM-T5747 MM-T5751 keeps attribute order consistent in profile settings and the profile popover',
{tag: '@custom_profile_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, team, user, userClient} = await pw.initSetup();
const suffix = pw.random.id();
const labels = [`First ${suffix}`, `Second ${suffix}`, `Third ${suffix}`];
const attributes: CustomProfileAttribute[] = labels.map((label, index) => ({
name: `order_${index}_${suffix}`,
type: 'text',
value: `Value ${index + 1}`,
attrs: {display_name: label, visibility: 'always'},
}));
// # Create ordered attributes and values
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
try {
await setupCustomProfileAttributeValues(userClient, attributes, fields);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
// * Verify the order in profile settings
const profileModal = await channelsPage.openProfileModal();
const settingsHeadings = await profileModal.sectionHeadings.allTextContents();
expect(matchingOrder(settingsHeadings, labels)).toEqual(labels);
await profileModal.closeModal();
// # Open the user's profile popover
await channelsPage.postMessage(`Attribute order ${suffix}`);
const post = await channelsPage.getLastPost();
const popover = await channelsPage.openProfilePopover(post);
// * Verify the same order in the profile popover
const popoverHeadings = await popover.attributeHeadings.allTextContents();
expect(matchingOrder(popoverHeadings, labels)).toEqual(labels);
} finally {
await deleteCustomProfileAttributes(adminClient, fields);
}
},
);
/**
* @objective Verify a long profile popover scrolls while its bottom action row remains fixed.
*/
test(
'MM-T5752 keeps the profile popover bottom row fixed while attributes scroll',
{tag: '@custom_profile_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, team, user, userClient} = await pw.initSetup();
const suffix = pw.random.id();
const attributes: CustomProfileAttribute[] = Array.from({length: 10}, (_, index) => ({
name: `scroll_${index}_${suffix}`,
type: 'text',
value: `Value ${index + 1}`,
attrs: {display_name: `Attribute ${index + 1} ${suffix}`, visibility: 'always'},
}));
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
try {
// # Open a profile popover containing ten populated attributes
await setupCustomProfileAttributeValues(userClient, attributes, fields);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.postMessage(`Popover scroll ${suffix}`);
const post = await channelsPage.getLastPost();
const popover = await channelsPage.openProfilePopover(post);
// * Verify the popover is scrollable
expect(
await popover.container.evaluate((element: HTMLElement) => element.scrollHeight > element.clientHeight),
).toBe(true);
const bottomBefore = await popover.bottomRow.boundingBox();
if (!bottomBefore) {
throw new Error('Expected the profile popover bottom row to have a bounding box');
}
// # Scroll to the bottom of the popover
await popover.container.evaluate((element: HTMLElement) => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => popover.container.evaluate((element: HTMLElement) => element.scrollTop))
.toBeGreaterThan(0);
// * Verify the bottom action row remains visible and fixed
await expect(popover.bottomRow).toBeVisible();
const bottomAfter = await popover.bottomRow.boundingBox();
if (!bottomAfter) {
throw new Error('Expected the profile popover bottom row to remain visible after scrolling');
}
expect(Math.abs(bottomAfter.y - bottomBefore.y)).toBeLessThan(2);
} finally {
await deleteCustomProfileAttributes(adminClient, fields);
}
},
);

View File

@@ -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 {
type CustomProfileAttribute,
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValues,
} from './helpers';
/**
* @objective Verify maximum-length attribute names and values display with ellipsis styling in profile settings.
*/
test(
'MM-T5748 displays long attribute names and values correctly in profile settings',
{tag: '@custom_profile_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, userClient} = await pw.initSetup();
const displayName = '40_characters_allowed_000000000000000000';
const value = '64-characters-allowed-000000000000000000123456789012345678901234';
const attributes: CustomProfileAttribute[] = [
{
name: `long_${pw.random.id()}`,
type: 'text',
value,
attrs: {display_name: displayName},
},
];
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
await setupCustomProfileAttributeValues(userClient, attributes, fields);
// # Open profile settings containing the maximum-length attribute
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
// * Verify the full label/value exist and the value uses ellipsis styling
await expect(profileModal.sectionHeadings.getByText(displayName, {exact: true})).toBeVisible();
const displayedValue = profileModal.getAttributeValue(displayName, value);
await expect(displayedValue).toBeVisible();
await expect(displayedValue).toHaveCSS('text-overflow', 'ellipsis');
},
);
/**
* @objective Verify cancelling an attribute edit discards the unsaved value.
*/
test(
'MM-T5749 cancels custom profile attribute changes without saving',
{tag: '@custom_profile_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, userClient} = await pw.initSetup();
const suffix = pw.random.id();
const displayName = `Cancel Test ${suffix}`;
const originalValue = `Original ${suffix}`;
const attributes: CustomProfileAttribute[] = [
{
name: `cancel_${suffix}`,
type: 'text',
value: originalValue,
attrs: {display_name: displayName},
},
];
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
await setupCustomProfileAttributeValues(userClient, attributes, fields);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
// # Change the unique attribute value, then cancel the edit
await profileModal.editAttribute(displayName);
await profileModal.getAttributeInput(displayName).fill(`Unsaved ${suffix}`);
await profileModal.cancelButton.click();
// * Verify editing again shows the original persisted value
await profileModal.editAttribute(displayName);
await expect(profileModal.getAttributeInput(displayName)).toHaveValue(originalValue);
},
);
/**
* @objective Verify deleting an attribute while a user edits it removes the editor without crashing.
*/
test(
'MM-T5750 does not crash when an attribute is deleted while the user edits it',
{tag: '@custom_profile_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, userClient} = await pw.initSetup();
const displayName = `Favorite Color ${pw.random.id()}`;
const attributes: CustomProfileAttribute[] = [
{
name: `color_${pw.random.id()}`,
type: 'text',
value: 'Blue',
attrs: {display_name: displayName},
},
];
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fields)[0];
await setupCustomProfileAttributeValues(userClient, attributes, fields);
// # Open the attribute editor and change its unsaved value
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
await profileModal.editAttribute(displayName);
await profileModal.getAttributeInput(displayName).fill('Green');
// # Delete the attribute concurrently through the admin API
await adminClient.deleteCustomProfileAttributeField(fieldId);
// * Verify the editor disappears and the application remains usable
await expect(profileModal.getAttributeInput(displayName)).not.toBeVisible();
await expect(profileModal.closeButton).toBeVisible();
await profileModal.closeModal();
await channelsPage.toBeVisible();
},
);

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Channel} from '@mattermost/types/channels';
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from './helpers';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValues,
deleteCustomProfileAttributes,
verifyAttributeInPopover,
verifyAttributeNotInPopover,
updateCustomProfileAttributeVisibility,
TEST_PHONE,
TEST_URL,
TEST_DEPARTMENT,
TEST_LOCATION,
TEST_UPDATED_DEPARTMENT,
TEST_UPDATED_LOCATION,
TEST_TITLE,
TEST_MESSAGE,
} from './helpers';
// Custom attribute definitions
const customAttributes: CustomProfileAttribute[] = [
{
name: 'Department',
value: TEST_DEPARTMENT,
type: 'text',
},
{
name: 'Location',
value: TEST_LOCATION,
type: 'text',
},
{
name: 'Title',
value: TEST_TITLE,
type: 'text',
},
{
name: 'Phone',
value: TEST_PHONE,
type: 'text',
attrs: {
value_type: 'phone',
},
},
{
name: 'Website',
value: TEST_URL,
type: 'text',
attrs: {
value_type: 'url',
},
},
];
let team: Team;
let user: UserProfile;
let otherUser: UserProfile;
let testChannel: Channel;
let attributeFieldsMap: Record<string, UserPropertyField>;
let adminClient: Client4;
let userClient: Client4;
test.beforeEach(async ({pw}) => {
// Skip test if no license for "Custom Profile Attributes"
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Initialize with admin client
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
const channel = pw.random.channel({
teamId: team.id,
name: 'test-channel',
displayName: 'Test Channel',
});
testChannel = await adminClient.createChannel(channel);
// Create another user to test profile popover
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
await adminClient.addToTeam(team.id, otherUser.id);
await adminClient.addToChannel(otherUser.id, testChannel.id);
// Add the test user to the test channel
await adminClient.addToChannel(user.id, testChannel.id);
// Set up custom profile attribute fields
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, customAttributes);
// Login as the test user
const {page} = await pw.testBrowser.login(user);
// Set up initial values for custom profile attributes
await setupCustomProfileAttributeValues(userClient, customAttributes, attributeFieldsMap);
// Visit the test channel
await page.goto(`/${team.name}/channels/${testChannel.name}`);
});
test.afterAll(async () => {
// Clean up by deleting custom profile attributes
await deleteCustomProfileAttributes(adminClient, attributeFieldsMap);
});
/**
* Verify that custom profile attributes are displayed correctly in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Test user has values set for custom profile attributes
* 4. User is a member of a channel
*/
test('MM-T5773 Display custom profile attributes in profile popover @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 3. Open the profile popover for the user's post
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify each custom attribute is displayed correctly
for (const attribute of customAttributes) {
if (attribute.value) {
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
}
}
});
/**
* Verify that custom profile attributes are not displayed in the profile popover
* if the user has no values set.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Test user has no values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5774 Do not display custom profile attributes if none exist @custom_profile_attributes', async ({pw}) => {
// 1. Login as the other user
const {channelsPage} = await pw.testBrowser.login(otherUser);
await channelsPage.goto();
// 2. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 3. Open the profile popover for the user's post
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify custom attributes are not displayed
for (const attribute of customAttributes) {
await verifyAttributeNotInPopover(channelsPage, attribute.name);
}
});
/**
* Verify that custom profile attributes are updated correctly when changed
* and the changes are reflected in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Other user has values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5775 Update custom profile attributes when changed @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 3. Open the profile popover for the user's post
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify each custom attribute is displayed correctly
for (const attribute of customAttributes) {
if (attribute.value) {
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
}
}
// 4. Close the profile popover
await channelsPage.page.click('body', {position: {x: 10, y: 10}});
// 5. Update custom profile attributes
const updatedAttributes: CustomProfileAttribute[] = [
{
name: 'Department',
value: TEST_UPDATED_DEPARTMENT,
type: 'text',
},
{
name: 'Location',
value: TEST_UPDATED_LOCATION,
type: 'text',
},
];
await setupCustomProfileAttributeValues(userClient, updatedAttributes, attributeFieldsMap);
// 6. Open the profile popover again
await channelsPage.openProfilePopover(lastPost);
// * Verify updated attributes are displayed correctly
for (const attribute of updatedAttributes) {
if (attribute.value) {
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
}
}
// * Verify non-updated attribute is still displayed correctly
await verifyAttributeInPopover(channelsPage, 'Title', TEST_TITLE);
});
/**
* Verify that custom profile attributes with visibility set to hidden
* are not displayed in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Other user has values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5776 Hide custom profile attributes when visibility is set to hidden @custom_profile_attributes', async ({
pw,
}) => {
// 1. Update the visibility of the Department attribute to hidden
await updateCustomProfileAttributeVisibility(adminClient, attributeFieldsMap, 'Department', 'hidden');
// 2. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 3. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 4. Open the profile popover for the user's post
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify the Department attribute is not displayed
await verifyAttributeNotInPopover(channelsPage, 'Department');
// * Verify other attributes are still displayed
await verifyAttributeInPopover(channelsPage, 'Location', TEST_LOCATION);
});
/**
* Verify that custom profile attributes with visibility set to always
* are displayed in the profile popover even if they have no value.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Other user has values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5777 Always display custom profile attributes with visibility set to always @custom_profile_attributes', async ({
pw,
}) => {
// 1. Update the visibility of the Title attribute to always
await updateCustomProfileAttributeVisibility(adminClient, attributeFieldsMap, 'Title', 'always');
// 2. Login as the other user
const {channelsPage} = await pw.testBrowser.login(otherUser);
await channelsPage.goto();
// 3. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 4. Open the profile popover for the user's post
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify custom attributes are displayed correctly
for (const attribute of customAttributes) {
if (attribute.name === 'Title') {
// * Verify the Title attribute is displayed even though it has no value
const popover = channelsPage.userProfilePopover.container;
const nameElement = popover.getByText('Title', {exact: false});
await expect(nameElement).toBeVisible();
} else {
await verifyAttributeNotInPopover(channelsPage, attribute.name);
}
}
});
/**
* Verify that phone and URL type custom profile attributes are displayed
* correctly in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes including phone and URL types
* 3. Other user has values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5778 Display phone and URL type custom profile attributes correctly @custom_profile_attributes', async ({
pw,
}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 3. Open the profile popover for the other user
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify the Phone attribute is displayed correctly
await verifyAttributeInPopover(channelsPage, 'Phone', TEST_PHONE);
// * Verify the Website attribute is displayed correctly
await verifyAttributeInPopover(channelsPage, 'Website', TEST_URL);
});
/**
* Verify that phone and URL type custom profile attributes are clickable
* in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes including phone and URL types
* 3. Other user has values set for custom profile attributes
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5779 Verify phone and URL attributes are clickable in profile popover @custom_profile_attributes', async ({
pw,
}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 3. Open the profile popover for the other user
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify the Phone attribute has a clickable link with tel: protocol
const popover = channelsPage.userProfilePopover.container;
const phoneLink = popover.getByText(TEST_PHONE, {exact: false});
await expect(phoneLink).toHaveAttribute('href', new RegExp('^tel:'));
// * Verify the Website attribute has a clickable link with https: protocol
const urlLink = popover.getByText(TEST_URL, {exact: false});
await expect(urlLink).toHaveAttribute('href', new RegExp('^https:'));
});

View File

@@ -0,0 +1,584 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {FieldType} from '@mattermost/types/properties';
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties_user';
import type {ChannelsPage} from '@mattermost/playwright-lib';
import {expect} from '@mattermost/playwright-lib';
// Common test data constants
export const TEST_PHONE = '555-123-4567';
export const TEST_UPDATED_PHONE = '555-987-6543';
export const TEST_URL = 'https://example.com';
export const TEST_UPDATED_URL = 'https://mattermost.com';
export const TEST_INVALID_URL = 'ftp://invalid-url';
export const TEST_VALID_URL = 'https://example2.com';
export const TEST_DEPARTMENT = 'Engineering';
export const TEST_UPDATED_DEPARTMENT = 'Product';
export const TEST_LOCATION = 'Remote';
export const TEST_UPDATED_LOCATION = 'Office';
export const TEST_TITLE = 'Software Engineer';
export const TEST_CHANGED_VALUE = 'Changed Value';
export const TEST_MESSAGE = 'Hello from the test user';
export const TEST_MESSAGE_OTHER = 'Hello from the other user';
export const TEST_LOCATION_OPTIONS = [
{name: 'Remote', color: '#00FFFF'},
{name: 'Office', color: '#FF00FF'},
{name: 'Hybrid', color: '#FFFF00'},
];
export const TEST_SKILLS_OPTIONS = [
{name: 'JavaScript', color: '#F0DB4F'},
{name: 'React', color: '#61DAFB'},
{name: 'Node.js', color: '#68A063'},
{name: 'Python', color: '#3776AB'},
];
/**
* Represents a custom profile attribute definition
*/
export type CustomProfileAttribute = {
name: string;
value?: string;
type: string;
options?: Array<{name: string; color: string; sort_order?: number}>;
attrs?: {
value_type?: string;
visibility?: string;
managed?: string;
options?: Array<{name: string; color: string}>;
display_name?: string;
};
};
/** Like Record<string, UserPropertyField> but tracks which field IDs this call created (vs reused). */
export type CpaFieldsMap = Record<string, UserPropertyField> & {
ownedIds: Set<string>;
};
// Custom attribute definitions for user settings tests (with select/multiselect attributes)
export const userSettingsAttributes: CustomProfileAttribute[] = [
{
name: 'Department',
value: TEST_DEPARTMENT,
type: 'text',
},
{
name: 'Location',
type: 'select',
options: TEST_LOCATION_OPTIONS,
},
{
name: 'Skills',
type: 'multiselect',
options: TEST_SKILLS_OPTIONS,
},
{
name: 'Phone',
value: TEST_PHONE,
type: 'text',
attrs: {
value_type: 'phone',
},
},
{
name: 'Website',
value: TEST_URL,
type: 'text',
attrs: {
value_type: 'url',
},
},
];
// Custom attribute definitions for custom attributes tests (with text attributes and Title)
export const customAttributesTestData: CustomProfileAttribute[] = [
{
name: 'Department',
value: TEST_DEPARTMENT,
type: 'text',
},
{
name: 'Location',
value: TEST_LOCATION,
type: 'text',
},
{
name: 'Title',
value: TEST_TITLE,
type: 'text',
},
{
name: 'Phone',
value: TEST_PHONE,
type: 'text',
attrs: {
value_type: 'phone',
},
},
{
name: 'Website',
value: TEST_URL,
type: 'text',
attrs: {
value_type: 'url',
},
},
];
/**
* Helper function to get field ID by name
* @param {Object} fieldsMap - Map of field IDs to field objects
* @param {string} name - The name of the field to find
* @returns {string} - The field ID
*/
export function getFieldIdByName(fieldsMap: Record<string, UserPropertyField>, name: string): string {
for (const [id, field] of Object.entries(fieldsMap)) {
if (field.name === name) {
return id;
}
}
throw new Error(`Could not find field ID for attribute: ${name}`);
}
/**
* Helper function to edit a text attribute
* @param {Page} page - The Playwright page object
* @param {Object} fieldsMap - Map of field IDs to field objects
* @param {string} attributeName - The name of the attribute to edit
* @param {string} newValue - The new value to set
*/
export async function editTextAttribute(
page: Page,
fieldsMap: Record<string, UserPropertyField>,
attributeName: string,
newValue: string,
): Promise<void> {
const fieldId = getFieldIdByName(fieldsMap, attributeName);
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).click();
await page.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}`).clear();
if (newValue) {
await page.locator(`#customAttribute_${fieldId}`).fill(newValue);
}
await page.locator('button:has-text("Save")').click();
// Wait for the Edit button to reappear — it is only visible when the section is in
// display mode (not editing). It returns to display mode only after the save API call
// resolves and the component calls updateSection(''). Without this wait, the next
// Edit click fires updateSection() while the save is still in-flight, which closes
// the active section and disrupts subsequent saves.
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
}
/**
* Helper function to edit a select attribute
* @param {Page} page - The Playwright page object
* @param {Object} fieldsMap - Map of field IDs to field objects
* @param {string} attributeName - The name of the attribute to edit
* @param {number} optionIndex - The index of the option to select
*/
export async function editSelectAttribute(
page: Page,
fieldsMap: Record<string, UserPropertyField>,
attributeName: string,
optionIndex: number,
): Promise<void> {
const fieldId = getFieldIdByName(fieldsMap, attributeName);
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).click();
// Open the dropdown — the control div carries the field-scoped id
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
await selectControl.scrollIntoViewIfNeeded();
await selectControl.click();
// Pick the option by index inside this specific dropdown's open menu.
// Scoping to the react-select container avoids fragile global react-select-N-option-M IDs.
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
const option = selectContainer.locator('.react-select__option').nth(optionIndex);
await option.waitFor({state: 'visible'});
await option.click();
await page.locator('button:has-text("Save")').click();
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
}
/**
* Helper function to edit a multiselect attribute
* @param {Page} page - The Playwright page object
* @param {Object} fieldsMap - Map of field IDs to field objects
* @param {string} attributeName - The name of the attribute to edit
* @param {Array<number>} optionIndices - The indices of the options to select
*/
export async function editMultiselectAttribute(
page: Page,
fieldsMap: Record<string, UserPropertyField>,
attributeName: string,
optionIndices: number[],
): Promise<void> {
const fieldId = getFieldIdByName(fieldsMap, attributeName);
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await page.locator(`#customAttribute_${fieldId}Edit`).click();
// The react-select container wraps the control; scope option lookups to it
// to avoid relying on fragile global react-select-N-option-M IDs.
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
for (const index of optionIndices) {
// Open the dropdown for each selection (it closes after each pick)
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
await selectControl.scrollIntoViewIfNeeded();
await selectControl.click();
// Wait for menu to appear and click the nth option
const option = selectContainer.locator('.react-select__option').nth(index);
await option.waitFor({state: 'visible'});
await option.click();
}
await page.locator('button:has-text("Save")').click();
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
}
/**
* Helper function to verify an attribute exists in the profile settings
* @param {Page} page - The Playwright page object
* @param {Array} attributes - Array of attribute objects with name
*/
export async function verifyAttributesExistInSettings(page: Page, attributes: CustomProfileAttribute[]): Promise<void> {
for (const attribute of attributes) {
// Wait for the attribute label to appear — custom profile attribute fields are
// fetched asynchronously after the settings modal opens, so we need an explicit
// wait before asserting visibility.
const label = page.locator('.user-settings').getByText(attribute.name, {exact: false});
await label.waitFor({state: 'visible', timeout: 15000});
await label.scrollIntoViewIfNeeded();
}
}
/**
* Helper function to verify an attribute is displayed in the profile popover
* @param {ChannelsPage} channelsPage - The Playwright channels page object
* @param {string} attributeName - The name of the attribute to verify
* @param {string} attributeValue - The value of the attribute to verify
*/
export async function verifyAttributeInPopover(
channelsPage: ChannelsPage,
attributeName: string,
attributeValue: string,
): Promise<void> {
const popover = channelsPage.userProfilePopover.container;
// Check for the attribute name
const nameElement = popover.getByText(attributeName, {exact: false});
await expect(nameElement).toBeVisible();
// Check for the attribute value
const valueElement = popover.getByText(attributeValue, {exact: false});
await expect(valueElement).toBeVisible();
}
/**
* Helper function to verify an attribute is not displayed in the profile popover
* @param {ChannelsPage} channelsPage - The Playwright channels page object
* @param {string} attributeName - The name of the attribute to verify
*/
export async function verifyAttributeNotInPopover(channelsPage: ChannelsPage, attributeName: string): Promise<void> {
const popover = channelsPage.userProfilePopover.container;
// Check that the attribute name is not present
const nameElement = popover.getByText(attributeName, {exact: false});
await expect(nameElement).not.toBeVisible();
}
/**
* Updates the visibility property of a custom profile attribute field
* @param {Client4} adminClient - Admin API client
* @param {Object} fieldsMap - Map of field IDs to field objects
* @param {string} attributeName - The name of the attribute to update
* @param {string} visibility - The visibility value to set ('when_set', 'hidden', or 'always')
*/
export async function updateCustomProfileAttributeVisibility(
adminClient: Client4,
fieldsMap: Record<string, UserPropertyField>,
attributeName: string,
visibility: 'when_set' | 'hidden' | 'always',
): Promise<void> {
const fieldID = getFieldIdByName(fieldsMap, attributeName);
try {
// Update the visibility property
const updatedField = await adminClient.patchCustomProfileAttributeField(fieldID, {
// @ts-expect-error The type definition requires more properties than we need to set
attrs: {
visibility,
},
});
// Update the fieldsMap with the updated field
fieldsMap[updatedField.id] = updatedField;
} catch (error) {
// eslint-disable-next-line no-console
console.log(`Failed to update visibility for attribute ${attributeName}:`, error);
}
}
/**
* Sets up custom profile attributes fields
* @param {Client4} adminClient - Admin API client
* @param {Array} attributes - Array of attribute objects with name and value
* @returns {Promise<Object>} - A promise that resolves to a map of field IDs to field objects
*/
export async function setupCustomProfileAttributeFields(
adminClient: Client4,
attributes: CustomProfileAttribute[],
): Promise<CpaFieldsMap> {
const fieldsMap: Record<string, UserPropertyField> = {};
const ownedIds = new Set<string>();
// Create the attribute fields array
const attributeFields: UserPropertyFieldPatch[] = attributes.map((attr, index) => {
// Start with basic field properties
const field: UserPropertyFieldPatch = {
name: attr.name,
type: (attr.type as FieldType) || 'text',
// @ts-expect-error @mattermost/types needs to be updated
attrs: {
sort_order: index,
},
};
// Add options for select and multiselect fields
if ((attr.type === 'select' || attr.type === 'multiselect') && attr.options) {
// @ts-expect-error @mattermost/types needs to be updated
field.attrs.options = attr.options;
}
// Add any additional attributes if provided
if (attr.attrs) {
// @ts-expect-error @mattermost/types needs to be updated
field.attrs = {
...field.attrs,
...attr.attrs,
};
}
return field;
});
// Build a name -> existing field map so we can reuse fields that already
// exist (e.g. a 'Department' field created by global test setup) and only
// create the ones that are genuinely missing.
const existingByName: Record<string, UserPropertyField> = {};
try {
const existingFields = await adminClient.getCustomProfileAttributeFields();
for (const field of existingFields) {
existingByName[field.name] = field;
}
} catch (error) {
// If request fails, continue to create new fields
// eslint-disable-next-line no-console
console.log('Error getting existing custom profile fields, will create new ones', error);
}
// Create fields sequentially, reusing any that already exist by name AND type.
// If a same-name field exists with a different type, delete it first then recreate.
for (const field of attributeFields) {
const existing = field.name ? existingByName[field.name] : undefined;
if (existing && existing.type === field.type) {
// Name and type both match — safe to reuse without touching ownedIds.
fieldsMap[existing.id] = existing;
} else if (existing && existing.type !== field.type) {
// Same name but wrong type (e.g. a previous spec created 'Location' as 'text'
// while this spec needs it as 'select'). Delete the stale field and recreate.
try {
await adminClient.deleteCustomProfileAttributeField(existing.id);
} catch {
// Ignore delete errors — the field may already be gone.
}
try {
const createdField = await adminClient.createCustomProfileAttributeField(field);
fieldsMap[createdField.id] = createdField;
ownedIds.add(createdField.id);
} catch {
// Race: another worker recreated it first — borrow it (not owned).
try {
const currentFields = await adminClient.getCustomProfileAttributeFields();
const raceCreated = currentFields.find((f) => f.name === field.name);
if (raceCreated) {
fieldsMap[raceCreated.id] = raceCreated;
}
} catch {
// ignore — missing field surfaces via getFieldIdByName()
}
}
} else {
// Field does not exist at all — create it.
try {
const createdField = await adminClient.createCustomProfileAttributeField(field);
fieldsMap[createdField.id] = createdField;
ownedIds.add(createdField.id);
} catch {
// Race: another shard created the field first — re-fetch and borrow it (not owned).
try {
const currentFields = await adminClient.getCustomProfileAttributeFields();
const raceCreated = currentFields.find((f) => f.name === field.name);
if (raceCreated) {
fieldsMap[raceCreated.id] = raceCreated;
}
} catch {
// ignore — missing field surfaces via getFieldIdByName()
}
}
}
}
// Non-enumerable so Object.keys/values/entries/JSON.stringify skip it.
Object.defineProperty(fieldsMap, 'ownedIds', {
value: ownedIds,
enumerable: false,
configurable: true,
writable: true,
});
return fieldsMap as CpaFieldsMap;
}
/**
* Sets up custom profile attribute values for the current user
* @param {Client4} userClient - User client object
* @param {Array} attributes - Array of attribute objects with name and value
* @param {Object} fields - Map of field IDs to field objects
*/
export async function setupCustomProfileAttributeValues(
userClient: Client4,
attributes: CustomProfileAttribute[],
fields: Record<string, UserPropertyField>,
): Promise<void> {
// Create a map of attribute values by field ID
const valuesByFieldId: Record<string, string | string[]> = {};
for (const attr of attributes) {
let fieldID = '';
// Find the field ID for this attribute name
for (const [id, field] of Object.entries(fields)) {
if (field.name === attr.name) {
fieldID = id;
break;
}
}
// If we found a matching field, add it to our values object
if (fieldID && attr.value) {
valuesByFieldId[fieldID] = attr.value;
}
}
// Only make the API call if we have values to set
if (Object.keys(valuesByFieldId).length > 0) {
try {
await userClient.updateCustomProfileAttributeValues(valuesByFieldId);
} catch (error) {
// eslint-disable-next-line no-console
console.log('Failed to set attribute values:', error);
}
}
}
/**
* Sets up custom profile attribute values for a specific user (admin function)
* @param {Client4} adminClient - Admin client object
* @param {Array} attributes - Array of attribute objects with name and value
* @param {Object} fields - Map of field IDs to field objects
* @param {string} targetUserId - ID of the user to set values for
*/
export async function setupCustomProfileAttributeValuesForUser(
adminClient: Client4,
attributes: CustomProfileAttribute[],
fields: Record<string, UserPropertyField>,
targetUserId: string,
): Promise<void> {
// Create a map of attribute values by field ID
const valuesByFieldId: Record<string, string | string[]> = {};
for (const attr of attributes) {
let fieldID = '';
// Find the field ID for this attribute name
for (const [id, field] of Object.entries(fields)) {
if (field.name === attr.name) {
fieldID = id;
break;
}
}
// If we found a matching field, add it to our values object
if (fieldID && attr.value) {
valuesByFieldId[fieldID] = attr.value;
}
}
// Only make the API call if we have values to set
if (Object.keys(valuesByFieldId).length > 0) {
try {
// Use the admin client method for updating other user's values
await adminClient.updateUserCustomProfileAttributesValues(targetUserId, valuesByFieldId);
} catch (error) {
// eslint-disable-next-line no-console
console.log('Failed to set attribute values for user:', error);
}
}
}
/**
* Deletes all custom profile attributes
* @param {Client4} adminClient - Admin API client
* @param {Object} attributes - Map of field IDs to field objects
*/
export async function deleteCustomProfileAttributes(
adminClient: Client4,
attributes: Record<string, UserPropertyField>,
): Promise<void> {
// Only delete owned fields; fall back to all keys for legacy callers without ownedIds.
const ownedIds: Set<string> =
'ownedIds' in attributes ? (attributes as CpaFieldsMap).ownedIds : new Set(Object.keys(attributes));
for (const id of ownedIds) {
try {
await adminClient.deleteCustomProfileAttributeField(id);
} catch (error) {
// eslint-disable-next-line no-console
console.log(`Failed to delete field ${id}:`, error);
}
}
// Verify only owned fields were deleted (concurrent tests may still have their own fields).
if (ownedIds.size === 0) {
return;
}
try {
const remaining = await adminClient.getCustomProfileAttributeFields();
const leakedFields = remaining.filter((f: {id: string}) => ownedIds.has(f.id));
if (leakedFields.length > 0) {
// eslint-disable-next-line no-console
console.log(
`Warning: ${leakedFields.length} field(s) were not deleted:`,
leakedFields.map((f: {id: string; name: string}) => f.name).join(', '),
);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log('Error verifying field deletion:', error);
}
}

View File

@@ -0,0 +1,373 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {Channel} from '@mattermost/types/channels';
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from './helpers';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValues,
deleteCustomProfileAttributes,
verifyAttributesExistInSettings,
verifyAttributeInPopover,
verifyAttributeNotInPopover,
editTextAttribute,
editSelectAttribute,
editMultiselectAttribute,
getFieldIdByName,
TEST_PHONE,
TEST_UPDATED_PHONE,
TEST_URL,
TEST_UPDATED_URL,
TEST_INVALID_URL,
TEST_VALID_URL,
TEST_DEPARTMENT,
TEST_UPDATED_DEPARTMENT,
TEST_CHANGED_VALUE,
TEST_LOCATION_OPTIONS,
TEST_SKILLS_OPTIONS,
TEST_MESSAGE,
TEST_MESSAGE_OTHER,
} from './helpers';
// Custom attribute definitions.
// Names are intentionally suffixed with 'US' to avoid sharing server fields with
// custom_attributes.spec.ts, which defines identically-typed 'Department', 'Phone',
// and 'Website' attributes. With greedy-bin-packing shard balancing the two spec
// files often land in different shards; if they shared fields the owning spec's
// afterAll would delete the field while the other spec is still using it, causing
// consistent CI failures (observed as Department absent from the profile popover).
const customAttributes: CustomProfileAttribute[] = [
{
name: 'DepartmentUS',
value: TEST_DEPARTMENT,
type: 'text',
},
{
name: 'LocationUS',
type: 'select',
options: TEST_LOCATION_OPTIONS,
},
{
name: 'SkillsUS',
type: 'multiselect',
options: TEST_SKILLS_OPTIONS,
},
{
name: 'PhoneUS',
value: TEST_PHONE,
type: 'text',
attrs: {
value_type: 'phone',
},
},
{
name: 'WebsiteUS',
value: TEST_URL,
type: 'text',
attrs: {
value_type: 'url',
},
},
];
let team: Team;
let user: UserProfile;
let otherUser: UserProfile;
let testChannel: Channel;
let attributeFieldsMap: Record<string, UserPropertyField>;
let adminClient: Client4;
let userClient: Client4;
test.beforeEach(async ({pw}) => {
// Skip test if no license for "Custom Profile Attributes"
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Initialize with admin client
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
const channel = pw.random.channel({
teamId: team.id,
name: 'test-channel',
displayName: 'Test Channel',
});
testChannel = await adminClient.createChannel(channel);
// Create another user to test profile popover
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
await adminClient.addToTeam(team.id, otherUser.id);
await adminClient.addToChannel(otherUser.id, testChannel.id);
// Add the test user to the test channel
await adminClient.addToChannel(user.id, testChannel.id);
// Set up custom profile attribute fields
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, customAttributes);
// Login as the test user
const {page} = await pw.testBrowser.login(user);
// Set up initial values for custom profile attributes
await setupCustomProfileAttributeValues(userClient, customAttributes, attributeFieldsMap);
// Visit the test channel
await page.goto(`/${team.name}/channels/${testChannel.name}`);
});
test.afterAll(async () => {
// Clean up by deleting custom profile attributes
await deleteCustomProfileAttributes(adminClient, attributeFieldsMap);
});
/**
* Verify that users can edit different types of custom profile attributes
* (text, select, and multiselect fields) and that changes appear correctly in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes:
* - Department (text field)
* - Location (select field with options: Remote, Office, Hybrid)
* - Skills (multiselect field with options: JavaScript, React, Node.js, Python)
* - Phone (text field with phone validation)
* - Website (text field with URL validation)
* 3. Test user has initial values:
* - Department: Engineering
* - Phone: 555-123-4567
* - Website: https://example.com
* 4. Two user accounts (test user and other user) exist
* 5. Both users are members of the same channel
*/
test('MM-T5768 Editing Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Open profile settings modal
const profileModal = await channelsPage.openProfileModal();
await profileModal.toBeVisible();
// * Verify that custom profile attributes section exists
await verifyAttributesExistInSettings(page, customAttributes);
// 3. Edit the DepartmentUS attribute and change to "Product"
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
// 4. Edit the LocationUS attribute (select field) and select "Remote" (index 0)
await editSelectAttribute(page, attributeFieldsMap, 'LocationUS', 0); // Remote is the first option (index 0)
// 5. Edit the SkillsUS attribute (multiselect field) and select "Python" and "Node.js"
await editMultiselectAttribute(page, attributeFieldsMap, 'SkillsUS', [3, 2]); // Python (index 3) and Node.js (index 2)
// 6. Close the profile settings modal
await profileModal.closeModal();
// 7. Post a message to make the user visible in the channel
await channelsPage.postMessage(TEST_MESSAGE);
// 8. Login as the other user to view the profile popover
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
await otherChannelsPage.goto();
// 9. View the test user's profile popover
const lastPost = await otherChannelsPage.getLastPost();
await otherChannelsPage.openProfilePopover(lastPost);
// * Profile popover shows updated custom attributes
await verifyAttributeInPopover(otherChannelsPage, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
await verifyAttributeInPopover(otherChannelsPage, 'LocationUS', 'Remote'); // Remote is index 0 in TEST_LOCATION_OPTIONS
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Python');
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Node.js');
});
/**
* Verify that users can clear custom profile attribute values and that cleared
* attributes with "when_set" visibility aren't displayed in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Test user has Department value set to "Engineering"
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5769 Clearing Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// Prepare the environment by posting a message as other user
await adminClient.createPost({
channel_id: testChannel.id,
message: TEST_MESSAGE_OTHER,
user_id: otherUser.id,
});
// 2. Open profile settings modal
const profileModal = await channelsPage.openProfileModal();
await profileModal.toBeVisible();
// 3. Edit DepartmentUS field and delete all text to clear the value
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', '');
// 4. Close the profile settings modal
await profileModal.closeModal();
// 5. Post a message to make the user visible in the channel
await channelsPage.postMessage('Testing cleared attributes');
// 6. Login as the other user
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
await otherChannelsPage.goto();
// 7. View the test user's profile popover
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * DepartmentUS attribute is not displayed in the profile popover
await verifyAttributeNotInPopover(otherChannelsPage, 'DepartmentUS');
});
/**
* Verify that cancelling changes to custom profile attributes properly
* discards the changes without saving them.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Test user has Department value set to "Engineering"
*/
test('MM-T5770 Cancelling Changes to Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Open profile settings modal
const profileModal = await channelsPage.openProfileModal();
await profileModal.toBeVisible();
// 3. Edit DepartmentUS field and change to "Changed Value"
const department = 'DepartmentUS';
const fieldId = getFieldIdByName(attributeFieldsMap, department);
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
await profileModal.container.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_CHANGED_VALUE);
// 4. Click Cancel button
await profileModal.cancelButton.click();
// 5. Open DepartmentUS field for editing again
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
// * After cancelling, DepartmentUS field should still show original value "Engineering"
await expect(profileModal.container.locator(`#customAttribute_${fieldId}`)).toHaveValue(TEST_DEPARTMENT);
});
/**
* Verify that users can edit custom profile attributes with specialized formats
* (phone numbers and URLs) and that they display correctly in the profile popover.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created custom profile attributes
* 3. Test user has initial values:
* - Phone: 555-123-4567
* - Website: https://example.com
* 4. Two user accounts exist and are members of the same channel
*/
test('MM-T5771 Editing Phone and URL Type Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// Prepare the environment by posting a message as other user
await adminClient.createPost({
channel_id: testChannel.id,
message: TEST_MESSAGE_OTHER,
user_id: otherUser.id,
});
// 2. Open profile settings modal
const profileModal = await channelsPage.openProfileModal();
await profileModal.toBeVisible();
// 3. Edit PhoneUS field and change to "555-987-6543"
await editTextAttribute(page, attributeFieldsMap, 'PhoneUS', TEST_UPDATED_PHONE);
// 4. Edit WebsiteUS field and change to "https://mattermost.com"
await editTextAttribute(page, attributeFieldsMap, 'WebsiteUS', TEST_UPDATED_URL);
// 5. Close the profile settings modal
await profileModal.closeModal();
// 6. Post a message to make the user visible in the channel
await channelsPage.postMessage('Testing phone and URL attributes');
// 7. Login as the other user
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
await otherChannelsPage.goto();
// 8. View the test user's profile popover
const lastPost = await otherChannelsPage.getLastPost();
await otherChannelsPage.openProfilePopover(lastPost);
// * Profile popover shows updated attributes
await verifyAttributeInPopover(otherChannelsPage, 'PhoneUS', TEST_UPDATED_PHONE);
await verifyAttributeInPopover(otherChannelsPage, 'WebsiteUS', TEST_UPDATED_URL);
});
/**
* Verify that URL validation works properly for custom profile attributes,
* showing errors for invalid URLs and allowing valid ones.
*
* Precondition:
* 1. A test server with valid license to support 'Custom Profile Attributes'
* 2. Admin has created Website custom profile attribute with URL validation
* 3. Test user has Website value set to "https://example.com"
*/
test('MM-T5772 URL Validation in Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
// 1. Login as the test user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
// 2. Open profile settings modal
const profileModal = await channelsPage.openProfileModal();
await profileModal.toBeVisible();
// 3. Edit WebsiteUS field and enter an invalid URL
const fieldId = getFieldIdByName(attributeFieldsMap, 'WebsiteUS');
await profileModal.container.locator('text=WebsiteUS').scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
await profileModal.container.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_INVALID_URL);
await profileModal.container.locator(`#customAttribute_${fieldId}`).blur();
// * Save button doesn't complete the operation with invalid URL
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).toBeVisible();
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).toHaveText(
'Please enter a valid url.',
);
// 5. Edit Website field and enter a valid URL
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_VALID_URL);
// 6. Save the changes
await profileModal.saveButton.click();
// * Valid URL saves successfully with no error message
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).not.toBeVisible();
await expect(profileModal.container).toContainText(TEST_VALID_URL);
});

View File

@@ -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);
});

View File

@@ -0,0 +1,58 @@
// 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 direct message can be edited and the recipient sees the updated body and edited marker without a mention badge.
*/
test('MM-T449 edits a direct message body', {tag: '@direct_messages'}, async ({pw}) => {
const originalMessage = 'Hello';
const editedMessage = 'Hello World';
// # Create a direct message and send its initial post
const {user: sender, team, adminClient} = await pw.initSetup();
const [recipient] = await adminClient.createUsers(team.id, 1, 'dm-edit');
const {client: senderClient} = await pw.makeClient(sender);
const dmChannel = await adminClient.createDirectChannel([sender.id, recipient.id]);
const post = await senderClient.createPost({
channel_id: dmChannel.id,
message: originalMessage,
});
// # Open the direct message as the recipient
const {channelsPage: recipientChannelsPage, page: recipientPage} = await pw.testBrowser.login(recipient);
await recipientChannelsPage.goto(team.name, `@${sender.username}`);
await recipientChannelsPage.toBeVisible();
// * Verify the original message is sent and is not pending
const recipientPost = await recipientChannelsPage.centerView.getPostById(post.id);
await recipientPost.toContainText(originalMessage);
await recipientPost.toHaveId(post.id);
// # Open the direct message as the sender and edit the post with the Up arrow shortcut
const {channelsPage: senderChannelsPage, page: senderPage} = await pw.testBrowser.login(sender);
await senderChannelsPage.goto(team.name, `@${recipient.username}`);
await senderChannelsPage.toBeVisible();
await senderChannelsPage.centerView.postCreate.input.focus();
await senderPage.keyboard.press('ArrowUp');
await senderChannelsPage.centerView.postEdit.toBeVisible();
await expect(senderChannelsPage.centerView.postEdit.input).toHaveValue(originalMessage);
await senderChannelsPage.centerView.postEdit.writeMessage(editedMessage);
await senderChannelsPage.centerView.postEdit.sendMessage();
// * Verify the sender sees the edited body and marker
const senderPost = await senderChannelsPage.centerView.getPostById(post.id);
await senderPost.toContainText(editedMessage);
await senderPost.toBeEdited();
// # Reload the recipient's direct message
await recipientPage.reload();
await recipientChannelsPage.toBeVisible();
// * Verify the recipient sees the edit without an unread mention indicator
const updatedRecipientPost = await recipientChannelsPage.centerView.getPostById(post.id);
await updatedRecipientPost.toContainText(editedMessage);
await updatedRecipientPost.toBeEdited();
await expect(recipientChannelsPage.sidebarLeft.unreadMentionsBadge(sender.username)).not.toBeVisible();
});

View File

@@ -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);
},
);

View File

@@ -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}`);
},
);

View File

@@ -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';
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.
*
* MM-T480 covers the same behavior as MM-T466 and is covered by this test.
*/
test(
'MM-T466 MM-T480 creates a group message with a mention, closes it, and recreates it',
{tag: '@direct_messages'},
async ({pw}) => {
// # Create the test user plus two more users on the team
const {user, team, adminClient} = 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 with the two members
const dmModal = await channelsPage.openDirectChannelsModal();
await dmModal.selectUser(member1);
await dmModal.selectUser(member2);
await dmModal.goToChannel();
// # Post a message that mentions one of the members
const token = `gm mention ${pw.random.id()}`;
await channelsPage.postMessage(`@${member1.username} ${token}`);
const lastPost = await channelsPage.getLastPost();
await lastPost.toContainText(token);
// # Close the group message conversation
const slug = getChannelSlugFromUrl(page);
await channelsPage.sidebarLeft.closeConversationAndWait(slug);
// # Recreate the group message with the same members
const dmModal2 = await channelsPage.openDirectChannelsModal();
await dmModal2.selectUser(member1);
await dmModal2.selectUser(member2);
await dmModal2.goToChannel();
// * Verify the same group message channel is reopened
await expect.poll(() => page.url(), {timeout: pw.duration.ten_sec}).toContain(`/messages/${slug}`);
},
);

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import type {Page} from '@playwright/test';
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify that a group message whose channel has fallen out of the sidebar (because the user
* has more DMs/GMs than the configured "Number of direct messages to show" limit) still appears in the
* Direct Messages modal with its members fully loaded — i.e. with a non-zero member count and the
* participant usernames as its name.
*/
test(
"MM-65058 Direct Messages modal should load group members for GMs which haven't been loaded otherwise",
{tag: '@direct_messages'},
async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup({withDefaultProfileImage: false});
// Use a lower visible DM limit than the UI normally lets you use to speed up this test
const totalGms = 2;
const visibleLimit = 1;
// # Limit the user's visible DMs/GMs in the sidebar so one GM falls off the sidebar
await userClient.savePreferences(user.id, [
{
user_id: user.id,
category: 'sidebar_settings',
name: 'limit_visible_dms_gms',
value: visibleLimit.toString(),
},
]);
// # Create enough users to populate 11 GMs with unique users
const users = [];
for (let i = 0; i < totalGms * 2; i++) {
const user = await pw.createNewUserProfile(adminClient, {prefix: `mm65058gm${i}`});
users.push(user);
}
// # Log the user in and open the channels page
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Create 11 GMs using the Direct Channels modal
const gmChannels = [];
for (let i = 0; i < totalGms; i++) {
const memberA = users[i * 2];
const memberB = users[i * 2 + 1];
// # Open the modal
const dialog = await channelsPage.openDirectChannelsModal();
// # Select the users and create the channel
await dialog.selectUser(memberA);
await dialog.selectUser(memberB);
await dialog.goToChannel();
// # Make a post in the channel to ensure that it has a last_post_at value
await channelsPage.postMessage(`gm message ${i}`);
// # Save the channel's information for later
gmChannels.push({
channel: await getCurrentChannel(page),
members: [memberA, memberB],
});
}
const targetGm = gmChannels[0];
const otherGms = gmChannels.slice(1);
// # Refresh the app and go back to Town Square
await channelsPage.goto(team.name, 'town-square');
// * Verify the target GM is not present in the sidebar to ensure that the sidebar hasn't loaded it
await expect(page.locator(`#sidebarItem_${targetGm.channel.name}`)).toHaveCount(0);
// * Wait until the other GMs are loaded and present in the sidebar
for (const otherGm of otherGms) {
const otherGmEntry = page.locator(`#sidebarItem_${otherGm.channel.name}`);
await expect(otherGmEntry).toHaveCount(1);
await expect(otherGmEntry).toContainText(gmChannelDisplayName(otherGm.members));
}
// * Verify that the members of the target GM haven't been loaded and the members of other GMs have
await assertChannelUsersNotLoaded(page, targetGm.channel.id);
for (const otherGm of otherGms) {
await assertChannelUsersLoaded(page, otherGm.channel.id, otherGm.members);
}
// # Open the Direct Messages modal again
const dialog = await channelsPage.openDirectChannelsModal();
// # Wait for the list to populate
const rows = dialog.container.locator('#multiSelectList .more-modal__row');
await expect.poll(async () => rows.count()).toBeGreaterThanOrEqual(totalGms);
// * Verify the modal contains an entry for every GM the user has, including the one that fell
// * out of the sidebar
for (const {channel, members} of gmChannels) {
// Each GM row renders the member usernames joined by ', '. We use the second member's
// username (which is unique per GM) to locate the corresponding row.
const usernameMarker = `@${members[1].username}`;
const gmRow = rows.filter({hasText: usernameMarker});
// * Verify the row is rendered
await expect(gmRow, `expected to find a row in the DM modal for GM ${channel.id}`).toHaveCount(1);
// * Verify the GM icon shows the correct member count (channel members minus current user)
await expect(
gmRow.locator('.more-modal__gm-icon'),
`expected GM ${channel.id} to show a member count of ${members.length}`,
).toHaveText(members.length.toString());
// * Verify the row's name section includes every participant's username
const nameContainer = gmRow.locator('.more-modal__name');
for (const participant of members) {
await expect(
nameContainer,
`expected GM ${channel.id} to include @${participant.username} in its name`,
).toContainText(`@${participant.username}`);
}
}
// * Double check that the members of the target GM have been loaded now
await assertChannelUsersLoaded(page, targetGm.channel.id, targetGm.members);
},
);
async function getCurrentChannel(page: Page) {
return page.evaluate<Channel>(
'store.getState().entities.channels.channels[store.getState().entities.channels.currentChannelId]',
);
}
function gmChannelDisplayName(users: UserProfile[]) {
return users
.toSorted((a, b) => {
return a.username.localeCompare(b.username, undefined, {numeric: true});
})
.map((user) => user.username)
.join(', ');
}
async function assertChannelUsersLoaded(page: Page, channelId: string, expectedUsers: UserProfile[]) {
// profilesInChannel contains Sets which aren't serializable for return from page.evaluate
const loadedIds = await page.evaluate(
`Array.from(store.getState().entities.users.profilesInChannel['${channelId}'])`,
);
await expect(loadedIds).toHaveLength(expectedUsers.length);
await expect(loadedIds).toEqual(expect.arrayContaining(expectedUsers.map((user) => user.id)));
}
async function assertChannelUsersNotLoaded(page: Page, channelId: string) {
const loadedIds = await page.evaluate(`store.getState().entities.users.profilesInChannel['${channelId}']`);
await expect(loadedIds).toBeUndefined();
}

Some files were not shown because too many files have changed in this diff Show More