snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { Account, AccountSectionsHref } from './account';
export class AccountAccessibility extends Account {
protected readonly route = AccountSectionsHref.accessibilityAndAppearance;
protected readonly title = 'Accessibility & appearance';
}

View File

@@ -0,0 +1,7 @@
import { Account, AccountSectionsHref } from './account';
export class AccountFeaturePreview extends Account {
protected readonly route = AccountSectionsHref.featurePreview;
protected readonly title = 'Feature preview';
}

View File

@@ -0,0 +1,7 @@
import { Account, AccountSectionsHref } from './account';
export class AccountOmnichannel extends Account {
protected readonly route = AccountSectionsHref.omnichannel;
protected readonly title = 'Omnichannel';
}

View File

@@ -0,0 +1,107 @@
import type { Locator, Page } from '@playwright/test';
import { Account, AccountSectionsHref } from './account';
import { DeleteAccountModal } from './fragments';
export class AccountProfile extends Account {
protected readonly route = AccountSectionsHref.profile;
protected readonly title = 'Profile';
readonly deleteAccountModal: DeleteAccountModal;
constructor(page: Page) {
super(page);
this.deleteAccountModal = new DeleteAccountModal(page);
}
get inputName(): Locator {
return this.page.locator('//label[contains(text(), "Name")]/..//input');
}
get inputAvatarLink(): Locator {
return this.page.getByRole('textbox', { name: 'Use URL for avatar' });
}
get btnSetAvatarLink(): Locator {
return this.page.getByRole('button', { name: 'Add URL', exact: true });
}
get inputUsername(): Locator {
return this.page.locator('//label[contains(text(), "Username")]/..//input');
}
// TODO: remove this locator
get btnSubmit(): Locator {
return this.page.getByRole('button', { name: 'Save changes', exact: true });
}
get avatarFileInput(): Locator {
return this.page.locator('.avatar-file-input');
}
get userAvatarEditor(): Locator {
return this.page.getByAltText('profile picture');
}
get emailTextInput(): Locator {
return this.page.locator('//label[contains(text(), "Email")]/..//input');
}
get inputStatusText(): Locator {
return this.page.getByRole('textbox', { name: 'Status' });
}
get selectClearStatusAfter(): Locator {
return this.page.getByLabel('Clear status after');
}
async chooseClearStatusAfter(option: string): Promise<void> {
await this.selectClearStatusAfter.click();
await this.page.getByRole('option', { name: new RegExp(option) }).click();
}
get preferencesSoundAccordionOption(): Locator {
return this.page.locator('h2:has-text("Sound")');
}
get preferencesCallRingerVolumeSlider(): Locator {
return this.page.getByRole('slider', { name: 'Call Ringer Volume' });
}
get btnClose(): Locator {
return this.page.locator('role=navigation >> role=button[name=Close]');
}
get inputImageFile(): Locator {
return this.page.locator('input[type=file]');
}
getAccordionItemByName(name: string): Locator {
return this.page.getByRole('button', { name, exact: true });
}
getCheckboxByLabelText(name: string): Locator {
return this.page.locator('label', { has: this.page.getByRole('checkbox', { name }) });
}
get btnSaveChanges(): Locator {
return this.page.getByRole('button', { name: 'Save changes', exact: true });
}
get profileHeading(): Locator {
return this.page.getByRole('heading', { name: 'Profile', exact: true });
}
get btnDeleteMyAccount(): Locator {
return this.page.getByRole('button', { name: 'Delete my account' });
}
private getErrorAlertByText(text: string): Locator {
return this.page.getByRole('alert').filter({ hasText: text });
}
get errorInvalidUrl(): Locator {
return this.getErrorAlertByText('Invalid image URL');
}
}

View File

@@ -0,0 +1,103 @@
import type { Locator, Page } from '@playwright/test';
import { Account, AccountSectionsHref } from './account';
import { EnterPasswordModal } from './fragments/modals';
export class AccountSecurity extends Account {
protected readonly route = AccountSectionsHref.security;
protected readonly title = 'Security';
private readonly enterPasswordModal: EnterPasswordModal;
constructor(page: Page) {
super(page);
this.enterPasswordModal = new EnterPasswordModal(page);
}
get inputNewPassword() {
return this.page.getByRole('textbox', { name: 'New password' });
}
private get inputConfirmPassword() {
return this.page.getByRole('textbox', { name: 'Confirm password' });
}
private get btnExpandE2EESection() {
return this.page.getByRole('button', { name: 'End-to-end encryption' });
}
private get btnResetE2EEPassword() {
return this.page.getByRole('button', { name: 'Reset E2EE password' });
}
private get inputNewE2EEPassword() {
return this.page.getByRole('textbox', { name: 'New E2EE password' });
}
private get inputConfirmNewE2EEPassword() {
return this.page.getByRole('textbox', { name: 'Confirm new E2EE password' });
}
get securityHeader(): Locator {
return this.page.getByRole('main').getByRole('heading', { level: 1, name: 'Security', exact: true });
}
get securityPasswordSection(): Locator {
return this.page.locator('[role="button"]:has-text("Password")');
}
get security2FASection(): Locator {
return this.page.locator('[role="button"]:has-text("Two Factor Authentication")');
}
get securityE2EEncryptionSection(): Locator {
return this.page.locator('[role="button"]:has-text("End-to-end encryption")');
}
get securityE2EEncryptionResetKeyButton(): Locator {
return this.page.locator("role=button[name='Reset E2EE password']");
}
get securityE2EEncryptionSavePasswordButton(): Locator {
return this.page.locator("role=button[name='Save changes']");
}
get email2FASwitch(): Locator {
return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Two-factor authentication via email' }) });
}
get totp2FASwitch(): Locator {
return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Two-factor authentication via TOTP' }) });
}
get required2faModalSetUpButton(): Locator {
return this.page.locator('dialog >> button');
}
async changePassword(newPassword: string, confirmPassword: string, currentPassword: string) {
await this.inputNewPassword.fill(newPassword);
await this.inputConfirmPassword.fill(confirmPassword);
await this.saveChangesButton.click();
await this.enterPasswordModal.enterPassword(currentPassword);
}
async resetE2EEPassword() {
await this.btnExpandE2EESection.click();
await this.btnResetE2EEPassword.click();
await this.toastMessage.dismissToast('success');
// Logged out
}
async setE2EEPassword(newPassword: string) {
await this.btnExpandE2EESection.click();
await this.inputNewE2EEPassword.fill(newPassword);
await this.inputConfirmNewE2EEPassword.fill(newPassword);
await this.saveChangesButton.click();
await this.toastMessage.dismissToast('success');
}
async close() {
await this.sidebar.close();
}
}

View File

@@ -0,0 +1,49 @@
import type { Locator } from '@playwright/test';
import { Account, AccountSectionsHref } from './account';
export class AccountTokens extends Account {
protected readonly route = AccountSectionsHref.tokens;
protected readonly title = 'Personal Access Tokens';
get inputToken(): Locator {
return this.page.locator('[data-qa="PersonalTokenField"]');
}
get tokensTableEmpty(): Locator {
return this.page.locator('//h3[contains(text(), "No results found")]');
}
get btnTokensAdd(): Locator {
return this.page.locator('role=button[name="Add"]');
}
get tokenAddedModal(): Locator {
return this.page.locator('role=dialog[name="Personal Access Token successfully generated"]');
}
get btnTokenAddedOk(): Locator {
return this.tokenAddedModal.locator('role=button[name="Ok"]');
}
get tokensRows(): Locator {
return this.page.locator('table tbody tr');
}
tokenInTable(name: string): Locator {
return this.page.locator(`tr[qa-token-name="${name}"]`);
}
get btnRegenerateTokenModal(): Locator {
return this.page.locator('role=button[name="Regenerate token"]');
}
get removeTokenModal(): Locator {
return this.page.locator('role=dialog', { hasText: 'personal access token' });
}
get btnRemoveTokenModal(): Locator {
return this.removeTokenModal.getByRole('button', { name: 'Remove' });
}
}

View File

@@ -0,0 +1,33 @@
import type { Page } from 'playwright-core';
import { Account, AccountSectionsHref } from './account';
import { ConfirmLogoutModal, DevicesTable } from './fragments';
export class AccountManageDevices extends Account {
protected readonly route = AccountSectionsHref.manageDevices;
protected readonly title = 'Manage Devices';
readonly logoutModal: ConfirmLogoutModal;
readonly table: DevicesTable;
constructor(page: Page) {
super(page);
this.logoutModal = new ConfirmLogoutModal(page.getByRole('dialog', { name: 'Log out device' }));
this.table = new DevicesTable(page);
}
async getNthDeviceId(nth: number): Promise<string> {
const deviceId = await this.pageContent.getByRole('row').nth(nth).getAttribute('aria-label');
if (!deviceId) {
throw new Error(`Device ID not found for the device ${nth}`);
}
return deviceId || '';
}
async logoutDeviceById(deviceId: string) {
await this.table.getDeviceRowById(deviceId).getByRole('button', { name: 'Logout' }).click();
await this.logoutModal.confirmLogout();
}
}

View File

@@ -0,0 +1,50 @@
import type { Locator, Page } from '@playwright/test';
import { AccountSidebar, ToastMessages } from './fragments';
import { RoutedPage } from './routed-page';
export enum AccountSectionsHref {
preferences = '/account/preferences',
profile = '/account/profile',
security = '/account/security',
integrations = '/account/integrations',
tokens = '/account/tokens',
omnichannel = '/account/omnichannel',
featurePreview = '/account/feature-preview',
accessibilityAndAppearance = '/account/accessibility-and-appearance',
manageDevices = '/account/manage-devices',
}
/**
* A page under `/account/*`. Subclasses declare `route` and `title` and get `goto()`,
* `pageContent` and their ready state for free.
*/
export abstract class Account extends RoutedPage {
protected abstract override readonly route: AccountSectionsHref;
/** Text of the page's main heading. Identifies the page and scopes `pageContent`. */
protected abstract readonly title: string;
readonly toastMessage: ToastMessages;
readonly sidebar: AccountSidebar;
constructor(page: Page) {
super(page);
this.toastMessage = new ToastMessages(page);
this.sidebar = new AccountSidebar(page);
}
/** The `main` region of this page, scoped by its heading so it cannot match another route. */
get pageContent(): Locator {
return this.page.getByRole('main').filter({ has: this.page.getByRole('heading', { name: this.title, exact: true }) });
}
async waitForReady(): Promise<void> {
await this.pageContent.waitFor({ state: 'visible' });
}
protected get saveChangesButton() {
return this.page.getByRole('button', { name: 'Save changes' });
}
}

View File

@@ -0,0 +1,62 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
import { MenuOptions, DevicesTable } from './fragments';
import { DeviceInfoFlexTab } from './fragments/flextabs';
import { ConfirmLogoutModal } from './fragments/modals';
export class AdminDeviceManagement extends Admin {
readonly deviceInfo: DeviceInfoFlexTab;
readonly deviceRowMenu: MenuOptions;
readonly table: DevicesTable;
readonly logoutModal: ConfirmLogoutModal;
constructor(page: Page) {
super(page);
this.deviceInfo = new DeviceInfoFlexTab(page.getByRole('dialog', { name: 'Device Info' }));
this.deviceRowMenu = new MenuOptions(page);
this.table = new DevicesTable(page);
this.logoutModal = new ConfirmLogoutModal(page.getByRole('dialog', { name: 'Log out device' }));
}
protected readonly route = AdminSectionsHref.deviceManagement;
protected readonly title = 'Device management';
get upsellModal(): Locator {
return this.page.getByRole('dialog', { name: 'Device management' });
}
get emptyState(): Locator {
return this.pageContent.getByRole('heading', { name: 'No results found', exact: true });
}
async searchUserDevice(user: string): Promise<void> {
await this.pageContent.getByRole('textbox', { name: 'Search devices or users' }).fill(user);
}
async getUsersDeviceId(username: string): Promise<string> {
await this.searchUserDevice(username);
const deviceId = await this.pageContent
.getByRole('row')
.filter({ has: this.page.getByRole('cell', { name: username }) })
.first()
.getAttribute('aria-label');
return deviceId || '';
}
async openDeviceOptionsById(deviceId: string) {
const row = this.table.getDeviceRowById(deviceId);
await row.getByRole('button', { name: 'Options' }).click();
}
async logoutDeviceById(deviceId: string) {
await this.openDeviceOptionsById(deviceId);
await this.deviceRowMenu.getMenuItem('Log out device').click();
await this.logoutModal.confirmLogout();
}
}

View File

@@ -0,0 +1,69 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminEmailInboxes extends Admin {
constructor(page: Page) {
super(page);
}
get btnNewEmailInbox(): Locator {
return this.page.locator('role=button[name="New Email Inbox"]');
}
protected readonly route = AdminSectionsHref.emailInboxes;
protected readonly title = 'Email inboxes';
get inputName(): Locator {
return this.page.locator('input[name="name"]');
}
get inputEmail(): Locator {
return this.page.locator('input[name="email"]');
}
// SMTP
get inputSmtpServer(): Locator {
return this.page.locator('input[name="smtpServer"]');
}
get inputSmtpUsername(): Locator {
return this.page.locator('input[name="smtpUsername"]');
}
get inputSmtpPassword(): Locator {
return this.page.locator('input[name="smtpPassword"]');
}
get inputSmtpSecure(): Locator {
return this.page.locator('label >> text="Connect with SSL/TLS"').first();
}
// IMAP
get inputImapServer(): Locator {
return this.page.locator('input[name="imapServer"]');
}
get inputImapUsername(): Locator {
return this.page.locator('input[name="imapUsername"]');
}
get inputImapPassword(): Locator {
return this.page.locator('input[name="imapPassword"]');
}
get inputImapSecure(): Locator {
return this.page.locator('label >> text="Connect with SSL/TLS"').last();
}
itemRow(name: string): Locator {
return this.page.locator(`td >> text="${name}"`);
}
async deleteEmailInboxByName(name: string): Promise<void> {
await this.itemRow(name).click();
await this.btnDelete.click();
await this.deleteModal.confirmDelete();
}
}

View File

@@ -0,0 +1,39 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
import { AddEmojiFlexTab, EditEmojiFlexTab } from './fragments/flextabs';
export class AdminEmoji extends Admin {
readonly addEmojiFlexTab: AddEmojiFlexTab;
readonly editEmojiFlexTab: EditEmojiFlexTab;
protected readonly route = AdminSectionsHref.emoji;
protected readonly title = 'Emoji';
constructor(page: Page) {
super(page);
this.addEmojiFlexTab = new AddEmojiFlexTab(page);
this.editEmojiFlexTab = new EditEmojiFlexTab(page);
}
private get inputSearch(): Locator {
return this.page.getByRole('textbox', { name: 'Search' });
}
private get emojiTable() {
return this.page.getByRole('table', { name: 'Emoji' });
}
async findEmojiByName(emojiName: string) {
await this.inputSearch.fill(emojiName);
return this.emojiTable.getByRole('link', { name: emojiName });
}
async deleteEmoji(emojiName: string) {
await (await this.findEmojiByName(emojiName)).click();
await this.editEmojiFlexTab.delete();
await this.deleteModal.confirmDelete();
}
}

View File

@@ -0,0 +1,13 @@
import type { Locator } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminEngagement extends Admin {
protected readonly route = AdminSectionsHref.engagement;
protected readonly title = 'Engagement';
get upsellModal(): Locator {
return this.page.getByRole('dialog', { name: 'Engagement dashboard' });
}
}

View File

@@ -0,0 +1,38 @@
import type { Locator } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminImports extends Admin {
protected readonly route = AdminSectionsHref.import;
protected readonly title = 'Import';
override async waitForReady(): Promise<void> {
await this.btnImportNewFile.waitFor({ state: 'visible' });
}
get btnImportNewFile(): Locator {
return this.page.locator('.rcx-button--primary.rcx-button >> text="Import New File"');
}
async getOptionFileType(option: string): Promise<Locator> {
await this.page.locator('.rcx-select').click();
return this.page.locator(`.rcx-option__content >> text="${option}"`);
}
get inputFile(): Locator {
return this.page.locator('input[type=file]');
}
get btnImport(): Locator {
return this.page.locator('.rcx-button--primary.rcx-button >> text="Import"');
}
get btnStartImport(): Locator {
return this.page.locator('.rcx-button--primary.rcx-button >> text="Start Importing"');
}
get importStatusTableFirstRowCell(): Locator {
return this.page.locator('[data-qa-id="ImportTable"] tbody tr:first-child td >> text="Completed successfully"');
}
}

View File

@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminInfo extends Admin {
constructor(page: Page) {
super(page);
}
protected readonly route = AdminSectionsHref.workspace;
protected readonly title = 'Workspace';
/** Opens the workspace info page in the embedded layout. */
async gotoEmbedded(): Promise<void> {
await this.navigateTo(`${AdminSectionsHref.workspace}?layout=embedded`);
}
}

View File

@@ -0,0 +1,47 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminIntegrations extends Admin {
constructor(page: Page) {
super(page);
}
get btnInstructions(): Locator {
return this.page.getByRole('button', { name: 'Instructions', exact: true });
}
protected readonly route = AdminSectionsHref.integrations;
protected readonly title = 'Integrations';
codeExamplePayload(text: string): Locator {
return this.page.locator('code', { hasText: text });
}
get inputName(): Locator {
return this.page.getByRole('textbox', { name: 'Name' });
}
get inputPostToChannel(): Locator {
return this.page.getByRole('textbox', { name: 'Post to Channel' });
}
get inputPostAs(): Locator {
return this.page.getByRole('textbox', { name: 'Post as' });
}
getIntegrationByName(name: string): Locator {
return this.page.getByRole('table', { name: 'Integrations table' }).locator('tr', { hasText: name });
}
get inputWebhookUrl(): Locator {
return this.page.getByRole('textbox', { name: 'Webhook URL' });
}
async deleteIntegrationByName(name: string) {
await this.getIntegrationByName(name).click();
await this.btnDelete.click();
await this.deleteModal.confirmDelete();
}
}

View File

@@ -0,0 +1,7 @@
import { Admin, AdminSectionsHref } from './admin';
export class AdminMailer extends Admin {
protected readonly route = AdminSectionsHref.mailer;
protected readonly title = 'Mailer';
}

View File

@@ -0,0 +1,25 @@
import type { Locator } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminModeration extends Admin {
protected readonly route = AdminSectionsHref.moderation;
protected readonly title = 'Moderation';
get tabReportedMessages(): Locator {
return this.page.getByRole('tab', { name: 'Reported messages' });
}
get tabReportedUsers(): Locator {
return this.page.getByRole('tab', { name: 'Reported users' });
}
async gotoMessages(): Promise<void> {
await this.navigateTo(`${AdminSectionsHref.moderation}/messages`, this.tabReportedMessages);
}
async gotoUsers(): Promise<void> {
await this.navigateTo(`${AdminSectionsHref.moderation}/users`, this.tabReportedUsers);
}
}

View File

@@ -0,0 +1,42 @@
import type { Locator } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminPermissions extends Admin {
protected readonly route = AdminSectionsHref.permissions;
protected readonly title = 'Permissions';
get btnCreateRole(): Locator {
return this.page.locator('button[name="New role"]');
}
openRoleByName(name: string): Locator {
return this.page.getByRole('table').getByRole('button', { name });
}
get btnUsersInRole(): Locator {
return this.page.getByRole('dialog').getByRole('button', { name: 'Users in role', exact: true });
}
get tableUsersInRole(): Locator {
return this.page.getByRole('table');
}
get inputRoom(): Locator {
return this.page.locator('input[placeholder="Room"]');
}
get inputUsers(): Locator {
return this.page.locator('input[placeholder="Users"]');
}
getUserInRoleRowByUsername(username: string): Locator {
return this.tableUsersInRole.locator('tr', { hasText: username });
}
removeUserFromRoleByUsername = async (username: string): Promise<void> => {
await this.getUserInRoleRowByUsername(username).getByRole('button', { name: 'Remove' }).click();
await this.deleteModal.confirmDelete();
};
}

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
import { EditAdminRoomFlexTab } from './fragments/flextabs';
export class AdminRooms extends Admin {
readonly editRoom: EditAdminRoomFlexTab;
constructor(page: Page) {
super(page);
this.editRoom = new EditAdminRoomFlexTab(page.getByRole('dialog', { name: 'Room Information' }));
}
protected readonly route = AdminSectionsHref.rooms;
protected readonly title = 'Rooms';
get inputSearchRooms(): Locator {
return this.pageContent.getByPlaceholder('Search rooms');
}
getRoomRow(name?: string): Locator {
return this.pageContent.getByRole('link', { name });
}
get btnEdit(): Locator {
return this.pageContent.getByRole('button', { name: 'Edit' });
}
dropdownFilterRoomType(text = 'All rooms'): Locator {
return this.pageContent.getByRole('button', { name: text });
}
}

