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