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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.threadComposer.inputMessage.fill(text); await this.threadComposer.btnSend.click(); } async sendMessageInVideoConfPopup(text: string): Promise { 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 { 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 { 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 { 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(); } }