View File

@@ -0,0 +1,79 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
/**
* A single settings section, e.g. `/admin/settings/Omnichannel`. Its `title` is the section heading, so it gets its ready state from `Admin` like any other page.
*/
export abstract class AdminSettingsSection extends Admin {
protected abstract override readonly route: `${AdminSectionsHref.settings}/${string}`;
}
/** The settings index at `/admin/settings`. Individual sections are `AdminSettingsSection`s. */
export class AdminSettings extends Admin {
constructor(page: Page) {
super(page);
}
get inputSearchSettings(): Locator {
return this.page.locator('input[type=search]');
}
protected readonly route = AdminSectionsHref.settings;
protected readonly title = 'Settings';
override async waitForReady(): Promise<void> {
await this.inputSearchSettings.waitFor({ state: 'visible' });
}
get inputSiteURL(): Locator {
return this.page.getByRole('textbox', { name: 'Site URL' });
}
get btnResetSiteURL(): Locator {
return this.page.locator('//label[@title="Site_Url"]//following-sibling::button');
}
get btnAssetsSettings(): Locator {
return this.page.locator('[data-qa-id="Assets"] >> role=link[name="Open"]');
}
get btnDeleteAssetsLogo(): Locator {
return this.page.locator('//label[@title="Assets_logo"]/following-sibling::span >> role=button[name="Delete"]');
}
get inputAssetsLogo(): Locator {
return this.page.locator('//label[@title="Assets_logo"]/following-sibling::span >> input[type="file"]');
}
get btnFullScreen(): Locator {
return this.page.getByRole('button', { name: 'Full Screen', exact: true });
}
get btnExitFullScreen(): Locator {
return this.page.getByRole('button', { name: 'Exit Full Screen', exact: true });
}
getSectionHeading(section: string): Locator {
return this.page.getByRole('main').getByRole('heading', { level: 1, name: section, exact: true });
}
/**
* Opens a settings section, e.g. `Message` for `/admin/settings/Message`.
*
* @param section id of the section, which doubles as its heading
* @param until element to wait for, when the section heading is not a strong enough ready signal
*/
async gotoSection(section: string, until: Locator = this.getSectionHeading(section)): Promise<void> {
await this.navigateTo(`${AdminSectionsHref.settings}/${section}`, until);
}
async gotoGeneral(): Promise<void> {
await this.gotoSection('General', this.inputSiteURL);
}
async gotoLayout(): Promise<void> {
await this.gotoSection('Layout', this.getAccordionBtnByName('Custom CSS'));
}
}

View File

@@ -0,0 +1,47 @@
import type { Locator } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
export class AdminThirdPartyLogin extends Admin {
protected readonly route = AdminSectionsHref.thirdPartyLogin;
protected readonly title = 'Third-party login';
get btnNewApplication(): Locator {
return this.page.getByRole('button', { name: 'New Application', exact: true });
}
get inputRedirectURI(): Locator {
return this.page.getByRole('textbox', { name: 'Redirect URI' });
}
get inputApplicationName(): Locator {
return this.page.getByRole('textbox', { name: 'Application Name' });
}
get inputClientId(): Locator {
return this.page.getByRole('textbox', { name: 'Client ID' });
}
get inputClientSecret(): Locator {
return this.page.getByRole('textbox', { name: 'Client Secret' });
}
get inputAuthUrl(): Locator {
return this.page.getByRole('textbox', { name: 'Authorization URL' });
}
get inputTokenUrl(): Locator {
return this.page.getByRole('textbox', { name: 'Access Token URL' });
}
getThirdPartyAppByName(name: string): Locator {
return this.page.getByRole('table', { name: 'Third-party applications table' }).locator('tr', { hasText: name });
}
async deleteThirdPartyAppByName(name: string) {
await this.getThirdPartyAppByName(name).click();
await this.btnDelete.click();
await this.deleteModal.confirmDelete();
}
}

View File

@@ -0,0 +1,79 @@
import type { Locator, Page } from '@playwright/test';
import { Admin, AdminSectionsHref } from './admin';
import { MenuMoreActions, UserInfoFlexTab, EditUserFlexTab } from './fragments';
import { expect } from '../utils/test';
type UserActions = 'Make Admin' | 'Remove Admin' | 'Activate' | 'Deactivate';
export class AdminUsers extends Admin {
readonly editUser: EditUserFlexTab;
readonly userInfo: UserInfoFlexTab;
readonly userRowMenu: MenuMoreActions;
constructor(page: Page) {
super(page);
this.editUser = new EditUserFlexTab(page);
this.userInfo = new UserInfoFlexTab(page);
this.userRowMenu = new MenuMoreActions(page);
}
protected readonly route = AdminSectionsHref.users;
protected readonly title = 'Users';
override async waitForReady(): Promise<void> {
await this.inputSearchUsers.waitFor({ state: 'visible' });
}
get btnNewUser(): Locator {
return this.page.getByRole('button', { name: 'New user', exact: true });
}
get btnInvite(): Locator {
return this.page.getByRole('button', { name: 'Invite', exact: true });
}
private get inputSearchUsers(): Locator {
return this.page.getByRole('textbox', { name: 'Search Users' });
}
get btnMoreActionsMenu(): Locator {
return this.page.getByRole('button', { name: 'More actions' });
}
getUserRowByUsername(username: string): Locator {
return this.page.locator('tr', { hasText: username }).first();
}
getTabByName(name: 'All' | 'Pending' | 'Active' | 'Deactivated' = 'All'): Locator {
return this.page.getByRole('tab', { name });
}
private async openUserActionMenu(username: string): Promise<void> {
await this.getUserRowByUsername(username).getByRole('button', { name: 'More actions' }).click();
}
async dispatchUserAction(username: string, action: UserActions) {
await this.openUserActionMenu(username);
await this.userRowMenu.root.getByRole('menuitem', { name: action }).click();
}
async activatePendingUser(username: string): Promise<void> {
await this.getUserRowByUsername(username).getByRole('button', { name: 'Activate', exact: true }).click();
}
async deleteUser(username: string): Promise<void> {
await this.searchUser(username);
await this.getUserRowByUsername(username).click();
await this.userInfo.btnMoreActions.click();
await this.userInfo.menuItemDeleteUser.click();
}
async searchUser(username: string): Promise<void> {
await this.inputSearchUsers.fill(username);
await expect(this.getUserRowByUsername(username)).toHaveCount(1);
}
}

View File

@@ -0,0 +1,101 @@
import type { Locator, Page } from '@playwright/test';
import { AdminSidebar, ToastMessages } from './fragments';
import { ConfirmDeleteModal } from './fragments/modals';
import { RoutedPage } from './routed-page';
export enum AdminSectionsHref {
workspace = '/admin/info',
subscription = '/admin/subscription',
engagement = '/admin/engagement/users',
moderation = '/admin/moderation',
rooms = '/admin/rooms',
users = '/admin/users',
invites = '/admin/invites',
userStatus = '/admin/user-status',
permissions = '/admin/permissions',
deviceManagement = '/admin/device-management',
emailInboxes = '/admin/email-inboxes',
mailer = '/admin/mailer',
thirdPartyLogin = '/admin/third-party-login',
integrations = '/admin/integrations',
import = '/admin/import',
reports = '/admin/analytic-reports',
sounds = '/admin/sounds',
emoji = '/admin/emoji',
settings = '/admin/settings',
}
/**
* A page under `/admin/*`. Subclasses declare `route` and `title` and get `goto()`, `pageContent`
* and their ready state for free; override `waitForReady()` when the heading is not a strong enough
* signal that the page is usable.
*/
export abstract class Admin extends RoutedPage {
protected abstract override readonly route: AdminSectionsHref | `${AdminSectionsHref}/${string}`;
/** Text of the page's main heading. Identifies the page and scopes `pageContent`. */
protected abstract readonly title: string;
readonly sidebar: AdminSidebar;
readonly deleteModal: ConfirmDeleteModal;
readonly toastMessage: ToastMessages;
constructor(page: Page) {
super(page);
this.sidebar = new AdminSidebar(page);
this.deleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' }));
this.toastMessage = new ToastMessages(page);
}
/** The `main` region of this page, scoped by its heading so it cannot match another route. */
get pageContent(): Locator {
return this.page.getByRole('main').filter({ has: this.page.getByRole('heading', { name: this.title, exact: true }) });
}
async waitForReady(): Promise<void> {
await this.pageContent.waitFor({ state: 'visible' });
}
get btnAdd(): Locator {
return this.page.getByRole('button', { name: 'Add', exact: true });
}
get btnBack(): Locator {
return this.page.getByRole('button', { name: 'Back', exact: true });
}
get btnSave(): Locator {
return this.page.getByRole('button', { name: 'Save', exact: true });
}
get btnNew(): Locator {
return this.page.getByRole('button', { name: 'New', exact: true });
}
get btnSaveChanges(): Locator {
return this.page.getByRole('button', { name: 'Save changes', exact: true });
}
get btnDelete(): Locator {
return this.page.getByRole('button', { name: 'Delete', exact: true });
}
getAccordionBtnByName(name: string): Locator {
return this.page.getByRole('button', { name, exact: true });
}
async adminSectionButton(href: AdminSectionsHref): Promise<Locator> {
return this.page.locator(`a[href="${href}"]`);
}
findFileRowByUsername(username: string) {
return this.page.locator('tr', { has: this.page.getByRole('cell', { name: username }) });
}
findFileCheckboxByUsername(username: string) {
return this.findFileRowByUsername(username).locator('label', { has: this.page.getByRole('checkbox') });
}
}

View File

@@ -0,0 +1,194 @@
import type { FrameLocator, Locator, Page } from '@playwright/test';
import { expect } from '../utils/test';
abstract class Main {
protected abstract readonly page: Page;
constructor(protected root: Locator) {}
/**
* Navigates to `url` and waits for this screen to render.
*
* Auth screens are what an unauthenticated visitor is bounced to, so the url a test navigates to
* is usually not the one it lands on — hence the parameter. Pass `until` when the route settles
* on something else, e.g. a login iframe replacing the login form.
*/
async goto(url = '/home', until?: Locator): Promise<void> {
await this.page.goto(url);
if (until) {
await expect(until).toBeVisible();
return;
}
await this.waitForDisplay();
}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
}
export class Authenticated extends Main {
constructor(protected page: Page) {
super(page.locator('#main-content'));
}
}
class LoginForm {
readonly root: Locator;
constructor(protected page: Page) {
this.root = page?.getByRole('form', { name: 'Login' });
}
get username(): Locator {
return this.root.locator('role=textbox[name=/username/i]');
}
get inputPassword(): Locator {
return this.root.getByRole('textbox', { name: 'Password', exact: true });
}
get btnLogin(): Locator {
return this.root.getByRole('button', { name: 'Login', exact: true });
}
async fillForm(username: string, password: string) {
await this.username.fill(username);
await this.inputPassword.fill(password);
}
}
export class Login extends Main {
readonly form: LoginForm;
constructor(protected page: Page) {
super(page.getByRole('main', { name: 'Login', exact: true }));
this.form = new LoginForm(page);
}
async login(username: string, password: string) {
await this.form.fillForm(username, password);
await this.form.btnLogin.click();
}
get loginIframe(): FrameLocator {
return this.page.frameLocator('iframe[title="Login"]');
}
get loginIframeForm(): Locator {
return this.loginIframe.locator('#login-form');
}
get loginIframeSubmitButton(): Locator {
return this.loginIframe.locator('#submit');
}
get loginIframeError(): Locator {
return this.loginIframe.locator('#login-error', { hasText: 'Login failed' });
}
}
export class Registration extends Main {
constructor(protected page: Page) {
super(page.getByRole('main'));
}
get btnSendInstructions(): Locator {
return this.page.locator('role=button[name="Send instructions"]');
}
get btnReset(): Locator {
return this.page.locator('role=button[name="Reset"]');
}
get btnLoginWithSaml(): Locator {
return this.page.locator('role=button[name="SAML test login button"]');
}
get btnLoginWithGoogle(): Locator {
return this.page.locator('role=button[name="Sign in with Google"]');
}
get btnLoginWithCustomOAuth(): Locator {
return this.page.locator('role=button[name="Sign in with Test"]');
}
get goToRegister(): Locator {
return this.page.locator('role=link[name="Create an account"]');
}
get btnRegister(): Locator {
return this.page.locator('role=button[name="Join your team"]');
}
get btnRegisterConfirmUsername(): Locator {
return this.page.locator('role=button[name="Use this username"]');
}
get btnForgotPassword(): Locator {
return this.page.locator('role=link[name="Forgot your password?"]');
}
get username(): Locator {
return this.page.locator('role=textbox[name=/username/i]');
}
get inputName(): Locator {
return this.page.locator('[name=name]');
}
get inputEmail(): Locator {
return this.page.locator('role=textbox[name=/Email/]');
}
get inputPassword(): Locator {
return this.page.locator('[name=password]');
}
get inputReason(): Locator {
return this.page.locator('role=textbox[name=/Reason/]');
}
get inputPasswordConfirm(): Locator {
return this.page.locator('[name=passwordConfirmation]');
}
get forgotPasswordEmailCallout(): Locator {
return this.page.locator('role=status');
}
get registrationDisabledCallout(): Locator {
return this.page.getByRole('status').filter({ hasText: 'New user registration is currently disabled' });
}
/** Opens an invite url, e.g. `/invite/<id>`. */
async gotoInvite(inviteId: string): Promise<void> {
await this.goto(`/invite/${inviteId}`);
}
/**
* Opens the secret registration url, e.g. `/register/<secret>`.
*
* Depending on `Accounts_RegistrationForm` the page settles on the register form or on an
* invalid-secret callout, and neither renders a `main` landmark - so the caller says which it expects.
*/
async gotoWithSecret(secret: string, until: Locator): Promise<void> {
await this.goto(`/register/${secret}`, until);
}
/** Opens the password reset url for a token. */
async gotoResetPassword(token: string): Promise<void> {
await this.goto(`/reset-password/${token}`, this.inputPassword);
}
get registrationInvalidUrlCallout(): Locator {
return this.page.getByRole('status').filter({ hasText: 'The URL provided is invalid' });
}
}

View File

@@ -0,0 +1,28 @@
import type { Locator } from '@playwright/test';
import { RoutedPage } from './routed-page';
export class Directory extends RoutedPage {
protected readonly route = '/directory';
async waitForReady(): Promise<void> {
await this.directoryHeader.waitFor({ state: 'visible' });
}
async searchChannel(name: string) {
await this.page.getByRole('textbox', { name: 'Search' }).fill(name);
}
getSearchByChannelName(name: string) {
return this.page.locator(`role=table >> role=link >> text="${name}"`);
}
async openChannel(name: string) {
await this.searchChannel(name);
await this.getSearchByChannelName(name).click();
}
private get directoryHeader(): Locator {
return this.page.locator('main').getByRole('heading', { name: 'Directory', exact: true });
}
}

View File

@@ -0,0 +1,51 @@
import type { Page } from '@playwright/test';
import { EncryptedRoomToolbar, HomeContent } from './fragments';
import { Message } from './fragments/message';
import { DisableRoomEncryptionModal, EnableRoomEncryptionModal } from './fragments/modals';
export class EncryptedRoomPage extends HomeContent {
readonly toolbar: EncryptedRoomToolbar;
constructor(page: Page) {
super(page);
this.toolbar = new EncryptedRoomToolbar(page);
}
get encryptedTitle() {
return this.page.getByRole('button', { name: '- encrypted' });
}
get encryptionNotReadyIndicator() {
return this.page.getByText("You're sending an unencrypted message");
}
get lastMessage() {
return new Message(this.lastUserMessage);
}
lastNthMessage(index: number) {
return new Message(this.nthMessage(-index - 1));
}
async enableEncryption() {
const enableRoomEncryptionModal = new EnableRoomEncryptionModal(this.page);
await this.toolbar.openMoreOptions();
await this.toolbar.menuItemEnableE2EEncryption.click();
await enableRoomEncryptionModal.enable();
}
async disableEncryption() {
const disableRoomEncryptionModal = new DisableRoomEncryptionModal(this.page);
await this.toolbar.openMoreOptions();
await this.toolbar.menuItemDisableE2EEncryption.click();
await disableRoomEncryptionModal.disable();
}
async showExportMessagesTab() {
await this.toolbar.openMoreOptions();
await this.toolbar.menuItemExportMessages.click();
}
}

View File

@@ -0,0 +1,137 @@
import type { Locator, Page } from 'playwright-core';
import { Popup } from './popup';
class VideoRecorderPopup extends Popup {
constructor(root: Locator) {
super(root);
}
get btnRecord(): Locator {
return this.root.getByRole('button', { name: 'Record' });
}
get btnStopRecording(): Locator {
return this.root.getByRole('button', { name: 'Stop Recording' });
}
get btnSend(): Locator {
return this.root.getByRole('button', { name: 'Send' });
}
async record() {
await this.btnRecord.click();
await this.btnStopRecording.waitFor({ state: 'visible' }).then(() => this.btnStopRecording.click());
await this.btnSend.click();
await this.waitForDismissal();
}
}
export abstract class Composer {
readonly videoRecorderPopup: VideoRecorderPopup;
constructor(protected root: Locator) {
this.root = root;
this.videoRecorderPopup = new VideoRecorderPopup(this.root.page().getByRole('dialog', { name: 'Video record' }));
}
get inputMessage(): Locator {
return this.root.locator('[name="msg"]');
}
get btnJoinRoom(): Locator {
return this.root.getByRole('button', { name: 'Join' });
}
get toolbarPrimaryActions(): Locator {
return this.root.getByRole('toolbar', { name: 'Composer Primary Actions' });
}
get allPrimaryActions(): Locator {
return this.toolbarPrimaryActions.getByRole('button');
}
get btnEmoji(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Emoji', exact: true });
}
get btnAudioMessage(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Audio message' });
}
getFileByName(fileName: string): Locator {
return this.root.getByRole('group', { name: fileName, exact: true });
}
get groupUploads() {
return this.root.getByRole('group', { name: 'Uploads', exact: true });
}
getFilesInComposer(): Locator {
return this.groupUploads.getByRole('group', { name: /^(?!Close$)/ });
}
async removeFileByName(fileName: string): Promise<void> {
return this.getFileByName(fileName).getByRole('button', { name: 'Remove', exact: true }).click();
}
get btnSend(): Locator {
return this.root.getByRole('button', { name: 'Send' });
}
get btnCancel(): Locator {
return this.root.getByRole('button', { name: 'Cancel', exact: true });
}
get btnOptionFileUpload(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Upload file' });
}
get btnVideoMessage(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Video message' });
}
get btnMenuMoreActions() {
return this.toolbarPrimaryActions.getByRole('button', { name: 'More actions' });
}
get btnItalicFormatter(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Italic', exact: true });
}
get btnBoldFormatter(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Bold', exact: true });
}
get btnLinkFormatter(): Locator {
return this.toolbarPrimaryActions.getByRole('button', { name: 'Link', exact: true });
}
get boxPopup(): Locator {
return this.root.locator('[role="menu"][name="ComposerBoxPopup"]');
}
get readOnlyFooter(): Locator {
return this.root.getByText('This room is read only');
}
get typingIndicator(): Locator {
return this.root.getByRole('status').getByText(/typing/i);
}
get addLinkModal(): Locator {
return this.root.page().getByRole('dialog', { name: 'Add link' });
}
}
export class RoomComposer extends Composer {
constructor(page: Page) {
super(page.locator('footer[aria-label="Room composer"]'));
}
}
export class ThreadComposer extends Composer {
constructor(page: Page) {
super(page.locator('footer[aria-label="Thread composer"]'));
}
}

View File

@@ -0,0 +1,60 @@
import type { Locator, Page } from '@playwright/test';
import { Navbar } from './navbar';
import { expect } from '../../utils/test';
abstract class E2EEBanner {
constructor(protected root: Locator) {}
click() {
return this.root.click();
}
async waitForDisappearance() {
await expect(this.root).not.toBeVisible();
}
}
export class SaveE2EEPasswordBanner extends E2EEBanner {
constructor(page: Page) {
super(page.getByRole('button', { name: 'Save your new E2EE password' }));
}
}
export class EnterE2EEPasswordBanner extends E2EEBanner {
constructor(page: Page) {
// TODO: there is a typo in the default translation
super(page.getByRole('button', { name: 'Enter your E2E password' }));
}
}
export class E2EEKeyDecodeFailureBanner extends E2EEBanner {
constructor(page: Page) {
super(page.getByRole('button', { name: "Wasn't possible to decode your encryption key to be imported." }));
}
async expectToNotBeVisible() {
await expect(this.root).not.toBeVisible();
}
}
export class CreateE2EEChannel {
private readonly navbar: Navbar;
constructor(page: Page) {
this.navbar = new Navbar(page);
}
async create(name: string): Promise<void> {
await this.navbar.createEncryptedChannel(name);
}
async createAndStore(name: string, createdChannels: string[]): Promise<void> {
await this.create(name);
this.store(name, createdChannels);
}
store(name: string, createdChannels: string[]): void {
createdChannels.push(name);
}
}

