diff --git a/.changeset/shy-dolphins-share.md b/.changeset/shy-dolphins-share.md new file mode 100644 index 0000000000000..d1dea022140ba --- /dev/null +++ b/.changeset/shy-dolphins-share.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes intermittent error "Cannot read properties of undefined" when editing messages diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index a675c413fe18f..0ff51d07eabdd 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -2,6 +2,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isVideoConfMessage } from '@rocket.chat/core-typings'; import type { IActionManager } from '@rocket.chat/ui-contexts'; +import { CurrentEditingMessage } from './CurrentEditingMessage'; import { UserAction } from './UserAction'; import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI'; import { createDataAPI } from '../../../../client/lib/chats/data'; @@ -15,10 +16,7 @@ import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage'; import { uploadFiles } from '../../../../client/lib/chats/flows/uploadFiles'; import { ReadStateManager } from '../../../../client/lib/chats/readStateManager'; import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; -import { - setHighlightMessage, - clearHighlightMessage, -} from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; +import { setHighlightMessage } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; type DeepWritable = T extends (...args: any) => any ? T @@ -29,6 +27,8 @@ type DeepWritable = T extends (...args: any) => any export class ChatMessages implements ChatAPI { public uid: string | null; + public tmid?: IMessage['_id']; + public composer: ComposerAPI | undefined; public setComposerAPI = (composer?: ComposerAPI): void => { @@ -38,6 +38,8 @@ export class ChatMessages implements ChatAPI { public data: DataAPI; + public currentEditingMessage: CurrentEditingMessage; + public readStateManager: ReadStateManager; public uploads: UploadsAPI; @@ -55,15 +57,15 @@ export class ChatMessages implements ChatAPI { performContinuously(action: 'recording' | 'uploading' | 'playing'): Promise | void; }; - private currentEditingMID?: string; - public messageEditing: ChatAPI['messageEditing'] = { toPreviousMessage: async () => { if (!this.composer) { return; } - if (!this.currentEditing) { + const mid = this.currentEditingMessage.getMID(); + + if (!mid) { let lastMessage = await this.data.findPreviousOwnMessage(); // Videoconf messages should not be edited @@ -79,7 +81,7 @@ export class ChatMessages implements ChatAPI { return; } - const currentMessage = await this.data.findMessageByID(this.currentEditing.mid); + const currentMessage = await this.data.findMessageByID(mid); let previousMessage = currentMessage ? await this.data.findPreviousOwnMessage(currentMessage) : undefined; // Videoconf messages should not be edited @@ -92,14 +94,17 @@ export class ChatMessages implements ChatAPI { return; } - await this.currentEditing.cancel(); + await this.currentEditingMessage.cancel(); + await this.currentEditingMessage.stop(); }, toNextMessage: async () => { - if (!this.composer || !this.currentEditing) { + const mid = this.currentEditingMessage.getMID(); + + if (!this.composer || !mid) { return; } - const currentMessage = await this.data.findMessageByID(this.currentEditing.mid); + const currentMessage = await this.data.findMessageByID(mid); let nextMessage = currentMessage ? await this.data.findNextOwnMessage(currentMessage) : undefined; // Videoconf messages should not be edited @@ -112,18 +117,19 @@ export class ChatMessages implements ChatAPI { return; } - await this.currentEditing.cancel(); + await this.currentEditingMessage.cancel(); + await this.currentEditingMessage.stop(); }, editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => { const text = (await this.data.getDraft(message._id)) || message.attachments?.[0]?.description || message.msg; - await this.currentEditing?.stop(); + await this.currentEditingMessage.stop(); if (!this.composer || !(await this.data.canUpdateMessage(message))) { return; } - this.currentEditingMID = message._id; + this.currentEditingMessage.setMID(message._id); setHighlightMessage(message._id); this.composer.setEditingMode(true); @@ -136,19 +142,14 @@ export class ChatMessages implements ChatAPI { public flows: DeepWritable; - public constructor( - private params: { - rid: IRoom['_id']; - tmid?: IMessage['_id']; - uid: IUser['_id'] | null; - actionManager: IActionManager; - }, - ) { + public constructor(params: { rid: IRoom['_id']; tmid?: IMessage['_id']; uid: IUser['_id'] | null; actionManager: IActionManager }) { const { rid, tmid } = params; + this.tmid = tmid; this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); this.uploads = createUploadsAPI({ rid, tmid }); this.ActionManager = params.actionManager; + this.currentEditingMessage = new CurrentEditingMessage(this); const unimplemented = () => { throw new Error('Flow is not implemented'); @@ -185,60 +186,11 @@ export class ChatMessages implements ChatAPI { }; } - public get currentEditing() { - if (!this.composer || !this.currentEditingMID) { - return undefined; - } - - return { - mid: this.currentEditingMID, - reset: async (): Promise => { - if (!this.composer || !this.currentEditingMID) { - return false; - } - - const message = await this.data.findMessageByID(this.currentEditingMID); - if (this.composer.text !== message?.msg) { - this.composer.setText(message?.msg ?? ''); - return true; - } - - return false; - }, - stop: async (): Promise => { - if (!this.composer || !this.currentEditingMID) { - return; - } - - const message = await this.data.findMessageByID(this.currentEditingMID); - const draft = this.composer.text; - - if (draft === message?.msg) { - await this.data.discardDraft(this.currentEditingMID); - } else { - await this.data.saveDraft(this.currentEditingMID, (await this.data.getDraft(this.currentEditingMID)) || draft); - } - - this.composer.setEditingMode(false); - this.currentEditingMID = undefined; - clearHighlightMessage(); - }, - cancel: async (): Promise => { - if (!this.currentEditingMID) { - return; - } - - await this.data.discardDraft(this.currentEditingMID); - await this.currentEditing?.stop(); - this.composer?.setText((await this.data.getDraft(undefined)) ?? ''); - }, - }; - } - public async release() { - if (this.currentEditing) { - if (!this.params.tmid) { - await this.currentEditing.cancel(); + if (this.currentEditingMessage.getMID()) { + if (!this.tmid) { + await this.currentEditingMessage.cancel(); + await this.currentEditingMessage.stop(); } this.composer?.clear(); } diff --git a/apps/meteor/app/ui/client/lib/CurrentEditingMessage.ts b/apps/meteor/app/ui/client/lib/CurrentEditingMessage.ts new file mode 100644 index 0000000000000..f7f958a5f15fd --- /dev/null +++ b/apps/meteor/app/ui/client/lib/CurrentEditingMessage.ts @@ -0,0 +1,114 @@ +import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI'; +import { clearHighlightMessage } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; + +export class CurrentEditingMessage { + private lock = false; + + private mid?: string; + + private queue: { + resolve: (release: () => void) => void; + }[] = []; + + private chat: ChatAPI; + + constructor(chat: ChatAPI) { + this.chat = chat; + } + + public reset = async () => { + return this.runExclusive(async () => { + if (!this.chat.composer || !this.mid) { + return false; + } + + const message = await this.chat.data.findMessageByID(this.mid); + + if (this.chat.composer.text !== message?.msg) { + this.chat.composer.setText(message?.msg ?? ''); + return true; + } + + return false; + }); + }; + + public stop = async () => { + await this.runExclusive(async () => { + if (!this.chat.composer || !this.mid) { + return; + } + + const message = await this.chat.data.findMessageByID(this.mid); + const draft = this.chat.composer.text; + + if (draft === message?.msg) { + await this.chat.data.discardDraft(this.mid); + } else { + await this.chat.data.saveDraft(this.mid, (await this.chat.data.getDraft(this.mid)) || draft); + } + + this.chat.composer.setEditingMode(false); + this.mid = undefined; + clearHighlightMessage(); + }); + }; + + public cancel = async () => { + await this.runExclusive(async () => { + if (!this.mid) { + return; + } + + await this.chat.data.discardDraft(this.mid); + this.chat.composer?.setText((await this.chat.data.getDraft(undefined)) ?? ''); + }); + }; + + private acquire = async () => { + return new Promise<() => void>((resolve) => { + this.queue.push({ resolve }); + this.dispatch(); + }); + }; + + private dispatch() { + if (this.lock) { + return; + } + + const next = this.queue.shift(); + + if (!next) { + return; + } + + this.lock = true; + next.resolve(this.buildRelease()); + } + + private buildRelease = () => { + return async () => { + this.lock = false; + this.dispatch(); + }; + }; + + public getMID() { + return this.mid; + } + + public setMID(mid: string) { + this.mid = mid; + } + + private async runExclusive(callback: () => Promise) { + const release = await this.acquire(); + + try { + return await callback(); + } finally { + release(); + } + } +} diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index f41e9366ceee9..5c341d356995d 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -118,14 +118,13 @@ export type ChatAPI = { editMessage(message: IMessage, options?: { cursorAtStart?: boolean }): Promise; }; - readonly currentEditing: - | { - readonly mid: IMessage['_id']; - reset(): Promise; - stop(): Promise; - cancel(): Promise; - } - | undefined; + readonly currentEditingMessage: { + setMID(mid: IMessage['_id']): void; + getMID(): string | undefined; + reset(): Promise; + stop(): Promise; + cancel(): Promise; + }; readonly emojiPicker: { open(el: Element, cb: (emoji: string) => void): void; diff --git a/apps/meteor/client/lib/chats/flows/processMessageEditing.ts b/apps/meteor/client/lib/chats/flows/processMessageEditing.ts index d152f47f40965..a990d05d4303f 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageEditing.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageEditing.ts @@ -9,7 +9,8 @@ export const processMessageEditing = async ( message: Pick & Partial>, previewUrls?: string[], ): Promise => { - if (!chat.currentEditing) { + const mid = chat.currentEditingMessage.getMID(); + if (!mid) { return false; } @@ -22,12 +23,12 @@ export const processMessageEditing = async ( } try { - await chat.data.updateMessage({ ...message, _id: chat.currentEditing.mid }, previewUrls); + await chat.data.updateMessage({ ...message, _id: mid }, previewUrls); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - chat.currentEditing.stop(); + chat.currentEditingMessage.stop(); return true; }; diff --git a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts index d80b59549efc2..1e09a4c09c14c 100644 --- a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts +++ b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts @@ -15,7 +15,7 @@ export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick((resolve, reject) => { + const mid = chat.currentEditingMessage.getMID(); const onConfirm = async (): Promise => { try { if (!(await chat.data.canDeleteMessage(message))) { @@ -24,8 +25,8 @@ export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): imperativeModal.close(); - if (chat.currentEditing?.mid === message._id) { - chat.currentEditing.stop(); + if (mid === message._id) { + chat.currentEditingMessage.stop(); } chat.composer?.focus(); @@ -40,8 +41,8 @@ export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): const onCloseModal = async (): Promise => { imperativeModal.close(); - if (chat.currentEditing?.mid === message._id) { - chat.currentEditing.stop(); + if (mid === message._id) { + chat.currentEditingMessage.stop(); } chat.composer?.focus(); diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 0fb16296a8582..d592729e295b6 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -11,6 +11,8 @@ import { processSlashCommand } from './processSlashCommand'; import { processTooLongMessage } from './processTooLongMessage'; const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise => { + const mid = chat.currentEditingMessage.getMID(); + if (await processSetReaction(chat, message)) { return; } @@ -23,7 +25,7 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], return; } - message = (await onClientBeforeSendMessage({ ...message, isEditing: !!chat.currentEditing })) as IMessage & { isEditing?: boolean }; + message = (await onClientBeforeSendMessage({ ...message, isEditing: !!mid })) as IMessage & { isEditing?: boolean }; // e2e should be a client property only delete message.e2e; @@ -57,8 +59,8 @@ export const sendMessage = async ( chat.readStateManager.clearUnreadMark(); text = text.trim(); - - if (!text && !chat.currentEditing) { + const mid = chat.currentEditingMessage.getMID(); + if (!text && !mid) { // Nothing to do return false; } @@ -67,11 +69,11 @@ export const sendMessage = async ( const message = await chat.data.composeMessage(text, { sendToChannel: tshow, quotedMessages: chat.composer?.quotedMessages.get() ?? [], - originalMessage: chat.currentEditing ? await chat.data.findMessageByID(chat.currentEditing.mid) : null, + originalMessage: mid ? await chat.data.findMessageByID(mid) : null, }); - if (chat.currentEditing) { - const originalMessage = await chat.data.findMessageByID(chat.currentEditing.mid); + if (mid) { + const originalMessage = await chat.data.findMessageByID(mid); if ( originalMessage?.t === 'e2e' && @@ -94,8 +96,8 @@ export const sendMessage = async ( return true; } - if (chat.currentEditing) { - const originalMessage = await chat.data.findMessageByID(chat.currentEditing.mid); + if (mid) { + const originalMessage = await chat.data.findMessageByID(mid); if (!originalMessage) { dispatchToastMessage({ type: 'warning', message: t('Message_not_found') }); @@ -104,11 +106,11 @@ export const sendMessage = async ( try { if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' }, previewUrls)) { - chat.currentEditing.stop(); + chat.currentEditingMessage.stop(); return false; } - await chat.currentEditing?.reset(); + await chat.currentEditingMessage.reset(); await chat.flows.requestMessageDeletion(originalMessage); return false; } catch (error) { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index d633e45134bec..6e29b60b39356 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -172,13 +172,15 @@ const MessageBox = ({ }); const closeEditing = (event: KeyboardEvent | MouseEvent) => { - if (chat.currentEditing) { + const mid = chat.currentEditingMessage.getMID(); + if (mid) { event.preventDefault(); event.stopPropagation(); - chat.currentEditing.reset().then((reset) => { + chat.currentEditingMessage.reset().then((reset) => { if (!reset) { - chat.currentEditing?.cancel(); + chat.currentEditingMessage.cancel(); + chat.currentEditingMessage.stop(); } }); } diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index e02f5b3bcf11a..b74d78541f6a7 100644 --- a/apps/meteor/tests/e2e/messaging.spec.ts +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -3,14 +3,15 @@ import type { Page } from '@playwright/test'; import { createAuxContext } from './fixtures/createAuxContext'; import { Users } from './fixtures/userStates'; -import { HomeChannel } from './page-objects'; -import { createTargetChannel } from './utils'; +import { HomeChannel, ToastBar } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; import { expect, test } from './utils/test'; test.use({ storageState: Users.user1.state }); test.describe('Messaging', () => { let poHomeChannel: HomeChannel; + let poToastBar: ToastBar; let targetChannel: string; test.beforeAll(async ({ api }) => { @@ -19,91 +20,108 @@ test.describe('Messaging', () => { test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); + poToastBar = new ToastBar(page); await page.goto('/home'); }); + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + test.describe.serial('Navigation', () => { test('should navigate on messages using keyboard', async ({ page }) => { - await poHomeChannel.sidenav.openChat(targetChannel); - await poHomeChannel.content.sendMessage('msg1'); - await poHomeChannel.content.sendMessage('msg2'); + await test.step('open chat and send message', async () => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('msg1'); + await poHomeChannel.content.sendMessage('msg2'); + }); - // move focus to the second message - await page.keyboard.press('Shift+Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + await test.step('move focus to the second message', async () => { + await page.keyboard.press('Shift+Tab'); + await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + }); - // move focus to the first system message - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await expect(page.locator('[data-qa="system-message"]').first()).toBeFocused(); + await test.step('move focus to the first system message', async () => { + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await expect(page.locator('[data-qa="system-message"]').first()).toBeFocused(); + }); - // move focus to the first typed message - await page.keyboard.press('ArrowDown'); - await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); + await test.step('move focus to the first typed message', async () => { + await page.keyboard.press('ArrowDown'); + await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); + }); - // move focus to the room title - await page.keyboard.press('Shift+Tab'); - await expect(page.getByRole('button', { name: targetChannel }).first()).toBeFocused(); - - // refocus on the first typed message - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); - - // move focus to the message toolbar - await page - .locator('[data-qa-type="message"]:has-text("msg1")') - .locator('[role=toolbar][aria-label="Message actions"]') - .getByRole('button', { name: 'Add reaction' }) - .waitFor(); - - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await expect( - page + await test.step('move focus to the room title', async () => { + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('button', { name: targetChannel }).first()).toBeFocused(); + }); + + await test.step('move focus to the channel list', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.locator('[data-qa-type="message"]:has-text("msg1")')).toBeFocused(); + }); + + await test.step('move focus to the message toolbar', async () => { + await page .locator('[data-qa-type="message"]:has-text("msg1")') .locator('[role=toolbar][aria-label="Message actions"]') - .getByRole('button', { name: 'Add reaction' }), - ).toBeFocused(); - - // move focus to the composer - await page.keyboard.press('Tab'); - await page - .locator('[data-qa-type="message"]:has-text("msg2")') - .locator('[role=toolbar][aria-label="Message actions"]') - .getByRole('button', { name: 'Add reaction' }) - .waitFor(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await expect(poHomeChannel.composer).toBeFocused(); + .getByRole('button', { name: 'Add reaction' }) + .waitFor(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect( + page + .locator('[data-qa-type="message"]:has-text("msg1")') + .locator('[role=toolbar][aria-label="Message actions"]') + .getByRole('button', { name: 'Add reaction' }), + ).toBeFocused(); + }); + + await test.step('move focus to the composer', async () => { + await page.keyboard.press('Tab'); + await page + .locator('[data-qa-type="message"]:has-text("msg2")') + .locator('[role=toolbar][aria-label="Message actions"]') + .getByRole('button', { name: 'Add reaction' }) + .waitFor(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(poHomeChannel.composer).toBeFocused(); + }); }); test('should navigate properly on the user card', async ({ page }) => { await poHomeChannel.sidenav.openChat(targetChannel); - // open UserCard - await page.keyboard.press('Shift+Tab'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Space'); - await expect(poHomeChannel.userCardToolbar).toBeVisible(); - - // close UserCard with Esc - await page.keyboard.press('Escape'); - await expect(poHomeChannel.userCardToolbar).not.toBeVisible(); - - // with focus restored reopen toolbar - await page.keyboard.press('Space'); - await expect(poHomeChannel.userCardToolbar).toBeVisible(); - - // close UserCard with button - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Space'); - await expect(poHomeChannel.userCardToolbar).not.toBeVisible(); + await test.step('open UserCard', async () => { + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + await expect(poHomeChannel.userCardToolbar).toBeVisible(); + }); + + await test.step('close UserCard with Esc', async () => { + await page.keyboard.press('Escape'); + await expect(poHomeChannel.userCardToolbar).not.toBeVisible(); + }); + + await test.step('with focus restored reopen toolbar', async () => { + await page.keyboard.press('Space'); + await expect(poHomeChannel.userCardToolbar).toBeVisible(); + }); + + await test.step('close UserCard with button', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + await expect(poHomeChannel.userCardToolbar).not.toBeVisible(); + }); }); test('should not restore focus on the last focused if it was triggered by click', async ({ page }) => { @@ -126,19 +144,67 @@ test.describe('Messaging', () => { await poHomeChannel.sidenav.openChat(targetChannel); await page.getByRole('button', { name: targetChannel }).first().focus(); - // move focus to the list - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + await test.step('move focus to the list', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + }); - await page.getByRole('button', { name: targetChannel }).first().focus(); + await test.step('move focus to the list again', async () => { + await page.getByRole('button', { name: targetChannel }).first().focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + }); + }); + }); - // move focus to the list again - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await expect(page.locator('[data-qa-type="message"]').last()).toBeFocused(); + test.describe.serial('Message edition', () => { + test('should edit messages', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + + await test.step('focus on the second message', async () => { + await page.keyboard.press('ArrowUp'); + + expect(await poHomeChannel.composer.inputValue()).toBe('msg2'); + }); + + await test.step('send edited message', async () => { + const editPromise = page.waitForResponse( + (response) => + /api\/v1\/method.call\/updateMessage/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'POST', + ); + + await poHomeChannel.content.sendMessage('edited msg2', false); + await editPromise; + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('edited msg2'); + }); + + await test.step('stress test on message editions', async () => { + const editPromise = page.waitForResponse( + (response) => + /api\/v1\/method.call\/updateMessage/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'POST', + ); + + for (const element of ['edited msg2 a', 'edited msg2 b', 'edited msg2 c', 'edited msg2 d', 'edited msg2 e']) { + // eslint-disable-next-line no-await-in-loop + await page.keyboard.press('ArrowUp'); + // eslint-disable-next-line no-await-in-loop + await poHomeChannel.content.sendMessage(element, false); + } + + await editPromise; + const toastError = await poToastBar.waitForError(); + + expect(toastError).toBe(true); + }); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index d9faf15c2a4e2..f767c9321245a 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -89,13 +89,15 @@ export class HomeContent { await this.joinRoomIfNeeded(); await this.page.waitForSelector('[name="msg"]:not([disabled])'); await this.page.locator('[name="msg"]').fill(text); - const responsePromise = this.page.waitForResponse( - (response) => - /api\/v1\/method.call\/sendMessage/.test(response.url()) && response.status() === 200 && response.request().method() === 'POST', - ); - await this.page.getByRole('button', { name: 'Send', exact: true }).click(); if (enforce) { + const responsePromise = this.page.waitForResponse( + (response) => + /api\/v1\/method.call\/sendMessage/.test(response.url()) && response.status() === 200 && response.request().method() === 'POST', + ); + + await this.page.getByRole('button', { name: 'Send', exact: true }).click(); + const response = await (await responsePromise).json(); const mid = JSON.parse(response.message).result._id; @@ -103,6 +105,8 @@ export class HomeContent { await expect(messageLocator).toBeVisible(); await expect(messageLocator).not.toHaveClass('rcx-message--pending'); + } else { + await this.page.getByRole('button', { name: 'Send', exact: true }).click(); } } diff --git a/apps/meteor/tests/e2e/page-objects/index.ts b/apps/meteor/tests/e2e/page-objects/index.ts index 80914a7b75d2e..b7edff294fc7f 100644 --- a/apps/meteor/tests/e2e/page-objects/index.ts +++ b/apps/meteor/tests/e2e/page-objects/index.ts @@ -22,3 +22,4 @@ export * from './omnichannel-tags'; export * from './utils'; export * from './modal'; export * from './marketplace'; +export * from './toastBar'; diff --git a/apps/meteor/tests/e2e/page-objects/toastBar.ts b/apps/meteor/tests/e2e/page-objects/toastBar.ts index d17d4f328606d..44d96f777c8e5 100644 --- a/apps/meteor/tests/e2e/page-objects/toastBar.ts +++ b/apps/meteor/tests/e2e/page-objects/toastBar.ts @@ -15,7 +15,21 @@ export class ToastBar { return this.content.getByRole('alert'); } + get error(): Locator { + return this.page.locator('.rcx-toastbar.rcx-toastbar--error'); + } + get dismiss(): Locator { return this.content.getByRole('button', { name: 'Dismiss alert', exact: true }); } + + async waitForError(): Promise { + try { + await this.error.waitFor({ timeout: 1000 }); + + return false; + } catch (error) { + return true; + } + } }