/* Copyright 2024 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 { JSHandle, Locator, Page } from "@playwright/test"; import type { MatrixEvent, IContent, Room } 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<{ room1Name?: string; room1: { name: string; roomId: string }; room2Name?: string; room2: { name: string; roomId: string }; msg: MessageBuilder; util: Helpers; }>({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); await use({ name, roomId }); }, room2Name: "Room 2", room2: async ({ room2Name: 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 ({ room1, room2, 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 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 or an object with the message content */ threadedOff(rootMessage: string, newMessage: string | IContent): 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) => { if (typeof newMessage === "string") { return { "msgtype": "m.text", "body": newMessage, "m.relates_to": { event_id: ev.getId(), is_falling_back: true, rel_type: "m.thread", }, }; } else { return { "msgtype": "m.text", "m.relates_to": { event_id: ev.getId(), is_falling_back: true, rel_type: "m.thread", }, ...newMessage, }; } }, newMessage); } })(this); } } /** * 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 we will turn into a message or event when we pass it in to * e.g. receiveMessages. */ export type Message = string | MessageContentSpec; export 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)); } // 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); } /** * 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(); } async findRoomByName(roomName: string): Promise> { return this.app.client.evaluateHandle((cli, roomName) => { return cli.getRooms().find((r) => r.name === roomName); }, roomName); } /** * 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); } /** * Get the threads activity centre button * @private */ private getTacButton(): Locator { return this.page.getByRole("navigation", { name: "Spaces" }).getByLabel("Threads"); } /** * Return the threads activity centre panel */ getTacPanel() { return this.page.getByRole("menu", { name: "Threads" }); } /** * Open the Threads Activity Centre */ openTac() { return this.getTacButton().click(); } /** * Hover over the Threads Activity Centre button */ hoverTacButton() { return this.getTacButton().hover(); } /** * Click on a room in the Threads Activity Centre * @param name - room name */ clickRoomInTac(name: string) { return this.getTacPanel().getByRole("menuitem", { name }).click(); } /** * Assert that the threads activity centre button has no indicator */ async assertNoTacIndicator() { // Assert by checkng neither of the known indicators are visible first. This will wait // if it takes a little time to disappear, but the screenshot comparison won't. await expect(this.getTacButton().locator("[data-indicator='success']")).not.toBeVisible(); await expect(this.getTacButton().locator("[data-indicator='critical']")).not.toBeVisible(); await expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png"); } /** * Assert that the threads activity centre button has a notification indicator */ assertNotificationTac() { return expect(this.getTacButton().locator("[data-indicator='success']")).toBeVisible(); } /** * Assert that the threads activity centre button has a highlight indicator */ assertHighlightIndicator() { return expect(this.getTacButton().locator("[data-indicator='critical']")).toBeVisible(); } /** * Assert that the threads activity centre panel has the expected rooms * @param content - the expected rooms and their notification levels */ async assertRoomsInTac(content: Array<{ room: string; notificationLevel: "highlight" | "notification" }>) { const getBadgeClass = (notificationLevel: "highlight" | "notification") => notificationLevel === "highlight" ? "mx_NotificationBadge_level_highlight" : "mx_NotificationBadge_level_notification"; // Ensure that we have the right number of rooms await expect(this.getTacPanel().getByRole("menuitem")).toHaveCount(content.length); // Ensure that each room is present in the correct order and has the correct notification level const roomsLocator = this.getTacPanel().getByRole("menuitem"); for (const [index, { room, notificationLevel }] of content.entries()) { const roomLocator = roomsLocator.nth(index); // Ensure that the room name are correct await expect(roomLocator).toHaveText(new RegExp(room)); // There is no accessibility marker for the StatelessNotificationBadge await expect(roomLocator.locator(`.${getBadgeClass(notificationLevel)}`)).toBeVisible(); } } /** * Assert that the thread panel is opened */ assertThreadPanelIsOpened() { return expect(this.page.locator(".mx_ThreadPanel")).toBeVisible(); } /** * Assert that the thread panel is focused (actually the 'close' button, specifically) */ assertThreadPanelFocused() { return expect( this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByLabel("Close"), ).toBeFocused(); } /** * Populate the rooms with messages and threads * @param room1 * @param room2 * @param msg - MessageBuilder * @param hasMention - whether to include a mention in the first message */ async populateThreads( room1: { name: string; roomId: string }, room2: { name: string; roomId: string }, msg: MessageBuilder, hasMention = true, ) { if (hasMention) { await this.receiveMessages(room2, [ "Msg1", msg.threadedOff("Msg1", { "body": "User", "format": "org.matrix.custom.html", "formatted_body": "User", "m.mentions": { user_ids: ["@user:localhost"], }, }), ]); } await this.receiveMessages(room2, ["Msg2", msg.threadedOff("Msg2", "Resp2")]); await this.receiveMessages(room1, ["Msg3", msg.threadedOff("Msg3", "Resp3")]); } /** * Get the space panel */ getSpacePanel() { return this.page.getByRole("navigation", { name: "Spaces" }); } /** * Expand the space panel */ expandSpacePanel() { return this.page.getByRole("button", { name: "Expand" }).click(); } /** * Clicks the button to mark all threads as read in the current room */ clickMarkAllThreadsRead() { return this.page.getByLabel("Mark all as read").click(); } } export { expect };