View File

@@ -0,0 +1,49 @@
import type { Locator } from '@playwright/test';
import { FlexTab } from './flextab';
export class EditAdminRoomFlexTab extends FlexTab {
constructor(locator: Locator) {
super(locator);
}
get roomNameInput(): Locator {
return this.root.locator('input[name="roomName"]');
}
get privateLabel(): Locator {
return this.root.locator(`label >> text=Private`);
}
get privateInput(): Locator {
return this.root.locator('input[name="roomType"]');
}
get roomOwnerInput(): Locator {
return this.root.locator('input[name="roomOwner"]');
}
get archivedLabel(): Locator {
return this.root.locator('label >> text=Archived');
}
get archivedInput(): Locator {
return this.root.locator('input[name="archived"]');
}
get favoriteLabel(): Locator {
return this.root.locator('label >> text=Favorite');
}
get favoriteInput(): Locator {
return this.root.locator('input[name="favorite"]');
}
get defaultLabel(): Locator {
return this.root.locator('label >> text=Default');
}
get defaultInput(): Locator {
return this.root.locator('input[name="isDefault"]');
}
}

View File

@@ -0,0 +1,19 @@
import type { Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class AddEmojiFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Add New Emoji' }));
}
}
export class EditEmojiFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Custom Emoji Info' }));
}
async delete() {
await this.btnDelete.click();
}
}

View File

@@ -0,0 +1,28 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class AutoTranslateFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Auto-Translate' }));
}
get checkboxAutomaticTranslation(): Locator {
return this.root.getByRole('checkbox', { name: 'Automatic Translation' });
}
get textAutomaticTranslation(): Locator {
return this.root.getByText('Automatic Translation', { exact: true });
}
get textEncryptedRoomCallout(): Locator {
return this.root.getByText('Automatic translation not available');
}
async setAutomaticTranslation(enabled: boolean): Promise<void> {
if ((await this.checkboxAutomaticTranslation.isChecked()) === enabled) {
return;
}
await this.textAutomaticTranslation.click();
}
}

View File

@@ -0,0 +1,83 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { Listbox } from '../listbox';
import { ConfirmDeleteRoomModal, ConfirmRemoveModal } from '../modals';
import { Modal } from '../modals/modal';
class AddExistingChannelModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Add Existing Channels' }));
}
get inputChannels(): Locator {
return this.root.getByRole('textbox');
}
get btnAdd(): Locator {
return this.root.getByRole('button', { name: 'Add', exact: true });
}
async confirmAdd() {
await this.btnAdd.click();
await this.waitForDismissal();
}
}
export class ChannelsFlexTab extends FlexTab {
readonly confirmRemoveModal: ConfirmRemoveModal;
readonly confirmDeleteModal: ConfirmDeleteRoomModal;
readonly addExistingChannelModal: AddExistingChannelModal;
readonly listbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Team Channels' }));
this.confirmRemoveModal = new ConfirmRemoveModal(page.getByRole('dialog', { name: 'Are you sure?' }));
this.confirmDeleteModal = new ConfirmDeleteRoomModal(page);
this.addExistingChannelModal = new AddExistingChannelModal(page);
this.listbox = new Listbox(page);
}
get btnAddExisting(): Locator {
return this.root.getByRole('button', { name: 'Add Existing', exact: true });
}
get btnCreateNew(): Locator {
return this.root.getByRole('button', { name: 'Create new', exact: true });
}
get channelsList(): Locator {
return this.root.getByRole('list');
}
getListboxOption(name: string): Locator {
return this.listbox.getOption(name);
}
channelOption(name: string) {
return this.root.locator('li', { hasText: name });
}
async openChannelOptionMoreActions(name: string) {
await this.channelOption(name).hover();
await this.channelOption(name).getByRole('button', { name: 'More' }).click();
}
async confirmRemoveChannel() {
return this.confirmRemoveModal.confirmRemove();
}
async confirmDeleteRoom() {
return this.confirmDeleteModal.confirmDelete();
}
async addExistingChannel(name: string) {
await this.btnAddExisting.click();
await this.addExistingChannelModal.inputChannels.fill(name);
await this.listbox.selectOption(name);
await this.addExistingChannelModal.confirmAdd();
}
}

View File

@@ -0,0 +1,17 @@
import type { Locator } from '@playwright/test';
import { FlexTab } from './flextab';
export class DeviceInfoFlexTab extends FlexTab {
constructor(locator: Locator) {
super(locator);
}
get btnLogoutDevice(): Locator {
return this.root.getByRole('button', { name: 'Log out device' });
}
getDeviceInfoId(deviceId: string): Locator {
return this.root.getByText(deviceId, { exact: true });
}
}

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class OmnichannelEditContactFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'contact' }));
}
get inputEmail(): Locator {
return this.root.locator('input[name="emails.0.address"]');
}
get inputPhone(): Locator {
return this.root.locator('input[name="phones.0.phoneNumber"]');
}
get inputContactManager(): Locator {
return this.root.locator('input[name=contactManager]');
}
get btnAddEmail(): Locator {
return this.root.locator('role=button[name="Add email"]');
}
get btnAddPhone(): Locator {
return this.root.locator('role=button[name="Add phone"]');
}
getErrorMessage(message: string): Locator {
return this.root.locator(`role=alert >> text="${message}"`);
}
}

View File

@@ -0,0 +1,114 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { Listbox } from '../listbox';
export class EditRoomFlexTab extends FlexTab {
constructor(locator: Locator) {
super(locator);
}
get inputTopic(): Locator {
return this.root.getByRole('textbox', { name: 'Topic' });
}
get inputAnnouncement(): Locator {
return this.root.getByRole('textbox', { name: 'Announcement' });
}
get inputDescription(): Locator {
return this.root.getByRole('textbox', { name: 'Description' });
}
get checkboxReadOnly(): Locator {
return this.root.locator('label', { hasText: 'Read-only' });
}
get calloutRetentionPolicy(): Locator {
return this.root.getByRole('alert', { name: 'Retention policy warning callout' });
}
get advancedSettingsAccordion(): Locator {
return this.root.getByRole('button', { name: 'Advanced settings' });
}
get pruneAccordion(): Locator {
return this.root.getByRole('button', { name: 'Prune', exact: true });
}
getMaxAgeLabel(maxAge = '30') {
return this.root.getByText(`Maximum message age in days (default: ${maxAge})`);
}
get inputRetentionMaxAge(): Locator {
return this.root.locator('input[name="retentionMaxAge"]');
}
get checkboxPruneMessages(): Locator {
return this.root.locator('label', { hasText: 'Automatically prune old messages' });
}
get checkboxOverrideGlobalRetention(): Locator {
return this.root.locator('label', { hasText: 'Override global retention policy' });
}
get checkboxIgnoreThreads(): Locator {
return this.root.locator('label', { hasText: 'Do not prune Threads' });
}
get checkboxChannels(): Locator {
return this.root.locator('label', { hasText: 'Channels' });
}
get checkboxDiscussions(): Locator {
return this.root.locator('label', { hasText: 'Discussions' });
}
async toggleSidepanelItems() {
await this.checkboxChannels.click();
await this.checkboxDiscussions.click();
}
}
export class EditTeamFlexTab extends EditRoomFlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Edit team' }));
}
}
export class OmnichannelEditRoomFlexTab extends EditRoomFlexTab {
private readonly tagsListbox: Listbox;
private readonly slaListbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Edit Room' }));
this.tagsListbox = new Listbox(page);
this.slaListbox = new Listbox(page, 'SLA Policy');
}
get inputSLAPolicy(): Locator {
return this.root.getByRole('button', { name: 'SLA Policy', exact: true });
}
optionTag(name: string): Locator {
return this.tagsListbox.getOption(name);
}
async selectTag(name: string) {
await this.tagsListbox.selectOption(name);
}
async selectSLA(name: string) {
await this.inputSLAPolicy.click();
await this.slaListbox.selectOption(name, true);
}
get inputTags(): Locator {
return this.root.getByRole('textbox', { name: 'Select an option' });
}
getRoomCustomField(label: string): Locator {
return this.root.getByRole('group', { name: 'Custom Fields' }).getByLabel(label);
}
}

View File

@@ -0,0 +1,53 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class EditUserFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog'));
}
get btnAddUser(): Locator {
return this.root.locator('role=button[name="Add user"]');
}
get btnSaveUser(): Locator {
return this.root.locator('role=button[name="Save user"]');
}
get inputUserName(): Locator {
return this.root.getByRole('textbox', { name: 'Username', exact: true });
}
get inputEmail(): Locator {
return this.root.getByRole('textbox', { name: 'Email', exact: true });
}
get inputSetManually(): Locator {
return this.root.locator('//label[text()="Set manually"]');
}
get inputPassword(): Locator {
return this.root.getByPlaceholder('Password', { exact: true });
}
get inputConfirmPassword(): Locator {
return this.root.getByPlaceholder('Confirm password', { exact: true });
}
get joinDefaultChannels(): Locator {
return this.root.locator('//label[text()="Join default channels"]');
}
get chipUserRole(): Locator {
return this.root.getByRole('button', { name: 'user', exact: true });
}
get setupSmtpLink(): Locator {
return this.root.getByRole('link', { name: 'Set up SMTP' });
}
getCustomField(fieldName: string): Locator {
return this.root.getByRole('textbox', { name: fieldName, exact: true });
}
}

View File

@@ -0,0 +1,87 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class ExportMessagesFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Export Messages' }));
}
async exposeMethods() {
await this.method.click();
}
async setMethod(optionName: string) {
await this.exposeMethods();
await this.root.page().getByRole('option', { name: optionName }).click();
}
async exposeOutputFormats() {
await this.outputFormat.click();
}
async setOutputFormat(optionName: string) {
await this.exposeOutputFormats();
await this.root.page().getByRole('option', { name: optionName }).click();
}
getMethodOptionByName(name: string) {
return this.root.page().getByRole('option', { name });
}
getOutputFormatOptionByName(name: string) {
return this.root.page().getByRole('option', { name });
}
async selectAllMessages() {
await this.root
.page()
.getByRole('button', { name: /Select \d+ messages/ })
.click();
}
async downloadMessages() {
const [download] = await Promise.all([this.root.page().waitForEvent('download'), this.downloadButton.click()]);
return download;
}
async send() {
await this.sendButton.click();
}
async setAdditionalEmail(email: string) {
await this.inputAdditionalEmails.fill(email);
}
getMessageCheckbox(messageText: string): Locator {
return this.root.page().getByRole('listitem').filter({ hasText: messageText }).getByRole('checkbox');
}
get inputUsers() {
return this.root.getByRole('combobox', { name: 'To users' });
}
get inputAdditionalEmails() {
return this.root.getByRole('textbox', { name: 'To additional emails' });
}
get method() {
return this.root.getByTestId('export-messages-method');
}
get outputFormat() {
return this.root.page().getByTestId('export-messages-output-format');
}
get downloadButton() {
return this.root.getByRole('button', { name: 'Download', exact: true });
}
get sendButton() {
return this.root.getByRole('button', { name: 'Send', exact: true });
}
get clearSelectionButton() {
return this.root.page().getByRole('button', { name: 'Clear selection' });
}
}

View File

@@ -0,0 +1,31 @@
import type { Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { MenuMore } from '../menu';
import { ConfirmDeleteModal } from '../modals';
export class FilesFlexTab extends FlexTab {
readonly menu: MenuMore;
readonly confirmDeleteModal: ConfirmDeleteModal;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Files' }));
this.menu = new MenuMore(page);
this.confirmDeleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' }));
}
get fileList() {
return this.root.getByRole('list', { name: 'Files list' });
}
getFileByName(name: string) {
return this.fileList.getByRole('listitem').filter({ hasText: name });
}
async deleteFile(name: string) {
await this.getFileByName(name).getByRole('button', { name: 'More' }).click();
await this.menu.selectMenuItem('Delete');
await this.confirmDeleteModal.confirmDelete();
}
}

View File

@@ -0,0 +1,56 @@
import type { Locator } from '@playwright/test';
import { expect } from '../../../utils/test';
export abstract class FlexTab {
/**
* @param root should be protected, but for now there are a lot of tests relying on accessing it directly, * so we need to keep it public until we can refactor those tests
*/
constructor(public root: Locator) {}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
private get btnClose() {
return this.root.getByRole('button', { name: 'Close', exact: true });
}
get inputName() {
return this.root.getByRole('textbox', { name: 'Name', exact: true });
}
get btnSave() {
return this.root.getByRole('button', { name: 'Save', exact: true });
}
get btnCancel() {
return this.root.getByRole('button', { name: 'Cancel', exact: true });
}
get btnDelete() {
return this.root.getByRole('button', { name: 'Delete', exact: true });
}
get btnReset() {
return this.root.getByRole('button', { name: 'Reset', exact: true });
}
errorMessage(message: string): Locator {
return this.root.locator('[role="alert"]', { hasText: message });
}
async close() {
await this.btnClose.click();
await this.waitForDismissal();
}
async save() {
await this.btnSave.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,17 @@
export * from './auto-translate-flextab';
export * from './admin-flextab-emoji';
export * from './admin-edit-room-flextab';
export * from './channels-flextab';
export * from './device-info-flextab';
export * from './edit-room-flextab';
export * from './edit-user-flextab';
export * from './edit-contact-flextab';
export * from './export-messages-flextab';
export * from './files-flextab';
export * from './members-flextab';
export * from './notification-preferences-flextab';
export * from './prune-messages-flextab';
export * from './room-info-flextab';
export * from './threads-flextab';
export * from './search-messages-flextab';
export * from './user-info-flextab';

View File

@@ -0,0 +1,179 @@
import type { Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { Listbox } from '../listbox';
import { MenuMore } from '../menu';
import { ConfirmRemoveModal } from '../modals';
import { UserInfoFlexTab } from './user-info-flextab';
import { Modal } from '../modals/modal';
export class ConfirmMuteModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Are you sure' }));
}
private get btnMute() {
return this.root.getByRole('button', { name: 'Yes, mute user!' });
}
async confirmMute() {
await this.btnMute.click();
await this.waitForDismissal();
}
}
export class RemoveTeamMemberModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Removing Member' }));
}
get description() {
return this.root.getByText('Select the channels you want the user to be removed from');
}
channel(channelName: string) {
return this.root.getByText(channelName);
}
channelCheckbox(channelName: string) {
return this.root.getByRole('row', { name: channelName }).getByRole('checkbox');
}
async selectChannel(channelName: string) {
await this.channel(channelName).click();
}
private get btnContinue() {
return this.root.getByRole('button', { name: 'Continue' });
}
async continue() {
await this.btnContinue.click();
await this.waitForDismissal();
}
}
class AddUsersFlexTab extends FlexTab {
readonly listbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Add users' }));
this.listbox = new Listbox(page);
}
private btnAddUsers() {
return this.root.getByRole('button', { name: 'Add users' });
}
async addUser(username: string) {
await this.root.getByRole('combobox', { name: 'Choose users' }).pressSequentially(username);
await this.listbox.selectOption(username);
await this.btnAddUsers().click();
await this.waitForDismissal();
}
}
export class MembersFlexTab extends FlexTab {
readonly listbox: Listbox;
readonly removeModal: ConfirmRemoveModal;
readonly removeTeamMemberModal: RemoveTeamMemberModal;
readonly menu: MenuMore;
readonly confirmMuteModal: ConfirmMuteModal;
readonly addUsersFlexTab: AddUsersFlexTab;
readonly userInfo: UserInfoFlexTab;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Members' }));
this.removeModal = new ConfirmRemoveModal(page.getByRole('dialog', { name: 'Confirmation', exact: true }));
this.removeTeamMemberModal = new RemoveTeamMemberModal(page);
this.listbox = new Listbox(page);
this.menu = new MenuMore(page);
this.confirmMuteModal = new ConfirmMuteModal(page);
this.addUsersFlexTab = new AddUsersFlexTab(page);
this.userInfo = new UserInfoFlexTab(page);
}
memberOption(username: string) {
return this.root.locator('li', { hasText: username });
}
getMenuItemAction(action: string) {
return this.menu.getMenuItem(action);
}
async openMemberInfo(username: string) {
await this.memberOption(username).click();
}
async openMoreActions() {
await this.root.getByRole('button', { name: 'More' }).click();
}
async openMemberOptionMoreActions(username: string) {
await this.memberOption(username).hover();
await this.memberOption(username).getByRole('button', { name: 'More' }).click();
}
async addUser(username: string) {
await this.root.getByRole('button', { name: 'Add' }).click();
await this.addUsersFlexTab.addUser(username);
}
private get btnInviteLink() {
return this.root.getByRole('button', { name: 'Invite Link' });
}
async inviteUser() {
await this.btnInviteLink.click();
}
async muteUser(username: string) {
await this.openMemberOptionMoreActions(username);
await this.menu.selectMenuItem('Mute user');
await this.confirmMuteModal.confirmMute();
}
async unmuteUser(username: string) {
await this.openMemberOptionMoreActions(username);
await this.menu.selectMenuItem('Unmute user');
}
async setUserAsModerator(username: string) {
await this.openMemberOptionMoreActions(username);
await this.menu.selectMenuItem('Set as moderator');
}
async setUserAsOwner(username: string) {
await this.openMemberOptionMoreActions(username);
await this.menu.selectMenuItem('Set as owner');
}
async showAllUsers() {
await this.root.getByRole('button', { name: 'Online' }).click();
await this.listbox.selectOption('All');
}
private async ignoreUserAction(action: string, username: string) {
await this.openMemberInfo(username);
await this.userInfo.openMoreActions();
await this.userInfo.menu.selectMenuItem(action);
}
async ignoreUser(username: string) {
await this.ignoreUserAction('Ignore', username);
}
async unignoreUser(username: string) {
await this.ignoreUserAction('Unignore', username);
}
async confirmRemoveUser() {
return this.removeModal.confirmRemove();
}
}

View File

@@ -0,0 +1,41 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { Listbox } from '../listbox';
export class NotificationPreferencesFlexTab extends FlexTab {
readonly listbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Notifications Preferences' }));
this.listbox = new Listbox(page);
}
getPreferenceByDevice(device: string): Locator {
return this.root.locator(`//div[@id="${device}Alert"]`);
}
async selectDropdownById(text: string): Promise<void> {
await this.root.locator(`//div[@id="${text}"]`).click();
}
async selectOptionByLabel(text: string): Promise<void> {
await this.listbox.selectOption(text);
}
async selectDevice(text: string): Promise<void> {
await this.root.getByRole('button', { name: text }).click();
}
async updateDevicePreference(device: string): Promise<void> {
await this.selectDevice(device);
await this.selectDropdownById(`${device}Alert`);
await this.selectOptionByLabel('Mentions');
}
async updateAllNotificationPreferences(): Promise<void> {
await this.updateDevicePreference('Desktop');
await this.updateDevicePreference('Mobile');
await this.updateDevicePreference('Email');
}
}

View File

@@ -0,0 +1,37 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { Modal } from '../modals/modal';
class ConfirmPruneMessageModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Are you sure?' }));
}
async pruneConfirm(): Promise<void> {
await this.root.getByRole('button', { name: 'Yes, prune them!' }).click();
await this.waitForDismissal();
}
}
export class PruneMessagesFlexTab extends FlexTab {
readonly confirmPruneModal: ConfirmPruneMessageModal;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Prune Messages' }));
this.confirmPruneModal = new ConfirmPruneMessageModal(page);
}
get labelDoNotPrunePinned(): Locator {
return this.root.locator('label', { hasText: 'Do not prune pinned messages' });
}
get labelFilesOnly(): Locator {
return this.root.locator('label', { hasText: 'Only remove the attached files, keep messages' });
}
async prune(): Promise<void> {
await this.root.getByRole('button', { name: 'Prune' }).click();
await this.confirmPruneModal.pruneConfirm();
}
}

View File

