mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-03 06:55:44 +00:00
Absorb the matrix-react-sdk repository (#28192)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Florian Duros <florian.duros@ormaz.fr> Co-authored-by: Kim Brose <kim.brose@nordeck.net> Co-authored-by: Florian Duros <florianduros@element.io> Co-authored-by: R Midhun Suresh <hi@midhun.dev> Co-authored-by: dbkr <986903+dbkr@users.noreply.github.com> Co-authored-by: ElementRobot <releases@riot.im> Co-authored-by: dbkr <dbkr@users.noreply.github.com> Co-authored-by: David Baker <dbkr@users.noreply.github.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Co-authored-by: David Langley <davidl@element.io> Co-authored-by: Michael Weimann <michaelw@matrix.org> Co-authored-by: Timshel <Timshel@users.noreply.github.com> Co-authored-by: Sahil Silare <32628578+sahil9001@users.noreply.github.com> Co-authored-by: Will Hunt <will@half-shot.uk> Co-authored-by: Hubert Chathi <hubert@uhoreg.ca> Co-authored-by: Andrew Ferrazzutti <andrewf@element.io> Co-authored-by: Robin <robin@robin.town> Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
committed by
GitHub
parent
2b99496025
commit
c05c429803
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
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 };
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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 { expect, test } from ".";
|
||||
import { CommandOrControl } from "../../utils";
|
||||
|
||||
test.describe("Threads Activity Centre", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
botCreateOpts: { displayName: "Other User" },
|
||||
labsFlags: ["threadsActivityCentre"],
|
||||
});
|
||||
|
||||
test("should have the button correctly aligned and displayed in the space panel when expanded", async ({
|
||||
util,
|
||||
}) => {
|
||||
// Open the space panel
|
||||
await util.expandSpacePanel();
|
||||
// The buttons in the space panel should be aligned when expanded
|
||||
await expect(util.getSpacePanel()).toMatchScreenshot("tac-button-expanded.png");
|
||||
});
|
||||
|
||||
test("should not show indicator when there is no thread", async ({ room1, util }) => {
|
||||
// No indicator should be shown
|
||||
await util.assertNoTacIndicator();
|
||||
|
||||
await util.goTo(room1);
|
||||
await util.receiveMessages(room1, ["Msg1"]);
|
||||
|
||||
// A message in the main timeline should not affect the indicator
|
||||
await util.assertNoTacIndicator();
|
||||
});
|
||||
|
||||
test("should show a notification indicator when there is a message in a thread", async ({ room1, util, msg }) => {
|
||||
await util.goTo(room1);
|
||||
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
|
||||
|
||||
// The indicator should be shown
|
||||
await util.assertNotificationTac();
|
||||
});
|
||||
|
||||
test("should show a highlight indicator when there is a mention in a thread", async ({ room1, util, msg }) => {
|
||||
await util.goTo(room1);
|
||||
await util.receiveMessages(room1, [
|
||||
"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"],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// The indicator should be shown
|
||||
await util.assertHighlightIndicator();
|
||||
});
|
||||
|
||||
test("should show the rooms with unread threads", async ({ room1, room2, util, msg }) => {
|
||||
await util.goTo(room2);
|
||||
await util.populateThreads(room1, room2, msg);
|
||||
// The indicator should be shown
|
||||
await util.assertHighlightIndicator();
|
||||
|
||||
// Verify that we have the expected rooms in the TAC
|
||||
await util.openTac();
|
||||
await util.assertRoomsInTac([
|
||||
{ room: room2.name, notificationLevel: "highlight" },
|
||||
{ room: room1.name, notificationLevel: "notification" },
|
||||
]);
|
||||
|
||||
// Verify that we don't have a visual regression
|
||||
await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png");
|
||||
});
|
||||
|
||||
test("should update with a thread is read", async ({ room1, room2, util, msg }) => {
|
||||
await util.goTo(room2);
|
||||
await util.populateThreads(room1, room2, msg);
|
||||
|
||||
// Click on the first room in TAC
|
||||
await util.openTac();
|
||||
await util.clickRoomInTac(room2.name);
|
||||
|
||||
// Verify that the thread panel is opened after a click on the room in the TAC
|
||||
await util.assertThreadPanelIsOpened();
|
||||
|
||||
// Open a thread and mark it as read
|
||||
// The room 2 doesn't have a mention anymore in its unread, so the highest notification level is notification
|
||||
await util.openThread("Msg1");
|
||||
await util.assertNotificationTac();
|
||||
await util.openTac();
|
||||
await util.assertRoomsInTac([
|
||||
{ room: room1.name, notificationLevel: "notification" },
|
||||
{ room: room2.name, notificationLevel: "notification" },
|
||||
]);
|
||||
await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png");
|
||||
});
|
||||
|
||||
test("should order by recency after notification level", async ({ room1, room2, util, msg }) => {
|
||||
await util.goTo(room2);
|
||||
await util.populateThreads(room1, room2, msg, false);
|
||||
|
||||
await util.openTac();
|
||||
await util.assertRoomsInTac([
|
||||
{ room: room1.name, notificationLevel: "notification" },
|
||||
{ room: room2.name, notificationLevel: "notification" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should block the Spotlight to open when the TAC is opened", async ({ util, page }) => {
|
||||
const toggleSpotlight = () => page.keyboard.press(`${CommandOrControl}+k`);
|
||||
|
||||
// Sanity check
|
||||
// Open and close the spotlight
|
||||
await toggleSpotlight();
|
||||
await expect(page.locator(".mx_SpotlightDialog")).toBeVisible();
|
||||
await toggleSpotlight();
|
||||
|
||||
await util.openTac();
|
||||
// The spotlight should not be opened
|
||||
await toggleSpotlight();
|
||||
await expect(page.locator(".mx_SpotlightDialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should have the correct hover state", async ({ util, page }) => {
|
||||
await util.hoverTacButton();
|
||||
await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered.png");
|
||||
|
||||
// Expand the space panel, hover the button and take a screenshot
|
||||
await util.expandSpacePanel();
|
||||
await util.hoverTacButton();
|
||||
await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png");
|
||||
});
|
||||
|
||||
test("should mark all threads as read", async ({ room1, room2, util, msg, page }) => {
|
||||
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
|
||||
|
||||
await util.assertNotificationTac();
|
||||
|
||||
await util.openTac();
|
||||
await util.clickRoomInTac(room1.name);
|
||||
|
||||
util.clickMarkAllThreadsRead();
|
||||
|
||||
await util.assertNoTacIndicator();
|
||||
});
|
||||
|
||||
test("should focus the thread tab when clicking an item in the TAC", async ({ room1, room2, util, msg }) => {
|
||||
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
|
||||
|
||||
await util.openTac();
|
||||
await util.clickRoomInTac(room1.name);
|
||||
|
||||
await util.assertThreadPanelIsOpened();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user