diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_badge.ts b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_badge.ts new file mode 100644 index 00000000000..63fe18258fc --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_badge.ts @@ -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 { + // 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 { + 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), + }; + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_concealed_placeholder.ts b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_concealed_placeholder.ts new file mode 100644 index 00000000000..7ce800492cb --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_concealed_placeholder.ts @@ -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 { + return (await this.text.textContent()) || ''; + } + + /** + * Get the aria-label of the button + */ + async getAriaLabel(): Promise { + return (await this.container.getAttribute('aria-label')) || ''; + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_confirmation_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_confirmation_modal.ts new file mode 100644 index 00000000000..413e0be4009 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_confirmation_modal.ts @@ -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 { + return (await this.title.textContent()) || ''; + } + + /** + * Get the modal message text + */ + async getMessageText(): Promise { + return (await this.message.textContent()) || ''; + } + + /** + * Check if "don't show again" checkbox is present + */ + async hasDontShowAgainOption(): Promise { + return await this.dontShowAgainCheckbox.isVisible(); + } + + /** + * Check if "don't show again" is already checked + */ + async isDontShowAgainChecked(): Promise { + return await this.dontShowAgainCheckbox.isChecked(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_timer_chip.ts b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_timer_chip.ts new file mode 100644 index 00000000000..06dcaba8da2 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/burn_on_read_timer_chip.ts @@ -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 { + return (await this.timerText.textContent()) || ''; + } + + /** + * Parse time remaining into seconds + */ + async getTimeRemainingInSeconds(): Promise { + 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 { + const className = await this.container.getAttribute('class'); + return className?.includes('BurnOnReadTimerChip--warning') || false; + } + + /** + * Check if timer has expired + */ + async isExpired(): Promise { + const className = await this.container.getAttribute('class'); + return className?.includes('BurnOnReadTimerChip--expired') || false; + } + + /** + * Get tooltip text + */ + async getTooltipText(): Promise { + 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()) || ''; + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts index 56033420dc3..609c6da8f54 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/center_view.ts @@ -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); } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts index 7c15b7befb3..c79bd544df8 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts @@ -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 { + // 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 { + return await this.concealedPlaceholder.container.isVisible(); + } + + /** + * Check if the BoR post is revealed + */ + async isRevealed(): Promise { + return !(await this.isConcealed()); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post_create.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post_create.ts index a03bd5ebcb2..f740cf7c0c4 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post_create.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post_create.ts @@ -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 { + return await this.burnOnReadLabel.isVisible(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index 383fa93c381..ca4e2a49196 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -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, diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/self_deleting_messages.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/self_deleting_messages.ts new file mode 100644 index 00000000000..9a7b11b0b03 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/self_deleting_messages.ts @@ -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 { + return await this.durationDropdown.inputValue(); + } + + async getMaxTimeToLiveValue(): Promise { + return await this.maxTimeToLiveDropdown.inputValue(); + } + + async clickSaveButton() { + await this.saveButton.click(); + } + + async isEnabled(): Promise { + return await this.enableToggleTrue.isChecked(); + } + + async isDurationDropdownDisabled(): Promise { + return await this.durationDropdown.isDisabled(); + } + + async isMaxTimeToLiveDropdownDisabled(): Promise { + return await this.maxTimeToLiveDropdown.isDisabled(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 280bbbfd843..8bc124e74ba 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -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'})); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/dm_gm_flow.spec.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/dm_gm_flow.spec.ts new file mode 100644 index 00000000000..7365213eba2 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/dm_gm_flow.spec.ts @@ -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(); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/receiver_flow.spec.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/receiver_flow.spec.ts new file mode 100644 index 00000000000..4540792655c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/receiver_flow.spec.ts @@ -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'); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/restrictions.spec.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/restrictions.spec.ts new file mode 100644 index 00000000000..c928dd7ca3a --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/restrictions.spec.ts @@ -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'); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/sender_flow.spec.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/sender_flow.spec.ts new file mode 100644 index 00000000000..10771ae3a56 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/sender_flow.spec.ts @@ -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+/); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts b/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts new file mode 100644 index 00000000000..987b7b64334 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/burn_on_read/support.ts @@ -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 { + 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 { + 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> { + const users: Array = []; + + 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; +} diff --git a/e2e-tests/playwright/specs/functional/system_console/self_deleting_messages.spec.ts b/e2e-tests/playwright/specs/functional/system_console/self_deleting_messages.spec.ts new file mode 100644 index 00000000000..c94e2771305 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/self_deleting_messages.spec.ts @@ -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); + } + }); +});