@@ -0,0 +1,124 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { MenuMore } from '../menu';
import { ConfirmDeleteTeamModal, ConfirmDeleteRoomModal } from '../modals';
import { Modal } from '../modals/modal';
class ConfirmLeaveRoomModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Confirmation', exact: true }));
}
private get btnLeave() {
return this.root.getByRole('button', { name: 'Leave', exact: true });
}
async confirmLeave() {
await this.btnLeave.click();
await this.waitForDismissal();
}
}
export class RoomInfoFlexTab extends FlexTab {
readonly menu: MenuMore;
readonly confirmLeaveModal: ConfirmLeaveRoomModal;
readonly confirmDeleteModal: ConfirmDeleteRoomModal;
constructor(root: Locator) {
super(root);
this.menu = new MenuMore(root.page());
this.confirmLeaveModal = new ConfirmLeaveRoomModal(root.page());
this.confirmDeleteModal = new ConfirmDeleteRoomModal(root.page());
}
get btnEdit(): Locator {
return this.root.getByRole('button', { name: 'Edit' });
}
get btnLeave(): Locator {
return this.root.getByRole('button', { name: 'Leave' });
}
get btnMore(): Locator {
return this.root.getByRole('button', { name: 'More' });
}
get optionDelete(): Locator {
return this.menu.getMenuItem('Delete');
}
async leaveRoom() {
await this.btnLeave.click();
await this.confirmLeaveModal.confirmLeave();
}
async deleteRoom() {
await this.btnMore.click();
await this.menu.selectMenuItem('Delete');
await this.confirmDeleteModal.confirmDelete();
}
}
class ConfirmConvertIntoChannelModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Confirmation', exact: true }));
}
private get btnConvert() {
return this.root.getByRole('button', { name: 'Convert', exact: true });
}
async confirmConvert() {
await this.btnConvert.click();
await this.waitForDismissal();
}
}
export class TeamInfoFlexTab extends RoomInfoFlexTab {
readonly confirmDeleteTeamModal: ConfirmDeleteTeamModal;
readonly confirmConvertIntoChannelModal: ConfirmConvertIntoChannelModal;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Team info' }));
this.confirmDeleteTeamModal = new ConfirmDeleteTeamModal(page);
this.confirmConvertIntoChannelModal = new ConfirmConvertIntoChannelModal(page);
}
async deleteTeam() {
await this.btnMore.click();
await this.menu.selectMenuItem('Delete');
return this.confirmDeleteTeamModal.confirmDelete();
}
async convertIntoChannel() {
await this.btnMore.click();
await this.menu.selectMenuItem('Convert to Channel');
await this.confirmConvertIntoChannelModal.confirmConvert();
}
}
export class OmnichannelRoomInfoFlexTab extends RoomInfoFlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Room Information' }));
}
getInfo(value: string): Locator {
return this.root.locator(`span >> text="${value}"`);
}
getLabel(label: string): Locator {
return this.root.locator(`div >> text="${label}"`);
}
getInfoByLabel(label: string): Locator {
return this.root.getByLabel(label);
}
getTagInfoByLabel(label: string): Locator {
return this.root.getByRole('list', { name: 'Tags' }).getByText(label, { exact: true });
}
}

View File

@@ -0,0 +1,26 @@
import type { Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class SearchMessagesFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Search Messages' }));
}
async search(text: string, { global = false }: { global?: boolean } = {}) {
if (global) {
await this.root.getByText('Global search').click();
}
await this.root.getByPlaceholder('Search Messages').fill(text);
}
async getResultItem(messageText: string) {
return this.root.getByRole('listitem', { name: messageText });
}
async jumpToMessage(messageText: string) {
const message = (await this.getResultItem(messageText)).first();
await message.hover();
await message.getByRole('button', { name: 'Jump to message' }).click();
}
}

View File

@@ -0,0 +1,21 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
export class ThreadsFlexTab extends FlexTab {
constructor(page: Page) {
super(page.getByRole('dialog'));
}
private get listThreadMessages(): Locator {
return this.root.getByRole('list', { name: 'Thread message list' });
}
get lastThreadMessage(): Locator {
return this.listThreadMessages.locator('[data-qa-type="message"]').last().locator('[data-qa-type="message-body"]');
}
getThreadMessageByText(messageText: string): Locator {
return this.listThreadMessages.getByRole('listitem').getByText(messageText);
}
}

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test';
import { FlexTab } from './flextab';
import { MenuMore } from '../menu';
export class UserInfoFlexTab extends FlexTab {
readonly menu: MenuMore;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'User Info' }));
this.menu = new MenuMore(page);
}
get btnEdit(): Locator {
return this.root.getByRole('button', { name: 'Edit' });
}
get btnMoreActions(): Locator {
return this.root.getByRole('button', { name: 'More' });
}
get menuItemDeleteUser(): Locator {
return this.menu.root.getByRole('menuitem', { name: 'Delete' });
}
get username(): Locator {
return this.root.getByLabel('Username');
}
async openMoreActions() {
await this.btnMoreActions.click();
}
}

View File

@@ -0,0 +1,680 @@
import fs from 'fs/promises';
import { resolve, join, relative } from 'node:path';
import type { Locator, Page } from '@playwright/test';
import { RoomComposer, ThreadComposer } from './composer';
import { RoomHeader } from './room-header';
import { createMediaResponsePromise } from '../../fixtures/responses/mediaResponse';
import { expect } from '../../utils/test';
const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../../fixtures/files'));
export function getFilePath(fileName: string): string {
return join(FIXTURES_PATH, fileName);
}
export class HomeContent {
protected readonly page: Page;
readonly header: RoomHeader;
readonly composer: RoomComposer;
protected readonly threadComposer: ThreadComposer;
constructor(page: Page) {
this.page = page;
this.composer = new RoomComposer(page);
this.threadComposer = new ThreadComposer(page);
this.header = new RoomHeader(page.locator('main header'));
}
get channelHeader(): Locator {
return this.header.root;
}
get burgerButton(): Locator {
return this.channelHeader.getByRole('button', { name: 'Open sidebar' });
}
get channelRetentionPolicyWarning(): Locator {
return this.page.locator('main').getByRole('alert', { name: 'Retention policy warning banner' });
}
get messagePopupUsers(): Locator {
return this.page.locator('role=menu[name="People"]');
}
get mainMessageList(): Locator {
return this.page.getByRole('list', { name: 'Message list', exact: true });
}
get threadMessageList(): Locator {
return this.page.getByRole('list', { name: 'Thread message list', exact: true });
}
/** The open room, matched by id. Use it to assert a permalink landed in the room it should. */
getRoomById(roomId: string): Locator {
return this.page.locator(`[data-qa-rc-room="${roomId}"]`);
}
get messageListItems(): Locator {
return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"]');
}
get mainMessageListScroller(): Locator {
return this.page.locator('[data-overlayscrollbars]', { has: this.mainMessageList });
}
get threadMessageListScroller(): Locator {
return this.page.locator('[data-overlayscrollbars]', { has: this.threadMessageList });
}
get systemMessageListItems(): Locator {
return this.mainMessageList.locator('[role="listitem"][aria-roledescription="system message"]');
}
get threadMessageListItems(): Locator {
return this.threadMessageList.locator('[role="listitem"][aria-roledescription="thread message"]');
}
get lastUserMessage(): Locator {
return this.messageListItems.last();
}
get lastUserThreadMessage(): Locator {
return this.threadMessageListItems.last();
}
get lastThreadMessagePreview(): Locator {
return this.page.getByRole('listitem').locator('[role="link"][aria-roledescription="thread message preview"]').last();
}
get lastUserMessageDownloadLink(): Locator {
return this.lastUserMessage.getByRole('link', { name: 'Download' });
}
nthMessage(index: number): Locator {
return this.messageListItems.nth(index);
}
get lastUserMessageBody(): Locator {
return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]');
}
get lastUserMessageAttachment(): Locator {
return this.page.locator('[role="document"][aria-roledescription="message attachment"]').last();
}
get lastUserMessageNotSequential(): Locator {
return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"][data-sequential="false"]').last();
}
get encryptedRoomHeaderIcon(): Locator {
return this.page.locator('.rcx-room-header i.rcx-icon--name-key');
}
get lastIgnoredUserMessage(): Locator {
return this.lastUserMessageBody.locator('role=button[name="This message was ignored"]');
}
get lastIgnoredThreadMessage(): Locator {
return this.lastUserThreadMessage.getByRole('button', { name: 'This message was ignored' });
}
get ignoredThreadMessages(): Locator {
return this.threadMessageListItems.getByRole('button', { name: 'This message was ignored' });
}
async joinRoomIfNeeded(): Promise<void> {
if (await this.composer.inputMessage.isEnabled()) {
return;
}
if (!(await this.composer.btnJoinRoom.isVisible())) {
return;
}
await this.composer.btnJoinRoom.click();
}
async sendMessage(text: string, enforce = true): Promise<void> {
await this.joinRoomIfNeeded();
await expect(this.composer.inputMessage).toBeEnabled();
await this.composer.inputMessage.fill(text);
if (!enforce) {
await this.composer.btnSend.click();
return;
}
// Wait for the message to settle in the DOM. Transport-agnostic — the
// message may be sent via REST (`/api/v1/method.call/sendMessage`) or
// via DDP/WS depending on whether DDPSDK is the active dispatcher;
// either way Meteor's optimistic insert renders the new list item
// before the server confirms, and the `rcx-message--pending` class
// drops once the server result lands.
if ((await this.messageListItems.count()) > 0) {
const lastMessageBeforeSend = await this.messageListItems.last().getAttribute('id');
await this.composer.btnSend.click();
await expect.poll(() => this.messageListItems.last().getAttribute('id'), { timeout: 10_000 }).not.toEqual(lastMessageBeforeSend);
await expect(this.lastUserMessage).not.toHaveClass(/rcx-message--pending/);
} else {
// No previous messages, nothing to compare
await this.composer.btnSend.click();
await expect(this.lastUserMessage).not.toHaveClass(/rcx-message--pending/);
}
}
async dispatchSlashCommand(text: string): Promise<void> {
await this.joinRoomIfNeeded();
await expect(this.composer.inputMessage).toBeEnabled();
await this.composer.inputMessage.fill('');
await this.composer.inputMessage.fill(text);
await this.page.keyboard.press('Enter');
await this.page.keyboard.press('Enter');
}
async forwardMessage(chatName: string) {
await this.messageListItems.last().hover();
await this.page.locator('role=button[name="Forward message"]').click();
await this.page.getByRole('textbox', { name: 'Person or Channel', exact: true }).click();
await this.page.keyboard.type(chatName);
await this.page.locator('#position-container').getByText(chatName).waitFor();
await this.page.locator('#position-container').getByText(chatName).click();
await this.page.locator('role=button[name="Forward"]').click();
}
// TODO: use modal fragments -----------------------------------------
get btnModalCancel(): Locator {
return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--secondary');
}
private get fileUploadModal(): Locator {
return this.page.getByRole('dialog', { name: 'File Upload' });
}
get btnModalConfirm(): Locator {
return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary');
}
get btnModalConfirmDelete(): Locator {
return this.page.getByRole('button', { name: 'Yes, delete', exact: true });
}
get btnCancelQuotePreview(): Locator {
return this.page.getByRole('button', { name: 'Dismiss quoted message' });
}
get getFileDescription(): Locator {
return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]');
}
get inputFileUploadName(): Locator {
return this.fileUploadModal.getByRole('textbox', { name: 'File name' });
}
get btnUpdateFileUpload(): Locator {
return this.fileUploadModal.getByRole('button', { name: 'Update' });
}
get btnCancelUpdateFileUpload(): Locator {
return this.fileUploadModal.getByRole('button', { name: 'Cancel' });
}
// -----------------------------------------
getLastMessageByFileName(filename: string): Locator {
return this.messageListItems
.filter({ has: this.page.getByRole('link', { name: filename }) })
.last()
.getByRole('link', { name: filename });
}
get lastMessageTextAttachment(): Locator {
return this.messageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]');
}
get lastMessageTextAttachmentEqualsText(): Locator {
return this.messageListItems.last().locator('.rcx-attachment__details .rcx-message-body');
}
get btnQuoteMessage(): Locator {
return this.page.getByRole('button', { name: 'Quote' });
}
get quotePreview(): Locator {
return this.page.locator('footer blockquote');
}
get quotedMessage(): Locator {
return this.page.getByRole('blockquote');
}
quotedFileDescription(fileDescription: string): Locator {
return this.quotedMessage.getByText(fileDescription);
}
quotedFileName(fileName: string): Locator {
return this.quotedMessage.getByTitle(fileName);
}
threadMessageQuotedFileDescription(fileDescription: string): Locator {
return this.threadQuotedMessage.getByText(fileDescription);
}
threadMessageQuotedFileName(fileName: string): Locator {
return this.threadQuotedMessage.getByTitle(fileName);
}
get linkPreview(): Locator {
return this.lastUserMessage.getByText('Link Preview');
}
quotedLinkText(name: string): Locator {
return this.quotedMessage.getByRole('link', { name });
}
get threadQuotedMessage(): Locator {
return this.page.getByRole('dialog').getByRole('blockquote');
}
get threadQuotePreview(): Locator {
return this.page.getByRole('dialog').locator('footer blockquote');
}
get lastThreadMessageTextAttachmentEqualsText(): Locator {
return this.threadMessageListItems.last().locator('.rcx-attachment__details');
}
get mainThreadMessageText(): Locator {
return this.threadMessageListItems.first();
}
get lastThreadMessagePreviewText(): Locator {
return this.page.locator('div.messages-box .messages-list [role=link]').last();
}
get lastThreadMessageFileDescription(): Locator {
return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message body"]');
}
getLastThreadMessageByFileName(filename: string): Locator {
return this.threadMessageListItems.last().getByRole('link', { name: filename });
}
// TODO: improve locator specificity
get menuMore(): Locator {
return this.page.getByRole('menu', { name: 'More', exact: true });
}
get lastThreadMessageTextAttachment(): Locator {
return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]');
}
get btnOptionEditMessage(): Locator {
return this.menuMore.getByRole('menuitem', { name: 'Edit', exact: true });
}
get btnOptionDeleteMessage(): Locator {
return this.menuMore.getByRole('menuitem', { name: 'Delete', exact: true });
}
get btnOptionPinMessage(): Locator {
return this.menuMore.getByRole('menuitem', { name: 'Pin', exact: true });
}
get btnOptionStarMessage(): Locator {
return this.menuMore.getByRole('menuitem', { name: 'Star', exact: true });
}
get btnOptionReplyInDm(): Locator {
return this.menuMore.getByRole('menuitem', { name: 'Reply in direct message', exact: true });
}
get btnVoiceCall(): Locator {
return this.primaryRoomActionsToolbar.getByRole('button', { name: 'Voice call' });
}
get btnContactInformation(): Locator {
return this.page.getByRole('button', { name: 'User Info' });
}
get btnContactInfoVoiceCall(): Locator {
return this.page.getByRole('group').getByRole('button', { name: 'Voice call' });
}
get btnSendTranscript(): Locator {
return this.page.locator('role=button[name="Send transcript"]');
}
get btnSendTranscriptToEmail(): Locator {
return this.page.locator('li.rcx-option', { hasText: 'Send via email' });
}
get btnSendTranscriptAsPDF(): Locator {
return this.page.locator('li.rcx-option', { hasText: 'Export as PDF' });
}
get btnNewCannedResponse(): Locator {
return this.page.locator('.rcx-vertical-bar button:has-text("Create")');
}
get imageGallery(): Locator {
return this.page.getByRole('dialog', { name: 'Image gallery', exact: true });
}
get imageGalleryImage(): Locator {
return this.imageGallery.locator('.swiper-zoom-container img');
}
async getGalleryButtonByName(name: string) {
return this.imageGallery.locator(`button[name="${name}"]`);
}
async dragAndDropTxtFile({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8');
const dataTransfer = await this.page.evaluateHandle((contract) => {
const data = new DataTransfer();
const file = new File([`${contract}`], 'any_file.txt', {
type: 'text/plain',
});
data.items.add(file);
return data;
}, contract);
const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null;
await this.composer.inputMessage.dispatchEvent('dragenter', { dataTransfer });
await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer });
if (responsePromise) {
await responsePromise;
}
}
async dragAndDropLstFile({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
const contract = await fs.readFile(getFilePath('lst-test.lst'), 'utf-8');
const dataTransfer = await this.page.evaluateHandle((contract) => {
const data = new DataTransfer();
const file = new File([`${contract}`], 'lst-test.lst', {
type: 'text/plain',
});
data.items.add(file);
return data;
}, contract);
const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null;
await this.composer.inputMessage.dispatchEvent('dragenter', { dataTransfer });
await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer });
if (responsePromise) {
await responsePromise;
}
}
async dragAndDropTxtFileToThread({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8');
const dataTransfer = await this.page.evaluateHandle((contract) => {
const data = new DataTransfer();
const file = new File([`${contract}`], 'any_file.txt', {
type: 'text/plain',
});
data.items.add(file);
return data;
}, contract);
const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null;
await this.threadComposer.inputMessage.dispatchEvent('dragenter', { dataTransfer });
await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer });
if (responsePromise) {
await responsePromise;
}
}
async sendMultipleFilesMessage(fileNames: string[], { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
await this.page
.getByLabel('Room composer')
.locator('input[type=file]')
.setInputFiles(fileNames.map((name) => getFilePath(name)));
if (!waitForResponse) {
return;
}
await expect(this.composer.btnSend).toBeEnabled();
}
async sendFileMessage(fileName: string, { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null;
await this.page.getByLabel('Room composer').locator('input[type=file]').setInputFiles(getFilePath(fileName));
if (responsePromise) {
await responsePromise;
}
}
async sendFileMessageToThread(fileName: string, { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise<void> {
await this.threadComposer.inputMessage.click();
const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null;
await this.page.getByLabel('Thread composer').locator('input[type=file]').setInputFiles(getFilePath(fileName));
if (responsePromise) {
await responsePromise;
}
}
async openLastMessageMenu(): Promise<void> {
await this.lastUserMessage.hover();
await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).waitFor();
await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).click();
}
async openLastThreadMessageMenu(): Promise<void> {
await this.lastUserThreadMessage.hover();
await this.lastUserThreadMessage.getByRole('button', { name: 'More', exact: true }).waitFor();
await this.lastUserThreadMessage.getByRole('button', { name: 'More', exact: true }).click();
}
async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise<void> {
await this.page
.getByRole('dialog')
.locator('label', { has: this.page.getByRole('checkbox', { name: 'Also send to channel' }) })
.setChecked(isChecked);
}
get lastSystemMessageBody(): Locator {
return this.page.locator('[role=document][aria-roledescription="system message body"]').last();
}
get resumeOnHoldOmnichannelChatButton(): Locator {
return this.page.locator('button.rcx-button--primary >> text="Resume"');
}
get primaryRoomActionsToolbar(): Locator {
return this.page.getByRole('toolbar', { name: 'Primary Room actions' });
}
get btnVideoCall(): Locator {
return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]').getByRole('button', { name: 'Video call' });
}
get btnToolbarOptions(): Locator {
return this.primaryRoomActionsToolbar.getByRole('button', { name: 'Options', exact: true });
}
get optionsMenu(): Locator {
return this.page.getByRole('menu', { name: 'Options', exact: true });
}
get starredMessagesMenuOption(): Locator {
return this.optionsMenu.getByRole('menuitem', { name: 'Starred Messages', exact: true });
}
getVideoConfPopup(name?: string): Locator {
return this.page.getByRole('dialog', { name });
}
get btnStartVideoCall(): Locator {
return this.getVideoConfPopup().getByRole('button', { name: 'Start call' });
}
get btnVideoConfMic(): Locator {
return this.getVideoConfPopup().getByRole('button', { name: 'Mic' });
}
get btnDeclineVideoCall(): Locator {
return this.page.locator('.rcx-button--secondary-danger.rcx-button >> text="Decline"');
}
get videoConfMessageBlock(): Locator {
return this.page.locator('.rcx-videoconf-message-block');
}
get btnAnonymousSignIn(): Locator {
return this.page.locator('footer >> role=button[name="Sign in to start talking"]');
}
get btnAnonymousTalk(): Locator {
return this.page.locator('role=button[name="Or talk as anonymous"]');
}
get nextSlideButton(): Locator {
return this.page.getByLabel('Next slide');
}
get previousSlideButton(): Locator {
return this.page.getByLabel('Previous slide');
}
get currentGalleryImage(): Locator {
return this.page.locator('div[class="swiper-slide swiper-slide-active"] img');
}
// TODO: use getSystemMessageByText instead
findSystemMessage(text: string): Locator {
return this.page.locator(`[role="document"][aria-roledescription="system message body"]`, { hasText: text });
}
getSystemMessageByText(text: string): Locator {
return this.page.locator('[role="listitem"][aria-roledescription="system message"]', { hasText: text });
}
getMessageByText(text: string): Locator {
return this.page.locator('[role="listitem"][aria-roledescription="message"]', { hasText: text });
}
getLastMessageActionButton(name: string): Locator {
return this.lastUserMessage.getByRole('button', { name, exact: true });
}
getLastThreadMessageActionButton(name: string): Locator {
return this.lastUserThreadMessage.getByRole('button', { name, exact: true });
}
getMessageById(id: string): Locator {
return this.page.locator(`[role="listitem"][aria-roledescription="message"][id="${id}"]`);
}
/** Thread replies carry `aria-roledescription="thread message"`, so `getMessageById` skips them. */
getThreadMessageById(id: string): Locator {
return this.threadMessageList.locator(`[role="listitem"][id="${id}"]`);
}
async scrollToMessage(messageLocator: Locator, direction: 'up' | 'down' = 'up'): Promise<Locator> {
const scroller = this.mainMessageListScroller;
const delta = direction === 'up' ? -400 : 400;
await expect(async () => {
await scroller.evaluate((el, d) => el.scrollBy(0, d), delta);
await expect(messageLocator.first()).toBeVisible({ timeout: 500 });
}).toPass({ timeout: 30000, intervals: [100] });
return messageLocator;
}
get btnJoinChannel(): Locator {
return this.page.getByRole('main').getByRole('button', { name: 'Join channel', exact: true });
}
async waitForChannel(): Promise<void> {
await this.page.locator('role=main').waitFor();
// a room the user cannot preview renders a join screen instead of the header and message list
await this.mainMessageList.or(this.btnJoinChannel).first().waitFor();
if (await this.btnJoinChannel.isVisible()) {
return;
}
await this.page.locator('role=main >> role=heading[level=1]').waitFor();
await expect(this.mainMessageList).not.toHaveAttribute('aria-busy', 'true');
}
async waitForThread(): Promise<void> {
const messageList = this.page.getByRole('list', { name: 'Thread message list', exact: true });
await messageList.waitFor();
await expect(messageList).not.toHaveAttribute('aria-busy', 'true');
}
async openReplyInThread(): Promise<void> {
await this.lastUserMessage.hover();
await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).waitFor();
await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).click();
}
async sendMessageInThread(text: string): Promise<void> {
await this.threadComposer.inputMessage.fill(text);
await this.threadComposer.btnSend.click();
}
async sendMessageInVideoConfPopup(text: string): Promise<void> {
await this.page.getByRole('dialog').getByRole('textbox', { name: 'Message' }).fill(text);
await this.page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click();
}
async deleteLastMessage(): Promise<void> {
await this.lastUserMessage.hover();
await this.openLastMessageMenu();
await this.btnOptionDeleteMessage.click();
await this.btnModalConfirmDelete.click();
await expect(this.btnModalConfirmDelete).toBeDisabled();
}
get btnClearSelection() {
return this.page.getByRole('button', { name: 'Clear selection' });
}
get contactUnknownCallout() {
return this.page.getByRole('status', { name: 'Unknown contact. This contact is not on the contact list.' });
}
get btnDismissContactUnknownCallout() {
return this.contactUnknownCallout.getByRole('button', { name: 'Dismiss' });
}
get btnOptionStartDiscussion(): Locator {
return this.page.getByRole('menuitem', { name: 'Start a Discussion' });
}
async quoteMessage(quoteText: string, originalMessageText?: string): Promise<void> {
await this.lastUserMessage.hover();
await this.btnQuoteMessage.click();
if (originalMessageText) {
await expect(this.quotePreview).toBeVisible();
await expect(this.quotePreview).toContainText(originalMessageText);
}
await this.sendMessage(quoteText);
}
get clearAllUnreadsModal(): Locator {
return this.page.getByRole('dialog', { name: 'Clear all unreads?' });
}
async markAllRoomsAsRead(): Promise<void> {
await this.page.keyboard.down('Shift');
await this.page.keyboard.press('Escape');
await this.page.keyboard.up('Shift');
await expect(this.clearAllUnreadsModal).toBeVisible();
await this.clearAllUnreadsModal.getByRole('button', { name: 'Yes, clear all!' }).click();
}
}

