mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,444 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix";
import { type ElementAppPage } from "../../pages/ElementAppPage";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { UIFeature } from "../../../src/settings/UIFeature";
async function openSpaceCreateMenu(page: Page): Promise<Locator> {
await page.getByRole("button", { name: "Create a space" }).click();
return page.locator(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu");
}
async function openSpaceContextMenu(page: Page, app: ElementAppPage, spaceName: string): Promise<Locator> {
const button = await app.getSpacePanelButton(spaceName);
await button.click({ button: "right" });
return page.locator(".mx_SpacePanel_contextMenu");
}
function spaceCreateOptions(serverName: string, spaceName: string, roomIds: string[] = []): ICreateRoomOpts {
return {
creation_content: {
type: "m.space",
},
initial_state: [
{
type: "m.room.name",
content: {
name: spaceName,
},
},
...roomIds.map((r) => spaceChildInitialState(serverName, r)),
],
};
}
function spaceChildInitialState(
serverName: string,
roomId: string,
order?: string,
): ICreateRoomOpts["initial_state"]["0"] {
return {
type: "m.space.child",
state_key: roomId,
content: {
via: [serverName],
order,
},
};
}
test.describe("Spaces", () => {
test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488");
test.use({
displayName: "Sue",
botCreateOpts: { displayName: "BotBob" },
});
test(
"should allow user to create public space",
{ tag: ["@screenshot", "@no-webkit"] },
async ({ page, app, user }) => {
const contextMenu = await openSpaceCreateMenu(page);
await expect(contextMenu).toMatchScreenshot("space-create-menu.png");
await contextMenu.getByRole("button", { name: /Public/ }).click();
await contextMenu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot");
await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot");
await contextMenu
.getByRole("textbox", { name: "Description" })
.fill("This is a space to reminisce Riot.im!");
await contextMenu.getByRole("button", { name: "Create" }).click();
// Create the default General & Random rooms, as well as a custom "Jokes" room
await expect(page.getByPlaceholder("General")).toBeVisible();
await expect(page.getByPlaceholder("Random")).toBeVisible();
await page.getByPlaceholder("Support").fill("Jokes");
await page.getByRole("button", { name: "Continue" }).click();
// Copy matrix.to link
await page.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`);
// Go to space home
await page.getByRole("button", { name: "Go to my first room" }).click();
// Assert rooms exist in the room list
await expect(page.getByRole("option", { name: "General" })).toBeVisible();
await expect(page.getByRole("option", { name: "Random" })).toBeVisible();
await expect(page.getByRole("option", { name: "Jokes" })).toBeVisible();
},
);
test("should allow user to create private space", { tag: "@screenshot" }, async ({ page, app, user }) => {
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await menu.getByRole("textbox", { name: "Name" }).fill("This is not a Riot");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu.getByRole("textbox", { name: "Description" }).fill("This is a private space of mourning Riot.im...");
await menu.getByRole("button", { name: "Create" }).click();
await page.getByRole("button", { name: "Me and my teammates" }).click();
// Create the default General & Random rooms, as well as a custom "Projects" room
await expect(page.getByPlaceholder("General")).toBeVisible();
await expect(page.getByPlaceholder("Random")).toBeVisible();
await page.getByPlaceholder("Support").fill("Projects");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.locator(".mx_SpaceRoomView h1").getByText("Invite your teammates")).toBeVisible();
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("invite-teammates-dialog.png");
await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list
const roomList = page.getByRole("listbox", { name: "Room list", exact: true });
await expect(roomList.getByRole("option", { name: "General" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "Random" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "Projects" })).toBeVisible();
// Assert rooms exist in the space explorer
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }),
).toBeVisible();
});
test("should allow user to create just-me space", async ({ page, app, user }) => {
await app.client.createRoom({
name: "Sample Room",
});
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu.getByRole("textbox", { name: "Description" }).fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");
await menu.getByRole("textbox", { name: "Name" }).press("Enter");
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
// Temporal implementation as multiple elements with the role "button" and name "Add" are found
await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click();
await expect(
page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }),
).toBeVisible();
});
test(
"should allow user to add an existing room to a space after creation",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await app.client.createRoom({
name: "Sample Room",
});
await app.client.createRoom({
name: "A Room that will not be selected",
});
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");
await menu.getByRole("textbox", { name: "Name" }).press("Enter");
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
await page.getByRole("main").getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "Add existing room" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await expect(page.getByRole("dialog", { name: "Avatar Add existing rooms" })).toMatchScreenshot(
"add-existing-rooms-dialog.png",
);
await page.getByRole("button", { name: "Add" }).click();
await expect(
page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }),
).toBeVisible();
},
);
test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({
visibility: "public" as any,
room_alias_name: "space",
});
const menu = await openSpaceContextMenu(page, app, `#space:${user.homeServer}`);
await menu.getByRole("menuitem", { name: "Invite" }).click();
const shareDialog = page.locator(".mx_SpacePublicShare");
// Copy link first
await shareDialog.getByRole("button", { name: "Share invite link" }).click();
expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`);
// Start Matrix invite flow
await shareDialog.getByRole("button", { name: "Invite people" }).click();
const otherSection = page.locator(".mx_InviteDialog_other");
await otherSection.getByRole("textbox").fill(bot.credentials.userId);
await otherSection.getByRole("button", { name: "Invite" }).click();
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
});
test("should show space invites at the top of the space panel", async ({ page, app, user, bot }) => {
await app.client.createSpace({
name: "My Space",
});
await expect(await app.getSpacePanelButton("My Space")).toBeVisible();
const roomId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Space Space"));
await bot.inviteUser(roomId, user.userId);
// Assert that `Space Space` is above `My Space` due to it being an invite
const buttons = page.getByRole("tree", { name: "Spaces" }).locator(".mx_SpaceButton");
await expect(buttons.nth(1)).toHaveAttribute("aria-label", "Space Space");
await expect(buttons.nth(2)).toHaveAttribute("aria-label", "My Space");
});
test("should include rooms in space home", async ({ page, app, user }) => {
const roomId1 = await app.client.createRoom({
name: "Music",
});
const roomId2 = await app.client.createRoom({
name: "Gaming",
});
const spaceName = "Spacey Mc. Space Space";
await app.client.createSpace({
name: spaceName,
initial_state: [
spaceChildInitialState(user.homeServer, roomId1),
spaceChildInitialState(user.homeServer, roomId2),
],
});
await app.viewSpaceHomeByName(spaceName);
const hierarchyList = page.locator(".mx_SpaceRoomView .mx_SpaceHierarchy_list");
await expect(hierarchyList.getByRole("treeitem", { name: "Music" }).getByRole("button")).toBeVisible();
await expect(hierarchyList.getByRole("treeitem", { name: "Gaming" }).getByRole("button")).toBeVisible();
});
test(
"should render subspaces in the space panel only when expanded",
{ tag: "@screenshot" },
async ({ page, app, user, axe }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
// XXX: We have some known contrast issues here
"color-contrast",
]);
const childSpaceId = await app.client.createSpace({
name: "Child Space",
initial_state: [],
});
await app.client.createSpace({
name: "Root Space",
initial_state: [spaceChildInitialState(user.homeServer, childSpaceId)],
});
// Find collapsed Space panel
const spaceTree = page.getByRole("tree", { name: "Spaces" });
await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible();
await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible();
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png");
// This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another
// button with the same name with different class name "mx_SpacePanel_toggleCollapse".
await spaceTree.getByRole("button", { name: "Expand" }).click();
await expect(page.locator(".mx_SpacePanel:not(.collapsed)")).toBeVisible(); // TODO: replace :not() selector
const item = page.locator(".mx_SpaceItem", { hasText: "Root Space" });
await expect(item).toBeVisible();
await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible();
await expect(axe).toHaveNoViolations();
await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png");
},
);
test("should not soft crash when joining a room from space hierarchy which has a link in its topic", async ({
page,
app,
user,
bot,
}) => {
const roomId = await bot.createRoom({
preset: "public_chat" as Preset,
name: "Test Room",
topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link",
});
const spaceId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Test Space", [roomId]));
await bot.inviteUser(spaceId, user.userId);
await expect(await app.getSpacePanelButton("Test Space")).toBeVisible();
await app.viewSpaceByName("Test Space");
await page.getByRole("button", { name: "Accept" }).click();
await page.getByRole("button", { name: "Test Room" }).hover();
await page.getByRole("button", { name: "Join", exact: true }).click();
await page.getByRole("button", { name: "View", exact: true }).click();
// Assert we get shown the new room intro, and thus not the soft crash screen
await expect(page.locator(".mx_NewRoomIntro")).toBeVisible();
});
test("should render spaces view", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
// XXX: We have some known contrast issues here
"color-contrast",
]);
const childSpaceId1 = await app.client.createSpace({
name: "Child Space 1",
initial_state: [],
});
const childSpaceId2 = await app.client.createSpace({
name: "Child Space 2",
initial_state: [],
});
const childSpaceId3 = await app.client.createSpace({
name: "Child Space 3",
initial_state: [],
});
await app.client.createSpace({
name: "Root Space",
initial_state: [
spaceChildInitialState(user.homeServer, childSpaceId1, "a"),
spaceChildInitialState(user.homeServer, childSpaceId2, "b"),
spaceChildInitialState(user.homeServer, childSpaceId3, "c"),
],
});
await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
});
test("should render spaces visibility settings", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
await app.client.createSpace({
name: "My Space",
});
await app.viewSpaceByName("My space");
await page.getByLabel("Settings", { exact: true }).click();
await app.settings.switchTab("Visibility");
axe.disableRules("color-contrast"); // XXX: Inheriting colour contrast issues from room view.
await expect(axe).toHaveNoViolations();
await expect(page.locator("#mx_tabpanel_SPACE_VISIBILITY_TAB")).toMatchScreenshot(
"space-visibility-settings.png",
);
});
test.describe("Should hide public spaces option if not allowed", () => {
test.use({
config: {
setting_defaults: {
[UIFeature.AllowCreatingPublicSpaces]: false,
},
},
});
test("should disallow creating public rooms", { tag: "@screenshot" }, async ({ page, user, app }) => {
const menu = await openSpaceCreateMenu(page);
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await menu.getByRole("textbox", { name: "Name" }).fill("This is a private space");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a private space because we can't make public ones");
await menu.getByRole("button", { name: "Create" }).click();
await page.getByRole("button", { name: "Me and my teammates" }).click();
// Create the default General & Random rooms, as well as a custom "Projects" room
await expect(page.getByPlaceholder("General")).toBeVisible();
await expect(page.getByPlaceholder("Random")).toBeVisible();
await page.getByPlaceholder("Support").fill("Projects");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list
const roomList = page.getByRole("listbox", { name: "Room list", exact: true });
await expect(roomList.getByRole("option", { name: "General" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "Random" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "Projects" })).toBeVisible();
// Assert rooms exist in the space explorer
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "General" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Random" }),
).toBeVisible();
await expect(
page.locator(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", { hasText: "Projects" }),
).toBeVisible();
});
});
});
@@ -0,0 +1,395 @@
/*
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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type JSHandle, type Locator, type Page } from "@playwright/test";
import type { MatrixEvent, IContent, Room, Preset } from "matrix-js-sdk/src/matrix";
import { test as base, expect } from "../../../element-web-test";
import { type Bot } from "../../../pages/bot";
import { type Client } from "../../../pages/client";
import { type ElementAppPage } from "../../../pages/ElementAppPage";
import { type Credentials } from "../../../plugins/homeserver";
type RoomRef = { name: string; roomId: string };
/**
* 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],
preset: "public_chat" as Preset,
});
await bot.awaitRoomMembership(roomId);
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 bot.awaitRoomMembership(roomId);
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, roomRef: RoomRef, messages: Message[]) {
const roomId = roomRef.roomId;
const room = await this.findRoomById(roomId);
expect(room).toBeTruthy();
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: RoomRef) {
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 findRoomById(roomId: string): Promise<JSHandle<Room | undefined>> {
return this.app.client.evaluateHandle((cli, roomId) => {
return cli.getRooms().find((r) => r.roomId === roomId);
}, roomId);
}
/**
* 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: RoomRef, 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 checking 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 user - the user to mention in the first message
* @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,
user: Credentials,
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.userId}">User</a>`,
"m.mentions": {
user_ids: [user.userId],
},
}),
]);
}
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("navigation", { name: "Spaces" }).getByRole("button", { name: "Expand" }).click();
}
/**
* Clicks the button to mark all threads as read in the current room
*/
clickMarkAllThreadsRead() {
return this.page.locator("#thread-panel").getByRole("button", { name: "Mark all as read" }).click();
}
}
export { expect };
@@ -0,0 +1,178 @@
/*
* 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 OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from ".";
import { CommandOrControl } from "../../utils";
import { isDendrite } from "../../../plugins/homeserver/dendrite";
test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test.skip(
isDendrite,
"due to Dendrite lacking full threads support https://github.com/element-hq/dendrite/issues/3283",
);
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
});
test(
"should have the button correctly aligned and displayed in the space panel when expanded",
{ tag: "@screenshot" },
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", { tag: "@screenshot" }, 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,
user,
}) => {
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.userId}">User</a>`,
"m.mentions": {
user_ids: [user.userId],
},
}),
]);
// The indicator should be shown
await util.assertHighlightIndicator();
});
test(
"should show the rooms with unread threads",
{ tag: "@screenshot" },
async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user);
// 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", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user);
// 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, user }) => {
await util.goTo(room2);
await util.populateThreads(room1, room2, msg, user, 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", { tag: "@screenshot" }, 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", { tag: "@screenshot" }, 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);
await 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();
});
});