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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user