View File

@@ -0,0 +1,44 @@
import type { Locator, Page } from '@playwright/test';
import { HomeContent } from './home-content';
import { OmnichannelTransferChatModal, OmnichannelReturnToQueueModal } from './modals';
export class HomeOmnichannelContent extends HomeContent {
readonly forwardChatModal: OmnichannelTransferChatModal;
readonly returnToQueueModal: OmnichannelReturnToQueueModal;
constructor(page: Page) {
super(page);
this.forwardChatModal = new OmnichannelTransferChatModal(page);
this.returnToQueueModal = new OmnichannelReturnToQueueModal(page);
}
get btnReturnToQueue(): Locator {
return this.page.locator('role=button[name="Move to the queue"]');
}
get btnTakeChat(): Locator {
return this.page.locator('role=button[name="Take it!"]');
}
get btnReturn(): Locator {
return this.header.root.getByRole('button', { name: 'Back' });
}
get btnResume(): Locator {
return this.page.locator('role=button[name="Resume"]');
}
get infoHeaderName(): Locator {
return this.page.locator('.rcx-room-header').getByRole('heading');
}
async selectCannedResponse(cannedResponseName: string): Promise<void> {
await this.composer.inputMessage.pressSequentially('!');
const popup = this.composer.boxPopup;
await popup.waitFor({ state: 'visible' });
await this.composer.inputMessage.pressSequentially(cannedResponseName);
await popup.getByText(cannedResponseName, { exact: true }).click();
}
}

View File

@@ -0,0 +1,12 @@
export * from './home-content';
export * from './home-omnichannel-content';
export * from './navbar';
export * from './sidebar';
export * from './sidepanel';
export * from './modals';
export * from './toast-messages';
export * from './menu';
export * from './table';
export * from './toolbar';
export * from './composer';
export * from './flextabs';

View File

@@ -0,0 +1,21 @@
import type { Locator, Page } from 'playwright-core';
export class Listbox {
readonly root: Locator;
constructor(page: Page, name?: string) {
/**
* Currently, our selects and multiSelects has multiple listboxes in the DOM.
* So, to avoid selecting the wrong one, we will always select the last until we fix it.
*/
this.root = page.getByRole('listbox', { name }).last();
}
async selectOption(name: string, exact?: boolean) {
return this.root.getByRole('option', { name, exact }).click();
}
public getOption(name: string): Locator {
return this.root.getByRole('option', { name, exact: true });
}
}

View File

@@ -0,0 +1,50 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../utils/test';
export abstract class Menu {
constructor(public root: Locator) {}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
getMenuItem(itemName: string) {
return this.root.getByRole('menuitem', { name: itemName, exact: true });
}
async selectMenuItem(itemName: string, submenuTrigger = false) {
await this.getMenuItem(itemName).click();
if (!submenuTrigger) {
await this.waitForDismissal();
}
}
}
export class MenuMore extends Menu {
constructor(page: Page) {
super(page.getByRole('menu', { name: 'More' }));
}
}
export class MenuMoreActions extends Menu {
constructor(page: Page) {
super(page.getByRole('menu', { name: 'More actions' }));
}
}
export class MenuOptions extends Menu {
constructor(page: Page) {
super(page.getByRole('menu', { name: 'Options' }));
}
}
export class MenuMoveTo extends Menu {
constructor(page: Page) {
super(page.getByRole('menu', { name: 'Move to' }));
}
}

View File

@@ -0,0 +1,17 @@
import type { Locator } from '@playwright/test';
export class Message {
constructor(public readonly root: Locator) {}
get body() {
return this.root.locator('[role="document"][aria-roledescription="message body"]');
}
getFileUploadByName(filename: string) {
return this.root.getByRole('link', { name: filename });
}
get encryptedIcon() {
return this.root.locator('.rcx-icon--name-key');
}
}

View File

@@ -0,0 +1,27 @@
import type { Locator, Page } from 'playwright-core';
import { Modal } from './modal';
export class AppsModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Modal Example' }));
}
get textInput(): Locator {
return this.root.locator('[name="modal_input"]');
}
get textInputErrorMessage(): Locator {
return this.root.getByText('Validation failed');
}
get btnSubmit(): Locator {
return this.root.getByRole('button', { name: 'Submit' });
}
async submit(inputText: string) {
await this.textInput.fill(inputText);
await this.btnSubmit.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,63 @@
import type { Locator, Page } from 'playwright-core';
import { Modal } from './modal';
export class ConfirmDeleteModal extends Modal {
constructor(root: Locator) {
super(root);
}
get btnDelete() {
return this.root.getByRole('button', { name: 'Delete' });
}
async confirmDelete() {
await this.btnDelete.click();
await this.waitForDismissal();
}
}
export class ConfirmDeleteRoomModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete' }));
}
get btnDelete() {
return this.root.getByRole('button', { name: 'Yes, delete', exact: true });
}
async confirmDelete() {
await this.btnDelete.click();
await this.waitForDismissal();
}
}
export class ConfirmDeleteDepartmentModal extends ConfirmDeleteModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete Department?' }));
}
get inputConfirmDepartmentName() {
return this.root.getByRole('textbox', { name: 'Department name' });
}
async deleteDepartment(departmentName: string) {
await this.inputConfirmDepartmentName.fill(departmentName);
await this.confirmDelete();
}
}
export class ConfirmDeleteTeamModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete team', exact: true }));
}
private get btnDelete() {
return this.root.getByRole('button', { name: 'Yes, delete', exact: true });
}
async confirmDelete() {
await this.btnDelete.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,12 @@
import { Modal } from './modal';
export class ConfirmLogoutModal extends Modal {
get btnLogout() {
return this.root.getByRole('button', { name: 'Log out device' });
}
async confirmLogout() {
await this.btnLogout.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,18 @@
import type { Locator } from '@playwright/test';
import { Modal } from './modal';
export class ConfirmRemoveModal extends Modal {
constructor(root: Locator) {
super(root);
}
get btnRemove() {
return this.root.getByRole('button', { name: 'Remove' });
}
async confirmRemove() {
await this.btnRemove.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,129 @@
import type { Locator, Page } from '@playwright/test';
import { Listbox } from '../listbox';
import { Modal } from './modal';
export abstract class CreateNewModal extends Modal {
readonly listbox: Listbox;
constructor(root: Locator, page: Page) {
super(root, page);
this.listbox = new Listbox(page);
}
get inputName(): Locator {
return this.root.getByRole('textbox', { name: 'Name' });
}
get checkboxPrivate(): Locator {
return this.root.locator('label', { hasText: 'Private' });
}
get checkboxEncrypted(): Locator {
return this.root.locator('label', { hasText: 'Encrypted' });
}
get checkboxReadOnly(): Locator {
return this.root.locator('label', { hasText: 'Read-only' });
}
get checkboxFederated(): Locator {
return this.root.locator('label', { hasText: 'Federated' });
}
get btnCreate(): Locator {
return this.root.getByRole('button', { name: 'Create', exact: true });
}
get inputAddMembers(): Locator {
return this.root.getByRole('combobox', { name: 'Members' });
}
async addMember(memberName: string): Promise<void> {
await this.inputAddMembers.click();
await this.inputAddMembers.fill(memberName, { force: true });
await this.listbox.selectOption(memberName);
await this.inputAddMembers.click();
}
async create() {
await this.btnCreate.click();
await this.waitForDismissal();
}
}
export class CreateNewChannelModal extends CreateNewModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Create channel' }), page);
}
get advancedSettingsAccordion(): Locator {
return this.root.getByRole('button', { name: 'Advanced settings', exact: true });
}
async inviteUserToChannel(username: string) {
await this.inputAddMembers.click();
await this.inputAddMembers.fill(username);
await this.listbox.selectOption(username);
}
}
export class CreateNewDMModal extends CreateNewModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'New direct message' }), page);
}
get autocompleteUser(): Locator {
return this.root.getByRole('combobox', { name: 'Select one or more people to message', exact: true });
}
async inviteUserToDM(username: string) {
await this.autocompleteUser.click();
await this.autocompleteUser.fill(username);
await this.listbox.selectOption(username);
await this.page?.keyboard.press('Tab');
}
}
export class CreateNewTeamModal extends CreateNewModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Create team' }), page);
}
get advancedSettingsAccordion(): Locator {
return this.root.getByRole('button', { name: 'Advanced settings', exact: true });
}
}
export class CreateNewDiscussionModal extends CreateNewModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Create discussion' }), page);
}
get inputParentRoom(): Locator {
return this.root.getByRole('textbox', { name: 'Parent channel or team' });
}
getParentRoomListItem(name: string): Locator {
return this.listbox.getOption(name);
}
get inputMessage(): Locator {
return this.root.getByRole('textbox', { name: 'Message', exact: true });
}
}
export class CreateNewCategoryModal extends CreateNewModal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Create category', exact: true }), page);
}
private get btnCreateAndMove() {
return this.root.getByRole('button', { name: 'Create and move', exact: true });
}
override async create(hasRoom = false) {
await (hasRoom ? this.btnCreateAndMove.click() : this.btnCreate.click());
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,36 @@
import type { Locator, Page } from 'playwright-core';
import { Modal } from './modal';
export class DeleteAccountModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete account?' }));
}
get btnDeleteAccount(): Locator {
return this.root.getByRole('button', { name: 'Delete Account' });
}
get btnCancel(): Locator {
return this.root.getByRole('button', { name: 'Cancel' });
}
async confirmDelete({ waitForDismissal = true } = {}): Promise<void> {
await this.btnDeleteAccount.click();
if (waitForDismissal) {
await this.waitForDismissal();
}
}
get deleteAccountDialogMessageWithPassword(): Locator {
return this.root.getByText('Enter your password to delete your account. This cannot be undone.');
}
get inputPassword(): Locator {
return this.root.getByRole('textbox', { name: 'Enter your password to delete your account. This cannot be undone.' });
}
get inputErrorMessage(): Locator {
return this.root.locator('[role="alert"]', { hasText: 'Invalid password' });
}
}

View File

@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class DeleteCategoryModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete category', exact: true }), page);
}
private get btnDelete() {
return this.root.getByRole('button', { name: 'Delete', exact: true });
}
async delete() {
await this.btnDelete.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class DisableRoomEncryptionModal extends Modal {
private readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Disable encryption' }));
this.toastMessages = new ToastMessages(page);
}
private get disableButton() {
return this.root.getByRole('button', { name: 'Disable encryption' });
}
async disable() {
await this.disableButton.click();
await this.waitForDismissal();
await this.toastMessages.dismissToast('success');
}
}

View File

@@ -0,0 +1,97 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class EditStatusModal extends Modal {
readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Set your status' }), page);
this.toastMessages = new ToastMessages(page);
}
private get statusMessageInput() {
return this.root.getByRole('textbox', { name: 'Status' });
}
private get statusTypeButton() {
return this.root.getByRole('button', { name: 'User status menu' });
}
private get durationSelect() {
return this.root.getByLabel('Clear status after');
}
get customDateInput() {
return this.root.getByLabel('Expiration date');
}
get customTimeInput() {
return this.root.getByLabel('Expiration time');
}
get btnSubmit() {
return this.root.getByRole('button', { name: 'Save' });
}
get durationError() {
return this.root.getByRole('alert').filter({ hasText: 'Expiration must be in the future' });
}
get durationMissingError() {
return this.root.getByRole('alert').filter({ hasText: 'Choose date and time' });
}
async selectStatusType(status: string): Promise<void> {
await this.statusTypeButton.click();
await this.page!.getByRole('option', { name: status }).click();
}
async setStatusMessage(message: string): Promise<void> {
await this.statusMessageInput.fill(message);
}
async selectDuration(duration: string): Promise<void> {
await this.durationSelect.click();
await this.page!.getByRole('option', { name: new RegExp(duration) }).click();
}
async changeStatusMessage(statusMessage?: string): Promise<void> {
if (statusMessage !== undefined) {
await this.setStatusMessage(statusMessage);
}
await this.save();
await this.toastMessages.dismissToast();
}
async setStatusWithExpiration({
message,
statusType,
duration,
customDate,
customTime,
}: {
message?: string;
statusType?: string;
duration: string;
customDate?: string;
customTime?: string;
}): Promise<void> {
if (statusType) {
await this.selectStatusType(statusType);
}
if (message !== undefined) {
await this.setStatusMessage(message);
}
await this.selectDuration(duration);
if (customDate) {
await this.customDateInput.fill(customDate);
}
if (customTime) {
await this.customTimeInput.fill(customTime);
}
await this.save();
await this.toastMessages.dismissToast();
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class EnableRoomEncryptionModal extends Modal {
private readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Enable encryption' }));
this.toastMessages = new ToastMessages(page);
}
private get enableButton() {
return this.root.getByRole('button', { name: 'Enable encryption' });
}
async enable() {
await this.enableButton.click();
await this.waitForDismissal();
await this.toastMessages.dismissToast('success');
}
}

View File

@@ -0,0 +1,32 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class EnterE2EEPasswordModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Enter E2EE password' }));
}
private get passwordInput() {
return this.root.getByPlaceholder('Please enter your E2EE password');
}
private get forgotPasswordLink() {
return this.root.getByRole('link', { name: 'Forgot E2EE password?' });
}
private get enterE2EEPasswordButton() {
return this.root.getByRole('button', { name: 'Enable encryption' });
}
async enterPassword(password: string) {
await this.passwordInput.fill(password);
await this.enterE2EEPasswordButton.click();
await this.waitForDismissal();
}
async forgotPassword() {
await this.forgotPasswordLink.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,27 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class EnterPasswordModal extends Modal {
readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Please enter your password' }));
this.toastMessages = new ToastMessages(page);
}
private get inputPassword() {
return this.root.getByRole('textbox', { name: 'For your security, you must enter your current password to continue' });
}
private get btnVerify() {
return this.root.getByRole('button', { name: 'Verify' });
}
async enterPassword(password: string): Promise<void> {
await this.inputPassword.fill(password);
await this.btnVerify.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,58 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
export class FileUploadModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'File Upload' }));
}
private get inputFileName() {
return this.root.getByRole('textbox', { name: 'File name' });
}
private get updateButton() {
return this.root.getByRole('button', { name: 'Update' });
}
setName(fileName: string) {
return this.inputFileName.fill(fileName);
}
async update() {
await this.updateButton.click();
await this.waitForDismissal();
}
}
export class FileUploadWarningModal extends Modal {
constructor(root: Locator) {
super(root);
}
get btnOk() {
return this.root.getByRole('button', { name: 'Ok' });
}
get btnSendAnyway() {
return this.root.getByRole('button', { name: 'Send anyway' });
}
getContent(text: string) {
return this.root.getByText(text);
}
private get btnCancel() {
return this.root.getByRole('button', { name: 'Cancel' });
}
async cancel() {
await this.btnCancel.click();
await this.waitForDismissal();
}
async confirmSend() {
await this.btnSendAnyway.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,26 @@
export * from './apps-modal';
export * from './confirm-delete-modal';
export * from './confirm-logout-modal';
export * from './confirm-remove-modal';
export * from './create-new-modal';
export * from './manage-category-modal';
export * from './delete-account-modal';
export * from './delete-category-modal';
export * from './disable-room-encryption-modal';
export * from './edit-status-modal';
export * from './enable-room-encryption-modal';
export * from './enter-e2ee-password-modal';
export * from './enter-password-modal';
export * from './file-upload-modal';
export * from './omnichannel-close-chat-modal';
export * from './omnichannel-on-hold-modal';
export * from './omnichannel-transfer-chat-modal';
export * from './omnichannel-confirm-remove-chat';
export * from './omnichannel-contact-review-modal';
export * from './omnichannel-delete-contact-modal';
export * from './omnichannel-reset-priorities-modal';
export * from './omnichannel-return-to-queue-modal';
export * from './report-message-modal';
export * from './reset-e2ee-password-modal';
export * from './save-e2ee-password-modal';
export * from './upsell-modal';

View File

@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class ManageCategoryModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Manage category', exact: true }), page);
}
private get inputName() {
return this.root.getByRole('textbox', { name: 'Name', exact: true });
}
async rename(newName: string): Promise<void> {
await this.inputName.fill(newName);
await this.save();
}
}

View File

@@ -0,0 +1,39 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../../utils/test';
export abstract class Modal {
constructor(
protected root: Locator,
protected page?: Page,
) {
this.root = root;
this.page = page;
}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
private get btnClose() {
return this.root.getByRole('button', { name: 'Close' });
}
async close() {
await this.btnClose.click();
await this.waitForDismissal();
}
private get btnSave() {
return this.root.getByRole('button', { name: 'Save' });
}
async save() {
await this.btnSave.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,35 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class OmnichannelCloseChatModal extends Modal {
private readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Wrap up conversation' }));
this.toastMessages = new ToastMessages(page);
}
private get inputComment(): Locator {
return this.root.locator('input[name="comment"]');
}
private get btnConfirm(): Locator {
return this.root.locator('role=button[name="Confirm"]');
}
private get labelPDF(): Locator {
return this.root.locator('label[for="transcript-pdf"]');
}
async confirm(comment: string, downloadPDF: boolean): Promise<void> {
await this.inputComment.fill(comment);
if (downloadPDF) {
await this.labelPDF.click();
}
await this.btnConfirm.click();
await this.waitForDismissal();
await this.toastMessages.dismissToast('success');
}
}

View File

@@ -0,0 +1,18 @@
import type { Locator, Page } from 'playwright-core';
import { Modal } from './modal';
export class OmnichannelConfirmRemoveChat extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog'));
}
private get btnConfirmRemove(): Locator {
return this.root.getByRole('button', { name: 'Delete' });
}
async confirm() {
await this.btnConfirmRemove.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,27 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
import { expect } from '../../../utils/test';
import { Listbox } from '../listbox';
export class OmnichannelContactReviewModal extends Modal {
readonly listbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Review contact' }), page);
this.listbox = new Listbox(page);
}
private getFieldByName(name: string): Locator {
return this.root.getByLabel(name, { exact: true });
}
async solveConfirmation(field: string, value: string) {
await this.getFieldByName(field).click();
await this.listbox.selectOption(value);
const responsePromise = this.page?.waitForResponse('**/api/v1/omnichannel/contacts.conflicts');
await this.save();
const responseListener = await responsePromise;
expect(responseListener?.status()).toBe(200);
}
}

