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