MM-66742 - add BoR e2e tests (#34829)

* MM-66742 - add BoR e2e tests

* polish bor test code

* fix linter issues

* Align BoR e2e tests with Playwright page object patterns

* fix linter

* Fix sender timer assertion and sidebar nav after  reload
This commit is contained in:
Pablo Vélez 2026-03-23 15:25:27 -05:00 committed by GitHub
parent f0b2a36dbc
commit 2ce50d7c8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2328 additions and 2 deletions

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class BurnOnReadBadge {
readonly container: Locator;
readonly flameIcon: Locator;
constructor(container: Locator) {
this.container = container;
this.flameIcon = container.locator('svg');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
async click() {
await this.container.click();
}
async hover() {
await this.container.hover();
}
/**
* Get the tooltip/label text
* Uses aria-label which contains the same information as the visible tooltip
*/
async getTooltipText(): Promise<string> {
// The aria-label contains the full tooltip information
// e.g., "Click to delete message for everyone. Read by 1 of 2 recipients"
const ariaLabel = await this.container.getAttribute('aria-label');
if (ariaLabel) {
return ariaLabel;
}
// Fallback: try to get text content
return (await this.container.textContent()) || '';
}
/**
* Get aria-label for accessibility testing
*/
async getAriaLabel(): Promise<string> {
return (await this.container.getAttribute('aria-label')) || '';
}
/**
* Parse recipient count from tooltip
* Returns {revealed: number, total: number}
*/
async getRecipientCount(): Promise<{revealed: number; total: number}> {
const tooltipText = await this.getTooltipText();
const match = tooltipText.match(/Read by (\d+) of (\d+)/);
if (!match) {
throw new Error(`Could not parse recipient count from tooltip: ${tooltipText}`);
}
return {
revealed: parseInt(match[1], 10),
total: parseInt(match[2], 10),
};
}
}

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class BurnOnReadConcealedPlaceholder {
readonly container: Locator;
readonly icon: Locator;
readonly text: Locator;
constructor(container: Locator) {
this.container = container;
// The container itself is the button - no need for nested locator
this.icon = container.locator('.BurnOnReadConcealedPlaceholder__icon');
this.text = container.locator('.BurnOnReadConcealedPlaceholder__text');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
/**
* Click to reveal the concealed message
* The container itself is the clickable button
*/
async clickToReveal() {
await this.container.click();
}
/**
* Wait for the reveal process to complete
* The placeholder should disappear after successful reveal
*/
async waitForReveal(timeout = 5000) {
await expect(this.container).not.toBeVisible({timeout});
}
/**
* Get the placeholder text (e.g., "View message")
*/
async getText(): Promise<string> {
return (await this.text.textContent()) || '';
}
/**
* Get the aria-label of the button
*/
async getAriaLabel(): Promise<string> {
return (await this.container.getAttribute('aria-label')) || '';
}
}

View file

@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class BurnOnReadConfirmationModal {
readonly container: Locator;
readonly title: Locator;
readonly message: Locator;
readonly deleteButton: Locator;
readonly cancelButton: Locator;
readonly dontShowAgainCheckbox: Locator;
constructor(container: Locator) {
this.container = container;
// Modal elements
this.title = container.locator('.modal-title, h1, [role="heading"]').first();
this.message = container.locator('.modal-body, .modal-message').first();
// Action buttons - use flexible selectors
this.deleteButton = container.getByRole('button', {name: /delete|burn|confirm/i});
this.cancelButton = container.getByRole('button', {name: /cancel/i});
// Checkbox for "don't show again" preference
this.dontShowAgainCheckbox = container.getByRole('checkbox');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
/**
* Confirm deletion without checking "don't show again"
*/
async confirm() {
await this.deleteButton.click();
await this.toBeHidden();
}
/**
* Confirm deletion and check "don't show again"
*/
async confirmWithDontShowAgain() {
await this.dontShowAgainCheckbox.check();
await this.deleteButton.click();
await this.toBeHidden();
}
/**
* Cancel the deletion
*/
async cancel() {
await this.cancelButton.click();
await this.toBeHidden();
}
/**
* Get the modal title text
*/
async getTitleText(): Promise<string> {
return (await this.title.textContent()) || '';
}
/**
* Get the modal message text
*/
async getMessageText(): Promise<string> {
return (await this.message.textContent()) || '';
}
/**
* Check if "don't show again" checkbox is present
*/
async hasDontShowAgainOption(): Promise<boolean> {
return await this.dontShowAgainCheckbox.isVisible();
}
/**
* Check if "don't show again" is already checked
*/
async isDontShowAgainChecked(): Promise<boolean> {
return await this.dontShowAgainCheckbox.isChecked();
}
}

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class BurnOnReadTimerChip {
readonly container: Locator;
readonly flameIcon: Locator;
readonly timerText: Locator;
constructor(container: Locator) {
this.container = container;
this.flameIcon = container.locator('.BurnOnReadTimerChip__icon');
this.timerText = container.locator('.BurnOnReadTimerChip__time');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
async click() {
await this.container.click();
}
async hover() {
await this.container.hover();
}
/**
* Get the displayed time remaining (e.g., "0:45", "1:30")
*/
async getTimeRemaining(): Promise<string> {
return (await this.timerText.textContent()) || '';
}
/**
* Parse time remaining into seconds
*/
async getTimeRemainingInSeconds(): Promise<number> {
const timeText = await this.getTimeRemaining();
const parts = timeText.split(':');
if (parts.length !== 2) {
throw new Error(`Invalid timer format: ${timeText}`);
}
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return minutes * 60 + seconds;
}
/**
* Check if timer is in warning state (last 30 seconds)
*/
async isWarning(): Promise<boolean> {
const className = await this.container.getAttribute('class');
return className?.includes('BurnOnReadTimerChip--warning') || false;
}
/**
* Check if timer has expired
*/
async isExpired(): Promise<boolean> {
const className = await this.container.getAttribute('class');
return className?.includes('BurnOnReadTimerChip--expired') || false;
}
/**
* Get tooltip text
*/
async getTooltipText(): Promise<string> {
await this.hover();
// Wait for tooltip to appear
const tooltip = this.container.page().locator('[role="tooltip"]').first();
await tooltip.waitFor({state: 'visible', timeout: 2000});
return (await tooltip.textContent()) || '';
}
}

View file

@ -97,9 +97,12 @@ export default class ChannelsCenterView {
/**
* Returns the Center post by post's id
* @param postId Just the ID without the prefix
* Note: Handles both simple posts (post_id) and combined posts (post_id:timestamp)
*/
async getPostById(id: string) {
const postById = this.container.locator(`[id="post_${id}"]`);
// Match either exact ID or ID with timestamp suffix (for combined posts)
// Use CSS selector that matches: post_id OR post_id:*
const postById = this.container.locator(`[id="post_${id}"], [id^="post_${id}:"]`).first();
await postById.waitFor();
return new ChannelsPost(postById);
}

View file

@ -3,6 +3,9 @@
import {Locator, expect} from '@playwright/test';
import BurnOnReadBadge from './burn_on_read_badge';
import BurnOnReadConcealedPlaceholder from './burn_on_read_concealed_placeholder';
import BurnOnReadTimerChip from './burn_on_read_timer_chip';
import PostMenu from './post_menu';
import ThreadFooter from './thread_footer';
@ -17,6 +20,11 @@ export default class ChannelsPost {
readonly postMenu;
readonly threadFooter;
// Burn-on-Read elements
readonly burnOnReadBadge;
readonly burnOnReadTimerChip;
readonly concealedPlaceholder;
constructor(container: Locator) {
this.container = container;
@ -28,6 +36,13 @@ export default class ChannelsPost {
this.postMenu = new PostMenu(container.locator('.post-menu'));
this.threadFooter = new ThreadFooter(container.locator('.ThreadFooter'));
// Burn-on-Read components
this.burnOnReadBadge = new BurnOnReadBadge(container.locator('.BurnOnReadBadge'));
this.burnOnReadTimerChip = new BurnOnReadTimerChip(container.locator('.BurnOnReadTimerChip'));
this.concealedPlaceholder = new BurnOnReadConcealedPlaceholder(
container.locator('.BurnOnReadConcealedPlaceholder'),
);
}
async toBeVisible() {
@ -44,7 +59,10 @@ export default class ChannelsPost {
async getId() {
const id = await this.container.getAttribute('id');
expect(id, 'No post ID found.').toBeTruthy();
return (id || '').substring('post_'.length);
// Remove 'post_' prefix and any timestamp suffix (format: postId:timestamp for combined posts)
const postIdWithPossibleTimestamp = (id || '').substring('post_'.length);
// Return just the post ID (before any colon)
return postIdWithPossibleTimestamp.split(':')[0];
}
async getProfileImage(username: string) {
@ -93,4 +111,28 @@ export default class ChannelsPost {
async toNotContainText(text: string) {
await expect(this.container).not.toContainText(text);
}
/**
* Check if this is a burn-on-read post
*/
async isBurnOnReadPost(): Promise<boolean> {
// Check if BoR badge or timer chip is present
const hasBadge = await this.burnOnReadBadge.container.isVisible();
const hasTimer = await this.burnOnReadTimerChip.container.isVisible();
return hasBadge || hasTimer;
}
/**
* Check if the BoR post is concealed (not yet revealed)
*/
async isConcealed(): Promise<boolean> {
return await this.concealedPlaceholder.container.isVisible();
}
/**
* Check if the BoR post is revealed
*/
async isRevealed(): Promise<boolean> {
return !(await this.isConcealed());
}
}

View file

@ -21,6 +21,10 @@ export default class ChannelsPostCreate {
readonly suggestionList;
readonly filePreview;
// Burn-on-Read elements
readonly burnOnReadButton;
readonly burnOnReadLabel;
constructor(container: Locator, isRHS = false) {
this.container = container;
@ -37,6 +41,11 @@ export default class ChannelsPostCreate {
this.priorityButton = container.getByLabel('Message priority');
this.suggestionList = container.getByRole('listbox', {name: 'Suggestions'});
this.filePreview = container.locator('.file-preview__container');
// Burn-on-Read elements
// Use a flexible locator that matches the aria-label pattern
this.burnOnReadButton = container.getByRole('button', {name: /Burn-on-read/i});
this.burnOnReadLabel = container.locator('.BurnOnReadLabel');
}
async toBeVisible() {
@ -128,4 +137,20 @@ export default class ChannelsPostCreate {
{timeout},
);
}
/**
* Toggle the burn-on-read feature for the message
*/
async toggleBurnOnRead() {
await expect(this.burnOnReadButton).toBeVisible();
await this.burnOnReadButton.click();
}
/**
* Check if burn-on-read is currently enabled
* BoR is considered enabled if the label is visible above the input
*/
async isBurnOnReadEnabled(): Promise<boolean> {
return await this.burnOnReadLabel.isVisible();
}
}

View file

@ -47,6 +47,11 @@ import TeamMenu from './channels/team_menu';
import TeamSettingsModal from './channels/team_settings/team_settings_modal';
import ThreadFooter from './channels/thread_footer';
import UserProfilePopover from './channels/user_profile_popover';
// Burn-on-Read Components
import BurnOnReadBadge from './channels/burn_on_read_badge';
import BurnOnReadTimerChip from './channels/burn_on_read_timer_chip';
import BurnOnReadConcealedPlaceholder from './channels/burn_on_read_concealed_placeholder';
import BurnOnReadConfirmationModal from './channels/burn_on_read_confirmation_modal';
// System Console Components
import {AdminSectionPanel, DropdownSetting, RadioSetting, TextInputSetting} from './system_console/base_components';
import DelegatedGranularAdministration from './system_console/sections/user_management/delegated_granular_administration';
@ -112,6 +117,12 @@ const components = {
ThreadFooter,
UserProfilePopover,
// Burn-on-Read
BurnOnReadBadge,
BurnOnReadTimerChip,
BurnOnReadConcealedPlaceholder,
BurnOnReadConfirmationModal,
// System Console
AdminSectionPanel,
DelegatedGranularAdministration,
@ -183,6 +194,12 @@ export {
ThreadFooter,
UserProfilePopover,
// Burn-on-Read
BurnOnReadBadge,
BurnOnReadTimerChip,
BurnOnReadConcealedPlaceholder,
BurnOnReadConfirmationModal,
// System Console
AdminSectionPanel,
DelegatedGranularAdministration,

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
/**
* System Console -> Site Configuration -> Posts -> Self-Deleting Messages
*/
export default class SelfDeletingMessages {
readonly page: Page;
readonly container: Locator;
readonly enableToggleTrue: Locator;
readonly enableToggleFalse: Locator;
readonly durationDropdown: Locator;
readonly maxTimeToLiveDropdown: Locator;
readonly saveButton: Locator;
constructor(container: Locator, page: Page) {
this.container = container;
this.page = page;
this.enableToggleTrue = this.container.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
this.enableToggleFalse = this.container.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
this.durationDropdown = this.container.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
this.maxTimeToLiveDropdown = this.container.getByTestId(
'ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown',
);
this.saveButton = this.container.getByRole('button', {name: 'Save'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async clickEnableToggleTrue() {
await this.enableToggleTrue.click();
}
async clickEnableToggleFalse() {
await this.enableToggleFalse.click();
}
async selectDuration(value: string) {
await this.durationDropdown.selectOption(value);
}
async selectMaxTimeToLive(value: string) {
await this.maxTimeToLiveDropdown.selectOption(value);
}
async getDurationValue(): Promise<string> {
return await this.durationDropdown.inputValue();
}
async getMaxTimeToLiveValue(): Promise<string> {
return await this.maxTimeToLiveDropdown.inputValue();
}
async clickSaveButton() {
await this.saveButton.click();
}
async isEnabled(): Promise<boolean> {
return await this.enableToggleTrue.isChecked();
}
async isDurationDropdownDisabled(): Promise<boolean> {
return await this.durationDropdown.isDisabled();
}
async isMaxTimeToLiveDropdownDisabled(): Promise<boolean> {
return await this.maxTimeToLiveDropdown.isDisabled();
}
}

View file

@ -45,6 +45,7 @@ export default class ChannelsPage {
readonly teamSettingsModal;
readonly scheduledDraftModal;
readonly scheduleMessageModal;
readonly burnOnReadConfirmationModal;
readonly archivedChannelMessage;
readonly postContainer;
@ -79,6 +80,9 @@ export default class ChannelsPage {
this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'}));
this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'}));
this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'}));
this.burnOnReadConfirmationModal = new components.BurnOnReadConfirmationModal(
page.getByRole('dialog').filter({hasText: /burn|delete/i}),
);
// Menus
this.postDotMenu = new components.PostDotMenu(page.getByRole('menu', {name: 'Post extra options'}));

View file

@ -0,0 +1,409 @@
// 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).toBeVisible();
// # 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 ${await 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 ${await 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 ${await 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 ${await 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 ${await 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 ${await 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 ${await 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();
// Wait for posts to load - at least one concealed placeholder should be visible
await expect(receiverPage.centerView.container.locator('.BurnOnReadConcealedPlaceholder').first()).toBeVisible({
timeout: 10000,
});
// * 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 ${await pw.random.id()}`;
await senderPage.postMessage(message1);
// * Verify BoR is disabled after sending (toggle resets)
const isEnabledAfter = await senderPage.centerView.postCreate.isBurnOnReadEnabled();
expect(isEnabledAfter).toBe(false);
// * Verify the sent message has BoR badge (was sent as BoR)
const lastPost = await senderPage.getLastPost();
await expect(lastPost.burnOnReadBadge.container).toBeVisible();
});
});

View file

@ -0,0 +1,320 @@
// 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 ${await 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 ${await 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 ${await 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 ${await 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: 300,
});
// # 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 = `Auto-delete test ${await 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 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: 20000,
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 ${await pw.random.id()}`;
await senderPage.postMessage(message);
// # Login as receiver
const {channelsPage: receiverPage} = await pw.testBrowser.login(receiver);
await receiverPage.goto(team.name);
await receiverPage.toBeVisible();
await receiverPage.goto(team.name, `@${sender.username}`);
// # Get the BoR post (still concealed)
const borPost = await receiverPage.getLastPost();
// * Verify concealed placeholder is visible
await expect(borPost.concealedPlaceholder.container).toBeVisible();
// * Verify flame badge is also visible on concealed post
await expect(borPost.burnOnReadBadge.container).toBeVisible();
// * Verify badge tooltip shows appropriate message for receiver
const ariaLabel = await borPost.burnOnReadBadge.getAriaLabel();
expect(ariaLabel).toContain('Burn-on-read message');
});
});

View file

@ -0,0 +1,281 @@
// 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 ${await 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.getLastPost();
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 ${await 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.getLastPost();
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();
await senderPage.postMessage(`No edit test ${await pw.random.id()}`);
const borPost = await senderPage.getLastPost();
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 ${await 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.getLastPost();
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 ${await 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.getLastPost();
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 ${await 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.getLastPost();
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();
await senderPage.postMessage(`Sender copy link test ${await pw.random.id()}`);
const borPost = await senderPage.getLastPost();
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 ${await 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.getLastPost();
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 ${await 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.getLastPost();
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 ${await 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.getLastPost();
await borPost.concealedPlaceholder.clickToReveal();
await borPost.concealedPlaceholder.waitForReveal();
await borPost.hover();
await borPost.postMenu.openDotMenu();
// * Verify Delete option IS present
await expect(receiverPage.postDotMenu.deleteMenuItem).toBeVisible();
await receiverPage.page.keyboard.press('Escape');
});
});

View file

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

View file

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
export const BOR_TAG = '@burn_on_read';
export type BorSetup = {
adminClient: Client4;
adminUser: UserProfile;
user: UserProfile;
userClient: Client4;
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: Client4,
team: Team,
): Promise<UserProfile & {password: string}> {
const randomUser = await pw.random.user();
const user = await adminClient.createUser(randomUser, '', '');
(user as any).password = randomUser.password;
await adminClient.addToTeam(team.id, user.id);
return user as UserProfile & {password: string};
}
/**
* Create multiple users and add to team
* @param pw Playwright extended fixture
* @param adminClient Admin client for user creation
* @param team Team to add users to
* @param count Number of users to create
* @returns Array of created users with passwords
*/
export async function createMultipleUsers(
pw: PlaywrightExtended,
adminClient: Client4,
team: Team,
count: number,
): Promise<Array<UserProfile & {password: string}>> {
const users: Array<UserProfile & {password: string}> = [];
for (let i = 0; i < count; i++) {
const user = await createSecondUser(pw, adminClient, team);
users.push(user);
}
return users;
}
/**
* Parse recipient count from tooltip text
* @param tooltipText Tooltip text containing recipient info
* @returns Object with revealed and total counts
*/
export function parseRecipientCount(tooltipText: string): {revealed: number; total: number} {
const match = tooltipText.match(/Read by (\d+) of (\d+)/);
if (!match) {
throw new Error(`Could not parse recipient count from: ${tooltipText}`);
}
return {
revealed: parseInt(match[1], 10),
total: parseInt(match[2], 10),
};
}
/**
* Parse time remaining from timer text
* @param timerText Timer text (e.g., "0:45", "1:30")
* @returns Seconds remaining
*/
export function parseTimeRemaining(timerText: string): number {
const parts = timerText.split(':');
if (parts.length !== 2) {
throw new Error(`Invalid timer format: ${timerText}`);
}
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return minutes * 60 + seconds;
}

View file

@ -0,0 +1,468 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test.describe('System Console > Self-Deleting Messages', () => {
test('admin can enable and disable self-deleting messages', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify Posts section is visible
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
await expect(postsSection).toBeVisible();
// Get BoR setting elements
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # If feature is enabled, disable it first
if (await enableToggleTrue.isChecked()) {
await enableToggleFalse.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
}
// * Verify dropdowns are disabled when feature is off
expect(await durationDropdown.isDisabled()).toBe(true);
expect(await maxTTLDropdown.isDisabled()).toBe(true);
// # Enable the feature
await enableToggleTrue.click();
// * Verify feature is enabled
expect(await enableToggleTrue.isChecked()).toBe(true);
// * Verify dropdowns are now enabled
expect(await durationDropdown.isDisabled()).toBe(false);
expect(await maxTTLDropdown.isDisabled()).toBe(false);
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back to verify persistence
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify feature is still enabled
expect(await enableToggleTrue.isChecked()).toBe(true);
});
test('admin can configure message duration', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Ensure BoR is enabled via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Select 60 seconds duration
await durationDropdown.selectOption('60');
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify duration is still 60 seconds
expect(await durationDropdown.inputValue()).toBe('60');
});
test('admin can configure maximum time to live', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Ensure BoR is enabled via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Select 1 day (86400 seconds) max TTL
await maxTTLDropdown.selectOption('86400');
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify max TTL is still 1 day
expect(await maxTTLDropdown.inputValue()).toBe('86400');
});
test('dropdowns are disabled when feature is disabled', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Disable BoR via API to start with a known state
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = false;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
// * Verify feature is disabled (from API config)
expect(await enableToggleFalse.isChecked()).toBe(true);
// * Verify dropdowns are disabled when feature is off
expect(await durationDropdown.isDisabled()).toBe(true);
expect(await maxTTLDropdown.isDisabled()).toBe(true);
// # Enable the feature (just toggle, don't save)
await enableToggleTrue.click();
// * Verify dropdowns are now enabled
expect(await durationDropdown.isDisabled()).toBe(false);
expect(await maxTTLDropdown.isDisabled()).toBe(false);
// # Toggle back to disabled
await enableToggleFalse.click();
// * Verify dropdowns are disabled again
expect(await durationDropdown.isDisabled()).toBe(true);
expect(await maxTTLDropdown.isDisabled()).toBe(true);
});
test('settings persist after page reload', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure BoR via API with specific values (using valid dropdown options)
// Duration: 300 (5 minutes), Max TTL: 259200 (3 days)
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
config.ServiceSettings.BurnOnReadDurationSeconds = 300;
config.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = 259200;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
// * Verify configured values are displayed
expect(await enableToggleTrue.isChecked()).toBe(true);
expect(await durationDropdown.inputValue()).toBe('300');
expect(await maxTTLDropdown.inputValue()).toBe('259200');
// # Reload directly to Posts section
await page.goto('/admin_console/site_config/posts');
await page.waitForLoadState('networkidle');
// * Verify values persist after reload
expect(await enableToggleTrue.isChecked()).toBe(true);
expect(await durationDropdown.inputValue()).toBe('300');
expect(await maxTTLDropdown.inputValue()).toBe('259200');
});
test('BoR toggle appears in channels when feature is enabled in System Console', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # First, disable BoR via API to start clean
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = false;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Enable BoR feature
await enableToggleTrue.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate to Channels by going to the team URL
await page.goto(`/${team.name}/channels/off-topic`);
await page.waitForLoadState('networkidle');
// * Verify BoR toggle is visible in post create area
const borButton = page.getByRole('button', {name: /Burn-on-read/i});
await expect(borButton).toBeVisible({timeout: 10000});
});
test('BoR toggle is hidden when feature is disabled in System Console', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # First, enable BoR via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Disable BoR feature
await enableToggleFalse.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate to Channels by going to the team URL
await page.goto(`/${team.name}/channels/off-topic`);
await page.waitForLoadState('networkidle');
// * Verify BoR toggle is NOT visible in post create area
const borButton = page.getByRole('button', {name: /Burn-on-read/i});
await expect(borButton).not.toBeVisible({timeout: 5000});
});
test('configured duration affects timer countdown in channels', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure BoR with 5 minute (300 seconds) duration
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
config.ServiceSettings.BurnOnReadDurationSeconds = 300; // 5 minutes
config.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = 604800; // 7 days (so max TTL doesn't interfere)
await adminClient.patchConfig(config);
// # Create a second user to receive the message
const randomUser = await pw.random.user();
const receiver = await adminClient.createUser(randomUser, '', '');
(receiver as any).password = randomUser.password;
await adminClient.addToTeam(team.id, receiver.id);
// # Create a private channel with sender and receiver
const channelName = `bor-test-${Date.now().toString(36)}`;
const channel = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: `BoR Duration Test ${channelName}`,
type: 'P',
} as any);
await adminClient.addToChannel(receiver.id, channel.id);
// # Login as admin (sender) and post BoR message
const {channelsPage: senderChannelsPage} = await pw.testBrowser.login(adminUser);
await senderChannelsPage.goto(team.name, channelName);
await senderChannelsPage.toBeVisible();
// # Toggle BoR on and post message
await senderChannelsPage.centerView.postCreate.toggleBurnOnRead();
const messageContent = `Duration test ${Date.now()}`;
await senderChannelsPage.postMessage(messageContent);
// # Login as receiver and reveal the message
const {channelsPage: receiverChannelsPage, page: receiverPage} = await pw.testBrowser.login(receiver as any);
await receiverChannelsPage.goto(team.name, channelName);
await receiverChannelsPage.toBeVisible();
// # Wait for the concealed placeholder to be visible and enabled (not loading)
const concealedPlaceholder = receiverPage.locator('.BurnOnReadConcealedPlaceholder').first();
await expect(concealedPlaceholder).toBeVisible({timeout: 10000});
// Wait for it to not be in loading state
await expect(concealedPlaceholder).not.toHaveClass(/BurnOnReadConcealedPlaceholder--loading/, {timeout: 10000});
await expect(concealedPlaceholder).toBeEnabled({timeout: 5000});
// # Click to reveal the concealed message
await concealedPlaceholder.click();
// # Confirm reveal in modal if it appears
const confirmModal = receiverPage.locator('.BurnOnReadConfirmationModal');
if (await confirmModal.isVisible({timeout: 2000}).catch(() => false)) {
const confirmButton = confirmModal.getByRole('button', {name: /reveal/i});
await confirmButton.click();
}
// * Verify timer chip shows approximately 5 minutes (between 4:10 and 5:00)
const timerChip = receiverPage.locator('.BurnOnReadTimerChip').first();
await expect(timerChip).toBeVisible({timeout: 15000});
const timerText = await timerChip.textContent();
// Timer format is "M:SS" or "MM:SS", should be close to 5:00
const match = timerText?.match(/(\d+):(\d{2})/);
expect(match).not.toBeNull();
if (match) {
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const totalSeconds = minutes * 60 + seconds;
// Should be between 4:10 (250s) and 5:00 (300s) accounting for test execution time
expect(totalSeconds).toBeGreaterThanOrEqual(250);
expect(totalSeconds).toBeLessThanOrEqual(300);
}
});
});