View File

@@ -0,0 +1,22 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
export class OmnichannelDeleteContactModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Delete Contact' }));
}
get inputConfirmation(): Locator {
return this.root.getByRole('textbox', { name: 'Confirm Contact Removal' });
}
get btnDelete(): Locator {
return this.root.getByRole('button', { name: 'Delete' });
}
async delete() {
await this.btnDelete.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,18 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
export class OmnichannelOnHoldModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Place chat On-Hold' }));
}
private get btnPlaceChatOnHold(): Locator {
return this.root.getByRole('button', { name: 'Place chat On-Hold' });
}
async confirm(): Promise<void> {
await this.btnPlaceChatOnHold.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class OmnichannelResetPrioritiesModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Reset priorities' }));
}
private get btnResetConfirm() {
return this.root.getByRole('button', { name: 'Reset' });
}
async reset() {
await this.btnResetConfirm.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class OmnichannelReturnToQueueModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Return back to the Queue' }));
}
private get btnReturnToQueueConfirm() {
return this.root.getByRole('button', { name: 'Confirm' });
}
async confirm() {
await this.btnReturnToQueueConfirm.click();
await this.waitForDismissal();
}
}

View File

@@ -0,0 +1,41 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
import { Listbox } from '../listbox';
export class OmnichannelTransferChatModal extends Modal {
private readonly listbox: Listbox;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Forward chat' }));
this.listbox = new Listbox(page);
}
get inputComment(): Locator {
return this.root.locator('textarea[name="comment"]');
}
get inputForwardDepartment(): Locator {
return this.root.getByLabel('Forward to department').getByRole('textbox');
}
get inputForwardUser(): Locator {
return this.root.getByLabel('Forward to user').getByRole('textbox');
}
get btnForward(): Locator {
return this.root.locator('role=button[name="Forward"]');
}
async selectDepartment(name: string) {
await this.inputForwardDepartment.click();
await this.inputForwardDepartment.fill(name);
await this.listbox.selectOption(name);
}
async selectUser(name: string, id?: string) {
await this.inputForwardUser.click();
await this.inputForwardUser.fill(name);
await this.listbox.selectOption(id || name);
}
}

View File

@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test';
import { Modal } from './modal';
import { expect } from '../../../utils/test';
import { ToastMessages } from '../toast-messages';
export class ReportMessageModal extends Modal {
readonly toastMessage: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Report message' }));
this.toastMessage = new ToastMessages(page);
}
get inputReportDescription(): Locator {
return this.root.getByRole('textbox', { name: 'Report reason' });
}
private get btnSubmitReport(): Locator {
return this.root.getByRole('button', { name: 'Report' });
}
private get btnCancelReport(): Locator {
return this.root.getByRole('button', { name: 'Cancel' });
}
private get alertInputDescription(): Locator {
return this.root.getByRole('alert');
}
async cancelReport(): Promise<void> {
await this.btnCancelReport.click();
await this.waitForDismissal();
}
async submitReport(description?: string): Promise<void> {
if (!description) {
await this.btnSubmitReport.click();
await expect(this.alertInputDescription).toBeVisible();
return;
}
await this.inputReportDescription.fill(description);
await this.btnSubmitReport.click();
await this.waitForDismissal();
await this.toastMessage.waitForDisplay({ type: 'success', message: 'Report has been sent' });
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { LoginPage } from '../../login';
export class ResetE2EEPasswordModal extends Modal {
private readonly login: LoginPage;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Reset E2EE password' }));
this.login = new LoginPage(page);
}
private get resetE2EEPasswordButton() {
return this.root.getByRole('button', { name: 'Reset E2EE password' });
}
async confirmReset() {
await this.resetE2EEPasswordButton.click();
await this.waitForDismissal();
await this.login.waitForIt();
}
}

View File

@@ -0,0 +1,31 @@
import type { Page } from 'playwright-core';
import { Modal } from './modal';
import { ToastMessages } from '../toast-messages';
export class SaveE2EEPasswordModal extends Modal {
private readonly toastMessages: ToastMessages;
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Save your new E2EE password' }));
this.toastMessages = new ToastMessages(page);
}
private get password() {
return this.root.getByLabel('Your E2EE password is:').getByRole('code');
}
private get savedPasswordButton() {
return this.root.getByRole('button', { name: 'I saved my password' });
}
async getPassword() {
return (await this.password.textContent()) ?? '';
}
async confirm() {
await this.savedPasswordButton.click();
await this.waitForDismissal();
await this.toastMessages.dismissToast('success');
}
}

View File

@@ -0,0 +1,15 @@
import type { Page } from '@playwright/test';
import { Modal } from './modal';
export class VoiceCallsUpsellModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Team voice calls' }));
}
}
export class OmnichannelUpsellDepartmentsModal extends Modal {
constructor(page: Page) {
super(page.getByRole('dialog', { name: 'Departments' }));
}
}

View File

@@ -0,0 +1,335 @@
import type { Locator, Page } from '@playwright/test';
import {
EditStatusModal,
CreateNewChannelModal,
CreateNewDiscussionModal,
CreateNewDMModal,
CreateNewTeamModal,
CreateNewCategoryModal,
} from './modals';
import { expect } from '../../utils/test';
export class Navbar {
readonly modals: {
'Channel': CreateNewChannelModal;
'Team': CreateNewTeamModal;
'Discussion': CreateNewDiscussionModal;
'Direct message': CreateNewDMModal;
'editStatus': EditStatusModal;
'Category': CreateNewCategoryModal;
};
constructor(private readonly root: Page) {
this.modals = {
'Channel': new CreateNewChannelModal(root),
'Team': new CreateNewTeamModal(root),
'Discussion': new CreateNewDiscussionModal(root),
'Direct message': new CreateNewDMModal(root),
'editStatus': new EditStatusModal(root),
'Category': new CreateNewCategoryModal(root),
};
}
get btnVoiceAndOmnichannel(): Locator {
return this.root.getByRole('button', { name: 'Voice and omnichannel' });
}
get groupHistoryNavigation(): Locator {
return this.root.getByRole('group', { name: 'History navigation' });
}
get pagesGroup(): Locator {
return this.root.getByRole('group', { name: 'Pages and actions' });
}
get omnichannelGroup(): Locator {
return this.root.getByRole('group', { name: 'Omnichannel' });
}
get btnContactCenter(): Locator {
return this.omnichannelGroup.getByRole('button', { name: 'Contact Center' });
}
get voiceCallGroup(): Locator {
return this.root.getByRole('group', { name: 'Voice call' });
}
get btnNewVoiceCall(): Locator {
return this.voiceCallGroup.getByRole('button', { name: 'New voice call' });
}
get btnSwitchOmnichannelStatus(): Locator {
return this.omnichannelGroup.getByRole('button', { name: 'answer chats' });
}
get btnHome(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Home' });
}
get btnDirectory(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Directory' });
}
get btnMarketplace(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Marketplace' });
}
get btnMenuPages(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Pages' });
}
get btnDisplay(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Display' });
}
get menuDisplay(): Locator {
return this.root.getByRole('menu', { name: 'Display' });
}
get groupDisplay(): Locator {
return this.menuDisplay.getByRole('group', { name: 'Display' });
}
getDisplayMenuItem(mode: 'Extended' | 'Medium' | 'Condensed' | 'Avatars'): Locator {
return this.groupDisplay.getByRole('menuitemcheckbox', { name: mode });
}
get groupSortBy(): Locator {
return this.menuDisplay.getByRole('group', { name: 'Sort by' });
}
getSortMenuItem(mode: 'Activity' | 'Name'): Locator {
return this.groupSortBy.getByRole('menuitemcheckbox', { name: mode });
}
get groupGroupBy(): Locator {
return this.menuDisplay.getByRole('group', { name: 'Group by' });
}
getGroupByMenuItem(mode: 'Unread' | 'Favorites' | 'Types'): Locator {
return this.groupGroupBy.getByRole('menuitemcheckbox', { name: mode });
}
get btnCreateNew(): Locator {
return this.pagesGroup.getByRole('button', { name: 'Create new' });
}
get createNewMenu(): Locator {
return this.root.getByRole('menu', { name: 'Create new' });
}
get navbarSearchSection(): Locator {
return this.root.getByRole('search');
}
get searchInput(): Locator {
return this.navbarSearchSection.getByRole('combobox');
}
get searchList(): Locator {
return this.navbarSearchSection.getByRole('listbox', { name: 'Channels' });
}
get workspaceGroup(): Locator {
return this.root.getByRole('group', { name: 'Workspace and user preferences' });
}
get btnManageWorkspace(): Locator {
return this.workspaceGroup.getByRole('button', { name: 'Manage' });
}
async openManageMenuItem(name: 'Workspace' | 'Omnichannel'): Promise<void> {
await this.btnManageWorkspace.click();
await this.root.getByRole('menu', { name: 'Manage' }).getByRole('menuitem', { name }).click();
}
get btnUserMenu(): Locator {
return this.workspaceGroup.getByRole('button', { name: 'User menu' });
}
get userMenu(): Locator {
return this.root.getByRole('menu', { name: 'User menu' });
}
get btnLogout(): Locator {
return this.userMenu.getByRole('menuitemcheckbox', { name: 'Logout' });
}
get btnCustomStatus(): Locator {
return this.userMenu.getByRole('menuitemcheckbox', { name: 'Custom...' });
}
getUserProfileMenuOption(name: string): Locator {
return this.userMenu.getByRole('menuitemcheckbox', { name });
}
createNewMenuItem(name: 'Direct message' | 'Discussion' | 'Channel' | 'Team' | 'Outbound message' | 'Category'): Locator {
return this.createNewMenu.getByRole('menuitem', { name });
}
async openCreate(name: 'Direct message' | 'Discussion' | 'Channel' | 'Team' | 'Category'): Promise<void> {
await this.btnCreateNew.click();
await this.createNewMenuItem(name).click();
}
async openCreateCategory(): Promise<void> {
await this.btnCreateNew.click();
await this.createNewMenu.getByRole('menuitem', { name: 'Category', exact: true }).click();
}
async logout(): Promise<void> {
await this.btnUserMenu.click();
return this.btnLogout.click();
}
btnSidebarToggler(closeSidebar?: boolean): Locator {
return this.root.getByRole('button', { name: closeSidebar ? 'Close sidebar' : 'Open sidebar' });
}
async openAdminPanel(): Promise<void> {
await this.btnManageWorkspace.click();
await this.root.getByRole('menuitem', { name: 'Workspace' }).click();
await this.root.waitForURL(/\/admin/);
}
async typeSearch(name: string): Promise<void> {
return this.searchInput.fill(name);
}
async waitForChannel(): Promise<void> {
await this.root.locator('role=main').waitFor();
await this.root.locator('role=main >> role=heading[level=1]').waitFor();
const messageList = this.root.getByRole('main').getByRole('list', { name: 'Message list', exact: true });
await messageList.waitFor();
await expect(messageList).not.toHaveAttribute('aria-busy', 'true');
}
getSearchRoomByName(name: string): Locator {
return this.searchList.getByRole('option', { name }).filter({ has: this.root.getByText(name, { exact: true }) });
}
getSearchItemBadge(name: string): Locator {
return this.getSearchRoomByName(name).getByRole('status', { name: 'unread' });
}
async openChat(name: string): Promise<void> {
await this.typeSearch(name);
await this.getSearchRoomByName(name).waitFor();
await this.getSearchRoomByName(name).click();
await this.waitForChannel();
}
async setDisplayMode(mode: 'Extended' | 'Medium' | 'Condensed'): Promise<void> {
await this.btnDisplay.click();
await this.menuDisplay.getByRole('menuitemcheckbox', { name: mode }).click();
await this.root.keyboard.press('Escape');
}
async createNew(
type: 'Channel' | 'Team',
name: string,
options?: { private?: boolean; encrypted?: boolean; readOnly?: boolean; federated?: boolean; members?: string[] },
): Promise<void> {
await this.openCreate(type);
await this.modals[type].inputName.fill(name);
if (options?.private === false) {
await this.modals[type].checkboxPrivate.click();
}
if (options?.members && options.members.length > 0) {
await Promise.all(options.members.map((member) => this.modals[type].addMember(member)));
}
if (options && ('encrypted' in options || 'readOnly' in options || 'federated' in options)) {
await this.modals[type].advancedSettingsAccordion.click();
}
if (options?.encrypted) {
await this.modals[type].checkboxEncrypted.click();
}
if (options?.readOnly) {
await this.modals[type].checkboxReadOnly.click();
}
if (options?.federated) {
await this.modals[type].checkboxFederated.click();
}
await this.modals[type].btnCreate.click();
}
async createNewDM(username: string): Promise<void> {
await this.openCreate('Direct message');
await this.modals['Direct message'].inviteUserToDM(username);
await this.modals['Direct message'].btnCreate.click();
}
async createNewCategory(name: string): Promise<void> {
await this.openCreate('Category');
await this.modals.Category.inputName.fill(name);
await this.modals.Category.create();
}
async createNewDiscussion(parentRoom: string, name: string, message?: string): Promise<void> {
await this.openCreate('Discussion');
await this.modals.Discussion.inputParentRoom.click();
await this.modals.Discussion.inputParentRoom.pressSequentially(parentRoom);
await this.modals.Discussion.getParentRoomListItem(parentRoom).click();
await this.modals.Discussion.inputName.fill(name);
message && (await this.modals.Discussion.inputMessage.fill(message));
await this.modals.Discussion.btnCreate.click();
}
async createEncryptedChannel(name: string): Promise<void> {
await this.createNew('Channel', name, { encrypted: true });
}
async changeUserStatus(status: 'online' | 'away' | 'busy' | 'invisible' | 'offline' | string): Promise<void> {
await this.btnUserMenu.click();
await this.getUserProfileMenuOption(status).click();
}
async changeUserCustomStatus(text?: string): Promise<void> {
await this.openEditStatusModal();
await this.modals.editStatus.changeStatusMessage(text);
}
get editStatusModal(): EditStatusModal {
return this.modals.editStatus;
}
async openEditStatusModal(): Promise<void> {
await this.btnUserMenu.click();
await this.btnCustomStatus.click();
}
async changeUserCustomStatusWithExpiration(options: {
message?: string;
statusType?: string;
duration: string;
customDate?: string;
customTime?: string;
}): Promise<void> {
await this.openEditStatusModal();
await this.modals.editStatus.setStatusWithExpiration(options);
}
async switchOmnichannelStatus(status: 'offline' | 'online') {
const toggleButton = this.btnSwitchOmnichannelStatus;
await expect(toggleButton).toBeVisible();
const expectedTitle = status === 'offline' ? 'Turn on answer chats' : 'Turn off answer chats';
const currentStatus = await toggleButton.getAttribute('title');
if (currentStatus !== expectedTitle) {
await toggleButton.click();
}
await expect(toggleButton).toHaveAttribute('title', expectedTitle);
}
getUserStatusBadge(status: 'online' | 'away' | 'busy' | 'offline'): Locator {
return this.btnUserMenu.locator(`svg[class*="${status}"]`);
}
}

View File

@@ -0,0 +1,15 @@
import type { Locator } from 'playwright-core';
import { expect } from '../../utils/test';
export abstract class Popup {
constructor(protected root: Locator) {}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
}

View File

@@ -0,0 +1,42 @@
import type { Locator } from '@playwright/test';
import { MenuMoveTo } from './menu';
import { CreateNewCategoryModal } from './modals';
export class RoomHeader {
readonly menuMoveTo: MenuMoveTo;
readonly createCategoryModal: CreateNewCategoryModal;
constructor(public root: Locator) {
this.menuMoveTo = new MenuMoveTo(this.root.page());
this.createCategoryModal = new CreateNewCategoryModal(this.root.page());
}
private get btnSelectCategory(): Locator {
return this.root.getByRole('button', { name: 'Move to', exact: true });
}
async openCategorySelector(): Promise<void> {
await this.btnSelectCategory.click();
await this.menuMoveTo.waitForDisplay();
}
async pickCategoryMenuItem(name: string): Promise<void> {
await this.openCategorySelector();
await this.menuMoveTo.selectMenuItem(name);
}
async checkRoomBelongsToGroup(optionName: string): Promise<boolean> {
await this.openCategorySelector();
const exists = await this.menuMoveTo.getMenuItem(optionName).isVisible();
await this.root.page().keyboard.press('Escape');
return exists;
}
async createCategory(name: string): Promise<void> {
await this.pickCategoryMenuItem('New category');
await this.createCategoryModal.inputName.fill(name);
await this.createCategoryModal.create(true);
}
}

View File

