mirror of https://github.com/vector-im/riot-web
				
				
				
			
		
			
				
	
	
		
			384 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			384 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
/*
 | 
						|
Copyright 2024 New Vector Ltd.
 | 
						|
Copyright 2024 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 { 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<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 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<Room>): Promise<Record<string, unknown>> {
 | 
						|
                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<Room>): Promise<Record<string, unknown>>;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * 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<JSHandle<Room>> {
 | 
						|
        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();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * 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": "<a href='https://matrix.to/#/@user:localhost'>User</a>",
 | 
						|
                    "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 };
 |