feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
+405
View File
@@ -0,0 +1,405 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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, type Page, expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { Settings } from "./settings";
import { Client } from "./client";
import { Timeline } from "./timeline";
import { Spotlight } from "./Spotlight";
/**
* A set of utility methods for interacting with the Element-Web UI.
*/
export class ElementAppPage {
public constructor(public readonly page: Page) {}
// We create these lazily on first access to avoid calling setup code which might cause conflicts,
// e.g. the network routing code in the client subfixture.
private _settings?: Settings;
public get settings(): Settings {
if (!this._settings) this._settings = new Settings(this.page);
return this._settings;
}
private _client?: Client;
public get client(): Client {
if (!this._client) this._client = new Client(this.page);
return this._client;
}
private _timeline?: Timeline;
public get timeline(): Timeline {
if (!this._timeline) this._timeline = new Timeline(this.page);
return this._timeline;
}
public async cleanup() {
await this._client?.cleanup();
}
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
*/
public async openUserMenu(): Promise<Locator> {
return this.settings.openUserMenu();
}
/**
* Open room creation dialog.
*/
public async openCreateRoomDialog(roomKindname: "New room" | "New video room" = "New room"): Promise<Locator> {
await this.page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await this.page.getByRole("menuitem", { name: roomKindname }).click();
return this.page.locator(".mx_CreateRoomDialog");
}
/**
* Close dialog currently open dialog
*/
public async closeDialog(): Promise<void> {
return this.settings.closeDialog();
}
public async getClipboard(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}
/**
* Get the room ID from the current URL.
*
* @returns The room ID.
* @throws if the current URL does not contain a room ID.
*/
public async getCurrentRoomIdFromUrl(): Promise<string> {
const urlHash = await this.page.evaluate(() => window.location.hash);
if (!urlHash.startsWith("#/room/")) {
throw new Error("URL hash suggests we are not in a room");
}
return urlHash.replace("#/room/", "");
}
/**
* Opens the given room by name. The room must be visible in the
* room list and the room may contain unread messages.
*
* @param name The exact room name to find and click on/open.
*/
public async viewRoomByName(name: string): Promise<void> {
// We get the room list by test-id which is a listbox and matching title=name
return this.page.getByTestId("room-list").locator(`[title="${name}"]`).first().click();
}
/**
* Opens the given room on the old room list by name. The room must be visible in the
* room list, but the room list may be folded horizontally, and the
* room may contain unread messages.
*
* @param name The exact room name to find and click on/open.
*/
public async viewRoomByNameOnOldRoomList(name: string): Promise<void> {
// We look for the room inside the room list, which is a tree called Rooms.
//
// There are 3 cases:
// - the room list is folded:
// then the aria-label on the room tile is the name (with nothing extra)
// - the room list is unfolder and the room has messages:
// then the aria-label contains the unread count, but the title of the
// div inside the titleContainer equals the room name
// - the room list is unfolded and the room has no messages:
// then the aria-label is the name and so is the title of a div
//
// So by matching EITHER title=name OR aria-label=name we find this exact
// room in all three cases.
return this.page
.getByRole("tree", { name: "Rooms" })
.locator(`[title="${name}"],[aria-label="${name}"]`)
.first()
.click();
}
public async viewRoomById(roomId: string): Promise<void> {
await this.page.goto(`/#/room/${roomId}`);
}
/**
* Get the composer element
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposer(isRightPanel?: boolean): Locator {
const panelClass = isRightPanel ? ".mx_RightPanel" : ".mx_RoomView_body";
return this.page.locator(`${panelClass} .mx_MessageComposer`);
}
/**
* Get the composer input field
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public getComposerField(isRightPanel?: boolean): Locator {
return this.getComposer(isRightPanel).locator("div[contenteditable]");
}
/**
* Open the message composer kebab menu
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
*/
public async openMessageComposerOptions(isRightPanel?: boolean): Promise<Locator> {
const composer = this.getComposer(isRightPanel);
await composer.getByRole("button", { name: "More options", exact: true }).click();
return this.page.getByRole("menu");
}
/**
* Sets the files on the composers file input, causing it to open the file
* upload dialog.
* @param location Should the main room input or the thread view input be used.
*/
public setComposerInputFiles(
location: "room" | "thread",
...params: Parameters<Locator["setInputFiles"]>
): ReturnType<Locator["setInputFiles"]> {
const input = this.page
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
.getByRole("region", { name: "Message composer" })
.locator("input[type='file']");
return input.setInputFiles(...params);
}
/**
* Sets the files on the composers file input, causing it to open the file
* upload dialog, and then automaticlly submits the dialog that pops up which
* causes the file to be uploaded.
* @param location Should the main room input or the thread view input be used.
*/
public async composerUploadFiles(
location: "room" | "thread",
...params: Parameters<Locator["setInputFiles"]>
): Promise<void> {
await this.setComposerInputFiles(location, ...params);
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Drags a "file" into the specified composer and automatically uploads it.
* @param location Should the drop target the main room or the thread.
* @param path The path to the sample file so it can be read.
* @param type The mimetype of the file.
*/
public async composerDragAndUploadFiles(location: "room" | "thread", path: string, type: string): Promise<void> {
// Based on https://github.com/microsoft/playwright/issues/10667#issuecomment-2742123424
// This read a file, encodes it into base64 and then sends it along to the page to be treated
// as a DataTransfer (the mechanism for drag and dropped files).
const buffer = await readFile(path);
const name = basename(path);
const dataTransfer = await this.page.evaluateHandle(
async ([buffer, name, type]) => {
const dt = new DataTransfer();
const file = new File([Uint8Array.fromBase64(buffer)], name, {
type,
});
dt.items.add(file);
return dt;
},
[buffer.toString("base64"), name, type],
);
await this.page.dispatchEvent(location === "room" ? ".mx_RoomView_body" : ".mx_ThreadPanel", "drop", {
dataTransfer,
});
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Paste a "file" into the specified locator and automatically uploads it.
* @param location Should the drop target the main room or the thread.
* @param path The path to the sample file so it can be read.
* @param type The mimetype of the file.
*/
public async composerDragAndPasteFile(location: "room" | "thread", path: string, type: string): Promise<void> {
// Based on https://github.com/microsoft/playwright/issues/10667#issuecomment-2742123424
// This read a file, encodes it into base64 and then sends it along to the page to be treated
// as a DataTransfer (the mechanism for drag and dropped files).
const buffer = await readFile(path);
const name = basename(path);
const composer = this.getComposerField(location === "thread");
await composer.evaluate(
async (element, [buffer, name, type]) => {
const clipboardData = new DataTransfer();
const file = new File([Uint8Array.fromBase64(buffer)], name, {
type,
});
clipboardData.items.add(file);
element.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
}),
);
},
[buffer.toString("base64"), name, type],
);
await this.page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
}
/**
* Returns the space panel space button based on a name. The space
* must be visible in the space panel
* @param name The space name to find
*/
public async getSpacePanelButton(name: string): Promise<Locator> {
const button = this.page.getByRole("button", { name: name });
await expect(button).toHaveClass(/mx_SpaceButton/);
return button;
}
/**
* Opens the given space home by name. The space must be visible in
* the space list.
* @param name The space name to find and click on/open.
*/
public async viewSpaceHomeByName(name: string): Promise<void> {
const button = await this.getSpacePanelButton(name);
return button.dblclick();
}
/**
* Opens the given space by name. The space must be visible in the
* space list.
* @param name The space name to find and click on/open.
*/
public async viewSpaceByName(name: string): Promise<void> {
const button = await this.getSpacePanelButton(name);
return button.click();
}
public async openSpotlight(): Promise<Spotlight> {
const spotlight = new Spotlight(this.page);
await spotlight.open();
return spotlight;
}
/**
* Opens/closes the room info panel
* @returns locator to the right panel
*/
public async toggleRoomInfoPanel(): Promise<Locator> {
await this.page.getByRole("button", { name: "Room info" }).first().click();
return this.page.locator(".mx_RightPanel");
}
/**
* Opens the room info panel if it is not already open.
*
* TODO: fix this so that it works correctly if, say, the member list was open instead of the room info panel.
*
* @returns locator to the right panel
*/
public async openRoomInfoPanel(): Promise<Locator> {
const locator = this.page.getByTestId("right-panel");
if (!(await locator.isVisible())) {
await this.page.getByRole("button", { name: "Room info" }).first().click();
}
return locator;
}
/**
* Opens/closes the memberlist panel
* @returns locator to the memberlist panel
*/
public async toggleMemberlistPanel(): Promise<Locator> {
const locator = this.page.locator(".mx_FacePile");
await locator.click();
const memberlist = this.page.locator(".mx_MemberListView");
await memberlist.waitFor();
return memberlist;
}
/**
* Open the room info panel, and use it to send an invite to the given user.
*
* @param userId - The user to invite to the room.
* @param options - Options object
*/
public async inviteUserToCurrentRoom(
userId: string,
options?: {
/** If true, expect and acknowledge "Confirm inviting new users" page */
confirmUnknownUser?: boolean;
},
): Promise<void> {
const rightPanel = await this.openRoomInfoPanel();
await rightPanel.getByRole("menuitem", { name: "Invite" }).click();
const dialogLocator = this.page.getByRole("dialog");
const input = dialogLocator.getByTestId("invite-dialog-input");
await input.fill(userId);
await input.press("Enter");
await dialogLocator.getByRole("button", { name: "Invite" }).click();
if (options?.confirmUnknownUser) {
await expect(
dialogLocator.getByRole("heading", { name: "Invite new contacts to this room?" }),
).toBeVisible();
await dialogLocator.getByRole("button", { name: "Invite" }).click();
}
}
async closeToast(title: string, button: string): Promise<void> {
await this.page.locator(".mx_Toast_toast", { hasText: title }).getByRole("button", { name: button }).click();
}
/**
* Dismiss the "Notifications" toast.
*/
public async closeNotificationToast(): Promise<void> {
await this.closeToast("Notifications", "Dismiss");
}
/**
* Dismiss the "Turn on key storage" toast.
*/
public async closeKeyStorageToast() {
await this.closeToast("Turn on key storage", "Dismiss");
await this.page.getByRole("button", { name: "Yes, dismiss" }).click();
}
/**
* Dismiss the "Verify this device" toast by clicking "Later".
*/
public async closeVerifyToast() {
await this.closeToast("Verify this device", "Later");
}
/**
* Scroll an infinite list to the bottom.
* @param list The element to scroll
*/
public async scrollListToBottom(list: Locator): Promise<void> {
// First hover the mouse over the element that we want to scroll
await list.hover();
const needsScroll = async () => {
// From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const fullyScrolled = await list.evaluate(
(e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
);
return !fullyScrolled;
};
// Scroll the element until we detect that it is fully scrolled
do {
await this.page.mouse.wheel(0, 1000);
} while (await needsScroll());
}
}
+63
View File
@@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 { CommandOrControl } from "../e2e/utils";
export enum Filter {
People = "people",
PublicRooms = "public_rooms",
}
export class Spotlight {
private root: Locator;
constructor(private page: Page) {}
public async open() {
this.root = this.page.locator('[role=dialog][aria-label="Search Dialog"]');
const isSpotlightAlreadyOpen = !!(await this.root.count());
if (isSpotlightAlreadyOpen) {
// Close dialog if it is already open
await this.page.keyboard.press(`${CommandOrControl}+KeyK`);
}
await this.page.keyboard.press(`${CommandOrControl}+KeyK`);
}
public async filter(filter: Filter) {
let selector: string;
switch (filter) {
case Filter.People:
selector = "#mx_SpotlightDialog_button_startChat";
break;
case Filter.PublicRooms:
selector = "#mx_SpotlightDialog_button_explorePublicRooms";
break;
default:
selector = ".mx_SpotlightDialog_filter";
break;
}
await this.root.locator(selector).click();
}
public async search(query: string) {
await this.searchBox.getByRole("textbox", { name: "Search" }).fill(query);
}
public get searchBox() {
return this.root.locator(".mx_SpotlightDialog_searchBox");
}
public get results() {
return this.root.locator(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option");
}
public get dialog() {
return this.root;
}
}
+281
View File
@@ -0,0 +1,281 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 Page, type TestInfo } from "@playwright/test";
import { uniqueId } from "lodash";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import type { Logger } from "matrix-js-sdk/src/logger";
import type { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
import type { CryptoCallbacks, GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { bootstrapCrossSigningForClient, Client } from "./client";
export interface CredentialsOptionalAccessToken extends Omit<Credentials, "accessToken"> {
accessToken?: string;
}
export interface CreateBotOpts {
/**
* A prefix to use for the userid. If unspecified, "bot_" will be used.
*/
userIdPrefix?: string;
/**
* Whether the bot should automatically accept all invites.
*/
autoAcceptInvites?: boolean;
/**
* The display name to give to that bot user
*/
displayName?: string;
/**
* Whether to start the syncing client.
*/
startClient?: boolean;
/**
* Whether to generate cross-signing keys
*/
bootstrapCrossSigning?: boolean;
/**
* Whether to bootstrap the secret storage
*/
bootstrapSecretStorage?: boolean;
/**
* Whether to use a passphrase when creating the recovery key
*/
usePassphrase?: boolean;
}
const defaultCreateBotOptions = {
userIdPrefix: "bot_",
autoAcceptInvites: true,
startClient: true,
bootstrapCrossSigning: true,
usePassphrase: false,
} satisfies CreateBotOpts;
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
export class Bot extends Client {
public credentials?: CredentialsOptionalAccessToken;
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;
constructor(
page: Page,
private homeserver: HomeserverInstance,
private readonly opts: CreateBotOpts,
) {
super(page);
this.opts = Object.assign({}, defaultCreateBotOptions, opts);
}
/**
* Set the credentials used by the bot.
*
* If `credentials.accessToken` is unset, then `buildClient` will log in a
* new session. Note that `getCredentials` will return the credentials
* passed to this function, rather than the updated credentials from the new
* login. In particular, the `accessToken` and `deviceId` will not be
* updated.
*/
public setCredentials(credentials: CredentialsOptionalAccessToken): void {
if (this.credentials) throw new Error("Bot has already started");
this.credentials = credentials;
}
public async getRecoveryKey(): Promise<GeneratedSecretStorageKey> {
const client = await this.getClientHandle();
return client.evaluate((cli) => cli.__playwright_recovery_key);
}
private async getCredentials(): Promise<CredentialsOptionalAccessToken> {
if (this.credentials) return this.credentials;
// We want to pad the uniqueId but not the prefix
const username =
this.opts.userIdPrefix +
uniqueId(this.opts.userIdPrefix)
.substring(this.opts.userIdPrefix?.length ?? 0)
.padStart(4, "0");
const password = uniqueId("password_");
console.log(`getBot: Create bot user ${username} with opts ${JSON.stringify(this.opts)}`);
this.credentials = await this.homeserver.registerUser(username, password, this.opts.displayName);
return this.credentials;
}
protected async getClientHandle(): Promise<JSHandle<ExtendedMatrixClient>> {
if (!this.handlePromise) this.handlePromise = this.buildClient();
return this.handlePromise;
}
private async buildClient(): Promise<JSHandle<ExtendedMatrixClient>> {
const credentials = await this.getCredentials();
const clientHandle = await this.page.evaluateHandle(
async ({ baseUrl, credentials, opts }) => {
class NestedLogger implements Logger {
constructor(
public readonly loggerName: string,
private writeLog: (msg: string) => void,
) {}
getChild(namespace: string): Logger {
return new NestedLogger(`${this.loggerName}:${namespace}`, this.writeLog);
}
trace(...msg: any[]): void {
this.writeLog(`T ${this.loggerName} ${msg.join(" ")}`);
}
debug(...msg: any[]): void {
this.writeLog(`D ${this.loggerName} ${msg.join(" ")}`);
}
info(...msg: any[]): void {
this.writeLog(`I ${this.loggerName} ${msg.join(" ")}`);
}
warn(...msg: any[]): void {
this.writeLog(`W ${this.loggerName} ${msg.join(" ")}`);
}
error(...msg: any[]): void {
this.writeLog(`E ${this.loggerName} ${msg.join(" ")}`);
}
}
class PlaywrightLogger extends NestedLogger {
public log = "";
constructor() {
super("", (msg: string) => {
this.log += msg + "\n";
});
}
}
const logger = new PlaywrightLogger();
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array<ArrayBuffer> };
const cacheSecretStorageKey = (
keyId: string,
keyInfo: SecretStorageKeyDescription,
key: Uint8Array<ArrayBuffer>,
) => {
cachedKey = {
keyId,
key,
};
};
const getSecretStorageKey = () =>
Promise.resolve<[string, Uint8Array<ArrayBuffer>]>([cachedKey.keyId, cachedKey.key]);
const cryptoCallbacks: CryptoCallbacks = {
cacheSecretStorageKey,
getSecretStorageKey,
};
if (!("accessToken" in credentials)) {
const loginCli = new window.matrixcs.MatrixClient({
baseUrl,
store: new window.matrixcs.MemoryStore(),
scheduler: new window.matrixcs.MatrixScheduler(),
cryptoStore: new window.matrixcs.MemoryCryptoStore(),
cryptoCallbacks,
logger,
});
const loginResponse = await loginCli.loginRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: credentials.userId,
},
password: credentials.password,
});
credentials.accessToken = loginResponse.access_token;
credentials.userId = loginResponse.user_id;
credentials.deviceId = loginResponse.device_id;
}
const cli = new window.matrixcs.MatrixClient({
baseUrl,
userId: credentials.userId,
deviceId: credentials.deviceId,
accessToken: credentials.accessToken,
store: new window.matrixcs.MemoryStore(),
scheduler: new window.matrixcs.MatrixScheduler(),
cryptoStore: new window.matrixcs.MemoryCryptoStore(),
cryptoCallbacks,
logger,
}) as ExtendedMatrixClient;
if (opts.autoAcceptInvites) {
cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
if (member.membership === "invite" && member.userId === cli.getUserId()) {
void cli.joinRoom(member.roomId);
}
});
}
return cli;
},
{
baseUrl: this.homeserver.baseUrl,
credentials,
opts: this.opts,
},
);
// If we weren't configured to start the client, bail out now.
if (!this.opts.startClient) {
return clientHandle;
}
await clientHandle.evaluate(async (cli) => {
await cli.initRustCrypto({ useIndexedDB: false });
await cli.startClient();
});
if (this.opts.bootstrapCrossSigning) {
// XXX: workaround https://github.com/element-hq/element-web/issues/26755
// wait for out device list to be available, as a proxy for the device keys having been uploaded.
await clientHandle.evaluate(async (cli, credentials) => {
await cli.getCrypto()!.getUserDeviceInfo([credentials.userId]);
}, credentials);
await bootstrapCrossSigningForClient(clientHandle, credentials);
}
if (this.opts.bootstrapSecretStorage) {
await clientHandle.evaluate(async (cli, usePassphrase) => {
const passphrase = usePassphrase ? "new passphrase" : undefined;
const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase);
Object.assign(cli, { __playwright_recovery_key: recoveryKey });
await cli.getCrypto()!.bootstrapSecretStorage({
setupNewSecretStorage: true,
setupNewKeyBackup: true,
createSecretStorageKey: () => Promise.resolve(recoveryKey),
});
}, this.opts.usePassphrase);
}
return clientHandle;
}
public async onTestFinished(testInfo: TestInfo): Promise<void> {
if (testInfo.status !== "passed") {
const credentials = await this.getCredentials();
const client = await this.getClientHandle();
// @ts-ignore private field logger
const body = await client.evaluate((cli) => (<PlaywrightLogger>cli.logger).log);
await testInfo.attach(`playwright-bot-${credentials.userId}`, {
body,
contentType: "text/plain",
});
}
}
}
+574
View File
@@ -0,0 +1,574 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 Page } from "@playwright/test";
import { type ElementHandle } from "playwright-core";
import { Network } from "./network";
import type {
IContent,
ICreateRoomOpts,
ISendEventResponse,
MatrixClient,
MatrixEvent,
ReceiptType,
IRoomDirectoryOptions,
KnockRoomOpts,
Visibility,
UploadOpts,
Upload,
StateEvents,
TimelineEvents,
AccountDataEvents,
EmptyObject,
} from "matrix-js-sdk/src/matrix";
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type CredentialsOptionalAccessToken } from "./bot";
/** Types cribbed from playwright-core/types/structs as they are not importable */
export type NoHandles<Arg> = Arg extends JSHandle
? never
: Arg extends object
? { [Key in keyof Arg]: NoHandles<Arg[Key]> }
: Arg;
export type Unboxed<Arg> =
Arg extends ElementHandle<infer T>
? T
: Arg extends JSHandle<infer T>
? T
: Arg extends NoHandles<Arg>
? Arg
: Arg extends [infer A0]
? [Unboxed<A0>]
: Arg extends [infer A0, infer A1]
? [Unboxed<A0>, Unboxed<A1>]
: Arg extends [infer A0, infer A1, infer A2]
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>]
: Arg extends [infer A0, infer A1, infer A2, infer A3]
? [Unboxed<A0>, Unboxed<A1>, Unboxed<A2>, Unboxed<A3>]
: Arg extends Array<infer T>
? Array<Unboxed<T>>
: Arg extends object
? { [Key in keyof Arg]: Unboxed<Arg[Key]> }
: Arg;
export type PageFunctionOn<On, Arg2, R> = string | ((on: On, arg2: Unboxed<Arg2>) => R | Promise<R>);
export class Client {
public network: Network;
protected client: JSHandle<MatrixClient>;
protected getClientHandle(): Promise<JSHandle<MatrixClient>> {
return this.page.evaluateHandle(() => window.mxMatrixClientPeg.get());
}
public async prepareClient(): Promise<JSHandle<MatrixClient>> {
if (!this.client) {
this.client = await this.getClientHandle();
}
return this.client;
}
public constructor(protected readonly page: Page) {
page.on("framenavigated", async () => {
this.client = null;
});
this.network = new Network(page, this);
}
public async cleanup() {
await this.network.destroyRoute();
}
public evaluate<R, Arg, O extends MatrixClient = MatrixClient>(
pageFunction: PageFunctionOn<O, Arg, R>,
arg: Arg,
): Promise<R>;
public evaluate<R, O extends MatrixClient = MatrixClient>(
pageFunction: PageFunctionOn<O, void, R>,
arg?: any,
): Promise<R>;
public async evaluate<T>(fn: (client: MatrixClient) => T, arg?: any): Promise<T> {
await this.prepareClient();
return this.client.evaluate(fn, arg);
}
public evaluateHandle<R, Arg, O extends MatrixClient = MatrixClient>(
pageFunction: PageFunctionOn<O, Arg, R>,
arg: Arg,
): Promise<JSHandle<R>>;
public evaluateHandle<R, O extends MatrixClient = MatrixClient>(
pageFunction: PageFunctionOn<O, void, R>,
arg?: any,
): Promise<JSHandle<R>>;
public async evaluateHandle<T>(fn: (client: MatrixClient) => T, arg?: any): Promise<JSHandle<T>> {
await this.prepareClient();
return this.client.evaluateHandle(fn, arg);
}
/**
* @param roomId ID of the room to send the event into
* @param threadId ID of the thread to send into or null for main timeline
* @param eventType type of event to send
* @param content the event content to send
*/
public async sendEvent(
roomId: string,
threadId: string | null,
eventType: string,
content: IContent,
): Promise<ISendEventResponse> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { roomId, threadId, eventType, content }) => {
return client.sendEvent(
roomId,
threadId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
},
{ roomId, threadId, eventType, content },
);
}
/**
* Send a message into a room
* @param roomId ID of the room to send the message into
* @param content the event content to send
* @param threadId optional thread id
*/
public async sendMessage(
roomId: string,
content: IContent | string,
threadId: string | null = null,
): Promise<ISendEventResponse> {
if (typeof content === "string") {
content = {
msgtype: "m.text",
body: content,
};
}
const client = await this.prepareClient();
return client.evaluate(
(client, { roomId, content, threadId }) => {
return client.sendMessage(roomId, threadId, content as RoomMessageEventContent);
},
{
roomId,
content,
threadId,
},
);
}
public async redactEvent(roomId: string, eventId: string, reason?: string): Promise<ISendEventResponse> {
return this.evaluate(
async (client, { roomId, eventId, reason }) => {
return client.redactEvent(roomId, eventId, reason);
},
{ roomId, eventId, reason },
);
}
/**
* Send a reaction to to a message
* @param roomId ID of the room to send the reaction into
* @param threadId ID of the thread to send into or null for main timeline
* @param eventId Event ID of the message you are reacting to
* @param reaction The reaction text to send
* @returns
*/
public async reactToMessage(
roomId: string,
threadId: string | null,
eventId: string,
reaction: string,
): Promise<ISendEventResponse> {
return this.sendEvent(roomId, threadId ?? null, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: eventId,
key: reaction,
},
});
}
/**
* Create a room with given options.
* @param options the options to apply when creating the room
* @return the ID of the newly created room
*/
public async createRoom(options: ICreateRoomOpts): Promise<string> {
const client = await this.prepareClient();
const roomId = await client.evaluate(async (cli, options) => {
const { room_id: roomId } = await cli.createRoom(options);
return roomId;
}, options);
await this.awaitRoomMembership(roomId);
return roomId;
}
/**
* Create a space with given options.
* @param options the options to apply when creating the space
* @return the ID of the newly created space (room)
*/
public async createSpace(options: ICreateRoomOpts): Promise<string> {
return this.createRoom({
...options,
creation_content: {
...options.creation_content,
type: "m.space",
},
});
}
/**
* Joins the given room by alias or ID
* @param roomIdOrAlias the id or alias of the room to join
*/
public async joinRoom(roomIdOrAlias: string): Promise<void> {
const client = await this.prepareClient();
await client.evaluate(async (client, roomIdOrAlias) => {
return await client.joinRoom(roomIdOrAlias);
}, roomIdOrAlias);
}
/**
* Make this bot join a room by name
* @param roomName Name of the room to join
*/
public async joinRoomByName(roomName: string): Promise<string> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { roomName }) => {
const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName);
if (room) {
await client.joinRoom(room.roomId);
return room.roomId;
}
throw new Error(`Bot room join failed. Cannot find room '${roomName}'`);
},
{
roomName,
},
);
}
/**
* Wait until next sync from this client
*/
public async waitForNextSync(): Promise<void> {
await this.page.waitForResponse(async (response) => {
const accessToken = await this.evaluate((client) => client.getAccessToken());
const authHeader = await response.request().headerValue("authorization");
return response.url().includes("/sync") && authHeader.includes(accessToken);
});
}
/**
* Invites the given user to the given room.
* @param roomId the id of the room to invite to
* @param userId the id of the user to invite
*/
public async inviteUser(roomId: string, userId: string): Promise<void> {
const client = await this.prepareClient();
await client.evaluate((client, { roomId, userId }) => client.invite(roomId, userId), {
roomId,
userId,
});
}
/**
* Knocks the given room.
* @param roomId the id of the room to knock
* @param opts the options to use when knocking
*/
public async knockRoom(roomId: string, opts?: KnockRoomOpts): Promise<void> {
const client = await this.prepareClient();
await client.evaluate((client, { roomId, opts }) => client.knockRoom(roomId, opts), { roomId, opts });
}
/**
* Kicks the given user from the given room.
* @param roomId the id of the room to kick from
* @param userId the id of the user to kick
* @param reason the reason for the kick
*/
public async kick(roomId: string, userId: string, reason?: string): Promise<void> {
const client = await this.prepareClient();
await client.evaluate((client, { roomId, userId, reason }) => client.kick(roomId, userId, reason), {
roomId,
userId,
reason,
});
}
/**
* Bans the given user from the given room.
* @param roomId the id of the room to ban from
* @param userId the id of the user to ban
* @param reason the reason for the ban
*/
public async ban(roomId: string, userId: string, reason?: string): Promise<void> {
const client = await this.prepareClient();
await client.evaluate((client, { roomId, userId, reason }) => client.ban(roomId, userId, reason), {
roomId,
userId,
reason,
});
}
/**
* Unban the given user from the given room.
* @param roomId the id of the room to unban from
* @param userId the id of the user to unban
*/
public async unban(roomId: string, userId: string): Promise<void> {
const client = await this.prepareClient();
await client.evaluate((client, { roomId, userId }) => client.unban(roomId, userId), { roomId, userId });
}
/**
* Wait for the client to have specific membership of a given room
*
* This is often useful after joining a room, when we need to wait for the sync loop to catch up.
*
* Times out with an error after 1 second.
*
* @param roomId - ID of the room to check
* @param membership - required membership.
*/
public async awaitRoomMembership(roomId: string, membership: string = "join") {
await this.evaluate(
(cli: MatrixClient, { roomId, membership }) => {
const isReady = () => {
// Fetch the room on each check, because we get a different instance before and after the join arrives.
const room = cli.getRoom(roomId);
const myMembership = room?.getMyMembership();
// @ts-ignore access to private field "logger"
cli.logger.info(`waiting for room ${roomId}: membership now ${myMembership}`);
return myMembership === membership;
};
if (isReady()) return;
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 1000)).then(() => {
const room = cli.getRoom(roomId);
const myMembership = room?.getMyMembership();
throw new Error(
`Timeout waiting for room ${roomId} membership (now '${myMembership}', wanted '${membership}')`,
);
});
const readyPromise = new Promise<void>((resolve) => {
async function onEvent() {
if (isReady()) {
cli.removeListener(window.matrixcs.ClientEvent.Event, onEvent);
resolve();
}
}
cli.on(window.matrixcs.ClientEvent.Event, onEvent);
});
return Promise.race([timeoutPromise, readyPromise]);
},
{ roomId, membership },
);
}
/**
* @param {MatrixEvent} event
* @param {ReceiptType} receiptType
* @param {boolean} unthreaded
*/
public async sendReadReceipt(
event: JSHandle<MatrixEvent>,
receiptType?: ReceiptType,
unthreaded?: boolean,
): Promise<EmptyObject> {
const client = await this.prepareClient();
return client.evaluate(
(client, { event, receiptType, unthreaded }) => {
return client.sendReadReceipt(event, receiptType, unthreaded);
},
{ event, receiptType, unthreaded },
);
}
public async publicRooms(options?: IRoomDirectoryOptions): ReturnType<MatrixClient["publicRooms"]> {
const client = await this.prepareClient();
return client.evaluate((client, options) => {
return client.publicRooms(options);
}, options);
}
/**
* @param {string} name
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async setDisplayName(name: string): Promise<EmptyObject> {
const client = await this.prepareClient();
return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name);
}
/**
* @param {string} url
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: {} an empty object.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async setAvatarUrl(url: string): Promise<EmptyObject> {
const client = await this.prepareClient();
return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url);
}
/**
* Upload a file to the media repository on the homeserver.
*
* @param {object} file The object to upload. On a browser, something that
* can be sent to XMLHttpRequest.send (typically a File). Under node.js,
* a Buffer, String or ReadStream.
*/
public async uploadContent(file: Buffer, opts?: UploadOpts): Promise<Awaited<Upload["promise"]>> {
const client = await this.prepareClient();
return client.evaluate(
async (cli: MatrixClient, { file, opts }) => cli.uploadContent(new Uint8Array(file), opts),
{
file: [...file],
opts,
},
);
}
/**
* Bootstraps cross-signing.
*/
public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise<void> {
const client = await this.prepareClient();
return bootstrapCrossSigningForClient(client, credentials);
}
/**
* Sets account data for the user.
* @param type The type of account data to set
* @param content The content to set
*/
public async setAccountData<T extends keyof AccountDataEvents>(
type: T,
content: AccountDataEvents[T],
): Promise<void> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { type, content }) => {
await client.setAccountData(type as T, content as AccountDataEvents[T]);
},
{ type, content },
);
}
/**
* Sends a state event into the room.
* @param roomId ID of the room to send the event into
* @param eventType type of event to send
* @param content the event content to send
* @param stateKey the state key to use
*/
public async sendStateEvent(
roomId: string,
eventType: string,
content: IContent,
stateKey?: string,
): Promise<ISendEventResponse> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { roomId, eventType, content, stateKey }) => {
return client.sendStateEvent(roomId, eventType as keyof StateEvents, content, stateKey);
},
{ roomId, eventType, content, stateKey },
);
}
/**
* Set a power level to one or multiple users.
* Will apply changes atop of current power level event.
* @param roomId - the room to update power levels in
* @param userId - the ID of the user or users to update power levels of
* @param powerLevel - the numeric power level to update given users to
*/
public async setPowerLevel(
roomId: string,
userId: string | string[],
powerLevel: number,
): Promise<ISendEventResponse> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { roomId, userId, powerLevel }) => {
return client.setPowerLevel(roomId, userId, powerLevel);
},
{ roomId, userId, powerLevel },
);
}
/**
* Leaves the given room.
* @param roomId ID of the room to leave
*/
public async leave(roomId: string): Promise<void> {
const client = await this.prepareClient();
return client.evaluate(async (client, roomId) => {
await client.leave(roomId);
}, roomId);
}
/**
* Sets the directory visibility for a room.
* @param roomId ID of the room to set the directory visibility for
* @param visibility The new visibility for the room
*/
public async setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<void> {
const client = await this.prepareClient();
return client.evaluate(
async (client, { roomId, visibility }) => {
await client.setRoomDirectoryVisibility(roomId, visibility);
},
{ roomId, visibility },
);
}
}
/** Call `CryptoApi.bootstrapCrossSigning` on the given Matrix client, using the given credentials to authenticate
* the UIA request.
*/
export function bootstrapCrossSigningForClient(
client: JSHandle<MatrixClient>,
credentials: CredentialsOptionalAccessToken,
resetKeys: boolean = false,
) {
return client.evaluate(
async (client, { credentials, resetKeys }) => {
await client.getCrypto().bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
await func({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: credentials.userId,
},
password: credentials.password,
});
},
setupNewCrossSigning: resetKeys,
});
},
{ credentials, resetKeys },
);
}
+49
View File
@@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 APIRequestContext, type Page, expect } from "@playwright/test";
import { type HomeserverInstance } from "../plugins/homeserver";
export class Crypto {
public constructor(
private page: Page,
private homeserver: HomeserverInstance,
private request: APIRequestContext,
) {}
/**
* Check that the user has published cross-signing keys, and that the user's device has been cross-signed.
*/
public async assertDeviceIsCrossSigned(): Promise<void> {
const { userId, deviceId, accessToken } = await this.page.evaluate(() => ({
userId: window.mxMatrixClientPeg.get().getUserId(),
deviceId: window.mxMatrixClientPeg.get().getDeviceId(),
accessToken: window.mxMatrixClientPeg.get().getAccessToken(),
}));
const res = await this.request.post(`${this.homeserver.baseUrl}/_matrix/client/v3/keys/query`, {
headers: { Authorization: `Bearer ${accessToken}` },
data: { device_keys: { [userId]: [] } },
});
const json = await res.json();
// there should be three cross-signing keys
expect(json.master_keys[userId]).toHaveProperty("keys");
expect(json.self_signing_keys[userId]).toHaveProperty("keys");
expect(json.user_signing_keys[userId]).toHaveProperty("keys");
// and the device should be signed by the self-signing key
const selfSigningKeyId = Object.keys(json.self_signing_keys[userId].keys)[0];
expect(json.device_keys[userId][deviceId]).toBeDefined();
const myDeviceSignatures = json.device_keys[userId][deviceId].signatures[userId];
expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined();
}
}
+79
View File
@@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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 { Page, Request, Route, Disposable } from "@playwright/test";
import type { Client } from "./client";
/**
* Utility class to simulate offline mode by blocking all requests to the homeserver.
* Will not affect any requests before `setupRoute` is called,
* which happens implicitly using the goOffline/goOnline methods.
*/
export class Network {
private isOffline = false;
private setupPromise?: Promise<Disposable>;
constructor(
private page: Page,
private client: Client,
) {}
/**
* Checks if the request is from the client associated with this network object.
* We do this so that other clients (eg: bots) are not affected by the network change.
*/
private async isRequestFromOurClient(request: Request): Promise<boolean> {
const accessToken = await this.client.evaluate((client) => client.getAccessToken());
const authHeader = await request.headerValue("Authorization");
return authHeader === `Bearer ${accessToken}`;
}
private handler = async (route: Route) => {
if (this.isOffline && (await this.isRequestFromOurClient(route.request()))) {
await route.abort();
} else {
await route.continue();
}
};
/**
* Intercept all /_matrix/ networking requests for client ready to continue/abort them based on offline status
* which is set by the goOffline/goOnline methods
*/
public async setupRoute() {
if (!this.setupPromise) {
this.setupPromise = this.page.route("**/_matrix/**", this.handler);
}
await this.setupPromise;
}
/**
* Cease intercepting all /_matrix/ networking requests for client
*/
public async destroyRoute() {
if (!this.setupPromise) return;
await this.page.unroute("**/_matrix/**", this.handler);
this.setupPromise = undefined;
}
/**
* Reject all /_matrix/ networking requests for client
*/
async goOffline(): Promise<void> {
await this.setupRoute();
this.isOffline = true;
}
/**
* Continue all /_matrix/ networking requests for this client
*/
async goOnline(): Promise<void> {
await this.setupRoute();
this.isOffline = false;
}
}
+100
View File
@@ -0,0 +1,100 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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, type Page } from "@playwright/test";
import type { SettingLevel } from "../../src/settings/SettingLevel";
export class Settings {
public constructor(private readonly page: Page) {}
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
*/
public async openUserMenu(): Promise<Locator> {
const locator = this.page.getByRole("menu", { name: "User menu" });
if (await locator.isVisible()) return locator;
await this.page.getByRole("button", { name: "User menu" }).click();
await locator.waitFor();
return locator;
}
/**
* Close dialog currently open dialog
*/
public async closeDialog(): Promise<void> {
return this.page.getByRole("button", { name: "Close dialog", exact: true }).click();
}
/**
* Sets the value for a setting. The room ID is optional if the
* setting is not being set for a particular room, otherwise it
* should be supplied. The value may be null to indicate that the
* level should no longer have an override.
* @param {string} settingName The name of the setting to change.
* @param {String} roomId The room ID to change the value in, may be
* null.
* @param {SettingLevel} level The level to change the value at.
* @param {*} value The new value of the setting, may be null.
* @return {Promise} Resolves when the setting has been changed.
*/
public async setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void> {
return this.page.evaluate<
Promise<void>,
{
settingName: string;
roomId: string | null;
level: SettingLevel;
value: any;
}
>(
({ settingName, roomId, level, value }) => {
return window.mxSettingsStore.setValue(settingName, roomId, level, value);
},
{ settingName, roomId, level, value },
);
}
/**
* Switch settings tab to the one by the given name
* @param tab the name of the tab to switch to.
*/
public async switchTab(tab: string): Promise<void> {
await this.page
.locator(".mx_TabbedView_tabLabels")
.locator(".mx_TabbedView_tabLabel", { hasText: tab })
.click();
}
/**
* Open user settings (via user menu), returns a locator to the dialog
* @param tab the name of the tab to switch to after opening, optional.
*/
public async openUserSettings(tab?: string): Promise<Locator> {
const locator = await this.openUserMenu();
await locator.getByRole("menuitem", { name: "All settings", exact: true }).click();
if (tab) await this.switchTab(tab);
return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_UserSettingsDialog") });
}
/**
* Open room settings (via room info panel), returns a locator to the dialog
* @param tab the name of the tab to switch to after opening, optional.
*/
public async openRoomSettings(tab?: string): Promise<Locator> {
// Open right panel if not open
const rightPanel = this.page.locator(".mx_RightPanel");
if ((await rightPanel.count()) === 0) {
await this.page.getByRole("button", { name: "Room info" }).first().click();
}
await rightPanel.getByRole("menuitem", { name: "Settings" }).click();
if (tab) await this.switchTab(tab);
return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") });
}
}
+44
View File
@@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 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";
export class Timeline {
constructor(private page: Page) {}
// Scroll to the top of the timeline
async scrollToTop(): Promise<void> {
const locator = this.page.locator(".mx_RoomView_timeline .mx_ScrollPanel");
await locator.evaluate((node) => {
while (node.scrollTop > 0) {
node.scrollTo(0, 0);
}
});
}
public async scrollToBottom(): Promise<void> {
await this.page
.locator(".mx_ScrollPanel")
.evaluate((scrollPanel) => scrollPanel.scrollTo(0, scrollPanel.scrollHeight));
}
// Find the event tile matching the given sender & body
async findEventTile(sender: string, body: string): Promise<Locator> {
const locators = await this.page.locator(".mx_RoomView_MessageList .mx_EventTile").all();
let latestSender: string;
for (const locator of locators) {
const displayName = locator.locator(".mx_DisambiguatedProfile_displayName");
if (await displayName.count()) {
latestSender = await displayName.innerText();
}
if (latestSender === sender && (await locator.locator(".mx_EventTile_body").innerText()) === body) {
return locator;
}
}
}
}