@@ -0,0 +1,256 @@
import type { Locator, Page } from '@playwright/test';
import { MenuOptions, MenuMoveTo } from './menu';
import { DeleteCategoryModal, ManageCategoryModal, CreateNewCategoryModal } from './modals';
import { expect } from '../../utils/test';
export abstract class Sidebar {
constructor(protected root: Locator) {}
get btnClose(): Locator {
return this.root.getByRole('button', { name: 'Close' });
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
}
export class RoomSidebar extends Sidebar {
readonly menuOptions: MenuOptions;
readonly menuMoveTo: MenuMoveTo;
readonly modals: {
deleteCategory: DeleteCategoryModal;
manageCategory: ManageCategoryModal;
createCategory: CreateNewCategoryModal;
};
constructor(protected page: Page) {
super(page.getByRole('navigation', { name: 'Sidebar' }));
this.modals = {
deleteCategory: new DeleteCategoryModal(page),
manageCategory: new ManageCategoryModal(page),
createCategory: new CreateNewCategoryModal(page),
};
this.menuOptions = new MenuOptions(page);
this.menuMoveTo = new MenuMoveTo(page);
}
get teamCollabFilters(): Locator {
return this.root.getByRole('tablist', { name: 'Team collaboration filters' });
}
get omnichannelFilters(): Locator {
return this.root.getByRole('tablist', { name: 'Omnichannel filters' });
}
get allTeamCollabFilter(): Locator {
return this.teamCollabFilters.getByRole('tab', { name: 'All' });
}
get favoritesTeamCollabFilter(): Locator {
return this.teamCollabFilters.getByRole('tab', { name: 'Favorites' });
}
get discussionsTeamCollabFilter(): Locator {
return this.teamCollabFilters.getByRole('tab', { name: 'Discussions' });
}
// TODO: fix this filter, workaround due to virtuoso
get topChannelList(): Locator {
return this.root.getByTestId('virtuoso-top-item-list');
}
get channelsList(): Locator {
// TODO: fix this filter, workaround due to virtuoso
// return this.sidebar.getByRole('list', { name: 'Channels' }).filter({ has: this.page.getByRole('listitem') });
return this.root.getByTestId('virtuoso-item-list');
}
getSidebarItemByName(name: string) {
return this.channelsList.getByRole('link', { name }).filter({ has: this.page.getByText(name, { exact: true }) });
}
getFilterItemByName(name: string): Locator {
return this.root.getByRole('button', { name }).filter({ has: this.page.getByText(name, { exact: true }) });
}
getSidebarListItem(name: string): Locator {
return this.root.getByRole('listitem').filter({ has: this.page.getByText(name, { exact: true }) });
}
get firstCollapser(): Locator {
return this.topChannelList.getByRole('group').first().getByRole('button').first();
}
get teamsCollapser(): Locator {
return this.root.getByRole('group', { name: 'Collapse Teams' }).first();
}
get channelsCollapser(): Locator {
return this.channelsList.getByRole('group', { name: 'Collapse Channels' });
}
get directMessagesCollapser(): Locator {
return this.channelsList.getByRole('group', { name: 'Collapse Direct messages' });
}
get firstChannelFromList(): Locator {
return this.channelsList.getByRole('listitem').first();
}
async markItemAsUnread(item: Locator): Promise<void> {
await item.hover();
await item.focus();
await item.getByRole('button', { name: 'Options', exact: true }).click();
await this.page.getByRole('menuitem', { name: 'Mark Unread' }).click();
}
getCollapseGroupByName(name: string): Locator {
return this.root.getByRole('button').filter({ has: this.page.getByRole('heading', { name, exact: true }) });
}
getItemUnreadBadge(item: Locator): Locator {
return item.getByRole('status', { name: 'unread' });
}
getBadgeIndicator(name: string, title: string): Locator {
return this.getSidebarItemByName(name).getByTitle(title);
}
async selectPriority(name: string, priority: string) {
const sidebarItem = this.getSidebarItemByName(name);
await sidebarItem.hover();
await sidebarItem.focus();
await sidebarItem.getByRole('button', { name: 'Options', exact: true }).click();
await this.page.getByRole('menuitem', { name: priority }).click();
}
getSidebarListItemByName(name: string): Locator {
return this.channelsList.getByRole('listitem').filter({ has: this.getSidebarItemByName(name) });
}
getCategoryCollapser(name: string): Locator {
return this.root.getByRole('group', { name: `Collapse ${name}`, exact: true }).first();
}
getCategoryKebab(name: string): Locator {
return this.getCategoryCollapser(name).getByRole('button', { name: 'Options', exact: true });
}
async openCategoryMenu(name: string): Promise<void> {
await this.getCategoryCollapser(name).hover();
await this.getCategoryKebab(name).click();
}
async renameCategory(name: string, newName: string): Promise<void> {
await this.openCategoryMenu(name);
await this.page.getByRole('menuitemcheckbox', { name: 'Manage', exact: true }).click();
await this.modals.manageCategory.rename(newName);
}
async deleteCategory(name: string): Promise<void> {
await this.openCategoryMenu(name);
await this.page.getByRole('menuitemcheckbox', { name: 'Delete', exact: true }).click();
await this.modals.deleteCategory.delete();
}
get moveToOption(): Locator {
return this.page.getByRole('menuitem', { name: 'Move to', exact: true });
}
async openRoomMenu(name: string): Promise<void> {
const item = this.getSidebarItemByName(name);
await item.hover();
await item.focus();
await item.getByRole('button', { name: 'Options', exact: true }).click();
await this.menuOptions.waitForDisplay();
}
/** The open submenu keeps the first Escape, so the kebab menu itself needs a second one. */
async closeRoomMenu(): Promise<void> {
await this.page.keyboard.press('Escape');
await this.page.keyboard.press('Escape');
await expect(this.moveToOption).toBeHidden();
}
/** Move a room into a custom category (or to "Favorites") through the kebab "Move to ▸" submenu. */
async moveRoomToCategory(roomName: string, categoryName: string): Promise<void> {
await this.openRoomMoveToSubmenu(roomName);
await this.menuMoveTo.selectMenuItem(categoryName);
}
async removeRoomFromCategory(roomName: string, categoryName: string): Promise<void> {
await this.openRoomMoveToSubmenu(roomName);
await this.menuMoveTo.selectMenuItem(`Remove from ${categoryName}`);
}
async createCategoryFromRoom(roomName: string, name: string): Promise<void> {
await this.openRoomMoveToSubmenu(roomName);
await this.menuMoveTo.selectMenuItem('New category');
await this.modals.createCategory.inputName.fill(name);
await this.modals.createCategory.create(true);
}
async moveRoomToFavorites(roomName: string): Promise<void> {
await this.openRoomMoveToSubmenu(roomName);
await this.menuMoveTo.selectMenuItem('Favorites');
}
async removeRoomFromFavorites(roomName: string): Promise<void> {
await this.openRoomMoveToSubmenu(roomName);
await this.menuMoveTo.selectMenuItem('Remove from Favorites');
}
async openRoomMoveToSubmenu(roomName: string): Promise<void> {
await this.openRoomMenu(roomName);
await this.menuOptions.selectMenuItem('Move to', true);
await this.menuMoveTo.waitForDisplay();
}
}
export class AdminSidebar extends Sidebar {
constructor(page: Page) {
super(page.getByRole('navigation', { name: 'Administration' }));
}
get linkEmoji() {
return this.root.getByRole('link', { name: 'Emoji' });
}
async close(): Promise<void> {
await this.btnClose.click();
await this.waitForDismissal();
}
}
export class AccountSidebar extends Sidebar {
constructor(page: Page) {
super(page.getByRole('navigation', { name: 'Account' }));
}
get linkSecurity(): Locator {
return this.root.getByRole('link', { name: 'Security' });
}
async close(): Promise<void> {
await this.btnClose.click();
await this.waitForDismissal();
}
}
export class OmnichannelSidebar extends Sidebar {
constructor(page: Page) {
super(page.getByRole('navigation', { name: 'Omnichannel' }));
}
getSidebarLinkByName(name: string): Locator {
return this.root.getByRole('link', { name, exact: true });
}
}

View File

@@ -0,0 +1,61 @@
import type { Locator, Page } from '@playwright/test';
export class Sidepanel {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get sidepanel(): Locator {
return this.page.getByRole('tabpanel', { name: 'Side panel' });
}
get sidepanelList(): Locator {
return this.sidepanel.getByRole('list', { name: 'Channels' });
}
get firstChannelFromList(): Locator {
return this.sidepanelList.getByRole('listitem').first();
}
get unreadCheckbox(): Locator {
return this.sidepanel.getByRole('heading').getByRole('checkbox', { name: 'Unread' });
}
get unreadToggleLabel(): Locator {
return this.sidepanel.getByRole('heading').locator('label', { hasText: 'Unread' });
}
get sidepanelBackButton(): Locator {
return this.sidepanel.getByRole('button', { name: 'Back' });
}
getSidepanelHeader(name: string): Locator {
return this.sidepanel.getByRole('heading', { name, exact: true });
}
getTeamItemByName(name: string): Locator {
return this.sidepanelList
.getByRole('link')
.filter({ hasText: name })
.filter({ hasNot: this.page.getByRole('button', { name }) });
}
getMainRoomByName(name: string): Locator {
return this.getTeamItemByName(name);
}
getItemByName(name: string): Locator {
return this.sidepanelList.getByRole('link', { name });
}
getSidepanelItem(name: string, subtitle?: string): Locator {
const regex = new RegExp(`${name}.*${subtitle}`);
return this.sidepanelList.getByRole('link', { name: regex });
}
getItemUnreadBadge(item: Locator): Locator {
return item.getByRole('status', { name: 'unread' });
}
}

View File

@@ -0,0 +1,43 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../utils/test';
export class Table {
constructor(protected root: Locator) {}
/**
* @param fallback also satisfies the wait, for tables the page may legitimately
* replace with something else — an empty state, most commonly.
*/
waitForDisplay(fallback?: Locator) {
return expect(fallback ? this.root.or(fallback) : this.root).toBeVisible();
}
findRowByName(name: string): Locator {
return this.root.getByRole('row').filter({
has: this.root.page().getByText(name, { exact: true }),
});
}
}
export class DevicesTable extends Table {
constructor(page: Page) {
super(page.getByRole('table', { name: 'Devices' }));
}
getRowsForUsername(username: string): Locator {
return this.root.getByRole('row').getByRole('cell', { name: username, exact: true });
}
getDeviceRowById(deviceId: string): Locator {
return this.findRowByName(deviceId);
}
getColumnHeaderByName(name: string): Locator {
return this.root.getByRole('cell', { name, exact: true });
}
async orderByLastLogin() {
await this.getColumnHeaderByName('Last login').click();
}
}

View File

@@ -0,0 +1,29 @@
import type { Page } from '@playwright/test';
import { expect } from '../../utils/test';
export class ToastMessages {
constructor(private readonly page: Page) {}
private readonly toastByType = {
success: this.page.locator('.rcx-toastbar--success'),
error: this.page.locator('.rcx-toastbar--error'),
};
async dismissToast(type: 'success' | 'error' = 'success') {
await this.toastByType[type].last().getByRole('button', { name: 'Dismiss alert' }).click();
await this.page.mouse.move(0, 0);
}
waitForDisplay({ type, message }: { type: 'success' | 'error'; message?: string } = { type: 'success' }) {
if (message) {
return expect(
this.toastByType[type].last().locator(`[role=${type === 'error' ? '"alert"' : '"status"'}]`, {
hasText: message,
}),
).toBeVisible();
}
return expect(this.toastByType[type].last()).toBeVisible();
}
}

View File

@@ -0,0 +1,212 @@
import type { Locator, Page } from '@playwright/test';
import { MenuOptions } from './menu';
import { OmnichannelCloseChatModal, OmnichannelOnHoldModal } from './modals';
export abstract class Toolbar {
constructor(protected root: Locator) {}
click() {
return this.root.click();
}
}
export class RoomToolbar extends Toolbar {
readonly menu: MenuOptions;
constructor(page: Page) {
super(page.getByRole('toolbar', { name: 'Primary Room actions' }));
this.menu = new MenuOptions(page);
}
get btnRoomInfo() {
return this.root.getByRole('button', { name: 'Room Information' });
}
get btnMembers() {
return this.root.getByRole('button', { name: 'Members' });
}
get btnVideoCall() {
return this.root.getByRole('button', { name: 'Video Call' });
}
get btnVoiceCall() {
return this.root.getByRole('button', { name: 'Voice call' });
}
get btnUserInfo(): Locator {
return this.root.getByRole('button', { name: 'User Info' });
}
get btnDiscussion(): Locator {
return this.root.getByRole('button', { name: 'Discussions' });
}
get btnThreads(): Locator {
return this.root.getByRole('button', { name: 'Threads' });
}
get btnFiles(): Locator {
return this.root.getByRole('button', { name: 'Files' });
}
get btnMoreOptions(): Locator {
return this.root.getByRole('button', { name: 'Options' });
}
get btnSearchMessages(): Locator {
return this.root.getByRole('button', { name: 'Search Messages' });
}
get btnDisableE2EEncryption(): Locator {
return this.root.getByRole('button', { name: 'Disable E2E encryption' });
}
get menuItemExportMessages(): Locator {
return this.menu.getMenuItem('Export messages');
}
get menuItemMentions(): Locator {
return this.menu.getMenuItem('Mentions');
}
get menuItemStarredMessages(): Locator {
return this.menu.getMenuItem('Starred Messages');
}
get menuItemPinnedMessages(): Locator {
return this.menu.getMenuItem('Pinned Messages');
}
get menuItemPruneMessages(): Locator {
return this.menu.getMenuItem('Prune Messages');
}
get menuItemAutoTranslate(): Locator {
return this.menu.getMenuItem('Auto-Translate');
}
get menuItemNotificationsPreferences(): Locator {
return this.menu.getMenuItem('Notifications Preferences');
}
get menuItemDisableE2EEncryption(): Locator {
return this.menu.getMenuItem('Disable E2E encryption');
}
get menuItemEnableE2EEncryption(): Locator {
return this.menu.getMenuItem('Enable E2E encryption');
}
get menuItemFiles(): Locator {
return this.menu.getMenuItem('Files');
}
async openRoomInfo() {
await this.btnRoomInfo.click();
}
async openMembersTab() {
await this.btnMembers.click();
}
async openUserInfo() {
await this.btnUserInfo.click();
}
async openMoreOptions() {
await this.btnMoreOptions.click();
}
private get btnTeamChannels(): Locator {
return this.root.getByRole('button', { name: 'Team Channels' });
}
async openTeamChannels() {
await this.btnTeamChannels.click();
}
async waitFor() {
await this.root.waitFor({ state: 'visible' });
}
}
export class TeamToolbar extends RoomToolbar {
private get menuItemTeamMembers() {
return this.menu.getMenuItem('Teams Members');
}
private get btnTeamInfo() {
return this.root.getByRole('button', { name: 'Team info' });
}
async openTeamMembers() {
await this.menuItemTeamMembers.click();
}
async openTeamInfo() {
await this.btnTeamInfo.click();
}
}
export class EncryptedRoomToolbar extends RoomToolbar {}
export class OmnichannelRoomToolbar extends RoomToolbar {
private get btnContactInfo(): Locator {
return this.root.getByRole('button', { name: 'Contact Information' });
}
private get btnCannedResponses(): Locator {
return this.root.getByRole('button', { name: 'Canned Responses' });
}
async openContactInfo() {
await this.btnContactInfo.click();
}
async openCannedResponses() {
await this.btnCannedResponses.click();
}
}
export class OmnichannelQuickActionsRoomToolbar extends Toolbar {
private closeChatModal: OmnichannelCloseChatModal;
private onHoldModal: OmnichannelOnHoldModal;
constructor(page: Page) {
super(page.getByRole('toolbar', { name: 'Omnichannel Quick Actions' }));
this.closeChatModal = new OmnichannelCloseChatModal(page);
this.onHoldModal = new OmnichannelOnHoldModal(page);
}
get btnOnHold(): Locator {
return this.root.getByRole('button', { name: 'Place chat On-Hold' });
}
get btnForwardChat(): Locator {
return this.root.getByRole('button', { name: 'Forward chat' });
}
get btnEndConversation(): Locator {
return this.root.getByRole('button', { name: 'End conversation' });
}
/**
* FIXME: This `clickCount` seems a hack for a bad implementation
*/
async placeChatOnHold() {
await this.btnOnHold.click({ clickCount: 2 });
await this.onHoldModal.confirm();
}
async forwardChat() {
await this.btnForwardChat.click();
}
async closeChat({ comment = 'any_comment', downloadPDF = false } = {}): Promise<void> {
await this.btnEndConversation.click();
await this.closeChatModal.confirm(comment, downloadPDF);
}
}

View File

@@ -0,0 +1,31 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../utils/test';
export class UserCard {
readonly root: Locator;
constructor(protected page: Page) {
this.root = page.getByRole('dialog', { name: 'User card', exact: true });
}
waitForDisplay() {
return expect(this.root).toBeVisible();
}
waitForDismissal() {
return expect(this.root).not.toBeVisible();
}
get btnSeeFullProfile(): Locator {
return this.root.getByRole('button', { name: 'See full profile', exact: true });
}
get imgUserCard(): Locator {
return this.root.locator('img');
}
async openUserInfo() {
await this.btnSeeFullProfile.click();
}
}

View File

@@ -0,0 +1,399 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../utils/test';
const timerToSeconds = (time: string): number => {
const [seconds, minutes, hours = '00'] = time.split(':').reverse();
if (!seconds || !minutes) {
throw new Error('Voice call timer has an invalid time format');
}
return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
};
export class VoiceCallControls {
private readonly _controls: Locator;
constructor(controls: Locator) {
this._controls = controls;
}
get call(): Locator {
return this._controls.getByRole('button', { name: 'Call', exact: true });
}
get accept(): Locator {
return this._controls.getByRole('button', { name: 'Accept', exact: true });
}
get reject(): Locator {
return this._controls.getByRole('button', { name: 'Reject', exact: true });
}
get hangup(): Locator {
return this._controls.getByRole('button', { name: 'End call with', exact: false });
}
get cancel(): Locator {
return this._controls.getByRole('button', { name: 'Cancel', exact: true });
}
get dialpad(): Locator {
return this._controls.getByRole('button', { name: /Dialpad/i });
}
get mute(): Locator {
return this._controls.getByRole('button', { name: /Mute/i });
}
get hold(): Locator {
return this._controls.getByRole('button', { name: /Hold|Resume/i });
}
get transfer(): Locator {
return this._controls.getByRole('button', { name: 'Forward', exact: true });
}
get shareScreen(): Locator {
return this._controls.getByRole('button', { name: /Share screen|Stop sharing screen/i });
}
get toggleChat(): Locator {
return this._controls.getByRole('button', { name: /Hide chat|Show chat/i });
}
get directMessage(): Locator {
return this._controls.getByRole('button', { name: /Direct message/i });
}
get popout(): Locator {
return this._controls.getByRole('button', { name: /Open in new window|Return to main window/i });
}
}
export class TransferModal {
private readonly root: Locator;
private readonly page: Page;
constructor(page: Page, root: Locator) {
this.page = page;
this.root = root;
}
get content(): Locator {
return this.root;
}
get input(): Locator {
return this.root.getByRole('textbox');
}
get hangUpAndTransfer(): Locator {
return this.root.getByRole('button', { name: 'Hang up and transfer call', exact: true });
}
async transferCall(username: string): Promise<void> {
// await this.input.click();
await this.input.fill(username);
// Options have to be gotten from the page since the Options are rendered on a portal
await this.page.getByRole('option', { name: username }).getByRole('figure').click();
await expect(this.content).toContainText(username);
await this.hangUpAndTransfer.click();
}
}
export class Widget {
private readonly root: Locator;
private readonly callControls: VoiceCallControls;
private readonly headerControls: VoiceCallControls;
private readonly transferModal: TransferModal;
constructor(private readonly page: Page) {
this.transferModal = new TransferModal(page, page.getByRole('dialog', { name: 'Transfer call' }));
this.root = page.getByRole('dialog', { name: 'Voice call', exact: false });
this.callControls = new VoiceCallControls(this.root.getByRole('group'));
this.headerControls = new VoiceCallControls(this.root.getByRole('banner'));
}
get content(): Locator {
return this.root;
}
get controls(): VoiceCallControls {
return this.callControls;
}
get timer(): Locator {
return this.root.getByRole('time');
}
get btnShowCallHere(): Locator {
return this.root.getByRole('button', { name: 'Show call here' });
}
async showCallHere(): Promise<void> {
await this.btnShowCallHere.click();
await expect(this.btnShowCallHere).not.toBeVisible();
}
async getTimerContentInSeconds() {
const text = await this.timer.innerText();
return timerToSeconds(text);
}
async initiateCall(username?: string): Promise<void> {
if (username) {
await this.root.getByRole('textbox', { name: 'Enter username or number' }).fill(username);
await this.page.getByRole('listbox').getByRole('option', { name: username }).click();
}
await this.callControls.call.click();
await expect(this.callControls.cancel).toBeVisible();
}
async acceptCall(): Promise<void> {
await this.callControls.accept.click();
await expect(this.callControls.hangup).toBeVisible();
}
async hangup(): Promise<void> {
await this.callControls.hangup.click();
await expect(this.content).not.toBeVisible();
}
async reject(): Promise<void> {
await this.callControls.reject.click();
await expect(this.content).not.toBeVisible();
}
async goToDm(): Promise<void> {
await this.headerControls.directMessage.click();
await expect(this.content).not.toBeVisible();
}
async transferCall(username: string): Promise<void> {
await this.callControls.transfer.click();
await expect(this.transferModal.content).toBeVisible();
await this.transferModal.transferCall(username);
await expect(this.transferModal.content).not.toBeVisible();
await expect(this.content).not.toBeVisible();
}
async muteSelf(): Promise<void> {
await this.callControls.mute.click();
await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute');
}
async unmuteSelf(): Promise<void> {
await this.callControls.mute.click();
await expect(this.callControls.mute).toHaveAttribute('title', 'Mute');
}
async holdSelf(): Promise<void> {
await this.callControls.hold.click();
await expect(this.callControls.hold).toHaveAttribute('title', 'Resume');
}
async resumeSelf(): Promise<void> {
await this.callControls.hold.click();
await expect(this.callControls.hold).toHaveAttribute('title', 'Hold');
}
async openDialpad(): Promise<void> {
await this.callControls.dialpad.click();
await expect(this.callControls.dialpad).toHaveAttribute('title', 'Close dialpad');
}
async closeDialpad(): Promise<void> {
await this.callControls.dialpad.click();
await expect(this.callControls.dialpad).toHaveAttribute('title', 'Open dialpad');
}
get allScreenShareVideos(): Locator {
return this.root.locator('video');
}
async shareScreen(): Promise<void> {
await this.callControls.shareScreen.click();
await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen');
}
async stopSharing(): Promise<void> {
await this.callControls.shareScreen.click();
await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen');
}
async openPopout(): Promise<void> {
await this.headerControls.popout.click();
}
peerCard(username: string): Locator {
return this.root.getByText(username);
}
}
export class RoomSection {
private readonly root: Locator;
public readonly callControls: VoiceCallControls;
public readonly otherControls: VoiceCallControls;
constructor(root: Locator) {
this.root = root;
// Right now there are 2 control groups in the room view, with no discrimination between them
this.callControls = new VoiceCallControls(this.root.getByRole('group').first());
this.otherControls = new VoiceCallControls(this.root.getByRole('group').last());
}
get content(): Locator {
return this.root;
}
get controls(): VoiceCallControls {
return this.callControls;
}
get timer(): Locator {
return this.root.getByRole('time');
}
get allScreenShareVideos(): Locator {
return this.root.locator('video');
}
get btnShowCallHere(): Locator {
return this.root.getByRole('button', { name: 'Show call here' });
}
async showCallHere(): Promise<void> {
await this.btnShowCallHere.click();
await expect(this.btnShowCallHere).not.toBeVisible();
}
async getTimerContentInSeconds() {
const text = await this.timer.innerText();
return timerToSeconds(text);
}
async hangup(): Promise<void> {
await this.callControls.hangup.click();
await expect(this.content).not.toBeVisible();
}
async hideChat(): Promise<void> {
await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i);
await this.otherControls.toggleChat.click();
await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i);
}
async showChat(): Promise<void> {
await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i);
await this.otherControls.toggleChat.click();
await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i);
}
async muteSelf(): Promise<void> {
await this.callControls.mute.click();
await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute');
}
async unmuteSelf(): Promise<void> {
await this.callControls.mute.click();
await expect(this.callControls.mute).toHaveAttribute('title', 'Mute');
}
async holdSelf(): Promise<void> {
await this.callControls.hold.click();
await expect(this.callControls.hold).toHaveAttribute('title', 'Resume');
}
async resumeSelf(): Promise<void> {
await this.callControls.hold.click();
await expect(this.callControls.hold).toHaveAttribute('title', 'Hold');
}
async shareScreen(): Promise<void> {
await this.callControls.shareScreen.click();
await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen');
}
async stopSharing(): Promise<void> {
await this.callControls.shareScreen.click();
await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen');
}
peerCard(username: string): Locator {
return this.root.getByText(username);
}
async openPopout(): Promise<void> {
await this.otherControls.popout.click();
}
}
export class PopoutPage extends RoomSection {
private readonly page: Page;
constructor(page: Page) {
super(page.getByRole('main', { name: 'Voice call' }));
this.page = page;
}
override async hangup(): Promise<void> {
const pageClosed = new Promise((resolve) => this.page.on('close', () => resolve(true)));
await this.callControls.hangup.click();
await expect(pageClosed).resolves.toBe(true);
}
}
export class VoiceCalls {
public readonly widget: Widget;
public readonly roomSection: RoomSection;
public popoutPage: PopoutPage | undefined;
private readonly page: Page;
constructor(page: Page) {
this.page = page;
this.widget = new Widget(page);
this.roomSection = new RoomSection(page.getByRole('region', { name: 'Voice call' }));
}
get popout(): PopoutPage {
if (!this.popoutPage) {
throw new Error('Voice call - Popout is not open (call openPopout(Room|Widget) first)');
}
return this.popoutPage;
}
// Open the popout from the room section
async openPopoutRoom(): Promise<void> {
const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.roomSection.openPopout()]);
if (!popout) {
throw new Error('Voice call - Failed to open popout');
}
await popout.waitForLoadState();
popout.on('close', () => {
this.popoutPage = undefined;
});
this.popoutPage = new PopoutPage(popout);
}
// Open the popout from the widget
async openPopoutWidget(): Promise<void> {
const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.widget.openPopout()]);
if (!popout) {
throw new Error('Voice call - Failed to open popout');
}
await popout.waitForLoadState();
popout.on('close', () => {
this.popoutPage = undefined;
});
this.popoutPage = new PopoutPage(popout);
}
}

