/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import type { JSHandle, Page } from "@playwright/test"; import type { MatrixEvent, Room, IndexedDBStore, ReceiptType } from "matrix-js-sdk/src/matrix"; import { test as base, expect } from "../../element-web-test"; import { Bot } from "../../pages/bot"; import { Client } from "../../pages/client"; import { ElementAppPage } from "../../pages/ElementAppPage"; /** * Set up for a read receipt test: * - Create a user with the supplied name * - As that user, create two rooms with the supplied names * - Create a bot with the supplied name * - Invite the bot to both rooms and ensure that it has joined */ export const test = base.extend<{ roomAlphaName?: string; roomAlpha: { name: string; roomId: string }; roomBetaName?: string; roomBeta: { name: string; roomId: string }; msg: MessageBuilder; util: Helpers; }>({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, roomAlphaName: "Room Alpha", roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); await use({ name, roomId }); }, roomBetaName: "Room Beta", roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { await use(new MessageBuilder(page, app, util)); }, util: async ({ roomAlpha, roomBeta, page, app, bot }, use) => { await use(new Helpers(page, app, bot)); }, }); /** * A utility that is able to find messages based on their content, by looking * inside the `timeline` objects in the object model. * * Crucially, we hold on to references to events that have been edited or * redacted, so we can still look them up by their old content. * * Provides utilities that build on the ability to find messages, e.g. replyTo, * which finds a message and then constructs a reply to it. */ export class MessageBuilder { constructor( private page: Page, private app: ElementAppPage, private helpers: Helpers, ) {} /** * Map of message content -> event. */ messages = new Map>>(); /** * Utility to find a MatrixEvent by its body content * @param room - the room to search for the event in * @param message - the body of the event to search for * @param includeThreads - whether to search within threads too */ async getMessage(room: JSHandle, message: string, includeThreads = false): Promise> { const cached = this.messages.get(message); if (cached) { return cached; } const promise = room.evaluateHandle( async (room, { message, includeThreads }) => { let ev = room.timeline.find((e) => e.getContent().body === message); if (!ev && includeThreads) { for (const thread of room.getThreads()) { ev = thread.timeline.find((e) => e.getContent().body === message); if (ev) break; } } if (ev) return ev; return new Promise((resolve) => { room.on("Room.timeline" as any, (ev: MatrixEvent) => { if (ev.getContent().body === message) { resolve(ev); } }); }); }, { message, includeThreads }, ); this.messages.set(message, promise); return promise; } /** * MessageContentSpec to send an edit into a room * @param originalMessage - the body of the message to edit * @param newMessage - the message body to send in the edit */ editOf(originalMessage: string, newMessage: string): MessageContentSpec { return new (class extends MessageContentSpec { public async getContent(room: JSHandle): Promise> { const ev = await this.messageFinder.getMessage(room, originalMessage, true); return ev.evaluate((ev, newMessage) => { // If this event has been redacted, its msgtype will be // undefined. In that case, we guess msgtype as m.text. const msgtype = ev.getContent().msgtype ?? "m.text"; return { "msgtype": msgtype, "body": `* ${newMessage}`, "m.new_content": { msgtype: msgtype, body: newMessage, }, "m.relates_to": { rel_type: "m.replace", event_id: ev.getId(), }, }; }, newMessage); } })(this); } /** * MessageContentSpec to send a reply into a room * @param targetMessage - the body of the message to reply to * @param newMessage - the message body to send into the reply */ replyTo(targetMessage: string, newMessage: string): MessageContentSpec { return new (class extends MessageContentSpec { public async getContent(room: JSHandle): Promise> { const ev = await this.messageFinder.getMessage(room, targetMessage, true); return ev.evaluate((ev, newMessage) => { const threadRel = ev.getRelation()?.rel_type === "m.thread" ? { rel_type: "m.thread", event_id: ev.getRelation().event_id, } : {}; return { "msgtype": "m.text", "body": newMessage, "m.relates_to": { ...threadRel, "m.in_reply_to": { event_id: ev.getId(), }, }, }; }, newMessage); } })(this); } /** * MessageContentSpec to send a threaded response into a room * @param rootMessage - the body of the thread root message to send a response to * @param newMessage - the message body to send into the thread response */ threadedOff(rootMessage: string, newMessage: string): MessageContentSpec { return new (class extends MessageContentSpec { public async getContent(room: JSHandle): Promise> { const ev = await this.messageFinder.getMessage(room, rootMessage); return ev.evaluate((ev, newMessage) => { return { "msgtype": "m.text", "body": newMessage, "m.relates_to": { event_id: ev.getId(), is_falling_back: true, rel_type: "m.thread", }, }; }, newMessage); } })(this); } /** * Generate MessageContentSpecs to send multiple threaded responses into a room. * * @param rootMessage - the body of the thread root message to send a response to * @param newMessages - the contents of the messages */ manyThreadedOff(rootMessage: string, newMessages: Array): Array { return newMessages.map((body) => this.threadedOff(rootMessage, body)); } /** * BotActionSpec to send a reaction to an existing event into a room * @param targetMessage - the body of the message to send a reaction to * @param reaction - the key of the reaction to send into the room */ reactionTo(targetMessage: string, reaction: string): BotActionSpec { return new (class extends BotActionSpec { public async performAction(bot: Bot, room: JSHandle): Promise { const ev = await this.messageFinder.getMessage(room, targetMessage, true); const { id, threadId } = await ev.evaluate((ev) => ({ id: ev.getId(), threadId: !ev.isThreadRoot ? ev.threadRootId : undefined, })); const roomId = await room.evaluate((room) => room.roomId); await bot.sendEvent(roomId, threadId ?? null, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: id, key: reaction, }, }); } })(this); } /** * BotActionSpec to send a redaction into a room * @param targetMessage - the body of the message to send a redaction to */ redactionOf(targetMessage: string): BotActionSpec { return new (class extends BotActionSpec { public async performAction(bot: Bot, room: JSHandle): Promise { const ev = await this.messageFinder.getMessage(room, targetMessage, true); const { id, threadId } = await ev.evaluate((ev) => ({ id: ev.getId(), threadId: !ev.isThreadRoot ? ev.threadRootId : undefined, })); const roomId = await room.evaluate((room) => room.roomId); await bot.redactEvent(roomId, threadId, id); } })(this); } /** * Find and display a message. * * @param roomName the name of the room to look inside * @param message the content of the message to fine * @param includeThreads look for messages inside threads, not just the main timeline */ async jumpTo(roomName: string, message: string, includeThreads = false) { const room = await this.helpers.findRoomByName(roomName); const foundMessage = await this.getMessage(room, message, includeThreads); const roomId = await room.evaluate((room) => room.roomId); const foundMessageId = await foundMessage.evaluate((ev) => ev.getId()); await this.page.goto(`/#/room/${roomId}/${foundMessageId}`); } async sendThreadedReadReceipt(room: JSHandle, targetMessage: string) { const event = await this.getMessage(room, targetMessage, true); await this.app.client.evaluate( (client, { event }) => { return client.sendReadReceipt(event); }, { event }, ); } async sendUnthreadedReadReceipt(room: JSHandle, targetMessage: string) { const event = await this.getMessage(room, targetMessage, true); await this.app.client.evaluate( (client, { event }) => { return client.sendReadReceipt(event, "m.read" as any as ReceiptType, true); }, { event }, ); } } /** * Something that can provide the content of a message. * * For example, we return and instance of this from {@link * MessageBuilder.replyTo} which creates a reply based on a previous message. */ export abstract class MessageContentSpec { messageFinder: MessageBuilder | null; constructor(messageFinder: MessageBuilder = null) { this.messageFinder = messageFinder; } public abstract getContent(room: JSHandle): Promise>; } /** * Something that can perform an action at the time we would usually send a * message. * * For example, we return an instance of this from {@link * MessageBuilder.redactionOf} which redacts the message we are referring to. */ export abstract class BotActionSpec { messageFinder: MessageBuilder | null; constructor(messageFinder: MessageBuilder = null) { this.messageFinder = messageFinder; } public abstract performAction(client: Client, room: JSHandle): Promise; } /** * Something that we will turn into a message or event when we pass it in to * e.g. receiveMessages. */ export type Message = string | MessageContentSpec | BotActionSpec; class Helpers { constructor( private page: Page, private app: ElementAppPage, private bot: Bot, ) {} /** * Use the supplied client to send messages or perform actions as specified by * the supplied {@link Message} items. */ async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) { const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name); const roomId = await room.evaluate((room) => room.roomId); for (const message of messages) { if (typeof message === "string") { await cli.sendMessage(roomId, { body: message, msgtype: "m.text" }); } else if (message instanceof MessageContentSpec) { await cli.sendMessage(roomId, await message.getContent(room)); } else { await message.performAction(cli, room); } // TODO: without this wait, some tests that send lots of messages flake // from time to time. I (andyb) have done some investigation, but it // needs more work to figure out. The messages do arrive over sync, but // they never appear in the timeline, and they never fire a // Room.timeline event. I think this only happens with events that refer // to other events (e.g. replies), so it might be caused by the // referring event arriving before the referred-to event. await this.page.waitForTimeout(100); } } /** * Open the room with the supplied name. */ async goTo(room: string | { name: string }) { await this.app.viewRoomByName(typeof room === "string" ? room : room.name); } /** * Expand the message with the supplied index in the timeline. * @param index */ async openCollapsedMessage(index: number) { const button = this.page.locator(".mx_GenericEventListSummary_toggle"); await button.nth(index).click(); } /** * Click the thread with the supplied content in the thread root to open it in * the Threads panel. */ async openThread(rootMessage: string) { const tile = this.page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: rootMessage }); await tile.hover(); await tile.getByRole("button", { name: "Reply in thread" }).click(); await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible(); } /** * Close the threads panel. */ async closeThreadsPanel() { await this.page.locator(".mx_RightPanel").getByTestId("base-card-close-button").click(); await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible(); } /** * Return to the list of threads, given we are viewing a single thread. */ async backToThreadsList() { await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click(); } /** * Assert that the message containing the supplied text is visible in the UI. * Note: matches part of the message content as well as the whole of it. */ async assertMessageLoaded(messagePart: string) { await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).toBeVisible(); } /** * Assert that the message containing the supplied text is not visible in the UI. * Note: matches part of the message content as well as the whole of it. */ async assertMessageNotLoaded(messagePart: string) { await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).not.toBeVisible(); } /** * Scroll the messages panel up 1000 pixels. */ async pageUp() { await this.page.locator(".mx_RoomView_messagePanel").evaluateAll((messagePanels) => { messagePanels.forEach((messagePanel) => (messagePanel.scrollTop -= 1000)); }); } getRoomListTile(room: string | { name: string }) { const roomName = typeof room === "string" ? room : room.name; return this.page.getByRole("treeitem", { name: new RegExp("^" + roomName) }); } /** * Click the "Mark as Read" context menu item on the room with the supplied name * in the room list. */ async markAsRead(room: string | { name: string }) { await this.getRoomListTile(room).click({ button: "right" }); await this.page.getByText("Mark as read").click(); } /** * Assert that the room with the supplied name is "read" in the room list - i.g. * has not dot or count of unread messages. */ async assertRead(room: string | { name: string }) { const tile = this.getRoomListTile(room); await expect(tile.locator(".mx_NotificationBadge_dot")).not.toBeVisible(); await expect(tile.locator(".mx_NotificationBadge_count")).not.toBeVisible(); } /** * Assert that this room remains read, when it was previously read. * (In practice, this just waits a short while to allow any unread marker to * appear, and then asserts that the room is read.) */ async assertStillRead(room: string | { name: string }) { await this.page.waitForTimeout(200); await this.assertRead(room); } /** * Assert a given room is marked as unread (via the room list tile) * @param room - the name of the room to check * @param count - the numeric count to assert, or if "." specified then a bold/dot (no count) state is asserted */ async assertUnread(room: string | { name: string }, count: number | ".") { const tile = this.getRoomListTile(room); if (count === ".") { await expect(tile.locator(".mx_NotificationBadge_dot")).toBeVisible(); } else { await expect(tile.locator(".mx_NotificationBadge_count")).toHaveText(count.toString()); } } /** * Assert a given room is marked as unread, and the number of unread * messages is less than the supplied count. * * @param room - the name of the room to check * @param lessThan - the number of unread messages that is too many */ async assertUnreadLessThan(room: string | { name: string }, lessThan: number) { const tile = this.getRoomListTile(room); // https://playwright.dev/docs/test-assertions#expectpoll // .toBeLessThan doesn't have a retry mechanism, so we use .poll await expect .poll(async () => { return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); }) .toBeLessThan(lessThan); } /** * Assert a given room is marked as unread, and the number of unread * messages is greater than the supplied count. * * @param room - the name of the room to check * @param greaterThan - the number of unread messages that is too few */ async assertUnreadGreaterThan(room: string | { name: string }, greaterThan: number) { const tile = this.getRoomListTile(room); // https://playwright.dev/docs/test-assertions#expectpoll // .toBeGreaterThan doesn't have a retry mechanism, so we use .poll await expect .poll(async () => { return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10); }) .toBeGreaterThan(greaterThan); } /** * Click the "Threads" or "Back" button if needed to get to the threads list. */ async openThreadList() { // If we've just entered the room, the threads panel takes a while to decide // whether it's open or not - wait here to give it a chance to settle. await this.page.waitForTimeout(200); const threadPanel = this.page.locator(".mx_ThreadPanel"); const isThreadPanelOpen = (await threadPanel.count()) !== 0; if (!isThreadPanelOpen) { await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click(); } await expect(threadPanel).toBeVisible(); await threadPanel.evaluate(($panel) => { const $button = $panel.querySelector('[data-testid="base-card-back-button"]'); // If the Threads back button is present then click it - the // threads button can open either threads list or thread panel if ($button) { $button.click(); } }); } async findRoomByName(roomName: string): Promise> { return this.app.client.evaluateHandle((cli, roomName) => { return cli.getRooms().find((r) => r.name === roomName); }, roomName); } private async getThreadListTile(rootMessage: string) { await this.openThreadList(); return this.page.locator(".mx_ThreadPanel li", { hasText: rootMessage }); } /** * Assert that the thread with the supplied content in its root message is shown * as read in the Threads list. */ async assertReadThread(rootMessage: string) { const tile = await this.getThreadListTile(rootMessage); await expect(tile.locator(".mx_NotificationBadge")).not.toBeVisible(); } /** * Assert that the thread with the supplied content in its root message is shown * as unread in the Threads list. */ async assertUnreadThread(rootMessage: string) { const tile = await this.getThreadListTile(rootMessage); await expect(tile.locator(".mx_NotificationBadge")).toBeVisible(); } /** * Save our indexeddb information and then refresh the page. */ async saveAndReload() { await this.app.client.evaluate((cli) => { // @ts-ignore return (cli.store as IndexedDBStore).reallySave(); }); await this.page.reload(); // Wait for the app to reload await expect(this.page.locator(".mx_RoomView")).toBeVisible(); } /** * Sends messages into given room as a bot * @param room - the name of the room to send messages into * @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf` */ async receiveMessages(room: string | { name: string }, messages: Message[]) { await this.sendMessageAsClient(this.bot, room, messages); } /** * Open the room list menu */ async toggleRoomListMenu() { const tile = this.getRoomListTile("Rooms"); await tile.hover(); const button = tile.getByLabel("List options"); await button.click(); } /** * Toggle the `Show rooms with unread messages first` option for the room list */ async toggleRoomUnreadOrder() { await this.toggleRoomListMenu(); await this.page.getByText("Show rooms with unread messages first").click(); // Close contextual menu await this.page.locator(".mx_ContextualMenu_background").click(); } /** * Assert that the room list is ordered as expected * @param rooms */ async assertRoomListOrder(rooms: Array<{ name: string }>) { const roomList = this.page.locator(".mx_RoomTile_title"); for (const [i, room] of rooms.entries()) { await expect(roomList.nth(i)).toHaveText(room.name); } } } /** * BotActionSpec to send a custom event * @param eventType - the type of the event to send * @param content - the event content to send */ export function customEvent(eventType: string, content: Record): BotActionSpec { return new (class extends BotActionSpec { public async performAction(cli: Client, room: JSHandle): Promise { const roomId = await room.evaluate((room) => room.roomId); await cli.sendEvent(roomId, null, eventType, content); } })(); } /** * Generate strings with the supplied prefix, suffixed with numbers. * * @param prefix the prefix of each string * @param howMany the number of strings to generate */ export function many(prefix: string, howMany: number): Array { return Array.from(Array(howMany).keys()).map((i) => prefix + i.toString().padStart(4, "0")); } export { expect };