diff --git a/apps/web/playwright/e2e/timeline/event-list-summary.spec.ts b/apps/web/playwright/e2e/timeline/event-list-summary.spec.ts new file mode 100644 index 0000000000..cb92d87ffe --- /dev/null +++ b/apps/web/playwright/e2e/timeline/event-list-summary.spec.ts @@ -0,0 +1,332 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Page } from "playwright-core"; +import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; + +import { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; +import { type ElementAppPage } from "../../pages/ElementAppPage"; +import { type Credentials } from "../../plugins/homeserver"; + +test.describe("Event List Summary", () => { + test.use({ + displayName: "Finch", + }); + + test( + "should display a single join message on its own", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // When the bot joins the room + await bot.joinRoom(roomId); + + // Then we say that in a generic event list summary + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot joined the room", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("bot_joined_the_room.png", ignoreTimestamps); + }, + ); + + test( + "should display a single ban message on its own", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // Given the bot is in the room + await bot.joinRoom(roomId); + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot joined the room", + }), + ).toBeVisible(); + + // And we said something to separate out the messages + await app.client.sendMessage(roomId, "Saying something"); + + // When we ban the bot + await app.client.ban(roomId, bot.credentials.userId); + + // Then we say that + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "banned", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("bot_was_banned.png", ignoreTimestamps); + }, + ); + + test( + "should display multiple join/leave messages as a group", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // Given the bot is in the room + await bot.joinRoom(roomId); + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot joined the room", + }), + ).toBeVisible(); + + // When we perform multiple actions on it + await app.client.kick(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + + // Then those actions are gathered into a single summary + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "and joined", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_join_leave_messages.png", + ignoreTimestamps, + ); + }, + ); + + test( + "should display multiple messages as a group", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // Given the bot is in the room + await bot.joinRoom(roomId); + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot joined the room", + }), + ).toBeVisible(); + + // When we perform multiple actions on it, including a ban + await app.client.ban(roomId, bot.credentials.userId); + await app.client.unban(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + + // Then those actions are gathered into a single summary + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "and joined", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_join_ban_messages.png", + ignoreTimestamps, + ); + }, + ); + + test( + "should display join/leave messages for multiple people as a group", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // Given the bot is in the room + const bot2 = new Bot(page, homeserver, { + displayName: "MyBot2", + autoAcceptInvites: false, + }); + await bot2.prepareClient(); + await app.client.inviteUser(roomId, bot2.credentials.userId); + await app.client.sendMessage(roomId, "I invited MyBot2..."); + await bot.joinRoom(roomId); + await bot2.joinRoom(roomId); + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot2 joined the room", + }), + ).toBeVisible(); + + // When we perform multiple actions on both bots + await app.client.kick(roomId, bot.credentials.userId); + await app.client.kick(roomId, bot2.credentials.userId); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, bot2.credentials.userId); + await bot.joinRoom(roomId); + await bot2.joinRoom(roomId); + + // Then those actions are gathered into a single summary + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "joined, were removed, were invited, and joined", + }), + ).toBeVisible(); + + await expect(page.locator('div[aria-label="3 members"]')).toBeVisible(); + + await replaceBotIds(page, bot, bot2); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_people_join_leave_messages.png", + ignoreTimestampsRightColumnAndHeader, + ); + + // And when we expand the summary + // Note: we can't include "expand" in the screenshot because it + // moves around, but at least we know it exists because we click it + // here. + await page.getByRole("button", { name: "expand" }).nth(3).click(); + + // Then we see all the individual actions + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "removed MyBot2", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot, bot2); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_people_join_leave_messages_expanded.png", + ignoreTimestampsRightColumnAndHeader, + ); + }, + ); + + test( + "should display join/ban messages for multiple people as a group", + { tag: "@screenshot" }, + async ({ app, homeserver, page, user }) => { + const { bot, roomId } = await setupRoom(app, homeserver, page, user); + + // Given the bot is in the room + const bot2 = new Bot(page, homeserver, { + displayName: "MyBot2 with very long display name causing wrapping", + autoAcceptInvites: false, + }); + await bot2.prepareClient(); + await app.client.inviteUser(roomId, bot2.credentials.userId); + await app.client.sendMessage(roomId, "I invited MyBot2..."); + await bot.joinRoom(roomId); + await bot2.joinRoom(roomId); + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "MyBot2 with very long display name causing wrapping joined the room", + }), + ).toBeVisible(); + + // When we ban bot1 but not bot2 + await app.client.ban(roomId, bot.credentials.userId); + await app.client.unban(roomId, bot.credentials.userId); + await app.client.kick(roomId, bot2.credentials.userId); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, bot2.credentials.userId); + await bot.joinRoom(roomId); + await bot2.joinRoom(roomId); + + // Then those actions are gathered into a single summary + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "was removed, was invited, and joined", + }), + ).toBeVisible(); + + await expect(page.locator('div[aria-label="3 members"]')).toBeVisible(); + + await replaceBotIds(page, bot, bot2); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_people_ban_messages.png", + ignoreTimestampsRightColumnAndHeader, + ); + + // And when we expand the summary + // Note: we can't include "expand" in the screenshot because it + // moves around, but at least we know it exists because we click it + // here. + await page.getByRole("button", { name: "expand" }).nth(3).click(); + + // Then we see all the individual actions + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: "removed MyBot2", + }), + ).toBeVisible(); + + await replaceBotIds(page, bot, bot2); + await expect(page.locator(".mx_MainSplit")).toMatchScreenshot( + "multiple_people_ban_messages_expanded.png", + ignoreTimestampsRightColumnAndHeader, + ); + }, + ); +}); + +const ignoreTimestamps = { + css: ".mx_MessageTimestamp,.mx_TopUnreadMessagesBar { visibility: hidden; },", +}; + +const ignoreTimestampsRightColumnAndHeader = { + css: + ".mx_MessageTimestamp," + + ".mx_GenericEventListSummary_toggle," + + ".mx_ReadReceiptGroup," + + ".mx_RoomHeader, " + + ".mx_TopUnreadMessagesBar " + + "{ visibility: hidden; }", +}; + +/** + * Create a room, join it, create a bot and invite it to the room. + */ +async function setupRoom(app: ElementAppPage, homeserver: StartedHomeserverContainer, page: Page, user: Credentials) { + const roomId = await app.client.createRoom({ name: "My room" }); + await page.goto(`/#/room/${roomId}`); + + await expect( + page.locator(".mx_RoomView_body .mx_GenericEventListSummary[data-layout='group']", { + hasText: `${user.displayName} created and configured the room.`, + }), + ).toBeVisible(); + + const bot = new Bot(page, homeserver, { + displayName: "MyBot", + autoAcceptInvites: false, + }); + await bot.prepareClient(); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.client.sendMessage(roomId, "I invited MyBot..."); + + return { bot, roomId }; +} + +/** + * Find the ID of the supplied bot in the page and replace it with a known string. + * + * This allows us to create consistent screenshots. + */ +async function replaceBotIds(page: Page, bot: Bot, bot2?: Bot) { + await page.evaluate( + ([bot1UserId, bot2UserId]) => { + for (const el of document.querySelectorAll("div.mx_TextualEvent")) { + if ("innerText" in el) { + el.innerText = (el.innerText as any as string).replaceAll(bot1UserId, "<>"); + el.innerText = (el.innerText as any as string).replaceAll(bot2UserId, "<>"); + } + } + }, + [bot.credentials.userId, bot2?.credentials?.userId ?? "no_bot_2_to_replace"], + ); +} diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-joined-the-room-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-joined-the-room-linux.png new file mode 100644 index 0000000000..fd779a3752 Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-joined-the-room-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-was-banned-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-was-banned-linux.png new file mode 100644 index 0000000000..7b3cb7a698 Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/bot-was-banned-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-ban-messages-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-ban-messages-linux.png new file mode 100644 index 0000000000..36d390596e Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-ban-messages-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-leave-messages-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-leave-messages-linux.png new file mode 100644 index 0000000000..a998623d1d Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-join-leave-messages-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-expanded-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-expanded-linux.png new file mode 100644 index 0000000000..d610b63a9a Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-linux.png new file mode 100644 index 0000000000..3b775c08f8 Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-ban-messages-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-expanded-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-expanded-linux.png new file mode 100644 index 0000000000..83ce27bad0 Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-expanded-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-linux.png b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-linux.png new file mode 100644 index 0000000000..51489a1de1 Binary files /dev/null and b/apps/web/playwright/snapshots/timeline/event-list-summary.spec.ts/multiple-people-join-leave-messages-linux.png differ diff --git a/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx b/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx index 2f2c409db6..3254272d99 100644 --- a/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/EventListSummary-test.tsx @@ -154,6 +154,7 @@ describe("EventListSummary", function () { const children = container.querySelector(".mx_GenericEventListSummary_unstyledList")!.children; expect(children).toHaveLength(1); expect(children[0]).toHaveTextContent("Expanded membership"); + expect(children).toMatchSnapshot(); }); it("renders expanded events if there are less than props.threshold for join and leave", function () { @@ -175,6 +176,7 @@ describe("EventListSummary", function () { expect(children).toHaveLength(2); expect(children[0]).toHaveTextContent("Expanded membership"); expect(children[1]).toHaveTextContent("Expanded membership"); + expect(children).toMatchSnapshot(); }); it("renders collapsed events if events.length = props.threshold", function () { @@ -194,6 +196,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1 joined and left and joined"); + expect(summary).toMatchSnapshot(); }); it("truncates long join,leave repetitions", function () { @@ -224,6 +227,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1 joined and left 7 times"); + expect(summary).toMatchSnapshot(); }); it("truncates long join,leave repetitions between other events", function () { @@ -266,6 +270,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1 was unbanned, joined and left 7 times and was invited"); + expect(summary).toMatchSnapshot(); }); it("truncates multiple sequences of repetitions with other events between", function () { @@ -312,6 +317,7 @@ describe("EventListSummary", function () { expect(summary).toHaveTextContent( "user_1 was unbanned, joined and left 2 times, was banned, " + "joined and left 3 times and was invited", ); + expect(summary).toMatchSnapshot(); }); it("handles multiple users following the same sequence of memberships", function () { @@ -364,6 +370,7 @@ describe("EventListSummary", function () { expect(summary).toHaveTextContent( "user_1 and one other were unbanned, joined and left 2 times and were banned", ); + expect(summary).toMatchSnapshot(); }); it("handles many users following the same sequence of memberships", function () { @@ -396,6 +403,7 @@ describe("EventListSummary", function () { expect(summary).toHaveTextContent( "user_0 and 19 others were unbanned, joined and left 2 times and were banned", ); + expect(summary).toMatchSnapshot(); }); it("correctly orders sequences of transitions by the order of their first event", function () { @@ -441,6 +449,7 @@ describe("EventListSummary", function () { "user_2 was unbanned and joined and left 2 times, user_1 was unbanned, " + "joined and left 2 times and was banned", ); + expect(summary).toMatchSnapshot(); }); it("correctly identifies transitions", function () { @@ -509,6 +518,7 @@ describe("EventListSummary", function () { "user_1 was invited, was banned, joined, rejected their invitation, left, " + "had their invitation withdrawn, was unbanned, was removed, left and was removed", ); + expect(summary).toMatchSnapshot(); }); it("handles invitation plurals correctly when there are multiple users", function () { @@ -549,6 +559,7 @@ describe("EventListSummary", function () { expect(summary).toHaveTextContent( "user_1 and one other rejected their invitations and had their invitations withdrawn", ); + expect(summary).toMatchSnapshot(); }); it("handles invitation plurals correctly when there are multiple invites", function () { @@ -575,6 +586,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1 rejected their invitation 2 times"); + expect(summary).toMatchSnapshot(); }); it('handles a summary length = 2, with no "others"', function () { @@ -595,6 +607,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1 and user_2 joined 2 times"); + expect(summary).toMatchSnapshot(); }); it('handles a summary length = 2, with 1 "other"', function () { @@ -614,6 +627,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_1, user_2 and one other joined"); + expect(summary).toMatchSnapshot(); }); it('handles a summary length = 2, with many "others"', function () { @@ -629,6 +643,7 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("user_0, user_1 and 18 others joined"); + expect(summary).toMatchSnapshot(); }); it("should not blindly group 3pid invites and treat them as distinct users instead", () => { @@ -679,5 +694,6 @@ describe("EventListSummary", function () { const { container } = renderComponent(props); const summary = container.querySelector(".mx_GenericEventListSummary_summary"); expect(summary).toHaveTextContent("n...@d... was invited 2 times, d...@w... was invited"); + expect(summary).toMatchSnapshot(); }); }); diff --git a/apps/web/test/unit-tests/components/views/elements/__snapshots__/EventListSummary-test.tsx.snap b/apps/web/test/unit-tests/components/views/elements/__snapshots__/EventListSummary-test.tsx.snap new file mode 100644 index 0000000000..88e137e0b1 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/elements/__snapshots__/EventListSummary-test.tsx.snap @@ -0,0 +1,142 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`EventListSummary correctly identifies transitions 1`] = ` + + user_1 was invited, was banned, joined, rejected their invitation, left, had their invitation withdrawn, was unbanned, was removed, left and was removed + +`; + +exports[`EventListSummary correctly orders sequences of transitions by the order of their first event 1`] = ` + + user_2 was unbanned and joined and left 2 times + , + user_1 was unbanned, joined and left 2 times and was banned + +`; + +exports[`EventListSummary handles a summary length = 2, with 1 "other" 1`] = ` + + user_1, user_2 and one other joined + +`; + +exports[`EventListSummary handles a summary length = 2, with many "others" 1`] = ` + + user_0, user_1 and 18 others joined + +`; + +exports[`EventListSummary handles a summary length = 2, with no "others" 1`] = ` + + user_1 and user_2 joined 2 times + +`; + +exports[`EventListSummary handles invitation plurals correctly when there are multiple invites 1`] = ` + + user_1 rejected their invitation 2 times + +`; + +exports[`EventListSummary handles invitation plurals correctly when there are multiple users 1`] = ` + + user_1 and one other rejected their invitations and had their invitations withdrawn + +`; + +exports[`EventListSummary handles many users following the same sequence of memberships 1`] = ` + + user_0 and 19 others were unbanned, joined and left 2 times and were banned + +`; + +exports[`EventListSummary handles multiple users following the same sequence of memberships 1`] = ` + + user_1 and one other were unbanned, joined and left 2 times and were banned + +`; + +exports[`EventListSummary renders collapsed events if events.length = props.threshold 1`] = ` + + user_1 joined and left and joined + +`; + +exports[`EventListSummary renders expanded events if there are less than props.threshold 1`] = ` +HTMLCollection [ +
+ Expanded membership +
, +] +`; + +exports[`EventListSummary renders expanded events if there are less than props.threshold for join and leave 1`] = ` +HTMLCollection [ +
+ Expanded membership +
, +
+ Expanded membership +
, +] +`; + +exports[`EventListSummary should not blindly group 3pid invites and treat them as distinct users instead 1`] = ` + + n...@d... was invited 2 times + , + d...@w... was invited + +`; + +exports[`EventListSummary truncates long join,leave repetitions 1`] = ` + + user_1 joined and left 7 times + +`; + +exports[`EventListSummary truncates long join,leave repetitions between other events 1`] = ` + + user_1 was unbanned, joined and left 7 times and was invited + +`; + +exports[`EventListSummary truncates multiple sequences of repetitions with other events between 1`] = ` + + user_1 was unbanned, joined and left 2 times, was banned, joined and left 3 times and was invited + +`;