View File

@@ -0,0 +1,237 @@
import type { Locator, Page } from '@playwright/test';
import {
HomeContent,
Navbar,
Sidepanel,
RoomSidebar,
ToastMessages,
RoomComposer,
ThreadComposer,
MembersFlexTab,
ChannelsFlexTab,
NotificationPreferencesFlexTab,
AutoTranslateFlexTab,
ExportMessagesFlexTab,
PruneMessagesFlexTab,
SearchMessagesFlexTab,
RoomInfoFlexTab,
ThreadsFlexTab,
EditRoomFlexTab,
UserInfoFlexTab,
FilesFlexTab,
} from './fragments';
import { RoomToolbar } from './fragments/toolbar';
import { UserCard } from './fragments/user-card';
import { VoiceCalls } from './fragments/voice-calls';
import { RoutedPage } from './routed-page';
export class HomeChannel extends RoutedPage {
protected readonly route: string = '/home';
readonly content: HomeContent;
readonly sidebar: RoomSidebar;
readonly sidepanel: Sidepanel;
readonly navbar: Navbar;
readonly userCard: UserCard;
private _tabs: {
members: MembersFlexTab;
userInfo: UserInfoFlexTab;
room: RoomInfoFlexTab;
editRoom: EditRoomFlexTab;
channels: ChannelsFlexTab;
notificationPreferences: NotificationPreferencesFlexTab;
autoTranslate: AutoTranslateFlexTab;
exportMessages: ExportMessagesFlexTab;
pruneMessages: PruneMessagesFlexTab;
searchMessages: SearchMessagesFlexTab;
threads: ThreadsFlexTab;
files: FilesFlexTab;
};
readonly roomToolbar: RoomToolbar;
readonly voiceCalls: VoiceCalls;
readonly toastMessage: ToastMessages;
readonly composer: RoomComposer;
readonly threadComposer: ThreadComposer;
constructor(page: Page) {
super(page);
this.content = new HomeContent(page);
this.sidebar = new RoomSidebar(page);
this.sidepanel = new Sidepanel(page);
this.navbar = new Navbar(page);
this.userCard = new UserCard(page);
this._tabs = {
members: new MembersFlexTab(page),
userInfo: new UserInfoFlexTab(page),
room: new RoomInfoFlexTab(page.getByRole('dialog', { name: 'Channel info' })),
editRoom: new EditRoomFlexTab(page.getByRole('dialog', { name: 'Edit channel' })),
channels: new ChannelsFlexTab(page),
notificationPreferences: new NotificationPreferencesFlexTab(page),
autoTranslate: new AutoTranslateFlexTab(page),
exportMessages: new ExportMessagesFlexTab(page),
pruneMessages: new PruneMessagesFlexTab(page),
searchMessages: new SearchMessagesFlexTab(page),
threads: new ThreadsFlexTab(page),
files: new FilesFlexTab(page),
};
this.roomToolbar = new RoomToolbar(page);
this.voiceCalls = new VoiceCalls(page);
this.toastMessage = new ToastMessages(page);
this.composer = new RoomComposer(page);
this.threadComposer = new ThreadComposer(page);
}
get tabs() {
return this._tabs;
}
async waitForReady(): Promise<void> {
await this.homepageHeader.waitFor({ state: 'visible' });
}
async gotoChannel(name: string): Promise<void> {
await this.navigateTo(`/channel/${name}`, () => this.content.waitForChannel());
}
async gotoGroup(name: string): Promise<void> {
await this.navigateTo(`/group/${name}`, () => this.content.waitForChannel());
}
/** Opens a channel scrolled to a message. Pass `isThread` when the message lives in a thread. */
async gotoChannelMessage(name: string, messageId: string, isThread = false): Promise<void> {
await this.navigateTo(`/channel/${name}?msg=${messageId}`, async () => {
await this.content.waitForChannel();
if (isThread) {
await this.content.waitForThread();
}
});
}
/** Opens a thread by the id of the message that started it. */
async gotoChannelThread(name: string, threadMessageId: string): Promise<void> {
await this.navigateTo(`/channel/${name}/thread/${threadMessageId}`, async () => {
await this.content.waitForChannel();
await this.content.waitForThread();
});
}
/** Opens a channel with the Prune Messages panel open. */
async gotoChannelCleanHistory(name: string): Promise<void> {
await this.navigateTo(`/channel/${name}/clean-history`, this.tabs.pruneMessages.root);
}
get btnContextualbarClose(): Locator {
return this.page.locator('[data-qa="ContextualbarActionClose"]');
}
get userCardToolbar(): Locator {
return this.page.locator('[role=toolbar][aria-label="User card actions"]');
}
getRoomHeaderFavoriteBtn(isEnterprise: boolean): Locator {
return isEnterprise ? this.btnCategorySelector : this.roomHeaderFavoriteBtn;
}
private get roomHeaderFavoriteBtn(): Locator {
return this.page.getByRole('main').getByRole('button', { name: 'Favorite' });
}
private get btnCategorySelector(): Locator {
return this.page.getByRole('main').getByRole('button', { name: 'Move to', exact: true });
}
// TODO: this button should name open room info or something instead of the room name
getBtnOpenRoomInfo(roomName: string): Locator {
return this.page.getByRole('main').getByRole('button', { name: roomName, exact: true });
}
get roomHeaderToolbar(): Locator {
return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]');
}
get markUnread(): Locator {
return this.page.locator('role=menuitem[name="Mark Unread"]');
}
get dialogEnterE2EEPassword(): Locator {
return this.page.getByRole('dialog', { name: 'Enter E2EE password' });
}
get dialogSaveE2EEPassword(): Locator {
return this.page.getByRole('dialog', { name: 'Save your new E2EE password' });
}
get btnRoomSaveE2EEPassword(): Locator {
return this.page.getByRole('main').getByRole('button', { name: 'Save E2EE password' });
}
get btnRoomEnterE2EEPassword(): Locator {
return this.page.getByRole('main').getByRole('button', { name: 'Enter your E2E password' });
}
get btnSavedMyPassword(): Locator {
return this.dialogSaveE2EEPassword.getByRole('button', { name: 'I saved my password' });
}
get bannerSaveEncryptionPassword(): Locator {
return this.page.getByRole('button', { name: 'Save your new E2EE password' });
}
get bannerEnterE2EEPassword(): Locator {
return this.page.getByRole('button', { name: 'Enter your E2E password' });
}
get audioRecorder(): Locator {
return this.page.getByRole('group', { name: 'Audio recorder', exact: true });
}
get homepageHeader(): Locator {
return this.page.locator('main').getByRole('heading', { name: 'Home' });
}
get dialogEmojiPicker(): Locator {
return this.page.getByRole('dialog', { name: 'Emoji picker' });
}
get scrollerEmojiPicker(): Locator {
return this.dialogEmojiPicker.locator('[data-overlayscrollbars]');
}
get btnJoinChannel(): Locator {
return this.content.btnJoinChannel;
}
getEmojiPickerTabByName(name: string) {
return this.dialogEmojiPicker.locator(`role=tablist >> role=tab[name="${name}"]`);
}
getEmojiByName(name: string) {
return this.dialogEmojiPicker.locator(`role=tabpanel >> [data-emoji="${name}"]`);
}
async pickEmoji(emoji: string, section = 'Smileys & People') {
await this.composer.btnEmoji.click();
await this.getEmojiPickerTabByName(section).click();
await this.getEmojiByName(emoji).click();
}
async waitForHome(): Promise<void> {
await this.waitForReady();
}
async waitForRoomLoad(): Promise<void> {
await this.roomHeaderToolbar.waitFor({ state: 'visible' });
}
}

View File

@@ -0,0 +1,11 @@
import type { Page } from '@playwright/test';
import { RoomInfoFlexTab } from './fragments';
import { HomeChannel } from './home-channel';
export class HomeDiscussion extends HomeChannel {
constructor(page: Page) {
super(page);
this.tabs.room = new RoomInfoFlexTab(page.getByRole('dialog', { name: 'Discussion Info' }));
}
}

View File

@@ -0,0 +1,52 @@
import type { Locator, Page } from '@playwright/test';
import { HomeOmnichannelContent, OmnichannelQuickActionsRoomToolbar, OmnichannelRoomToolbar, OmnichannelSidebar } from './fragments';
import { OmnichannelEditRoomFlexTab } from './fragments/flextabs';
import { OmnichannelRoomInfoFlexTab } from './fragments/flextabs/room-info-flextab';
import { HomeChannel } from './home-channel';
import {
OmnichannelCannedResponses,
OmnichannelTranscript,
OmnichannelContactCenterContacts,
OmnichannelContactCenterChats,
} from './omnichannel';
export class HomeOmnichannel extends HomeChannel {
readonly omnisidenav: OmnichannelSidebar;
readonly transcript: OmnichannelTranscript;
readonly cannedResponses: OmnichannelCannedResponses;
readonly contacts: OmnichannelContactCenterContacts;
readonly chats: OmnichannelContactCenterChats;
readonly roomInfo: OmnichannelRoomInfoFlexTab;
readonly editRoomInfo: OmnichannelEditRoomFlexTab;
readonly quickActionsRoomToolbar: OmnichannelQuickActionsRoomToolbar;
override readonly content: HomeOmnichannelContent;
override readonly roomToolbar: OmnichannelRoomToolbar;
constructor(page: Page) {
super(page);
this.omnisidenav = new OmnichannelSidebar(page);
this.transcript = new OmnichannelTranscript(page);
this.cannedResponses = new OmnichannelCannedResponses(page);
this.contacts = new OmnichannelContactCenterContacts(page);
this.chats = new OmnichannelContactCenterChats(page);
this.roomInfo = new OmnichannelRoomInfoFlexTab(page);
this.quickActionsRoomToolbar = new OmnichannelQuickActionsRoomToolbar(page);
this.content = new HomeOmnichannelContent(page);
this.roomToolbar = new OmnichannelRoomToolbar(page);
this.editRoomInfo = new OmnichannelEditRoomFlexTab(page);
}
get btnContactInfo(): Locator {
return this.page.getByRole('button', { name: 'Contact Information' });
}
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
import { EditTeamFlexTab, TeamInfoFlexTab, TeamToolbar } from './fragments';
import { HomeChannel } from './home-channel';
export class HomeTeam extends HomeChannel {
readonly headerToolbar: TeamToolbar;
constructor(page: Page) {
super(page);
this.headerToolbar = new TeamToolbar(page);
}
override get tabs() {
return {
...super.tabs,
room: new TeamInfoFlexTab(this.page),
editRoom: new EditTeamFlexTab(this.page),
};
}
}

View File

@@ -0,0 +1,26 @@
export * from './account-accessibility';
export * from './account-feature-preview';
export * from './account-omnichannel';
export * from './account-profile';
export * from './account-tokens';
export * from './account-security';
export * from './admin-email-inboxes';
export * from './admin-rooms';
export * from './admin-users';
export * from './admin-settings';
export * from './admin-engagement';
export * from './admin-info';
export * from './admin-integrations';
export * from './admin-permissions';
export * from './admin-third-party-login';
export * from './admin-emojis';
export * from './admin-imports';
export * from './admin-mailer';
export * from './admin-moderation';
export * from './admin';
export * from './auth';
export * from './home-channel';
export * from './home-discussion';
export * from './home-team';
export * from './home-omnichannel';
export * from './marketplace';

View File

@@ -0,0 +1,61 @@
import type { Page } from '@playwright/test';
import type { IUser } from '@rocket.chat/core-typings';
import { MongoClient } from 'mongodb';
import * as constants from '../config/constants';
import type { IUserState } from '../fixtures/userStates';
import { expect } from '../utils/test';
export class LoginPage {
constructor(protected readonly page: Page) {}
get loginButton() {
return this.page.getByRole('button', { name: 'Login', exact: true });
}
/**
* Ideally the previous action should ensure the user is logged out and we should just assume to be at the login page
* */
async waitForIt() {
await this.loginButton.waitFor();
}
/** Navigates to `url` and waits for the login screen — for routes that bounce an anonymous visitor. */
async goto(url = '/home'): Promise<void> {
await this.page.goto(url);
await this.waitForIt();
}
protected async waitForLogin() {
await expect(this.loginButton).not.toBeVisible();
await expect(this.page.getByRole('main')).toBeVisible();
}
async loginByUserState(userState: IUserState, options: { except: string[] } = { except: [] }) {
// Creates a login token for the user
const connection = await MongoClient.connect(constants.URL_MONGODB);
await connection
.db()
.collection<IUser>('users')
.updateOne(
{ username: userState.data.username },
{ $addToSet: { 'services.resume.loginTokens': { when: userState.data.loginExpire, hashedToken: userState.data.hashedToken } } },
);
await connection.close();
const localStorageItems = userState.state.origins[0].localStorage.filter((item) => options.except.indexOf(item.name) === -1);
// Injects the login token to the local storage
await this.page.evaluate((items) => {
items.forEach(({ name, value }) => {
window.localStorage.setItem(name, value);
});
require('meteor/accounts-base').Accounts._pollStoredLoginToken();
}, localStorageItems);
await this.waitForLogin();
}
}

View File

@@ -0,0 +1,51 @@
import type { Locator } from '@playwright/test';
import { RoutedPage } from './routed-page';
export class Marketplace extends RoutedPage {
protected readonly route = '/marketplace/private';
async waitForReady(): Promise<void> {
await this.btnUploadPrivateApp.waitFor({ state: 'visible' });
}
get btnUploadPrivateApp(): Locator {
return this.page.locator('role=button[name="Upload private app"]');
}
get btnInstallPrivateApp(): Locator {
return this.page.locator('role=button[name="Install"]');
}
get btnUploadPrivateAppFile(): Locator {
return this.page.locator('role=button[name="Browse Files"]');
}
get appStatusTag(): Locator {
return this.page.locator('[data-qa-type="app-status-tag"]');
}
get btnConfirmAppUploadModal(): Locator {
return this.page.locator('role=button[name="Upload anyway"]');
}
get lastAppRow(): Locator {
return this.page.locator('[data-qa-type="app-row"]').last();
}
get appMenu(): Locator {
return this.page.getByTitle('More options');
}
get btnEnableApp(): Locator {
return this.page.getByRole('menuitem', { name: 'Enable' });
}
get btnDisableApp(): Locator {
return this.page.getByRole('menuitem', { name: 'Disable' });
}
get btnConfirmAppUpdate(): Locator {
return this.page.locator('role=button[name="Yes"]');
}
}

View File

@@ -0,0 +1,19 @@
export * from './omnichannel-agents';
export * from './omnichannel-departments';
export * from './omnichannel-canned-responses';
export * from './omnichannel-contact-center';
export * from './omnichannel-livechat';
export * from './omnichannel-livechat-appearance';
export * from './omnichannel-livechat-embedded';
export * from './omnichannel-manager';
export * from './omnichannel-custom-fields';
export * from './omnichannel-units';
export * from './omnichannel-monitors';
export * from './omnichannel-settings';
export * from './omnichannel-business-hours';
export * from './omnichannel-tags';
export * from './omnichannel-transcript';
export * from './omnichannel-triggers';
export * from './omnichannel-priorities';
export * from './omnichannel-reports';
export * from './omnichannel-sla-policies';

View File

@@ -0,0 +1,98 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '../../utils/test';
import { OmnichannelSidebar, ToastMessages } from '../fragments';
import { ConfirmDeleteModal } from '../fragments/modals';
import { Table } from '../fragments/table';
import { RoutedPage } from '../routed-page';
export enum OmnichannelSectionsHref {
currentChats = '/omnichannel/current/chats',
currentContacts = '/omnichannel/current/contacts',
agents = '/omnichannel/agents',
managers = '/omnichannel/managers',
monitors = '/omnichannel/monitors',
departments = '/omnichannel/departments',
units = '/omnichannel/units',
customFields = '/omnichannel/customfields',
cannedResponses = '/omnichannel/canned-responses',
triggers = '/omnichannel/triggers',
tags = '/omnichannel/tags',
priorities = '/omnichannel/priorities',
slaPolicies = '/omnichannel/sla-policies',
businessHours = '/omnichannel/businessHours',
appearance = '/omnichannel/appearance',
livechatAppearance = '/omnichannel/livechat-appearance',
analytics = '/omnichannel/analytics',
reports = '/omnichannel/reports',
realtimeMonitoring = '/omnichannel/realtime-monitoring',
securityPrivacy = '/omnichannel/security-privacy',
}
/**
* A page under `/omnichannel/*`. Subclasses declare `route` and `title` and get
* `goto()`, `pageHeader` and `table` for free.
*/
export abstract class OmnichannelAdmin extends RoutedPage {
protected abstract override readonly route: OmnichannelSectionsHref;
/** Text of the page's main heading. */
protected abstract readonly title: string;
/** Accessible name of the page's main table, when it differs from `title`. */
protected readonly tableName?: string;
protected readonly toastMessage: ToastMessages;
readonly sidebar: OmnichannelSidebar;
readonly deleteModal: ConfirmDeleteModal;
constructor(page: Page) {
super(page);
this.sidebar = new OmnichannelSidebar(page);
this.toastMessage = new ToastMessages(page);
this.deleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' }));
}
get inputSearch() {
return this.page.getByRole('main').getByRole('textbox', { name: 'Search' });
}
get btnSaveChanges(): Locator {
return this.page.getByRole('button', { name: 'Save changes' });
}
getButtonByType(type: 'unit' | 'SLA policy' | 'tag' | 'trigger' | 'department' | 'custom field'): Locator {
return this.page.locator('header').getByRole('button', { name: `Create ${type}` });
}
async search(text: string) {
await this.inputSearch.fill(text);
}
async clearSearch() {
await this.inputSearch.fill('');
}
get emptyState() {
return this.page.getByRole('group', { name: 'No results found', exact: true });
}
waitForEmptyState() {
return expect(this.emptyState).toBeVisible();
}
get pageHeader(): Locator {
return this.page.locator('main').getByRole('heading', { name: this.title, exact: true });
}
get table(): Table {
return new Table(this.page.getByRole('table', { name: this.tableName ?? this.title }));
}
async waitForReady(): Promise<void> {
await this.pageHeader.waitFor({ state: 'visible' });
await this.table.waitForDisplay(this.emptyState);
}
}

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