diff --git a/cypress/e2e/user-view/user-view.spec.ts b/cypress/e2e/user-view/user-view.spec.ts
deleted file mode 100644
index 12097ec786..0000000000
--- a/cypress/e2e/user-view/user-view.spec.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-///
-
-import { HomeserverInstance } from "../../plugins/utils/homeserver";
-import { MatrixClient } from "../../global";
-
-describe("UserView", () => {
- let homeserver: HomeserverInstance;
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
-
- cy.initTestUser(homeserver, "Violet");
- cy.getBot(homeserver, { displayName: "Usman" }).as("bot");
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it("should render the user view as expected", () => {
- cy.get("@bot").then((bot) => {
- cy.visit(`/#/user/${bot.getUserId()}`);
- });
-
- cy.get(".mx_RightPanel .mx_UserInfo_profile h2").within(() => {
- cy.findByText("Usman").should("exist");
- });
-
- cy.findByText("1 session").should("be.visible");
-
- cy.get(".mx_RightPanel").percySnapshotElement("User View", {
- // Hide the MXID field as it'll vary on each test
- percyCSS: ".mx_UserInfo_profile_mxid { visibility: hidden !important; }",
- widths: [260, 500],
- });
- });
-});
diff --git a/playwright/.gitignore b/playwright/.gitignore
index 2719122491..1d4efea520 100644
--- a/playwright/.gitignore
+++ b/playwright/.gitignore
@@ -2,5 +2,5 @@
/html-report/
/synapselogs/
# Only commit snapshots from Linux
-/snapshots/*/*.png
-!/snapshots/*/*-linux.png
+/snapshots/**/*.png
+!/snapshots/**/*-linux.png
diff --git a/playwright/e2e/user-view/user-view.spec.ts b/playwright/e2e/user-view/user-view.spec.ts
new file mode 100644
index 0000000000..a2191b8583
--- /dev/null
+++ b/playwright/e2e/user-view/user-view.spec.ts
@@ -0,0 +1,35 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { test, expect } from "../../element-web-test";
+
+test.describe("UserView", () => {
+ test.use({
+ displayName: "Violet",
+ botCreateOpts: { displayName: "Usman" },
+ });
+
+ test("should render the user view as expected", async ({ page, homeserver, user, bot }) => {
+ await page.goto(`/#/user/${bot.credentials.userId}`);
+
+ const rightPanel = page.getByRole("complementary");
+ await expect(rightPanel.getByRole("heading", { name: bot.credentials.displayName, exact: true })).toBeVisible();
+ await expect(rightPanel.getByText("1 session")).toBeVisible();
+ await expect(rightPanel).toHaveScreenshot("user-info.png", {
+ mask: [page.locator(".mx_BaseAvatar, .mx_UserInfo_profile_mxid")],
+ });
+ });
+});
diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts
index 8777b84b41..dcc4367b97 100644
--- a/playwright/element-web-test.ts
+++ b/playwright/element-web-test.ts
@@ -28,6 +28,7 @@ import { ElementAppPage } from "./pages/ElementAppPage";
import { OAuthServer } from "./plugins/oauth_server";
import { Crypto } from "./pages/crypto";
import { Toasts } from "./pages/toasts";
+import { Bot, CreateBotOpts } from "./pages/bot";
const CONFIG_JSON: Partial = {
// This is deliberately quite a minimal config.json, so that we can test that the default settings
@@ -67,6 +68,8 @@ export const test = base.extend<
room?: { roomId: string };
toasts: Toasts;
uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
+ botCreateOpts: CreateBotOpts;
+ bot: Bot;
}
>({
cryptoBackend: ["legacy", { option: true }],
@@ -173,6 +176,13 @@ export const test = base.extend<
toasts: async ({ page }, use) => {
await use(new Toasts(page));
},
+
+ botCreateOpts: {},
+ bot: async ({ page, homeserver, botCreateOpts }, use) => {
+ const bot = new Bot(page, homeserver, botCreateOpts);
+ await bot.start();
+ await use(bot);
+ },
});
test.use({});
diff --git a/playwright/global.d.ts b/playwright/global.d.ts
index 8b4a280153..87fdebcc5f 100644
--- a/playwright/global.d.ts
+++ b/playwright/global.d.ts
@@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { type MatrixClient } from "matrix-js-sdk/src/matrix";
+import {
+ ICreateClientOpts,
+ type MatrixClient,
+ MatrixScheduler,
+ MemoryCryptoStore,
+ MemoryStore,
+} from "matrix-js-sdk/src/matrix";
import { type SettingLevel } from "../src/settings/SettingLevel";
@@ -26,5 +32,13 @@ declare global {
mxSettingsStore: {
setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise;
};
+ // Partial type for the matrix-js-sdk module, exported by browser-matrix
+ matrixcs: {
+ MatrixClient: typeof MatrixClient;
+ MatrixScheduler: typeof MatrixScheduler;
+ MemoryStore: typeof MemoryStore;
+ MemoryCryptoStore: typeof MemoryCryptoStore;
+ createClient(opts: ICreateClientOpts | string);
+ };
}
}
diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts
new file mode 100644
index 0000000000..298c744e80
--- /dev/null
+++ b/playwright/pages/bot.ts
@@ -0,0 +1,220 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { JSHandle, Page } from "@playwright/test";
+import { uniqueId } from "lodash";
+
+import type { MatrixClient, ISendEventResponse } from "matrix-js-sdk/src/matrix";
+import type { AddSecretStorageKeyOpts } from "matrix-js-sdk/src/secret-storage";
+import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
+
+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 use the rust crypto impl. Defaults to false (for now!)
+ */
+ rustCrypto?: boolean;
+ /**
+ * Whether to bootstrap the secret storage
+ */
+ bootstrapSecretStorage?: boolean;
+}
+
+const defaultCreateBotOptions = {
+ userIdPrefix: "bot_",
+ autoAcceptInvites: true,
+ startClient: true,
+ bootstrapCrossSigning: true,
+} satisfies CreateBotOpts;
+
+export class Bot {
+ private client: JSHandle;
+ public credentials?: Credentials;
+
+ constructor(private page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) {
+ this.opts = Object.assign({}, defaultCreateBotOptions, opts);
+ }
+
+ public async start(): Promise {
+ this.credentials = await this.getCredentials();
+ this.client = await this.setupBotClient();
+ }
+
+ private async getCredentials(): Promise {
+ const username = uniqueId(this.opts.userIdPrefix);
+ const password = uniqueId("password_");
+ console.log(`getBot: Create bot user ${username} with opts ${JSON.stringify(this.opts)}`);
+ return await this.homeserver.registerUser(username, password, this.opts.displayName);
+ }
+
+ private async setupBotClient(): Promise> {
+ return this.page.evaluateHandle(
+ async ({ homeserver, credentials, opts }) => {
+ const keys = {};
+
+ const getCrossSigningKey = (type: string) => {
+ return keys[type];
+ };
+
+ const saveCrossSigningKeys = (k: Record) => {
+ Object.assign(keys, k);
+ };
+
+ // Store the cached secret storage key and return it when `getSecretStorageKey` is called
+ let cachedKey: { keyId: string; key: Uint8Array };
+ const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
+ cachedKey = {
+ keyId,
+ key,
+ };
+ };
+
+ const getSecretStorageKey = () =>
+ Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]);
+
+ const cryptoCallbacks = {
+ getCrossSigningKey,
+ saveCrossSigningKeys,
+ cacheSecretStorageKey,
+ getSecretStorageKey,
+ };
+
+ const cli = new window.matrixcs.MatrixClient({
+ baseUrl: homeserver.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,
+ });
+
+ if (opts.autoAcceptInvites) {
+ cli.on((window as any).matrixcs.RoomMemberEvent.Membership, (event, member) => {
+ if (member.membership === "invite" && member.userId === cli.getUserId()) {
+ cli.joinRoom(member.roomId);
+ }
+ });
+ }
+
+ if (!opts.startClient) {
+ return cli;
+ }
+
+ if (opts.rustCrypto) {
+ await cli.initRustCrypto({ useIndexedDB: false });
+ } else {
+ await cli.initCrypto();
+ }
+ cli.setGlobalErrorOnUnknownDevices(false);
+ await cli.startClient();
+
+ if (opts.bootstrapCrossSigning) {
+ await cli.getCrypto()!.bootstrapCrossSigning({
+ authUploadDeviceSigningKeys: async (func) => {
+ await func({
+ type: "m.login.password",
+ identifier: {
+ type: "m.id.user",
+ user: credentials.userId,
+ },
+ password: credentials.password,
+ });
+ },
+ });
+ }
+
+ if (opts.bootstrapSecretStorage) {
+ const passphrase = "new passphrase";
+ 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),
+ });
+ }
+
+ return cli;
+ },
+ {
+ homeserver: this.homeserver.config,
+ credentials: this.credentials,
+ opts: this.opts,
+ },
+ );
+ }
+
+ /**
+ * Make this bot join a room by name
+ * @param roomName Name of the room to join
+ */
+ public async joinRoomByName(roomName: string): Promise {
+ await this.client.evaluate(
+ (client, { roomName }) => {
+ const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName);
+ if (room) {
+ return client.joinRoom(room.roomId);
+ }
+ throw new Error(`Bot room join failed. Cannot find room '${roomName}'`);
+ },
+ {
+ roomName,
+ },
+ );
+ }
+
+ /**
+ * Send a message as a bot into a room
+ * @param roomId ID of the room to join
+ * @param message the message body to send
+ */
+ public async sendStringMessage(roomId: string, message: string): Promise {
+ return this.client.evaluate(
+ (client, { roomId, message }) => {
+ return client.sendMessage(roomId, {
+ msgtype: "m.text",
+ body: message,
+ });
+ },
+ {
+ roomId,
+ message,
+ },
+ );
+ }
+}
diff --git a/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png
new file mode 100644
index 0000000000..75b64546d6
Binary files /dev/null and b/playwright/snapshots/user-view/user-view.spec.ts/UserView-should-render-the-user-view-as-expected-1-linux.png differ
diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png
new file mode 100644
index 0000000000..f1360f8703
Binary files /dev/null and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