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
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:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
25
e2e-tests/playwright/specs/client/schema.ts
Normal file
25
e2e-tests/playwright/specs/client/schema.ts
Normal 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()),
|
||||
});
|
||||
138
e2e-tests/playwright/specs/client/upload_file.spec.ts
Normal file
138
e2e-tests/playwright/specs/client/upload_file.spec.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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},
|
||||
],
|
||||
});
|
||||
717
e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts
Normal file
717
e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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+/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)})`;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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:'));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
},
|
||||
);
|
||||
@@ -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
Reference in New Issue
Block a user