Split the read-receipt tests into logical units (#11649)
* Split the read-receipt tests into logical units * Move read-receipt docs into a readme file * Provide doc comments for exported functions in read-receipt tests
This commit is contained in:
parent
6fd46f3bc8
commit
1c2d604abe
8 changed files with 2978 additions and 2181 deletions
516
cypress/e2e/read-receipts/read-receipts-utils.ts
Normal file
516
cypress/e2e/read-receipts/read-receipts-utils.ts
Normal file
|
@ -0,0 +1,516 @@
|
|||
/*
|
||||
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 { MatrixClient, MatrixEvent, Room, IndexedDBStore } from "matrix-js-sdk/src/matrix";
|
||||
import Chainable = Cypress.Chainable;
|
||||
|
||||
/**
|
||||
* 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 MessageFinder {
|
||||
/**
|
||||
* Map of message content -> event.
|
||||
*/
|
||||
messages = new Map<String, 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: Room, message: string, includeThreads = false): Promise<MatrixEvent> {
|
||||
const cached = this.messages.get(message);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.messages.set(message, ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
room.on("Room.timeline" as any, (ev: MatrixEvent) => {
|
||||
if (ev.getContent().body === message) {
|
||||
this.messages.set(message, ev);
|
||||
resolve(ev);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Room): Promise<Record<string, unknown>> {
|
||||
const ev = await this.messageFinder?.getMessage(room, originalMessage, true);
|
||||
|
||||
// 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
})(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: Room): Promise<Record<string, unknown>> {
|
||||
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
|
||||
|
||||
return {
|
||||
"msgtype": "m.text",
|
||||
"body": newMessage,
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: ev.getId(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
})(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: Room): Promise<Record<string, unknown>> {
|
||||
const ev = await this.messageFinder.getMessage(room, rootMessage);
|
||||
|
||||
return {
|
||||
"msgtype": "m.text",
|
||||
"body": newMessage,
|
||||
"m.relates_to": {
|
||||
event_id: ev.getId(),
|
||||
is_falling_back: true,
|
||||
rel_type: "m.thread",
|
||||
},
|
||||
};
|
||||
}
|
||||
})(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(cli: MatrixClient, room: Room): Promise<void> {
|
||||
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
|
||||
const threadId = !ev.isThreadRoot ? ev.threadRootId : undefined;
|
||||
await cli.sendEvent(room.roomId, threadId ?? null, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: ev.getId(),
|
||||
key: reaction,
|
||||
},
|
||||
});
|
||||
}
|
||||
})(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* BotActionSpec to send a redaction into a room
|
||||
* @param messageFinder - used to find the existing event
|
||||
* @param targetMessage - the body of the message to send a redaction to
|
||||
*/
|
||||
redactionOf(targetMessage: string): BotActionSpec {
|
||||
return new (class extends BotActionSpec {
|
||||
public async performAction(cli: MatrixClient, room: Room): Promise<void> {
|
||||
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
|
||||
await cli.redactEvent(room.roomId, ev.threadRootId, ev.getId());
|
||||
}
|
||||
})(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and display a message.
|
||||
*
|
||||
* @param room 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
|
||||
*/
|
||||
jumpTo(room: string, message: string, includeThreads = false) {
|
||||
cy.log("Jump to message", room, message, includeThreads);
|
||||
cy.getClient().then((cli) => {
|
||||
findRoomByName(room).then(async ({ roomId }) => {
|
||||
const roomObject = cli.getRoom(roomId);
|
||||
const foundMessage = await this.getMessage(roomObject, message, includeThreads);
|
||||
cy.visit(`/#/room/${roomId}/${foundMessage.getId()}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something that can provide the content of a message.
|
||||
*
|
||||
* For example, we return and instance of this from {@link
|
||||
* MessageFinder.replyTo} which creates a reply based on a previous message.
|
||||
*/
|
||||
export abstract class MessageContentSpec {
|
||||
messageFinder: MessageFinder | null;
|
||||
|
||||
constructor(messageFinder: MessageFinder = null) {
|
||||
this.messageFinder = messageFinder;
|
||||
}
|
||||
|
||||
public abstract getContent(room: 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
|
||||
* MessageFinder.redactionOf} which redacts the message we are referring to.
|
||||
*/
|
||||
export abstract class BotActionSpec {
|
||||
messageFinder: MessageFinder | null;
|
||||
|
||||
constructor(messageFinder: MessageFinder = null) {
|
||||
this.messageFinder = messageFinder;
|
||||
}
|
||||
|
||||
public abstract performAction(cli: MatrixClient, room: 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;
|
||||
|
||||
/**
|
||||
* Use the supplied client to send messages or perform actions as specified by
|
||||
* the supplied {@link Message} items.
|
||||
*/
|
||||
export function sendMessageAsClient(cli: MatrixClient, room: string, messages: Message[]) {
|
||||
const roomIdFinder = findRoomByName(room);
|
||||
for (const message of messages) {
|
||||
roomIdFinder.then(async (room) => {
|
||||
if (typeof message === "string") {
|
||||
await cli.sendTextMessage(room.roomId, message);
|
||||
} else if (message instanceof MessageContentSpec) {
|
||||
await cli.sendMessage(room.roomId, await message.getContent(room));
|
||||
} else {
|
||||
await message.performAction(cli, room);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the room with the supplied name.
|
||||
*/
|
||||
export function goTo(room: string) {
|
||||
cy.viewRoomByName(room);
|
||||
}
|
||||
|
||||
function findRoomByName(room: string): Chainable<Room> {
|
||||
return cy.getClient().then((cli) => {
|
||||
return cli.getRooms().find((r) => r.name === room);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the thread with the supplied content in the thread root to open it in
|
||||
* the Threads panel.
|
||||
*/
|
||||
export function openThread(rootMessage: string) {
|
||||
cy.log("Open thread", rootMessage);
|
||||
cy.get(".mx_RoomView_body", { log: false }).within(() => {
|
||||
cy.contains(".mx_EventTile[data-scroll-tokens]", rootMessage, { log: false })
|
||||
.realHover()
|
||||
.findByRole("button", { name: "Reply in thread", log: false })
|
||||
.click();
|
||||
});
|
||||
cy.get(".mx_ThreadView_timelinePanelWrapper", { log: false }).should("have.length", 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the threads panel. (Actually, close any right panel, but for these
|
||||
* tests we only open the threads panel.)
|
||||
*/
|
||||
export function closeThreadsPanel() {
|
||||
cy.log("Close threads panel");
|
||||
cy.get(".mx_RightPanel").findByTitle("Close").click();
|
||||
cy.get(".mx_RightPanel").should("not.exist");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return to the list of threads, given we are viewing a single thread.
|
||||
*/
|
||||
export function backToThreadsList() {
|
||||
cy.log("Back to threads list");
|
||||
cy.get(".mx_RightPanel").findByTitle("Threads").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: MatrixClient, room: Room): Promise<void> {
|
||||
await cli.sendEvent(room.roomId, null, eventType, content);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function getRoomListTile(room: string) {
|
||||
return cy.findByRole("treeitem", { name: new RegExp("^" + room), log: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function assertMessageLoaded(messagePart: string) {
|
||||
cy.get(".mx_EventTile_body").contains(messagePart).should("exist");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function assertMessageNotLoaded(messagePart: string) {
|
||||
cy.get(".mx_EventTile_body").contains(messagePart).should("not.exist");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the messages panel up 1000 pixels.
|
||||
*/
|
||||
export function pageUp() {
|
||||
cy.get(".mx_RoomView_messagePanel").then((refs) =>
|
||||
refs.each((_, messagePanel) => {
|
||||
messagePanel.scrollTop -= 1000;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.toFixed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Mark as Read" context menu item on the room with the supplied name
|
||||
* in the room list.
|
||||
*/
|
||||
export function markAsRead(room: string) {
|
||||
cy.log("Marking room as read", room);
|
||||
getRoomListTile(room).rightclick();
|
||||
cy.findByText("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.
|
||||
*/
|
||||
export function assertRead(room: string) {
|
||||
cy.log("Assert room read", room);
|
||||
return getRoomListTile(room).within(() => {
|
||||
cy.get(".mx_NotificationBadge_dot").should("not.exist");
|
||||
cy.get(".mx_NotificationBadge_count").should("not.exist");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.)
|
||||
*/
|
||||
export function assertStillRead(room: string) {
|
||||
cy.wait(200);
|
||||
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
|
||||
*/
|
||||
export function assertUnread(room: string, count: number | ".") {
|
||||
cy.log("Assert room unread", room, count);
|
||||
return getRoomListTile(room).within(() => {
|
||||
if (count === ".") {
|
||||
cy.get(".mx_NotificationBadge_dot").should("exist");
|
||||
} else {
|
||||
cy.get(".mx_NotificationBadge_count").should("have.text", count);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function assertUnreadLessThan(room: string, lessThan: number) {
|
||||
cy.log("Assert unread less than", room, lessThan);
|
||||
return getRoomListTile(room).within(() => {
|
||||
cy.get(".mx_NotificationBadge_count").should(($count) =>
|
||||
expect(parseInt($count.get(0).textContent, 10)).to.be.lessThan(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
|
||||
*/
|
||||
export function assertUnreadGreaterThan(room: string, greaterThan: number) {
|
||||
cy.log("Assert unread greater than", room, greaterThan);
|
||||
return getRoomListTile(room).within(() => {
|
||||
cy.get(".mx_NotificationBadge_count").should(($count) =>
|
||||
expect(parseInt($count.get(0).textContent, 10)).to.be.greaterThan(greaterThan),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Threads" or "Back" button if needed to get to the threads list.
|
||||
*/
|
||||
export function openThreadList() {
|
||||
cy.log("Open threads list");
|
||||
|
||||
// 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.
|
||||
cy.wait(200);
|
||||
|
||||
cy.findByTestId("threadsButton", { log: false }).then(($button) => {
|
||||
if ($button?.attr("aria-current") !== "true") {
|
||||
cy.findByTestId("threadsButton", { log: false }).click();
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(".mx_ThreadPanel", { log: false })
|
||||
.should("exist")
|
||||
.then(($panel) => {
|
||||
const $button = $panel.find('.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.length) {
|
||||
$button.trigger("click");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getThreadListTile(rootMessage: string) {
|
||||
openThreadList();
|
||||
return cy.contains(".mx_ThreadPanel .mx_EventTile_body", rootMessage, { log: false }).closest("li");
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the thread with the supplied content in its root message is shown
|
||||
* as read in the Threads list.
|
||||
*/
|
||||
export function assertReadThread(rootMessage: string) {
|
||||
cy.log("Assert thread read", rootMessage);
|
||||
return getThreadListTile(rootMessage).within(() => {
|
||||
cy.get(".mx_NotificationBadge", { log: false }).should("not.exist");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the thread with the supplied content in its root message is shown
|
||||
* as unread in the Threads list.
|
||||
*/
|
||||
export function assertUnreadThread(rootMessage: string) {
|
||||
cy.log("Assert unread thread", rootMessage);
|
||||
return getThreadListTile(rootMessage).within(() => {
|
||||
cy.get(".mx_NotificationBadge").should("exist");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save our indexeddb information and then refresh the page.
|
||||
*/
|
||||
export function saveAndReload() {
|
||||
cy.log("Save and reload");
|
||||
cy.getClient().then((cli) => {
|
||||
// @ts-ignore
|
||||
return (cli.store as IndexedDBStore).reallySave();
|
||||
});
|
||||
cy.reload();
|
||||
// Wait for the app to reload
|
||||
cy.log("Waiting for app to reload");
|
||||
cy.get(".mx_RoomView", { log: false, timeout: 20000 }).should("exist");
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue