{3glnKcDyEBz&`cd2y&F
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
literal 0
HcmV?d00001
diff --git a/playwright.config.ts b/playwright.config.ts
index 7ab3093ba6..40065b92c4 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -26,7 +26,10 @@ export default defineConfig({
ignoreHTTPSErrors: true,
video: "retain-on-failure",
baseURL,
- permissions: ["clipboard-write", "clipboard-read"],
+ permissions: ["clipboard-write", "clipboard-read", "microphone"],
+ launchOptions: {
+ args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
+ },
trace: "on-first-retry",
},
webServer: {
diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts
new file mode 100644
index 0000000000..5e1102c09b
--- /dev/null
+++ b/playwright/e2e/timeline/timeline.spec.ts
@@ -0,0 +1,1130 @@
+/*
+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 * as fs from "node:fs";
+
+import type { Locator, Page } from "@playwright/test";
+import type { ISendEventResponse, EventType, MsgType } from "matrix-js-sdk/src/matrix";
+import { test, expect } from "../../element-web-test";
+import { SettingLevel } from "../../../src/settings/SettingLevel";
+import { Layout } from "../../../src/settings/enums/Layout";
+import { Client } from "../../pages/client";
+import { ElementAppPage } from "../../pages/ElementAppPage";
+import { Bot } from "../../pages/bot";
+
+// The avatar size used in the timeline
+const AVATAR_SIZE = 30;
+// The resize method used in the timeline
+const AVATAR_RESIZE_METHOD = "crop";
+
+const ROOM_NAME = "Test room";
+const OLD_AVATAR = fs.readFileSync("cypress/fixtures/riot.png");
+const NEW_AVATAR = fs.readFileSync("cypress/fixtures/element.png");
+const OLD_NAME = "Alan";
+const NEW_NAME = "Alan (away)";
+
+const getEventTilesWithBodies = (page: Page): Locator => {
+ return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") });
+};
+
+const expectDisplayName = async (e: Locator, displayName: string): Promise => {
+ await expect(e.locator(".mx_DisambiguatedProfile_displayName")).toHaveText(displayName);
+};
+
+const expectAvatar = async (cli: Client, e: Locator, avatarUrl: string): Promise => {
+ const size = await e.page().evaluate((size) => size * window.devicePixelRatio, AVATAR_SIZE);
+ const url = await cli.evaluate(
+ (client, { avatarUrl, size, resizeMethod }) => {
+ // eslint-disable-next-line no-restricted-properties
+ return client.mxcUrlToHttp(avatarUrl, size, size, resizeMethod);
+ },
+ { avatarUrl, size, resizeMethod: AVATAR_RESIZE_METHOD },
+ );
+ await expect(e.locator(".mx_BaseAvatar img")).toHaveAttribute("src", url);
+};
+
+const sendEvent = async (client: Client, roomId: string, html = false): Promise => {
+ const content = {
+ msgtype: "m.text" as MsgType,
+ body: "Message",
+ format: undefined,
+ formatted_body: undefined,
+ };
+ if (html) {
+ content.format = "org.matrix.custom.html";
+ content.formatted_body = "Message";
+ }
+ return client.sendEvent(roomId, null, "m.room.message" as EventType, content);
+};
+
+test.describe("Timeline", () => {
+ test.use({
+ displayName: OLD_NAME,
+ room: async ({ app, user }, use) => {
+ const roomId = await app.client.createRoom({ name: ROOM_NAME });
+ await use({ roomId });
+ },
+ });
+
+ let oldAvatarUrl: string;
+ let newAvatarUrl: string;
+
+ test.describe("useOnlyCurrentProfiles", () => {
+ test.beforeEach(async ({ app, user }) => {
+ ({ content_uri: oldAvatarUrl } = await app.client.uploadContent(OLD_AVATAR, { type: "image/png" }));
+ await app.client.setAvatarUrl(oldAvatarUrl);
+ ({ content_uri: newAvatarUrl } = await app.client.uploadContent(NEW_AVATAR, { type: "image/png" }));
+ });
+
+ test("should show historical profiles if disabled", async ({ page, app, room }) => {
+ await app.settings.setValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false);
+ await sendEvent(app.client, room.roomId);
+ await app.client.setDisplayName("Alan (away)");
+ await app.client.setAvatarUrl(newAvatarUrl);
+ // XXX: If we send the second event too quickly, there won't be
+ // enough time for the client to register the profile change
+ await page.waitForTimeout(500);
+ await sendEvent(app.client, room.roomId);
+ await app.viewRoomByName(ROOM_NAME);
+
+ const events = getEventTilesWithBodies(page);
+ await expect(events).toHaveCount(2);
+ await expectDisplayName(events.nth(0), OLD_NAME);
+ await expectAvatar(app.client, events.nth(0), oldAvatarUrl);
+ await expectDisplayName(events.nth(1), NEW_NAME);
+ await expectAvatar(app.client, events.nth(1), newAvatarUrl);
+ });
+
+ test("should not show historical profiles if enabled", async ({ page, app, room }) => {
+ await app.settings.setValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, true);
+ await sendEvent(app.client, room.roomId);
+ await app.client.setDisplayName(NEW_NAME);
+ await app.client.setAvatarUrl(newAvatarUrl);
+ // XXX: If we send the second event too quickly, there won't be
+ // enough time for the client to register the profile change
+ await page.waitForTimeout(500);
+ await sendEvent(app.client, room.roomId);
+ await app.viewRoomByName(ROOM_NAME);
+
+ const events = getEventTilesWithBodies(page);
+ await expect(events).toHaveCount(2);
+ for (const e of await events.all()) {
+ await expectDisplayName(e, NEW_NAME);
+ await expectAvatar(app.client, e, newAvatarUrl);
+ }
+ });
+ });
+
+ test.describe("configure room", () => {
+ test("should create and configure a room on IRC layout", async ({ page, app, room }) => {
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+ await expect(
+ page.locator(
+ ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary",
+ { hasText: `${OLD_NAME} created and configured the room.` },
+ ),
+ ).toBeVisible();
+
+ // wait for the date separator to appear to have a stable percy snapshot
+ await expect(page.locator(".mx_TimelineSeparator")).toHaveText("today");
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("configured-room-irc-layout.png");
+ });
+
+ test("should have an expanded generic event list summary (GELS) on IRC layout", async ({ page, app, room }) => {
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+
+ // Wait until configuration is finished
+ await expect(
+ page.locator(
+ ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary",
+ { hasText: `${OLD_NAME} created and configured the room.` },
+ ),
+ ).toBeVisible();
+
+ const gels = page.locator(".mx_GenericEventListSummary");
+ // Click "expand" link button
+ await gels.getByRole("button", { name: "Expand" }).click();
+ // Assert that the "expand" link button worked
+ await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible();
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-irc-layout.png", {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ });
+ });
+
+ test("should have an expanded generic event list summary (GELS) on compact modern/group layout", async ({
+ page,
+ app,
+ room,
+ }) => {
+ await page.goto(`/#/room/${room.roomId}`);
+
+ // Set compact modern layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
+ await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
+
+ // Wait until configuration is finished
+ await expect(
+ page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", {
+ hasText: `${OLD_NAME} created and configured the room.`,
+ }),
+ ).toBeVisible();
+
+ const gels = page.locator(".mx_GenericEventListSummary");
+ // Click "expand" link button
+ await gels.getByRole("button", { name: "Expand" }).click();
+ // Assert that the "expand" link button worked
+ await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible();
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-modern-layout.png", {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ });
+ });
+
+ test("should click 'collapse' on the first hovered info event line inside GELS on bubble layout", async ({
+ page,
+ app,
+ room,
+ }) => {
+ // This test checks clickability of the "Collapse" link button, which had been covered with
+ // MessageActionBar's safe area - https://github.com/vector-im/element-web/issues/22864
+
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
+ await expect(
+ page.locator(
+ ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='bubble'] .mx_GenericEventListSummary_summary",
+ { hasText: `${OLD_NAME} created and configured the room.` },
+ ),
+ ).toBeVisible();
+
+ const gels = page.locator(".mx_GenericEventListSummary");
+ // Click "expand" link button
+ await gels.getByRole("button", { name: "Expand" }).click();
+ // Assert that the "expand" link button worked
+ await expect(gels.getByRole("button", { name: "Collapse" })).toBeVisible();
+
+ // Make sure spacer is not visible on bubble layout
+ await expect(
+ page.locator(".mx_GenericEventListSummary[data-layout=bubble] .mx_GenericEventListSummary_spacer"),
+ ).not.toBeVisible(); // See: _GenericEventListSummary.pcss
+
+ // Save snapshot of expanded generic event list summary on bubble layout
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-bubble-layout.png", {
+ // Exclude timestamp from snapshot
+ mask: [page.locator(".mx_MessageTimestamp")],
+ });
+
+ // Click "collapse" link button on the first hovered info event line
+ const firstTile = gels.locator(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type");
+ await firstTile.hover();
+ await expect(firstTile.getByRole("toolbar", { name: "Message Actions" })).toBeVisible();
+ await gels.getByRole("button", { name: "Collapse" }).click();
+
+ // Assert that "collapse" link button worked
+ await expect(gels.getByRole("button", { name: "Expand" })).toBeVisible();
+
+ // Save snapshot of collapsed generic event list summary on bubble layout
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("collapsed-gels-bubble-layout.png", {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ });
+ });
+
+ test("should add inline start margin to an event line on IRC layout", async ({
+ page,
+ app,
+ room,
+ axe,
+ checkA11y,
+ }) => {
+ axe.disableRules("color-contrast");
+
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+
+ // Wait until configuration is finished
+ await expect(
+ page.locator(
+ ".mx_RoomView_body .mx_GenericEventListSummary[data-layout='irc'] .mx_GenericEventListSummary_summary",
+ { hasText: `${OLD_NAME} created and configured the room.` },
+ ),
+ ).toBeVisible();
+
+ // Click "expand" link button
+ await page.locator(".mx_GenericEventListSummary").getByRole("button", { name: "Expand" }).click();
+
+ // Check the event line has margin instead of inset property
+ // cf. _EventTile.pcss
+ // --EventTile_irc_line_info-margin-inline-start
+ // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding))
+ // = 80 + 14 + 5 = 99px
+
+ const firstEventLineIrc = page.locator(
+ ".mx_EventTile_info[data-layout=irc]:first-of-type .mx_EventTile_line",
+ );
+ await expect(firstEventLineIrc).toHaveCSS("margin-inline-start", "99px");
+ await expect(firstEventLineIrc).toHaveCSS("inset-inline-start", "0px");
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "event-line-inline-start-margin-irc-layout.png",
+ {
+ // Exclude timestamp and read marker from snapshot
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ },
+ );
+ await checkA11y();
+ });
+ });
+
+ test.describe("message displaying", () => {
+ const messageEdit = async (page: Page) => {
+ const line = page.locator(".mx_EventTile .mx_EventTile_line", { hasText: "Message" });
+ await line.hover();
+ await line.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }).click();
+ await page.getByRole("textbox", { name: "Edit message" }).pressSequentially("Edit");
+ await page.getByRole("textbox", { name: "Edit message" }).press("Enter");
+
+ // Assert that the edited message and the link button are found
+ // Regex patterns due to the edited date
+ await expect(
+ page.locator(".mx_EventTile .mx_EventTile_line", { hasText: "MessageEdit" }).getByRole("button", {
+ name: /Edited at .*? Click to view edits./,
+ }),
+ ).toBeVisible();
+ };
+
+ test("should align generic event list summary with messages and emote on IRC layout", async ({
+ page,
+ app,
+ room,
+ }) => {
+ // This test aims to check:
+ // 1. Alignment of collapsed GELS (generic event list summary) and messages
+ // 2. Alignment of expanded GELS and messages
+ // 3. Alignment of expanded GELS and placeholder of deleted message
+ // 4. Alignment of expanded GELS, placeholder of deleted message, and emote
+
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+
+ // Wait until configuration is finished
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(`${OLD_NAME} created and configured the room.`),
+ ).toBeVisible();
+
+ // Send messages
+ const composer = app.getComposerField();
+ await composer.fill("Hello Mr. Bot");
+ await composer.press("Enter");
+ await composer.fill("Hello again, Mr. Bot");
+ await composer.press("Enter");
+
+ // Make sure the second message was sent
+ await expect(
+ page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
+ ).toBeVisible();
+
+ // 1. Alignment of collapsed GELS (generic event list summary) and messages
+ // Check inline start spacing of collapsed GELS
+ // See: _EventTile.pcss
+ // .mx_GenericEventListSummary[data-layout="irc"] > .mx_EventTile_line
+ // = var(--name-width) + var(--icon-width) + var(--MessageTimestamp-width) + 2 * var(--right-padding)
+ // = 80 + 14 + 46 + 2 * 5
+ // = 150px
+ await expect(page.locator(".mx_GenericEventListSummary[data-layout=irc] > .mx_EventTile_line")).toHaveCSS(
+ "padding-inline-start",
+ "150px",
+ );
+ // Check width and spacing values of elements in .mx_EventTile, which should be equal to 150px
+ // --right-padding should be applied
+ for (const locator of await page.locator(".mx_EventTile > a").all()) {
+ if (await locator.isVisible()) {
+ await expect(locator).toHaveCSS("margin-right", "5px");
+ }
+ }
+ // --name-width width zero inline end margin should be applied
+ for (const locator of await page.locator(".mx_EventTile .mx_DisambiguatedProfile").all()) {
+ await expect(locator).toHaveCSS("width", "80px");
+ await expect(locator).toHaveCSS("margin-inline-end", "0px");
+ }
+ // --icon-width should be applied
+ for (const locator of await page.locator(".mx_EventTile .mx_EventTile_avatar > .mx_BaseAvatar").all()) {
+ await expect(locator).toHaveCSS("width", "14px");
+ }
+ // var(--MessageTimestamp-width) should be applied
+ for (const locator of await page.locator(".mx_EventTile > a").all()) {
+ await expect(locator).toHaveCSS("min-width", "46px");
+ }
+ // Record alignment of collapsed GELS and messages on messagePanel
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "collapsed-gels-and-messages-irc-layout.png",
+ {
+ // Exclude timestamp from snapshot of mx_MainSplit
+ mask: [page.locator(".mx_MessageTimestamp")],
+ },
+ );
+
+ // 2. Alignment of expanded GELS and messages
+ // Click "expand" link button
+ await page.locator(".mx_GenericEventListSummary").getByRole("button", { name: "Expand" }).click();
+ // Check inline start spacing of info line on expanded GELS
+ // See: _EventTile.pcss
+ // --EventTile_irc_line_info-margin-inline-start
+ // = 80 + 14 + 1 * 5
+ await expect(
+ page.locator(".mx_EventTile[data-layout=irc].mx_EventTile_info:first-of-type .mx_EventTile_line"),
+ ).toHaveCSS("margin-inline-start", "99px");
+ // Record alignment of expanded GELS and messages on messagePanel
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-and-messages-irc-layout.png", {
+ // Exclude timestamp from snapshot of mx_MainSplit
+ mask: [page.locator(".mx_MessageTimestamp")],
+ });
+
+ // 3. Alignment of expanded GELS and placeholder of deleted message
+ // Delete the second (last) message
+ const lastTile = page.locator(".mx_RoomView_MessageList > .mx_EventTile_last");
+ await lastTile.hover();
+ await lastTile.getByRole("button", { name: "Options" }).click();
+ await page.getByRole("menuitem", { name: "Remove" }).click();
+ // Confirm deletion
+ await page.locator(".mx_Dialog_buttons").getByRole("button", { name: "Remove" }).click();
+ // Make sure the dialog was closed and the second (last) message was redacted
+ await expect(page.locator(".mx_Dialog")).not.toBeVisible();
+ await expect(page.locator(".mx_GenericEventListSummary .mx_EventTile_last .mx_RedactedBody")).toBeVisible();
+ await expect(
+ page.locator(".mx_GenericEventListSummary .mx_EventTile_last .mx_EventTile_receiptSent"),
+ ).toBeVisible();
+ // Record alignment of expanded GELS and placeholder of deleted message on messagePanel
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-redaction-placeholder.png", {
+ // Exclude timestamp from snapshot of mx_MainSplit
+ mask: [page.locator(".mx_MessageTimestamp")],
+ });
+
+ // 4. Alignment of expanded GELS, placeholder of deleted message, and emote
+ // Send a emote
+ await page
+ .locator(".mx_RoomView_body")
+ .getByRole("textbox", { name: "Send a message…" })
+ .fill("/me says hello to Mr. Bot");
+ await page.locator(".mx_RoomView_body").getByRole("textbox", { name: "Send a message…" }).press("Enter");
+ // Check inline start margin of its avatar
+ // Here --right-padding is for the avatar on the message line
+ // See: _IRCLayout.pcss
+ // .mx_IRCLayout .mx_EventTile_emote .mx_EventTile_avatar
+ // = calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding))
+ // = 80 + 14 + 1 * 5
+ await expect(page.locator(".mx_EventTile_emote .mx_EventTile_avatar")).toHaveCSS("margin-left", "99px");
+ // Make sure emote was sent
+ await expect(page.locator(".mx_EventTile_last.mx_EventTile_emote .mx_EventTile_receiptSent")).toBeVisible();
+ // Record alignment of expanded GELS, placeholder of deleted message, and emote
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", {
+ // Exclude timestamp from snapshot of mx_MainSplit
+ mask: [page.locator(".mx_MessageTimestamp")],
+ });
+ });
+
+ test("should render EventTiles on IRC, modern (group), and bubble layout", async ({ page, app, room }) => {
+ const screenshotOptions = {
+ // Hide because flaky - See https://github.com/vector-im/element-web/issues/24957
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ };
+
+ await sendEvent(app.client, room.roomId);
+ await sendEvent(app.client, room.roomId); // check continuation
+ await sendEvent(app.client, room.roomId); // check the last EventTile
+
+ await page.goto(`/#/room/${room.roomId}`);
+ const composer = app.getComposerField();
+ // Send a plain text message
+ await composer.fill("Hello");
+ await composer.press("Enter");
+ // Send a big emoji
+ await composer.fill("🏀");
+ await composer.press("Enter");
+ // Send an inline emoji
+ await composer.fill("This message has an inline emoji 👒");
+ await composer.press("Enter");
+
+ await expect(page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒")).toBeVisible();
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // IRC layout
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+
+ // Wait until configuration is finished
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(`${OLD_NAME} created and configured the room.`),
+ ).toBeVisible();
+
+ await app.scrollToBottom(page);
+ await expect(
+ page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
+ ).toBeInViewport();
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "event-tiles-irc-layout.png",
+ screenshotOptions,
+ );
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Group/modern layout
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
+
+ // Check that the last EventTile is rendered
+ await app.scrollToBottom(page);
+ await expect(
+ page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
+ ).toBeInViewport();
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "event-tiles-modern-layout.png",
+ screenshotOptions,
+ );
+
+ // Check the same thing for compact layout
+ await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
+
+ // Check that the last EventTile is rendered
+ await app.scrollToBottom(page);
+ await expect(
+ page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
+ ).toBeInViewport();
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "event-tiles-compact-modern-layout.png",
+ screenshotOptions,
+ );
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Message bubble layout
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
+
+ await app.scrollToBottom(page);
+ await expect(
+ page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
+ ).toBeInViewport();
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "event-tiles-bubble-layout.png",
+ screenshotOptions,
+ );
+ });
+
+ test("should set inline start padding to a hidden event line", async ({ page, app, room }) => {
+ await sendEvent(app.client, room.roomId);
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true);
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(`${OLD_NAME} created and configured the room.`),
+ ).toBeVisible();
+
+ // Edit message
+ await messageEdit(page);
+
+ // Click timestamp to highlight hidden event line
+ await page.locator(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click();
+
+ // should not add inline start padding to a hidden event line on IRC layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+ await expect(
+ page.locator(".mx_EventTile[data-layout=irc].mx_EventTile_info .mx_EventTile_line").first(),
+ ).toHaveCSS("padding-inline-start", "0px");
+
+ // Exclude timestamp and read marker from snapshot
+ const screenshotOptions = {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ };
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "hidden-event-line-zero-padding-irc-layout.png",
+ screenshotOptions,
+ );
+
+ // should add inline start padding to a hidden event line on modern layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
+ // calc(var(--EventTile_group_line-spacing-inline-start) + 20px) = 64 + 20 = 84px
+ await expect(
+ page.locator(".mx_EventTile[data-layout=group].mx_EventTile_info .mx_EventTile_line").first(),
+ ).toHaveCSS("padding-inline-start", "84px");
+
+ await expect(page.locator(".mx_MainSplit")).toMatchScreenshot(
+ "hidden-event-line-padding-modern-layout.png",
+ screenshotOptions,
+ );
+ });
+
+ test("should click view source event toggle", async ({ page, app, room }) => {
+ // This test checks:
+ // 1. clickability of top left of view source event toggle
+ // 2. clickability of view source toggle on IRC layout
+
+ // Exclude timestamp from snapshot
+ const screenshotOptions = {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ };
+
+ await sendEvent(app.client, room.roomId);
+ await page.goto(`/#/room/${room.roomId}`);
+ await app.settings.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true);
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(OLD_NAME + " created and configured the room."),
+ ).toBeVisible();
+
+ // Edit message
+ await messageEdit(page);
+
+ // 1. clickability of top left of view source event toggle
+
+ // Click top left of the event toggle, which should not be covered by MessageActionBar's safe area
+ const viewSourceEventGroup = page.locator(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent");
+ await viewSourceEventGroup.hover();
+ await viewSourceEventGroup
+ .getByRole("button", { name: "toggle event" })
+ .click({ position: { x: 0, y: 0 } });
+
+ // Make sure the expand toggle works
+ const viewSourceEventExpanded = page.locator(
+ ".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded",
+ );
+ await viewSourceEventExpanded.hover();
+ const toggleEventButton = viewSourceEventExpanded.getByRole("button", { name: "toggle event" });
+ // Check size and position of toggle on expanded view source event
+ // See: _ViewSourceEvent.pcss
+ await expect(toggleEventButton).toHaveCSS("height", "12px"); // --ViewSourceEvent_toggle-size
+ await expect(toggleEventButton).toHaveCSS("align-self", "flex-end");
+ // Click again to collapse the source
+ await toggleEventButton.click({ position: { x: 0, y: 0 } });
+
+ // Make sure the collapse toggle works
+ await expect(
+ page.locator(".mx_EventTile_last[data-layout=group] .mx_ViewSourceEvent_expanded"),
+ ).not.toBeVisible();
+
+ // 2. clickability of view source toggle on IRC layout
+
+ // Enable IRC layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+
+ // Hover the view source toggle on IRC layout
+ const viewSourceEventIrc = page.locator(
+ ".mx_GenericEventListSummary[data-layout=irc] .mx_EventTile .mx_ViewSourceEvent",
+ );
+ await viewSourceEventIrc.hover();
+ await expect(viewSourceEventIrc).toMatchScreenshot(
+ "hovered-hidden-event-line-irc-layout.png",
+ screenshotOptions,
+ );
+
+ // Click view source event toggle
+ await viewSourceEventIrc.getByRole("button", { name: "toggle event" }).click({ position: { x: 0, y: 0 } });
+
+ // Make sure the expand toggle worked
+ await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible();
+ });
+
+ test("should render file size in kibibytes on a file tile", async ({ page, room }) => {
+ await page.goto(`/#/room/${room.roomId}`);
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(OLD_NAME + " created and configured the room."),
+ ).toBeVisible();
+
+ // Upload a file from the message composer
+ await page
+ .locator(".mx_MessageComposer_actions input[type='file']")
+ .setInputFiles("cypress/fixtures/matrix-org-client-versions.json");
+
+ // Click "Upload" button
+ await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
+
+ // Wait until the file is sent
+ await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
+ await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
+
+ // Assert that the file size is displayed in kibibytes (1024 bytes), not kilobytes (1000 bytes)
+ // See: https://github.com/vector-im/element-web/issues/24866
+ await expect(
+ page.locator(".mx_EventTile_last .mx_MFileBody_info_filename").getByText(/1.12 KB/),
+ ).toBeVisible();
+ });
+
+ test("should render url previews", async ({ page, app, room, axe, checkA11y }) => {
+ axe.disableRules("color-contrast");
+
+ await page.route(
+ "**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*",
+ async (route) => {
+ await route.fulfill({
+ path: "cypress/fixtures/riot.png",
+ });
+ },
+ );
+ await page.route(
+ "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*",
+ async (route) => {
+ await route.fulfill({
+ json: {
+ "og:title": "Element Call",
+ "og:description": null,
+ "og:image:width": 48,
+ "og:image:height": 48,
+ "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV",
+ "og:image:type": "image/png",
+ "matrix:image:size": 2121,
+ },
+ });
+ },
+ );
+
+ const requestPromises: Promise[] = [
+ page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"),
+ page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"),
+ ];
+
+ await app.client.sendMessage(room.roomId, "https://call.element.io/");
+ await page.goto(`/#/room/${room.roomId}`);
+
+ await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible();
+ await Promise.all(requestPromises);
+
+ await checkA11y();
+
+ await app.scrollToBottom(page);
+ await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
+ // Exclude timestamp and read marker from snapshot
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ });
+ });
+
+ test.describe("on search results panel", () => {
+ test("should highlight search result words regardless of formatting", async ({ page, app, room }) => {
+ await sendEvent(app.client, room.roomId);
+ await sendEvent(app.client, room.roomId, true);
+ await page.goto(`/#/room/${room.roomId}`);
+
+ await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
+
+ await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png");
+
+ await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message");
+ await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter");
+
+ for (const locator of await page
+ .locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight")
+ .all()) {
+ await expect(locator).toBeVisible();
+ }
+ await expect(page.locator(".mx_RoomView_searchResultsPanel")).toMatchScreenshot(
+ "highlighted-search-results.png",
+ );
+ });
+
+ test("should render a fully opaque textual event", async ({ page, app, room }) => {
+ const stringToSearch = "Message"; // Same with string sent with sendEvent()
+
+ await sendEvent(app.client, room.roomId);
+
+ await page.goto(`/#/room/${room.roomId}`);
+
+ // Open a room setting dialog
+ await page.getByRole("button", { name: "Room options" }).click();
+ await page.getByRole("menuitem", { name: "Settings" }).click();
+
+ // Set a room topic to render a TextualEvent
+ await page.getByRole("textbox", { name: "Room Topic" }).type(`This is a room for ${stringToSearch}.`);
+ await page.getByRole("button", { name: "Save" }).click();
+
+ await app.closeDialog();
+
+ // Assert that the TextualEvent is rendered
+ await expect(
+ page.getByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`),
+ ).toHaveClass(/mx_TextualEvent/);
+
+ // Display the room search bar
+ await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
+
+ // Search the string to display both the message and TextualEvent on search results panel
+ await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch);
+ await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter");
+
+ // On search results panel
+ const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");
+ // Assert that contextual event tiles are translucent
+ for (const locator of await resultsPanel.locator(".mx_EventTile.mx_EventTile_contextual").all()) {
+ await expect(locator).toHaveCSS("opacity", "0.4");
+ }
+ // Assert that the TextualEvent is fully opaque (visually solid).
+ for (const locator of await resultsPanel.locator(".mx_EventTile .mx_TextualEvent").all()) {
+ await expect(locator).toHaveCSS("opacity", "1");
+ }
+
+ await expect(page.locator(".mx_RoomView_searchResultsPanel")).toMatchScreenshot(
+ "search-results-with-TextualEvent.png",
+ );
+ });
+ });
+ });
+
+ test.describe("message sending", () => {
+ const MESSAGE = "Hello world";
+ const reply = "Reply";
+ const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => {
+ // View room
+ await page.goto(`/#/room/${roomId}`);
+
+ // Send a message
+ const composer = app.getComposerField();
+ await composer.fill(MESSAGE);
+ await composer.press("Enter");
+
+ // Reply to the message
+ const lastTile = page.locator(".mx_EventTile_last");
+ await expect(lastTile.getByText(MESSAGE)).toBeVisible();
+ await lastTile.hover();
+ await lastTile.getByRole("button", { name: "Reply", exact: true }).click();
+ };
+
+ // For clicking the reply button on the last line
+ const clickButtonReply = async (page: Page): Promise => {
+ const lastTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
+ await lastTile.hover();
+ await lastTile.getByRole("button", { name: "Reply", exact: true }).click();
+ };
+
+ test("can reply with a text message", async ({ page, app, room }) => {
+ await viewRoomSendMessageAndSetupReply(page, app, room.roomId);
+
+ await app.getComposerField().fill(reply);
+ await app.getComposerField().press("Enter");
+
+ const eventTileLine = page.locator(".mx_RoomView_body .mx_EventTile_last .mx_EventTile_line");
+ await expect(eventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(MESSAGE)).toBeVisible();
+ await expect(eventTileLine.getByText(reply)).toHaveCount(1);
+ });
+
+ test("can reply with a voice message", async ({ page, app, room, context }) => {
+ await context.grantPermissions(["microphone"]);
+ await viewRoomSendMessageAndSetupReply(page, app, room.roomId);
+
+ const composerOptions = await app.openMessageComposerOptions();
+ await composerOptions.getByRole("menuitem", { name: "Voice Message" }).click();
+
+ // Record an empty message
+ await page.waitForTimeout(3000);
+
+ const roomViewBody = page.locator(".mx_RoomView_body");
+ await roomViewBody
+ .locator(".mx_MessageComposer")
+ .getByRole("button", { name: "Send voice message" })
+ .click();
+
+ const lastEventTileLine = roomViewBody.locator(".mx_EventTile_last .mx_EventTile_line");
+ await expect(lastEventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(MESSAGE)).toBeVisible();
+
+ await expect(lastEventTileLine.locator(".mx_MVoiceMessageBody")).toHaveCount(1);
+ });
+
+ test("should not be possible to send flag with regional emojis", async ({ page, app, room }) => {
+ await page.goto(`/#/room/${room.roomId}`);
+
+ // Send a message
+ await app.getComposerField().pressSequentially(":regional_indicator_a");
+ await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_a:" }).click();
+ await app.getComposerField().pressSequentially(":regional_indicator_r");
+ await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_r:" }).click();
+ await app.getComposerField().pressSequentially(" :regional_indicator_z");
+ await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_z:" }).click();
+ await app.getComposerField().pressSequentially(":regional_indicator_a");
+ await page.locator(".mx_Autocomplete_Completion_title", { hasText: ":regional_indicator_a:" }).click();
+ await app.getComposerField().press("Enter");
+
+ await expect(
+ page.locator(
+ ".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_MTextBody .mx_EventTile_bigEmoji > *",
+ ),
+ ).toHaveCount(4);
+ });
+
+ test("should display a reply chain", async ({ page, app, room, homeserver }) => {
+ const reply2 = "Reply again";
+
+ await page.goto(`/#/room/${room.roomId}`);
+
+ // Wait until configuration is finished
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(OLD_NAME + " created and configured the room."),
+ ).toBeVisible();
+
+ // Create a bot "BotBob" and invite it
+ const bot = new Bot(page, homeserver, {
+ displayName: "BotBob",
+ autoAcceptInvites: false,
+ });
+ await bot.prepareClient();
+ await app.client.inviteUser(room.roomId, bot.credentials.userId);
+ await bot.joinRoom(room.roomId);
+
+ // Make sure the bot joined the room
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary .mx_EventTile_info.mx_EventTile_last")
+ .getByText("BotBob joined the room"),
+ ).toBeVisible();
+
+ // Have bot send MESSAGE to roomId
+ await bot.sendMessage(room.roomId, MESSAGE);
+
+ // Assert that MESSAGE is found
+ await expect(page.getByText(MESSAGE)).toBeVisible();
+
+ // Reply to the message
+ await clickButtonReply(page);
+ await app.getComposerField().fill(reply);
+ await app.getComposerField().press("Enter");
+
+ // Make sure 'reply' was sent
+ await expect(page.locator(".mx_RoomView_body .mx_EventTile_last").getByText(reply)).toBeVisible();
+
+ // Reply again to create a replyChain
+ await clickButtonReply(page);
+ await app.getComposerField().fill(reply2);
+ await app.getComposerField().press("Enter");
+
+ // Assert that 'reply2' was sent
+ await expect(page.locator(".mx_RoomView_body .mx_EventTile_last").getByText(reply2)).toBeVisible();
+
+ await expect(page.locator(".mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
+
+ // Exclude timestamp and read marker from snapshot
+ const screenshotOptions = {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ };
+
+ // Check the margin value of ReplyChains of EventTile at the bottom on IRC layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+ for (const locator of await page.locator(".mx_EventTile_last[data-layout='irc'] .mx_ReplyChain").all()) {
+ await expect(locator).toHaveCSS("margin", "0px");
+ }
+
+ // Take a snapshot on IRC layout
+ // Note that because zero margin is applied to mx_ReplyChain, the left borders of two mx_ReplyChain
+ // components may seem to be connected to one.
+ await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "event-tile-reply-chains-irc-layout.png",
+ screenshotOptions,
+ );
+
+ // Check the margin value of ReplyChains of EventTile at the bottom on group/modern layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
+ for (const locator of await page.locator(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").all()) {
+ await expect(locator).toHaveCSS("margin-bottom", "8px");
+ }
+
+ // Take a snapshot on modern layout
+ await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "event-tile-reply-chains-irc-modern.png",
+ screenshotOptions,
+ );
+
+ // Check the margin value of ReplyChains of EventTile at the bottom on group/modern compact layout
+ await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
+ for (const locator of await page.locator(".mx_EventTile_last[data-layout='group'] .mx_ReplyChain").all()) {
+ await expect(locator).toHaveCSS("margin-bottom", "4px");
+ }
+
+ // Take a snapshot on compact modern layout
+ await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "event-tile-reply-chains-compact-modern-layout.png",
+ screenshotOptions,
+ );
+
+ // Check the margin value of ReplyChains of EventTile at the bottom on bubble layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
+ for (const locator of await page.locator(".mx_EventTile_last[data-layout='bubble'] .mx_ReplyChain").all()) {
+ await expect(locator).toHaveCSS("margin-bottom", "8px");
+ }
+
+ // Take a snapshot on bubble layout
+ await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "event-tile-reply-chains-bubble-layout.png",
+ screenshotOptions,
+ );
+ });
+
+ test("should send, reply, and display long strings without overflowing", async ({
+ page,
+ app,
+ room,
+ homeserver,
+ }) => {
+ // Max 256 characters for display name
+ const LONG_STRING =
+ "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut " +
+ "et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " +
+ "aliquip";
+
+ // Create a bot with a long display name
+ const bot = new Bot(page, homeserver, {
+ displayName: LONG_STRING,
+ autoAcceptInvites: false,
+ });
+ await bot.prepareClient();
+
+ // Create another room with a long name, invite the bot, and open the room
+ const testRoomId = await app.client.createRoom({ name: LONG_STRING });
+ await app.client.inviteUser(testRoomId, bot.credentials.userId);
+ await bot.joinRoom(testRoomId);
+ await page.goto(`/#/room/${testRoomId}`);
+
+ // Wait until configuration is finished
+ await expect(
+ page
+ .locator(".mx_GenericEventListSummary_summary")
+ .getByText(OLD_NAME + " created and configured the room."),
+ ).toBeVisible();
+
+ // Set the display name to "LONG_STRING 2" in order to avoid a warning in Percy tests from being triggered
+ // due to the generated random mxid being displayed inside the GELS summary.
+ await app.client.setDisplayName(`${LONG_STRING} 2`);
+
+ // Have the bot send a long message
+ await bot.sendMessage(testRoomId, {
+ body: LONG_STRING,
+ msgtype: "m.text",
+ });
+
+ // Wait until the message is rendered
+ await expect(
+ page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText(LONG_STRING),
+ ).toBeVisible();
+
+ // Reply to the message
+ await clickButtonReply(page);
+ await app.getComposerField().fill(reply);
+ await app.getComposerField().press("Enter");
+
+ // Make sure the reply tile is rendered
+ const eventTileLine = page.locator(".mx_EventTile_last .mx_EventTile_line");
+ await expect(eventTileLine.locator(".mx_ReplyTile .mx_MTextBody").getByText(LONG_STRING)).toBeVisible();
+
+ await expect(eventTileLine.getByText(reply)).toHaveCount(1);
+
+ // Change the viewport size
+ await page.setViewportSize({ width: 1600, height: 1200 });
+
+ // Exclude timestamp and read marker from snapshot
+ const screenshotOptions = {
+ mask: [page.locator(".mx_MessageTimestamp")],
+ css: `
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+ };
+
+ // Make sure the strings do not overflow on IRC layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
+ // Scroll to the bottom to have Percy take a snapshot of the whole viewport
+ await app.scrollToBottom(page);
+ // Assert that both avatar in the introduction and the last message are visible at the same time
+ await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
+ const lastEventTileIrc = page.locator(".mx_EventTile_last[data-layout='irc']");
+ await expect(lastEventTileIrc.locator(".mx_MTextBody").first()).toBeVisible();
+ await expect(lastEventTileIrc.locator(".mx_EventTile_receiptSent")).toBeVisible(); // rendered at the bottom of EventTile
+ // Take a snapshot in IRC layout
+ await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot(
+ "long-strings-with-reply-irc-layout.png",
+ screenshotOptions,
+ );
+
+ // Make sure the strings do not overflow on modern layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
+ await app.scrollToBottom(page); // Scroll again in case
+ await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
+ const lastEventTileGroup = page.locator(".mx_EventTile_last[data-layout='group']");
+ await expect(lastEventTileGroup.locator(".mx_MTextBody").first()).toBeVisible();
+ await expect(lastEventTileGroup.locator(".mx_EventTile_receiptSent")).toBeVisible();
+ await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot(
+ "long-strings-with-reply-modern-layout.png",
+ screenshotOptions,
+ );
+
+ // Make sure the strings do not overflow on bubble layout
+ await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
+ await app.scrollToBottom(page); // Scroll again in case
+ await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
+ const lastEventTileBubble = page.locator(".mx_EventTile_last[data-layout='bubble']");
+ await expect(lastEventTileBubble.locator(".mx_MTextBody").first()).toBeVisible();
+ await expect(lastEventTileBubble.locator(".mx_EventTile_receiptSent")).toBeVisible();
+ await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot(
+ "long-strings-with-reply-bubble-layout.png",
+ screenshotOptions,
+ );
+ });
+ });
+});
diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts
index ecea296332..cb8638ebbf 100644
--- a/playwright/element-web-test.ts
+++ b/playwright/element-web-test.ts
@@ -250,6 +250,10 @@ export const expect = baseExpect.extend({
.mx_ReplyChain {
border-left-color: var(--cpd-color-blue-1200) !important;
}
+ /* Use monospace font for timestamp for consistent mask width */
+ .mx_MessageTimestamp {
+ font-family: Inconsolata !important;
+ }
${options?.css ?? ""}
`,
})) as ElementHandle;
diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts
index 4e065a4b17..8bc0f5ae0e 100644
--- a/playwright/pages/ElementAppPage.ts
+++ b/playwright/pages/ElementAppPage.ts
@@ -105,6 +105,14 @@ export class ElementAppPage {
return this.page.locator(`${panelClass} .mx_MessageComposer`);
}
+ /**
+ * Get the composer input field
+ * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
+ */
+ public getComposerField(isRightPanel?: boolean): Locator {
+ return this.getComposer(isRightPanel).locator("[contenteditable]");
+ }
+
/**
* Open the message composer kebab menu
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
@@ -155,4 +163,10 @@ export class ElementAppPage {
await spotlight.open();
return spotlight;
}
+
+ public async scrollToBottom(page: Page): Promise {
+ await page
+ .locator(".mx_ScrollPanel")
+ .evaluate((scrollPanel) => scrollPanel.scrollTo(0, scrollPanel.scrollHeight));
+ }
}
diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts
index 6c56cb90d3..a197f09333 100644
--- a/playwright/pages/client.ts
+++ b/playwright/pages/client.ts
@@ -28,6 +28,8 @@ import type {
IRoomDirectoryOptions,
KnockRoomOpts,
Visibility,
+ UploadOpts,
+ Upload,
} from "matrix-js-sdk/src/matrix";
import { Credentials } from "../plugins/homeserver";
@@ -293,6 +295,46 @@ export class Client {
}, options);
}
+ /**
+ * @param {string} name
+ * @param {module:client.callback} callback Optional.
+ * @return {Promise} Resolves: {} an empty object.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+ public async setDisplayName(name: string): Promise<{}> {
+ const client = await this.prepareClient();
+ return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name);
+ }
+
+ /**
+ * @param {string} url
+ * @param {module:client.callback} callback Optional.
+ * @return {Promise} Resolves: {} an empty object.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+ public async setAvatarUrl(url: string): Promise<{}> {
+ const client = await this.prepareClient();
+ return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url);
+ }
+
+ /**
+ * Upload a file to the media repository on the homeserver.
+ *
+ * @param {object} file The object to upload. On a browser, something that
+ * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
+ * a Buffer, String or ReadStream.
+ */
+ public async uploadContent(file: Buffer, opts?: UploadOpts): Promise> {
+ const client = await this.prepareClient();
+ return client.evaluate(
+ async (cli: MatrixClient, { file, opts }) => cli.uploadContent(new Uint8Array(file), opts),
+ {
+ file: [...file],
+ opts,
+ },
+ );
+ }
+
/**
* Boostraps cross-signing.
*/
diff --git a/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/collapsed-gels-and-messages-irc-layout-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..db736e2fe513e9c49474718edc928f576373c42d
GIT binary patch
literal 47123
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