mirror of https://github.com/vector-im/riot-web
				
				
				
			
		
			
				
	
	
		
			659 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			659 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			TypeScript
		
	
	
| /*
 | |
| Copyright 2023 The Matrix.org Foundation C.I.C.
 | |
| 
 | |
| Licensed under the Apache License, Version 2.0 (the "License");
 | |
| you may not use this file except in compliance with the License.
 | |
| You may obtain a copy of the License at
 | |
| 
 | |
|     http://www.apache.org/licenses/LICENSE-2.0
 | |
| 
 | |
| Unless required by applicable law or agreed to in writing, software
 | |
| distributed under the License is distributed on an "AS IS" BASIS,
 | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| See the License for the specific language governing permissions and
 | |
| limitations under the License.
 | |
| */
 | |
| 
 | |
| import 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<String, Promise<JSHandle<MatrixEvent>>>();
 | |
| 
 | |
|     /**
 | |
|      * 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<Room>, message: string, includeThreads = false): Promise<JSHandle<MatrixEvent>> {
 | |
|         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<MatrixEvent>((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<Room>): Promise<Record<string, unknown>> {
 | |
|                 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<Room>): Promise<Record<string, unknown>> {
 | |
|                 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<Room>): Promise<Record<string, unknown>> {
 | |
|                 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<string>): Array<MessageContentSpec> {
 | |
|         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<Room>): Promise<void> {
 | |
|                 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<Room>): Promise<void> {
 | |
|                 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<Room>, 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<Room>, 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<Room>): Promise<Record<string, unknown>>;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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<Room>): Promise<void>;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * 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. (Actually, close any right panel, but for these
 | |
|      * tests we only open the threads panel.)
 | |
|      */
 | |
|     async closeThreadsPanel() {
 | |
|         await this.page.locator(".mx_RightPanel").getByTitle("Close").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_RightPanel").getByTitle("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 ariaCurrent = await this.page.getByTestId("threadsButton").getAttribute("aria-current");
 | |
|         if (ariaCurrent !== "true") {
 | |
|             await this.page.getByTestId("threadsButton").click();
 | |
|         }
 | |
| 
 | |
|         const threadPanel = this.page.locator(".mx_ThreadPanel");
 | |
|         await expect(threadPanel).toBeVisible();
 | |
|         await threadPanel.evaluate(($panel) => {
 | |
|             const $button = $panel.querySelector<HTMLElement>('.mx_BaseCard_back[title="Threads"]');
 | |
|             // 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<JSHandle<Room>> {
 | |
|         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<string, any>): BotActionSpec {
 | |
|     return new (class extends BotActionSpec {
 | |
|         public async performAction(cli: Client, room: JSHandle<Room>): Promise<void> {
 | |
|             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<string> {
 | |
|     return Array.from(Array(howMany).keys()).map((i) => prefix + i.toString().padStart(4, "0"));
 | |
| }
 | |
| 
 | |
| export { expect };
 |