From a8949ea97c5498a951272bddcea63b7243747ab4 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Fri, 5 Jan 2024 15:26:54 +0530 Subject: [PATCH] Playwright: Convert `e2e/threads` (#12066) * Convert tests * Update comment * Use addInitScript * Add thread-id to sendMessage method * Update screenshot * Change signature * Fix prettier --- cypress/e2e/threads/threads.spec.ts | 515 ------------------ playwright/e2e/threads/threads.spec.ts | 450 +++++++++++++++ playwright/pages/client.ts | 12 +- ...ly-to-the-location-on-ThreadView-linux.png | Bin 0 -> 20834 bytes 4 files changed, 459 insertions(+), 518 deletions(-) delete mode 100644 cypress/e2e/threads/threads.spec.ts create mode 100644 playwright/e2e/threads/threads.spec.ts create mode 100644 playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts deleted file mode 100644 index 2192447e0b..0000000000 --- a/cypress/e2e/threads/threads.spec.ts +++ /dev/null @@ -1,515 +0,0 @@ -/* -Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; -import Chainable = Cypress.Chainable; - -describe("Threads", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - // Flaky: https://github.com/vector-im/element-web/issues/26452 - it.skip("should be usable for a conversation", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { - displayName: "BotBob", - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}) - .then((_roomId) => { - roomId = _roomId; - return cy.inviteUser(roomId, bot.getUserId()); - }) - .then(async () => { - await bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); - - // Around 200 characters - const MessageLong = - "Hello there. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt " + - "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi"; - - const ThreadViewGroupSpacingStart = "56px"; // --ThreadView_group_spacing-start - // Exclude timestamp and read marker from snapshots - const percyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - - cy.get(".mx_RoomView_body").within(() => { - // User sends message - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); - }); - - // Bot starts thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - // Send a message long enough to be wrapped to check if avatars inside the ReadReceiptGroup are visible - body: MessageLong, - msgtype: "m.text", - }); - }); - - // User asserts timeline thread summary visible & clicks it - cy.get(".mx_RoomView_body .mx_ThreadSummary") - .within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); - }) - .click(); - - // Wait until the both messages are read - cy.get(".mx_ThreadView .mx_EventTile_last[data-layout=group]").within(() => { - cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - - // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout - cy.get(".mx_EventTile_line").should("have.css", "padding-inline-start", ThreadViewGroupSpacingStart); - }); - - // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView").percySnapshotElement("Initial ThreadView on group layout", { percyCSS }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("Initial ThreadView on bubble layout", { percyCSS }); - - // Set the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last").within(() => { - // Wait until the messages are rendered - cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); - - // Make sure the avatar inside ReadReceiptGroup is visible on the group layout - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - }); - - // Enable the bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble'].mx_EventTile_last").within(() => { - // TODO: remove this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout - // See: https://github.com/vector-im/element-web/issues/23569 - cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("exist"); - - // Make sure the avatar inside ReadReceiptGroup is visible on bubble layout - // TODO: enable this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout - // See: https://github.com/vector-im/element-web/issues/23569 - // cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar").should("be.visible"); - }); - - // Re-enable the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - cy.get(".mx_ThreadView").within(() => { - // User responds in thread - cy.findByRole("textbox", { name: "Send a message…" }).type("Test{enter}"); - }); - - // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Test").should("exist"); - }); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Check reactions and hidden events - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - // Enable hidden events to make the event for reaction displayed - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); - - // User reacts to message instead - cy.get(".mx_ThreadView").within(() => { - cy.contains(".mx_EventTile .mx_EventTile_line", "Hello there") - .realHover() - .findByRole("toolbar", { name: "Message Actions" }) - .findByRole("button", { name: "React" }) - .click(); - }); - - cy.get(".mx_EmojiPicker").within(() => { - cy.findByRole("textbox").type("wave"); - cy.findByRole("gridcell", { name: "👋" }).click(); - }); - - cy.get(".mx_ThreadView").within(() => { - // Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout - cy.get(".mx_EventTile[data-layout=group] .mx_ReactionsRow").should( - "have.css", - "margin-inline-start", - ThreadViewGroupSpacingStart, - ); - - // Make sure the CSS style for spacing is applied to the hidden event on group/modern layout - cy.get( - ".mx_GenericEventListSummary[data-layout=group] .mx_EventTile_info.mx_EventTile_last " + - ".mx_EventTile_line", - ).should("have.css", "padding-inline-start", ThreadViewGroupSpacingStart); - }); - - // Take Percy snapshot of group layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with reaction and a hidden event on group layout", { - percyCSS, - }); - - // Enable bubble layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - - // Make sure the CSS style for spacing is applied to the hidden event on bubble layout - cy.get( - ".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last", - ).within(() => { - cy.get(".mx_EventTile_line .mx_EventTile_content") - // 76px: ThreadViewGroupSpacingStart + 14px + 6px - // 14px: avatar width - // See: _EventTile.pcss - .should("have.css", "margin-inline-start", "76px"); - cy.get(".mx_EventTile_line") - // Make sure the margin is NOT applied to mx_EventTile_line - .should("have.css", "margin-inline-start", "0px"); - }); - - // Take Percy snapshot of bubble layout - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with reaction and a hidden event on bubble layout", { - percyCSS, - }); - - // Disable hidden events - cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, false); - - // Reset to the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Check redactions - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - // User redacts their prior response - cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") - .realHover() - .findByRole("button", { name: "Options" }) - .click(); - cy.get(".mx_IconizedContextMenu").within(() => { - cy.findByRole("menuitem", { name: "Remove" }).click(); - }); - cy.get(".mx_TextInputDialog").within(() => { - cy.findByRole("button", { name: "Remove" }).should("have.class", "mx_Dialog_primary").click(); - }); - - cy.get(".mx_ThreadView").within(() => { - // Wait until the response is redacted - cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - }); - - // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with redacted messages on group layout", { - percyCSS, - }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with redacted messages on bubble layout", { - percyCSS, - }); - - // Set the group layout - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - - // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); - }); - - // User closes right panel after clicking back to thread list - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("button", { name: "Threads" }).click(); - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Bot responds to thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - body: "How are things?", - msgtype: "m.text", - }); - }); - - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); - }); - - cy.findByRole("button", { name: "Threads" }) - .should("have.class", "mx_LegacyRoomHeader_button--unread") // User asserts thread list unread indicator - .click(); // User opens thread list - - // User asserts thread with correct root & latest events & unread dot - cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { - cy.get(".mx_EventTile_body").findByText("Hello Mr. Bot").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); - - // Check the number of the replies - cy.get(".mx_ThreadPanel_replies_amount").findByText("2").should("exist"); - - // Make sure the notification dot is visible - cy.get(".mx_NotificationBadge_visible").should("be.visible"); - - // User opens thread via threads list - cy.get(".mx_EventTile_line").click(); - }); - - // User responds & asserts - cy.get(".mx_ThreadView").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Great!{enter}"); - }); - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Great!").should("exist"); - }); - - // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").within(() => { - cy.findByText("Great!").should("exist"); - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox").type(" How about yourself?{enter}"); - }); - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("Great! How about yourself?").should("exist"); - }); - - // User closes right panel - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Bot responds to thread and saves the id of their message to @eventId - cy.get("@threadId").then((threadId) => { - cy.wrap( - bot - .sendMessage(roomId, threadId, { - body: "I'm very good thanks", - msgtype: "m.text", - }) - .then((res) => res.event_id), - ).as("eventId"); - }); - - // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks").should("exist"); - }); - - // Bot edits their latest event - cy.get("@eventId").then((eventId) => { - bot.sendMessage(roomId, { - "body": "* I'm very good thanks :)", - "msgtype": "m.text", - "m.new_content": { - body: "I'm very good thanks :)", - msgtype: "m.text", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: eventId, - }, - }); - }); - - // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); - cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks :)").should("exist"); - }); - }); - - it("can send voice messages", () => { - // Increase viewport size and right-panel size, so that voice messages fit - cy.viewport(1280, 720); - cy.window().then((window) => { - window.localStorage.setItem("mx_rhs_size", "600"); - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.visit("/#/room/" + roomId); - }); - - // Send message - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Create thread - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - }); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Voice Message" }).click(); - cy.wait(3000); - cy.getComposer(true).findByRole("button", { name: "Send voice message" }).click(); - - cy.get(".mx_ThreadView .mx_MVoiceMessageBody").should("have.length", 1); - }); - - it("should send location and reply to the location on ThreadView", () => { - // See: location.spec.ts - const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.findByTestId(`share-location-option-${shareType}`); - }; - const submitShareLocation = (): void => { - cy.findByRole("button", { name: "Share location" }).click(); - }; - - let bot: MatrixClient; - cy.getBot(homeserver, { - displayName: "BotBob", - autoAcceptInvites: false, - }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}) - .then((_roomId) => { - roomId = _roomId; - return cy.inviteUser(roomId, bot.getUserId()); - }) - .then(async () => { - await bot.joinRoom(roomId); - cy.visit("/#/room/" + roomId); - }); - - // Exclude timestamp, read marker, and mapboxgl-map from snapshots - const percyCSS = - ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; - - cy.get(".mx_RoomView_body").within(() => { - // User sends message - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); - }); - - // Bot starts thread - cy.get("@threadId").then((threadId) => { - bot.sendMessage(roomId, threadId, { - body: "Hello there", - msgtype: "m.text", - }); - }); - - // User clicks thread summary - cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); - - // User sends location on ThreadView - cy.get(".mx_ThreadView").should("exist"); - cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Location" }).click(); - selectLocationShareTypeOption("Pin").click(); - cy.get("#mx_LocationPicker_map").click("center"); - submitShareLocation(); - cy.get(".mx_ThreadView .mx_EventTile_last .mx_MLocationBody", { timeout: 10000 }).should("exist"); - - // User replies to the location - cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - - cy.findByRole("textbox", { name: "Reply to thread…" }).type("Please come here.{enter}"); - - // Wait until the reply is sent - cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); - }); - - // Take a snapshot of reply to the shared location - cy.get(".mx_ThreadView").percySnapshotElement("Reply to the location on ThreadView", { percyCSS }); - }); - - it("right panel behaves correctly", () => { - // Create room - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.visit("/#/room/" + roomId); - }); - - // Send message - cy.get(".mx_RoomView_body").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - - // Create thread - cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - }); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - // Send message to thread - cy.get(".mx_ThreadPanel").within(() => { - cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. User{enter}"); - cy.get(".mx_EventTile_last").findByText("Hello Mr. User").should("exist"); - - // Close thread - cy.findByRole("button", { name: "Close" }).click(); - }); - - // Open existing thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .findByRole("button", { name: "Reply in thread" }) - .click(); - cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - - cy.get(".mx_BaseCard").within(() => { - cy.get(".mx_EventTile").first().findByText("Hello Mr. Bot").should("exist"); - cy.get(".mx_EventTile").last().findByText("Hello Mr. User").should("exist"); - }); - }); -}); diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts new file mode 100644 index 0000000000..34fe287d47 --- /dev/null +++ b/playwright/e2e/threads/threads.spec.ts @@ -0,0 +1,450 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import { test, expect } from "../../element-web-test"; + +test.describe("Threads", () => { + test.use({ + displayName: "Tom", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + }); + + // Flaky: https://github.com/vector-im/element-web/issues/26452 + test.skip("should be usable for a conversation", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await page.goto("/#/room/" + roomId); + + // Around 200 characters + const MessageLong = + "Hello there. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt " + + "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi"; + + const ThreadViewGroupSpacingStart = "56px"; // --ThreadView_group_spacing-start + // Exclude timestamp and read marker from snapshots + const mask = [page.locator(".mx_MessageTimestamp"), page.locator(".mx_MessagePanel_myReadMarker")]; + + const roomViewLocator = page.locator(".mx_RoomView_body"); + // User sends message + const textbox = roomViewLocator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + + // Wait for message to send, get its ID and save as @threadId + const threadId = await roomViewLocator + .locator(".mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }) + .getAttribute("data-scroll-tokens"); + + // Bot starts thread + await bot.sendMessage(roomId, MessageLong, threadId); + + // User asserts timeline thread summary visible & clicks it + let locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + await locator.click(); + + // Wait until the both messages are read + locator = page.locator(".mx_ThreadView .mx_EventTile_last[data-layout=group]"); + await expect(locator.locator(".mx_EventTile_line .mx_MTextBody").getByText(MessageLong)).toBeAttached(); + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout + await expect(locator.locator(".mx_EventTile_line")).toHaveCSS( + "padding-inline-start", + ThreadViewGroupSpacingStart, + ); + + // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_group_layout.png", { + mask: mask, + }); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Initial_ThreadView_on_bubble_layout.png", { + mask: mask, + }); + + // Set the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + locator = page.locator(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last"); + // Wait until the messages are rendered + await expect(locator.locator(".mx_EventTile_line .mx_MTextBody").getByText(MessageLong)).toBeAttached(); + // Make sure the avatar inside ReadReceiptGroup is visible on the group layout + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + + // Enable the bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + + locator = page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble'].mx_EventTile_last"); + // TODO: remove this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout + // See: https://github.com/vector-im/element-web/issues/23569 + await expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeAttached(); + // Make sure the avatar inside ReadReceiptGroup is visible on bubble layout + // TODO: enable this after fixing the issue of ReadReceiptGroup being hidden on the bubble layout + // See: https://github.com/vector-im/element-web/issues/23569 + // expect(locator.locator(".mx_ReadReceiptGroup .mx_BaseAvatar")).toBeVisible(); + + // Re-enable the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + // User responds in thread + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + await locator.fill("Test"); + await locator.press("Enter"); + + // User asserts summary was updated correctly + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("Test")).toBeAttached(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Check reactions and hidden events + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // Enable hidden events to make the event for reaction displayed + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + + // User reacts to message instead + locator = page + .locator(".mx_ThreadView") + .locator(".mx_EventTile .mx_EventTile_line") + .filter({ hasText: "Hello there" }); + await locator.hover(); + await locator.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "React" }).click(); + + locator = page.locator(".mx_EmojiPicker"); + await locator.getByRole("textbox").fill("wave"); + await page.getByRole("gridcell", { name: "👋" }).click(); + + locator = page.locator(".mx_ThreadView"); + // Make sure the CSS style for spacing is applied to mx_ReactionsRow on group/modern layout + await expect(locator.locator(".mx_EventTile[data-layout=group] .mx_ReactionsRow")).toHaveCSS( + "margin-inline-start", + ThreadViewGroupSpacingStart, + ); + // Make sure the CSS style for spacing is applied to the hidden event on group/modern layout + await expect( + locator.locator( + ".mx_GenericEventListSummary[data-layout=group] .mx_EventTile_info.mx_EventTile_last " + + ".mx_EventTile_line", + ), + ).toHaveCSS("padding-inline-start", ThreadViewGroupSpacingStart); + + // Take snapshot of group layout (IRC layout is not available on ThreadView) + expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_reaction_and_a_hidden_event_on_group_layout.png", + { + mask: mask, + }, + ); + + // Enable bubble layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + + // Make sure the CSS style for spacing is applied to the hidden event on bubble layout + locator = page.locator( + ".mx_ThreadView .mx_GenericEventListSummary[data-layout=bubble] .mx_EventTile_info.mx_EventTile_last", + ); + expect(locator.locator(".mx_EventTile_line .mx_EventTile_content")) + // 76px: ThreadViewGroupSpacingStart + 14px + 6px + // 14px: avatar width + // See: _EventTile.pcss + .toHaveCSS("margin-inline-start", "76px"); + await expect(locator.locator(".mx_EventTile_line")) + // Make sure the margin is NOT applied to mx_EventTile_line + .toHaveCSS("margin-inline-start", "0px"); + + // Take snapshot of bubble layout + expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_reaction_and_a_hidden_event_on_bubble_layout.png", + { + mask: mask, + }, + ); + + // Disable hidden events + await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, false); + + // Reset to the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Check redactions + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + // User redacts their prior response + locator = page.locator(".mx_ThreadView .mx_EventTile .mx_EventTile_line").filter({ hasText: "Test" }); + await locator.hover(); + await locator.getByRole("button", { name: "Options" }).click(); + + await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "Remove" }).click(); + locator = page.locator(".mx_TextInputDialog").getByRole("button", { name: "Remove" }); + await expect(locator).toHaveClass(/mx_Dialog_primary/); + await locator.click(); + + // Wait until the response is redacted + await expect( + page.locator(".mx_ThreadView").locator(".mx_EventTile_last .mx_EventTile_receiptSent"), + ).toBeVisible(); + + // Take snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_redacted_messages_on_group_layout.png", + { + mask: mask, + }, + ); + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']")).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot( + "ThreadView_with_redacted_messages_on_bubble_layout.png", + { + mask: mask, + }, + ); + + // Set the group layout + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + + // User asserts summary was updated correctly + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached(); + + // User closes right panel after clicking back to thread list + locator = page.locator(".mx_ThreadPanel"); + locator.getByRole("button", { name: "Threads" }).click(); + locator.getByRole("button", { name: "Close" }).click(); + + // Bot responds to thread + await bot.sendMessage(roomId, "How are things?", threadId); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + + locator = page.getByRole("button", { name: "Threads" }); + await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); // User asserts thread list unread indicator + await locator.click(); // User opens thread list + + // User asserts thread with correct root & latest events & unread dot + locator = page.locator(".mx_ThreadPanel .mx_EventTile_last"); + await expect(locator.locator(".mx_EventTile_body").getByText("Hello Mr. Bot")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached(); + // Check the number of the replies + await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached(); + // Make sure the notification dot is visible + await expect(locator.locator(".mx_NotificationBadge_visible")).toBeVisible(); + // User opens thread via threads list + await locator.locator(".mx_EventTile_line").click(); + + // User responds & asserts + locator = page.locator(".mx_ThreadView").getByRole("textbox", { name: "Send a message…" }); + await locator.fill("Great!"); + await locator.press("Enter"); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("Great!")).toBeAttached(); + + // User edits & asserts + locator = page.locator(".mx_ThreadView .mx_EventTile_last"); + await expect(locator.getByText("Great!")).toBeAttached(); + await locator.locator(".mx_EventTile_line").hover(); + await locator.locator(".mx_EventTile_line").getByRole("button", { name: "Edit" }).click(); + await locator.getByRole("textbox").fill(" How about yourself?{enter}"); + await locator.getByRole("textbox").press("Enter"); + + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached(); + await expect( + locator.locator(".mx_ThreadSummary_content").getByText("Great! How about yourself?"), + ).toBeAttached(); + + // User closes right panel + await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click(); + + // Bot responds to thread and saves the id of their message to @eventId + const { event_id: eventId } = await bot.sendMessage(roomId, threadId, "I'm very good thanks"); + + // User asserts + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks")).toBeAttached(); + + // Bot edits their latest event + await bot.sendMessage(roomId, { + "body": "* I'm very good thanks :)", + "msgtype": "m.text", + "m.new_content": { + body: "I'm very good thanks :)", + msgtype: "m.text", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: eventId, + }, + }); + + // User asserts + locator = page.locator(".mx_RoomView_body .mx_ThreadSummary"); + await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached(); + await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks :)")).toBeAttached(); + }); + + test.describe("with larger viewport", async () => { + // Increase viewport size so that voice messages fit + test.use({ viewport: { width: 1280, height: 720 } }); + + test.beforeEach(async ({ page }) => { + // Increase right-panel size, so that voice messages fit + await page.addInitScript(() => { + window.localStorage.setItem("mx_rhs_size", "600"); + }); + }); + + test("can send voice messages", async ({ page, app, user }) => { + // Increase right-panel size, so that voice messages fit + await page.evaluate(() => { + window.localStorage.setItem("mx_rhs_size", "600"); + }); + + const roomId = await app.client.createRoom({}); + await page.goto("/#/room/" + roomId); + + // Send message + const locator = page.locator(".mx_RoomView_body"); + await locator.getByRole("textbox", { name: "Send a message…" }).fill("Hello Mr. Bot"); + await locator.getByRole("textbox", { name: "Send a message…" }).press("Enter"); + // Create thread + const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" }); + await locator2.hover(); + await locator2.getByRole("button", { name: "Reply in thread" }).click(); + + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Voice Message" }).click(); + await page.waitForTimeout(3000); + await app.getComposer(true).getByRole("button", { name: "Send voice message" }).click(); + await expect(page.locator(".mx_ThreadView .mx_MVoiceMessageBody")).toHaveCount(1); + }); + }); + + test("should send location and reply to the location on ThreadView", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await page.goto("/#/room/" + roomId); + + // Exclude timestamp, read marker, and mapboxgl-map from snapshots + const css = + ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; + + let locator = page.locator(".mx_RoomView_body"); + // User sends message + let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + // Wait for message to send, get its ID and save as @threadId + const threadId = await locator + .locator(".mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }) + .getAttribute("data-scroll-tokens"); + + // Bot starts thread + await bot.sendMessage(roomId, "Hello there", threadId); + + // User clicks thread summary + await page.locator(".mx_RoomView_body .mx_ThreadSummary").click(); + + // User sends location on ThreadView + await expect(page.locator(".mx_ThreadView")).toBeAttached(); + await (await app.openMessageComposerOptions(true)).getByRole("menuitem", { name: "Location" }).click(); + await page.getByTestId(`share-location-option-Pin`).click(); + await page.locator("#mx_LocationPicker_map").click(); + await page.getByRole("button", { name: "Share location" }).click(); + await expect(page.locator(".mx_ThreadView .mx_EventTile_last .mx_MLocationBody")).toBeAttached({ + timeout: 10000, + }); + + // User replies to the location + locator = page.locator(".mx_ThreadView"); + await locator.locator(".mx_EventTile_last").hover(); + await locator.locator(".mx_EventTile_last").getByRole("button", { name: "Reply" }).click(); + textbox = locator.getByRole("textbox", { name: "Reply to thread…" }); + await textbox.fill("Please come here"); + await textbox.press("Enter"); + // Wait until the reply is sent + await expect(locator.locator(".mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible(); + + // Take a snapshot of reply to the shared location + await page.addStyleTag({ content: css }); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("Reply_to_the_location_on_ThreadView.png"); + }); + + test("right panel behaves correctly", async ({ page, app, user }) => { + // Create room + const roomId = await app.client.createRoom({}); + await page.goto("/#/room/" + roomId); + + // Send message + let locator = page.locator(".mx_RoomView_body"); + let textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. Bot"); + await textbox.press("Enter"); + // Create thread + const locator2 = locator.locator(".mx_EventTile[data-scroll-tokens]").filter({ hasText: "Hello Mr. Bot" }); + await locator2.hover(); + await locator2.getByRole("button", { name: "Reply in thread" }).click(); + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + // Send message to thread + locator = page.locator(".mx_ThreadPanel"); + textbox = locator.getByRole("textbox", { name: "Send a message…" }); + await textbox.fill("Hello Mr. User"); + await textbox.press("Enter"); + await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached(); + // Close thread + await locator.getByRole("button", { name: "Close" }).click(); + + // Open existing thread + locator = page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: "Hello Mr. Bot" }); + await locator.hover(); + await locator.getByRole("button", { name: "Reply in thread" }).click(); + await expect(page.locator(".mx_ThreadView_timelinePanelWrapper")).toHaveCount(1); + + locator = page.locator(".mx_BaseCard"); + await expect(locator.locator(".mx_EventTile").first().getByText("Hello Mr. Bot")).toBeAttached(); + await expect(locator.locator(".mx_EventTile").last().getByText("Hello Mr. User")).toBeAttached(); + }); +}); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index e73e101e3d..054b946845 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -107,8 +107,13 @@ export class Client { * Send a message into a room * @param roomId ID of the room to send the message into * @param content the event content to send + * @param threadId optional thread id */ - public async sendMessage(roomId: string, content: IContent | string): Promise { + public async sendMessage( + roomId: string, + content: IContent | string, + threadId: string | null = null, + ): Promise { if (typeof content === "string") { content = { msgtype: "m.text", @@ -118,12 +123,13 @@ export class Client { const client = await this.prepareClient(); return client.evaluate( - (client, { roomId, content }) => { - return client.sendMessage(roomId, content); + (client, { roomId, content, threadId }) => { + return client.sendMessage(roomId, threadId, content); }, { roomId, content, + threadId, }, ); } diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..3b9781553d0385f859f5d95faa97c216c3236311 GIT binary patch literal 20834 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 LS3j3^P6