diff --git a/.eslintrc.js b/.eslintrc.js index 28d26696cb..f310384972 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org"], + plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,6 +170,8 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", }, overrides: [ { @@ -262,6 +264,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", + "react-compiler/react-compiler": "off", }, }, { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 695a94254e..b31ec5e3bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,13 +3,17 @@ /package.json @element-hq/element-web-team /yarn.lock @element-hq/element-web-team -/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers -/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers -/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers -/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers +/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers +/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers +/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers +/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers +/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers +/src/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers +/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers +/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers # Ignore translations as those will be updated by GHA for Localazy download /src/i18n/strings diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b3603549d..14fbd22086 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,3 +96,4 @@ jobs: projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} directory: _deploy gitHubToken: ${{ secrets.GITHUB_TOKEN }} + branch: main diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 24c5dc39b3..276420e1f8 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -114,6 +114,8 @@ jobs: - Chrome - Firefox - WebKit + - Dendrite + - Pinecone runAllTests: - ${{ github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'X-Run-All-Tests') }} # Skip the Firefox & Safari runs unless this was a cron trigger or PR has X-Run-All-Tests label @@ -122,6 +124,10 @@ jobs: project: Firefox - runAllTests: false project: WebKit + - runAllTests: false + project: Dendrite + - runAllTests: false + project: Pinecone steps: - uses: actions/checkout@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e000f494..5411b67428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +Changes in [1.11.90](https://github.com/element-hq/element-web/releases/tag/v1.11.90) (2025-01-14) +================================================================================================== +## ✨ Features + +* Docker: run as non-root ([#28849](https://github.com/element-hq/element-web/pull/28849)). Contributed by @richvdh. +* Docker: allow configuration of HTTP listen port via env var ([#28840](https://github.com/element-hq/element-web/pull/28840)). Contributed by @richvdh. +* Update matrix-wysiwyg to consume WASM asset ([#28838](https://github.com/element-hq/element-web/pull/28838)). Contributed by @t3chguy. +* OIDC settings tweaks ([#28787](https://github.com/element-hq/element-web/pull/28787)). Contributed by @t3chguy. +* Delabs native OIDC support ([#28615](https://github.com/element-hq/element-web/pull/28615)). Contributed by @t3chguy. +* Move room header info button to right-most position ([#28754](https://github.com/element-hq/element-web/pull/28754)). Contributed by @t3chguy. +* Enable key backup by default ([#28691](https://github.com/element-hq/element-web/pull/28691)). Contributed by @dbkr. + +## 🐛 Bug Fixes + +* Fix building the automations mermaid diagram ([#28881](https://github.com/element-hq/element-web/pull/28881)). Contributed by @dbkr. +* Playwright: wait for the network listener on the postgres db ([#28808](https://github.com/element-hq/element-web/pull/28808)). Contributed by @dbkr. + + Changes in [1.11.89](https://github.com/element-hq/element-web/releases/tag/v1.11.89) (2024-12-18) ================================================================================================== This is a patch release to fix a bug which could prevent loading stored crypto state from storage, and also to fix URL previews when switching back to a room. diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index cac71db330..475648e774 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,6 +17,7 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); + remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/docs/playwright.md b/docs/playwright.md index 2c26b7ab2b..e03d1f5f8d 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -66,11 +66,11 @@ as is typical for Playwright tests. Likewise, tests live in `playwright/e2e`. of Synapse/Dendrite. These servers are what Element-web runs against in the tests. Synapse can be launched with different configurations in order to test element -in different configurations. You can specify `synapseConfigOptions` as such: +in different configurations. You can specify `synapseConfig` as such: ```typescript test.use({ - synapseConfigOptions: { + synapseConfig: { // The config options to pass to the Synapse instance }, }); diff --git a/package.json b/package.json index 2b65fa0834..2fb13e438c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.89", + "version": "1.11.90", "description": "A feature-rich client for Matrix.org", "author": "New Vector Ltd.", "repository": { @@ -89,6 +89,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", "@types/png-chunks-extract": "^1.0.2", + "@types/react-virtualized": "^9.21.30", "@vector-im/compound-design-tokens": "^2.1.0", "@vector-im/compound-web": "^7.5.0", "@vector-im/matrix-wysiwyg": "2.38.0", @@ -143,6 +144,7 @@ "react-dom": "^18.3.1", "react-focus-lock": "^2.5.1", "react-transition-group": "^4.4.1", + "react-virtualized": "^9.22.5", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.14.0", @@ -150,9 +152,7 @@ "temporal-polyfill": "^0.2.5", "ua-parser-js": "^1.0.2", "uuid": "^11.0.0", - "what-input": "^5.2.10", - "@types/react-virtualized": "^9.21.30", - "react-virtualized": "^9.22.5" + "what-input": "^5.2.10" }, "devDependencies": { "@action-validator/cli": "^0.6.0", @@ -237,6 +237,7 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^2.0.2", "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^4.18.2", @@ -293,6 +294,7 @@ "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^6.0.0", "webpack-dev-server": "^5.0.0", + "webpack-retry-chunk-load-plugin": "^3.1.1", "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, diff --git a/playwright.config.ts b/playwright.config.ts index d317c55a6d..09bd07bb3b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,19 +8,25 @@ Please see LICENSE files in the repository root for full details. import { defineConfig, devices } from "@playwright/test"; +import { Options } from "./playwright/services"; + const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080"; -export default defineConfig({ +const chromeProject = { + ...devices["Desktop Chrome"], + channel: "chromium", + permissions: ["clipboard-write", "clipboard-read", "microphone"], + launchOptions: { + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"], + }, +}; + +export default defineConfig({ projects: [ { name: "Chrome", use: { - ...devices["Desktop Chrome"], - channel: "chromium", - permissions: ["clipboard-write", "clipboard-read", "microphone"], - launchOptions: { - args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"], - }, + ...chromeProject, }, }, { @@ -48,6 +54,22 @@ export default defineConfig({ }, ignoreSnapshots: true, }, + { + name: "Dendrite", + use: { + ...chromeProject, + homeserverType: "dendrite", + }, + ignoreSnapshots: true, + }, + { + name: "Pinecone", + use: { + ...chromeProject, + homeserverType: "pinecone", + }, + ignoreSnapshots: true, + }, ], use: { viewport: { width: 1280, height: 720 }, diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index a6d920dcb8..bb766d6b88 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -13,6 +13,14 @@ import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { ElementAppPage } from "../../pages/ElementAppPage"; +// Find and click "Reply" button +const clickButtonReply = async (tile: Locator) => { + await expect(async () => { + await tile.hover(); + await tile.getByRole("button", { name: "Reply", exact: true }).click(); + }).toPass(); +}; + test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { test.use({ displayName: "Hanako", @@ -222,8 +230,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Find and click "Reply" button on MessageActionBar const tile = page.locator(".mx_EventTile_last"); - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/1sec.ogg"); @@ -251,18 +258,12 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const tile = page.locator(".mx_EventTile_last"); - // Find and click "Reply" button - const clickButtonReply = async () => { - await tile.hover(); - await tile.getByRole("button", { name: "Reply", exact: true }).click(); - }; - await uploadFile(page, "playwright/sample-files/upload-first.ogg"); // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with another audio file await uploadFile(page, "playwright/sample-files/upload-second.ogg"); @@ -270,7 +271,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that the audio player is rendered await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible(); - await clickButtonReply(); + await clickButtonReply(tile); // Reply to the player with yet another audio file to create a reply chain await uploadFile(page, "playwright/sample-files/upload-third.ogg"); diff --git a/playwright/e2e/create-room/create-room.spec.ts b/playwright/e2e/create-room/create-room.spec.ts index 4a4fee1620..087a89e68d 100644 --- a/playwright/e2e/create-room/create-room.spec.ts +++ b/playwright/e2e/create-room/create-room.spec.ts @@ -27,7 +27,7 @@ test.describe("Create Room", () => { // Submit await dialog.getByRole("button", { name: "Create room" }).click(); - await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/); + await expect(page).toHaveURL(new RegExp(`/#/room/#test-room-1:${user.homeServer}`)); const header = page.locator(".mx_RoomHeader"); await expect(header).toContainText(name); }); diff --git a/playwright/e2e/crypto/backups.spec.ts b/playwright/e2e/crypto/backups.spec.ts index a49495edfa..95bf708122 100644 --- a/playwright/e2e/crypto/backups.spec.ts +++ b/playwright/e2e/crypto/backups.spec.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function expectBackupVersionToBe(page: Page, version: string) { await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText( @@ -19,6 +20,7 @@ async function expectBackupVersionToBe(page: Page, version: string) { } test.describe("Backups", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Hanako", }); diff --git a/playwright/e2e/crypto/complete-security.spec.ts b/playwright/e2e/crypto/complete-security.spec.ts index da6974459c..d4c303fae4 100644 --- a/playwright/e2e/crypto/complete-security.spec.ts +++ b/playwright/e2e/crypto/complete-security.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { logIntoElement } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Complete security", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Jeff", }); diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 2b6844574e..f99a7a6458 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -11,6 +11,7 @@ import { expect, test } from "../../element-web-test"; import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; import { Bot } from "../../pages/bot"; import { ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const checkDMRoom = async (page: Page) => { const body = page.locator(".mx_RoomView_body"); @@ -67,6 +68,7 @@ const bobJoin = async (page: Page, bob: Bot) => { }; test.describe("Cryptography", function () { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.use({ displayName: "Alice", botCreateOpts: { diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index e1952bfec6..529251b223 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -28,6 +28,8 @@ test.describe("Cryptography", function () { }); test.describe("decryption failure messages", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test("should handle device-relative historical messages", async ({ homeserver, page, diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index df75ff5f77..4091b1e676 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -15,6 +15,7 @@ import { awaitVerifier, checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, + createBot, doTwoWaySasVerification, logIntoElement, waitForVerificationRequest, @@ -28,29 +29,9 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { let expectedBackupVersion: string; test.beforeEach(async ({ page, homeserver, credentials }) => { - // Visit the login page of the app, to load the matrix sdk - await page.goto("/#/login"); - - // wait for the page to load - await page.waitForSelector(".mx_AuthPage", { timeout: 30000 }); - - // Create a new device for alice - aliceBotClient = new Bot(page, homeserver, { - bootstrapCrossSigning: true, - bootstrapSecretStorage: true, - }); - aliceBotClient.setCredentials(credentials); - - // Backup is prepared in the background. Poll until it is ready. - const botClientHandle = await aliceBotClient.prepareClient(); - await expect - .poll(async () => { - expectedBackupVersion = await botClientHandle.evaluate((cli) => - cli.getCrypto()!.getActiveSessionBackupVersion(), - ); - return expectedBackupVersion; - }) - .not.toBe(null); + const res = await createBot(page, homeserver, credentials); + aliceBotClient = res.botClient; + expectedBackupVersion = res.expectedBackupVersion; }); // Click the "Verify with another device" button, and have the bot client auto-accept it. @@ -219,9 +200,10 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { /* And we're all done! */ const infoDialog = page.locator(".mx_InfoDialog"); await infoDialog.getByRole("button", { name: "They match" }).click(); - await expect( - infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`), - ).toBeVisible(); + // We don't assert the full string as the device name is unset on Synapse but set to the user ID on Dendrite + await expect(infoDialog.getByText(`You've successfully verified`)).toContainText( + `(${aliceBotClient.credentials.deviceId})`, + ); await infoDialog.getByRole("button", { name: "Got it" }).click(); }); }); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index 3811c2819e..c0f1e280a2 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -66,6 +66,9 @@ test.describe("Cryptography", function () { // Bob has a second, not cross-signed, device const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + // Dismiss the toast nagging us to set up recovery otherwise it gets in the way of clicking the room list + await page.getByRole("button", { name: "Not now" }).click(); + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "the bird is in the hand", diff --git a/playwright/e2e/crypto/logout.spec.ts b/playwright/e2e/crypto/logout.spec.ts index 2bafe0ece8..faaf1e6a1e 100644 --- a/playwright/e2e/crypto/logout.spec.ts +++ b/playwright/e2e/crypto/logout.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { createRoom, enableKeyBackup, logIntoElement, sendMessageInCurrentRoom } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Logout tests", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); test.beforeEach(async ({ page, homeserver, credentials }) => { await logIntoElement(page, credentials); }); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 697572faa7..7474c5a435 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -12,6 +12,7 @@ import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import type { CryptoEvent, EmojiMapping, + GeneratedSecretStorageKey, ShowSasCallbacks, VerificationRequest, Verifier, @@ -22,6 +23,46 @@ import { Client } from "../../pages/client"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; +/** + * Create a bot client using the supplied credentials, and wait for the key backup to be ready. + * @param page - the playwright `page` fixture + * @param homeserver - the homeserver to use + * @param credentials - the credentials to use for the bot client + */ +export async function createBot( + page: Page, + homeserver: HomeserverInstance, + credentials: Credentials, +): Promise<{ botClient: Bot; recoveryKey: GeneratedSecretStorageKey; expectedBackupVersion: string }> { + // Visit the login page of the app, to load the matrix sdk + await page.goto("/#/login"); + + // wait for the page to load + await page.waitForSelector(".mx_AuthPage", { timeout: 30000 }); + + // Create a new bot client + const botClient = new Bot(page, homeserver, { + bootstrapCrossSigning: true, + bootstrapSecretStorage: true, + }); + botClient.setCredentials(credentials); + // Backup is prepared in the background. Poll until it is ready. + const botClientHandle = await botClient.prepareClient(); + let expectedBackupVersion: string; + await expect + .poll(async () => { + expectedBackupVersion = await botClientHandle.evaluate((cli) => + cli.getCrypto()!.getActiveSessionBackupVersion(), + ); + return expectedBackupVersion; + }) + .not.toBe(null); + + const recoveryKey = await botClient.getRecoveryKey(); + + return { botClient, recoveryKey, expectedBackupVersion }; +} + /** * wait for the given client to receive an incoming verification request, and automatically accept it * @@ -372,3 +413,25 @@ export async function createSecondBotDevice(page: Page, homeserver: HomeserverIn await bobSecondDevice.prepareClient(); return bobSecondDevice; } + +/** + * Remove the cached secrets from the indexedDB + * This is a workaround to simulate the case where the secrets are not cached. + */ +export async function deleteCachedSecrets(page: Page) { + await page.evaluate(async () => { + const removeCachedSecrets = new Promise((resolve) => { + const request = window.indexedDB.open("matrix-js-sdk::matrix-sdk-crypto"); + request.onsuccess = (event: Event & { target: { result: IDBDatabase } }) => { + const db = event.target.result; + const request = db.transaction("core", "readwrite").objectStore("core").delete("private_identity"); + request.onsuccess = () => { + db.close(); + resolve(undefined); + }; + }; + }); + await removeCachedSecrets; + }); + await page.reload(); +} diff --git a/playwright/e2e/csAPI.ts b/playwright/e2e/csAPI.ts index d55816fb6a..4153d09199 100644 --- a/playwright/e2e/csAPI.ts +++ b/playwright/e2e/csAPI.ts @@ -9,24 +9,24 @@ import { APIRequestContext } from "playwright-core"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { HomeserverInstance } from "../plugins/homeserver"; +import { ClientServerApi } from "../plugins/utils/api.ts"; /** * A small subset of the Client-Server API used to manipulate the state of the * account on the homeserver independently of the client under test. */ -export class TestClientServerAPI { +export class TestClientServerAPI extends ClientServerApi { public constructor( - private request: APIRequestContext, - private homeserver: HomeserverInstance, + request: APIRequestContext, + homeserver: HomeserverInstance, private accessToken: string, - ) {} + ) { + super(homeserver.baseUrl); + this.setRequest(request); + } public async getCurrentBackupInfo(): Promise { - const res = await this.request.get(`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version`, { - headers: { Authorization: `Bearer ${this.accessToken}` }, - }); - - return await res.json(); + return this.request("GET", `/v3/room_keys/version`, this.accessToken); } /** @@ -34,15 +34,6 @@ export class TestClientServerAPI { * @param version The version to delete */ public async deleteBackupVersion(version: string): Promise { - const res = await this.request.delete( - `${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version/${version}`, - { - headers: { Authorization: `Bearer ${this.accessToken}` }, - }, - ); - - if (!res.ok) { - throw new Error(`Failed to delete backup version: ${res.status}`); - } + await this.request("DELETE", `/v3/room_keys/version/${version}`, this.accessToken); } } diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 83ae6ba2d9..934c4aa42e 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -12,6 +12,7 @@ import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } fro import { expect, test } from "../../element-web-test"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function sendEvent(app: ElementAppPage, roomId: string): Promise { return app.client.sendEvent(roomId, null, "m.room.message" as EventType, { @@ -31,6 +32,8 @@ function mkPadding(n: number): IContent { } test.describe("Editing", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); + // Edit "Message" const editLastMessage = async (page: Page, edit: string) => { const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); diff --git a/playwright/e2e/knock/create-knock-room.spec.ts b/playwright/e2e/knock/create-knock-room.spec.ts index 1c729ff610..e21b30a3c2 100644 --- a/playwright/e2e/knock/create-knock-room.spec.ts +++ b/playwright/e2e/knock/create-knock-room.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; import { Filter } from "../../pages/Spotlight"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Create Knock Room", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], @@ -79,6 +81,7 @@ test.describe("Create Knock Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); }); }); diff --git a/playwright/e2e/knock/knock-into-room.spec.ts b/playwright/e2e/knock/knock-into-room.spec.ts index a87c33415b..be6619697d 100644 --- a/playwright/e2e/knock/knock-into-room.spec.ts +++ b/playwright/e2e/knock/knock-into-room.spec.ts @@ -13,8 +13,10 @@ import { type Visibility } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; import { Filter } from "../../pages/Spotlight"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Knock Into Room", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], @@ -282,6 +284,7 @@ test.describe("Knock Into Room", () => { const spotlightDialog = await app.openSpotlight(); await spotlightDialog.filter(Filter.PublicRooms); + await spotlightDialog.search("Cyber"); await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity"); await spotlightDialog.results.nth(0).click(); diff --git a/playwright/e2e/knock/manage-knocks.spec.ts b/playwright/e2e/knock/manage-knocks.spec.ts index 6d0340170e..3f4c9616ca 100644 --- a/playwright/e2e/knock/manage-knocks.spec.ts +++ b/playwright/e2e/knock/manage-knocks.spec.ts @@ -10,8 +10,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { waitForRoom } from "../utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Manage Knocks", () => { + test.skip(isDendrite, "Dendrite does not have support for knocking"); test.use({ displayName: "Alice", labsFlags: ["feature_ask_to_join"], diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index ace6fdb738..06fb0b3a71 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -10,8 +10,12 @@ import { Bot } from "../../pages/bot"; import type { Locator, Page } from "@playwright/test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; import { test, expect } from "../../element-web-test"; +import { Credentials } from "../../plugins/homeserver"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Lazy Loading", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); + const charlies: Bot[] = []; test.use({ @@ -35,12 +39,18 @@ test.describe("Lazy Loading", () => { }); const name = "Lazy Loading Test"; - const alias = "#lltest:localhost"; const charlyMsg1 = "hi bob!"; const charlyMsg2 = "how's it going??"; let roomId: string; - async function setupRoomWithBobAliceAndCharlies(page: Page, app: ElementAppPage, bob: Bot, charlies: Bot[]) { + async function setupRoomWithBobAliceAndCharlies( + page: Page, + app: ElementAppPage, + user: Credentials, + bob: Bot, + charlies: Bot[], + ) { + const alias = `#lltest:${user.homeServer}`; const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public); roomId = await bob.createRoom({ name, @@ -95,7 +105,13 @@ test.describe("Lazy Loading", () => { } } - async function joinCharliesWhileAliceIsOffline(page: Page, app: ElementAppPage, charlies: Bot[]) { + async function joinCharliesWhileAliceIsOffline( + page: Page, + app: ElementAppPage, + user: Credentials, + charlies: Bot[], + ) { + const alias = `#lltest:${user.homeServer}`; await app.client.network.goOffline(); for (const charly of charlies) { await charly.joinRoom(alias); @@ -107,19 +123,19 @@ test.describe("Lazy Loading", () => { await app.client.waitForNextSync(); } - test("should handle lazy loading properly even when offline", async ({ page, app, bot }) => { + test("should handle lazy loading properly even when offline", async ({ page, app, bot, user }) => { test.slow(); const charly1to5 = charlies.slice(0, 5); const charly6to10 = charlies.slice(5); // Set up room with alice, bob & charlies 1-5 - await setupRoomWithBobAliceAndCharlies(page, app, bot, charly1to5); + await setupRoomWithBobAliceAndCharlies(page, app, user, bot, charly1to5); // Alice should see 2 messages from every charly with the correct display name await checkPaginatedDisplayNames(app, charly1to5); await openMemberlist(app); await checkMemberList(page, charly1to5); - await joinCharliesWhileAliceIsOffline(page, app, charly6to10); + await joinCharliesWhileAliceIsOffline(page, app, user, charly6to10); await checkMemberList(page, charly6to10); for (const charly of charlies) { diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index 33e1e21d7e..d92b427b93 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -12,6 +12,7 @@ import { expect, test } from "../../element-web-test"; import { selectHomeserver } from "../utils"; import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; // This test requires fixed credentials for the device signing keys below to work const username = "user1234"; @@ -113,6 +114,8 @@ test.use({ test.describe("Login", () => { test.describe("Password login", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({ credentials, page, diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index 03c93d2620..5185be43c9 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -58,6 +58,16 @@ async function editMessage(page: Page, message: Locator, newMsg: string): Promis await editComposer.press("Enter"); } +const screenshotOptions = (page?: Page) => ({ + mask: page ? [page.locator(".mx_MessageTimestamp")] : undefined, + // Hide the jump to bottom button in the timeline to avoid flakiness + css: ` + .mx_JumpToBottomButton { + display: none !important; + } + `, +}); + test.describe("Message rendering", () => { [ { direction: "ltr", displayName: "Quentin" }, @@ -79,9 +89,10 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "Hello, world!"); - await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }, ); @@ -89,14 +100,17 @@ test.describe("Message rendering", () => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays an egg"); - await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`, screenshotOptions()); }); test("should render an LTR rich text emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me lays a *free range* egg"); - await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-ltr-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited LTR message", async ({ page, user, app, room }) => { @@ -106,9 +120,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "Hello, universe!"); - await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a LTR message", async ({ page, user, app, room }) => { @@ -122,32 +137,37 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "response to multiline message"); - await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-ltr-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a basic RTL text message", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `basic-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render an RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me يضع بيضة"); - await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`, screenshotOptions()); }); test("should render a richtext RTL emote", async ({ page, user, app, room }) => { await page.goto(`#/room/${room.roomId}`); const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*"); - await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`); + await expect(msgTile).toMatchScreenshot( + `emote-rich-rtl-${direction}displayname.png`, + screenshotOptions(), + ); }); test("should render an edited RTL message", async ({ page, user, app, room }) => { @@ -157,9 +177,10 @@ test.describe("Message rendering", () => { await editMessage(page, msgTile, "مرحبا بالكون!"); - await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `edited-message-rtl-${direction}displayname.png`, + screenshotOptions(page), + ); }); test("should render a reply of a RTL message", async ({ page, user, app, room }) => { @@ -173,9 +194,10 @@ test.describe("Message rendering", () => { ]); await replyMessage(page, msgTile, "مرحبا بالعالم!"); - await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, { - mask: [page.locator(".mx_MessageTimestamp")], - }); + await expect(msgTile).toMatchScreenshot( + `reply-message-trl-${direction}displayname.png`, + screenshotOptions(page), + ); }); }); }); diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts index a50730ce74..4d7fc7538d 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -9,12 +9,10 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test.ts"; import { registerAccountMas } from "."; import { ElementAppPage } from "../../pages/ElementAppPage.ts"; -import { isDendrite } from "../../plugins/homeserver/dendrite"; import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts"; test.use(masHomeserver); test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { - test.skip(isDendrite, "does not yet support MAS"); test.slow(); // trace recording takes a while here test("can register the oauth2 client and an account", async ({ diff --git a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts index 8a4401f5f2..d03767205a 100644 --- a/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ b/playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -9,12 +9,15 @@ Please see LICENSE files in the repository root for full details. import { test as base, expect } from "../../element-web-test"; import { Credentials } from "../../plugins/homeserver"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const test = base.extend<{ user2?: Credentials; }>({}); test.describe("1:1 chat room", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492"); + test.use({ displayName: "Jeff", user2: async ({ homeserver }, use, testInfo) => { diff --git a/playwright/e2e/permalinks/permalinks.spec.ts b/playwright/e2e/permalinks/permalinks.spec.ts index 9b448455ec..e7657b1394 100644 --- a/playwright/e2e/permalinks/permalinks.spec.ts +++ b/playwright/e2e/permalinks/permalinks.spec.ts @@ -31,7 +31,7 @@ test.describe("permalinks", () => { await charlotte.prepareClient(); // We don't use a bot for danielle as we want a stable MXID. - const danielleId = "@danielle:localhost"; + const danielleId = `@danielle:${user.homeServer}`; const room1Id = await app.client.createRoom({ name: room1Name }); const room2Id = await app.client.createRoom({ name: room2Name }); diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index bb72c02610..de954fb8d4 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -35,10 +35,10 @@ test.describe("Pinned messages", () => { mask: [tile.locator(".mx_MessageTimestamp")], // Hide the jump to bottom button in the timeline to avoid flakiness css: ` - .mx_JumpToBottomButton { - display: none !important; - } - `, + .mx_JumpToBottomButton { + display: none !important; + } + `, }); }, ); diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts index 727c453a31..fc49906b47 100644 --- a/playwright/e2e/polls/polls.spec.ts +++ b/playwright/e2e/polls/polls.spec.ts @@ -11,8 +11,11 @@ import { Bot } from "../../pages/bot"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import type { Locator, Page } from "@playwright/test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Polls", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3492"); + type CreatePollOptions = { title: string; options: string[]; diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts index da27d39a2c..4fa204bace 100644 --- a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("in threads", () => { test("An edit of a threaded message makes the room unread", async ({ diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts index bc4eff711b..6c9596a5b2 100644 --- a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("in the main timeline", () => { test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts index a750fd9ba7..9cd158430a 100644 --- a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("editing messages", () => { test.describe("thread roots", () => { test("An edit of a thread root leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index 627b2d348d..a723928c57 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { customEvent, many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.slow(); test.describe("Ignored events", () => { diff --git a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts index 8deef2d2f5..2f3c153f20 100644 --- a/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("in threads", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts index 4f94e7b09f..16c8132378 100644 --- a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("in the main timeline", () => { test("Receiving a message makes a room unread", async ({ diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts index 0e101d311a..a711d889a1 100644 --- a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { many, test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("new messages", () => { test.describe("thread roots", () => { test("Reading a thread root does not mark the thread as read", async ({ diff --git a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts index 350140965c..b2cd2e554a 100644 --- a/playwright/e2e/read-receipts/reactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test, expect } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("reactions", () => { test.describe("in threads", () => { test("A reaction to a threaded message does not make the room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts index d8c1647383..77ed8cd582 100644 --- a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("reactions", () => { test.describe("in the main timeline", () => { test("Receiving a reaction to a message does not make a room unread", async ({ diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts index d83d55e5dc..a6d21cb34e 100644 --- a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts @@ -9,8 +9,10 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.describe("reactions", () => { test.describe("thread roots", () => { test("A reaction to a thread root does not make the room unread", async ({ diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts index 9cbdadfac8..6b9415e7ba 100644 --- a/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -12,8 +12,10 @@ import { expect } from "../../element-web-test"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); test.use({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, diff --git a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts index dc229d0b1b..4e8b6bef5a 100644 --- a/playwright/e2e/read-receipts/redactions-in-threads.spec.ts +++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("in threads", () => { test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts index 356d03938f..203cbb997f 100644 --- a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts +++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("in the main timeline", () => { test("Redacting the message pointed to by my receipt leaves the room read", async ({ diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts index d875b0cecb..108e61df34 100644 --- a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts +++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts @@ -9,8 +9,11 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ import { test } from "."; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Read receipts", { tag: "@mergequeue" }, () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.describe("redactions", () => { test.describe("thread roots", () => { test("Redacting a thread root after it was read leaves the room read", async ({ diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts index 74c8ba7962..cd990f9eaf 100644 --- a/playwright/e2e/register/email.spec.ts +++ b/playwright/e2e/register/email.spec.ts @@ -32,7 +32,7 @@ test.describe("Email Registration", async () => { }); test( - "registers an account and lands on the use case selection screen", + "registers an account and lands on the home page", { tag: "@screenshot" }, async ({ page, mailhogClient, request, checkA11y }) => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); @@ -57,7 +57,7 @@ test.describe("Email Registration", async () => { const [emailLink] = messages.items[0].text.match(/http.+/); await request.get(emailLink); // "Click" the link in the email - await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); + await expect(page.getByText("Welcome alice")).toBeVisible(); }, ); }); diff --git a/playwright/e2e/register/register.spec.ts b/playwright/e2e/register/register.spec.ts index 90854de33a..3df1d01678 100644 --- a/playwright/e2e/register/register.spec.ts +++ b/playwright/e2e/register/register.spec.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.use(consentHomeserver); test.use({ @@ -23,6 +24,8 @@ test.use({ }); test.describe("Registration", () => { + test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here"); + test.beforeEach(async ({ page }) => { await page.goto("/#/register"); }); @@ -71,12 +74,6 @@ test.describe("Registration", () => { await expect(termsPolicy.getByLabel("Privacy Policy")).toBeVisible(); await page.getByRole("button", { name: "Accept", exact: true }).click(); - - await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible(); - await expect(page).toMatchScreenshot("use-case-selection.png", screenshotOptions); - await checkA11y(); - await page.getByRole("button", { name: "Skip", exact: true }).click(); - await expect(page).toHaveURL(/\/#\/home$/); /* diff --git a/playwright/e2e/right-panel/file-panel.spec.ts b/playwright/e2e/right-panel/file-panel.spec.ts index 579ba05bb7..1c936f43b8 100644 --- a/playwright/e2e/right-panel/file-panel.spec.ts +++ b/playwright/e2e/right-panel/file-panel.spec.ts @@ -10,6 +10,7 @@ import { Download, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { viewRoomSummaryByName } from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const ROOM_NAME = "Test room"; const NAME = "Alice"; @@ -181,6 +182,8 @@ test.describe("FilePanel", () => { }); test.describe("download", () => { + test.skip(isDendrite, "due to a Dendrite sending Content-Disposition inline"); + test("should download an image via the link on the panel", async ({ page, context }) => { // Upload an image file await uploadFile(page, "playwright/sample-files/riot.png"); diff --git a/playwright/e2e/right-panel/right-panel.spec.ts b/playwright/e2e/right-panel/right-panel.spec.ts index 0bdd0a283a..de86d6bfe6 100644 --- a/playwright/e2e/right-panel/right-panel.spec.ts +++ b/playwright/e2e/right-panel/right-panel.spec.ts @@ -38,30 +38,34 @@ test.describe("RightPanel", () => { }); test.describe("in rooms", () => { - test("should handle long room address and long room name", { tag: "@screenshot" }, async ({ page, app }) => { - await app.client.createRoom({ name: ROOM_NAME_LONG }); - await viewRoomSummaryByName(page, app, ROOM_NAME_LONG); + test( + "should handle long room address and long room name", + { tag: "@screenshot" }, + async ({ page, app, user }) => { + await app.client.createRoom({ name: ROOM_NAME_LONG }); + await viewRoomSummaryByName(page, app, ROOM_NAME_LONG); - await app.settings.openRoomSettings(); + await app.settings.openRoomSettings(); - // Set a local room address - const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); - await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG); - await expect(page.getByText("This address is available to use")).toBeVisible(); - await localAddresses.getByRole("button", { name: "Add" }).click(); - await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:localhost`)).toHaveClass( - "mx_EditableItem_item", - ); + // Set a local room address + const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); + await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG); + await expect(page.getByText("This address is available to use")).toBeVisible(); + await localAddresses.getByRole("button", { name: "Add" }).click(); + await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:${user.homeServer}`)).toHaveClass( + "mx_EditableItem_item", + ); - await app.closeDialog(); + await app.closeDialog(); - // Close and reopen the right panel to render the room address - await app.toggleRoomInfoPanel(); - await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); - await app.toggleRoomInfoPanel(); + // Close and reopen the right panel to render the room address + await app.toggleRoomInfoPanel(); + await expect(page.locator(".mx_RightPanel")).not.toBeVisible(); + await app.toggleRoomInfoPanel(); - await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); - }); + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png"); + }, + ); test("should handle clicking add widgets", async ({ page, app }) => { await viewRoomSummaryByName(page, app, ROOM_NAME); diff --git a/playwright/e2e/room-directory/room-directory.spec.ts b/playwright/e2e/room-directory/room-directory.spec.ts index a38cc7d395..8f90ef4b7e 100644 --- a/playwright/e2e/room-directory/room-directory.spec.ts +++ b/playwright/e2e/room-directory/room-directory.spec.ts @@ -10,6 +10,7 @@ import type { Preset, Visibility } from "matrix-js-sdk/src/matrix"; import { test, expect } from "../../element-web-test"; test.describe("Room Directory", () => { + test.skip(({ homeserverType }) => homeserverType === "pinecone", "Pinecone's /publicRooms API takes forever"); test.use({ displayName: "Ray", botCreateOpts: { displayName: "Paul" }, @@ -32,14 +33,14 @@ test.describe("Room Directory", () => { await localAddresses.getByRole("textbox").fill("gaming"); await expect(page.getByText("This address is available to use")).toBeVisible(); await localAddresses.getByRole("button", { name: "Add" }).click(); - await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item"); + await expect(localAddresses.getByText(`#gaming:${user.homeServer}`)).toHaveClass("mx_EditableItem_item"); // Publish into the public rooms directory const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" }); - await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost"); + await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue(`#gaming:${user.homeServer}`); const checkbox = publishedAddresses .locator(".mx_SettingsFlag", { - hasText: "Publish this room to the public in localhost's room directory?", + hasText: `Publish this room to the public in ${user.homeServer}'s room directory?`, }) .getByRole("switch"); await checkbox.check(); @@ -87,7 +88,7 @@ test.describe("Room Directory", () => { .getByRole("button", { name: "Join" }) .click(); - await expect(page).toHaveURL("/#/room/#test1234:localhost"); + await expect(page).toHaveURL(`/#/room/#test1234:${user.homeServer}`); }, ); }); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 7fe0cb3d47..f19bd68f14 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -111,6 +111,10 @@ test.describe("Room Header", () => { async ({ page, app, user }) => { await createVideoRoom(page, app); + // Dismiss a toast that is otherwise in the way (it's the other + // side but there's no need to have it in the screenshot) + await page.getByRole("button", { name: "Later" }).click(); + const header = page.locator(".mx_RoomHeader"); // There's two room info button - the header itself and the i button diff --git a/playwright/e2e/room_options/marked_unread.spec.ts b/playwright/e2e/room_options/marked_unread.spec.ts index d7011684a7..2817bbc921 100644 --- a/playwright/e2e/room_options/marked_unread.spec.ts +++ b/playwright/e2e/room_options/marked_unread.spec.ts @@ -7,10 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import { test, expect } from "../../element-web-test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; const TEST_ROOM_NAME = "The mark unread test room"; test.describe("Mark as Unread", () => { + test.skip(isDendrite, "due to Dendrite bug https://github.com/element-hq/dendrite/issues/2970"); + test.use({ displayName: "Tom", botCreateOpts: { diff --git a/playwright/e2e/settings/encryption-user-tab/index.ts b/playwright/e2e/settings/encryption-user-tab/index.ts new file mode 100644 index 0000000000..9fc8eecb71 --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/index.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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 { Page } from "@playwright/test"; +import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { ElementAppPage } from "../../../pages/ElementAppPage"; +import { test as base, expect } from "../../../element-web-test"; +export { expect }; + +/** + * Set up for the encryption tab test + */ +export const test = base.extend<{ + util: Helpers; +}>({ + util: async ({ page, app, bot }, use) => { + await use(new Helpers(page, app)); + }, +}); + +class Helpers { + constructor( + private page: Page, + private app: ElementAppPage, + ) {} + + /** + * Open the encryption tab + */ + openEncryptionTab() { + return this.app.settings.openUserSettings("Encryption"); + } + + /** + * Go through the device verification flow using the recovery key. + */ + async verifyDevice(recoveryKey: GeneratedSecretStorageKey) { + // Select the security phrase + await this.page.getByRole("button", { name: "Verify with Security Key or Phrase" }).click(); + await this.enterRecoveryKey(recoveryKey); + await this.page.getByRole("button", { name: "Done" }).click(); + } + + /** + * Fill the recovery key in the dialog + * @param recoveryKey + */ + async enterRecoveryKey(recoveryKey: GeneratedSecretStorageKey) { + // Select to use recovery key + await this.page.getByRole("button", { name: "use your Security Key" }).click(); + + // Fill the recovery key + const dialog = this.page.locator(".mx_Dialog"); + await dialog.getByRole("textbox").fill(recoveryKey.encodedPrivateKey); + await dialog.getByRole("button", { name: "Continue" }).click(); + } + + /** + * Get the encryption tab content + */ + getEncryptionTabContent() { + return this.page.getByTestId("encryptionTab"); + } + + /** + * Set the default key id of the secret storage to `null` + */ + async removeSecretStorageDefaultKeyId() { + const client = await this.app.client.prepareClient(); + await client.evaluate(async (client) => { + await client.secretStorage.setDefaultKeyId(null); + }); + } + + /** + * Get the security key from the clipboard and fill in the input field + * Then click on the finish button + * @param title - The title of the dialog + * @param confirmButtonLabel - The label of the confirm button + * @param screenshot + */ + async confirmRecoveryKey(title: string, confirmButtonLabel: string, screenshot: `${string}.png`) { + const dialog = this.getEncryptionTabContent(); + await expect(dialog.getByText(title, { exact: true })).toBeVisible(); + await expect(dialog).toMatchScreenshot(screenshot); + + const clipboardContent = await this.app.getClipboard(); + await dialog.getByRole("textbox").fill(clipboardContent); + await dialog.getByRole("button", { name: confirmButtonLabel }).click(); + await expect(dialog).toMatchScreenshot("default-recovery.png"); + } +} diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts new file mode 100644 index 0000000000..7ce769059a --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; + +import { test, expect } from "."; +import { + checkDeviceIsConnectedKeyBackup, + checkDeviceIsCrossSigned, + createBot, + deleteCachedSecrets, + verifySession, +} from "../../crypto/utils"; + +test.describe("Recovery section in Encryption tab", () => { + test.use({ + displayName: "Alice", + }); + + let recoveryKey: GeneratedSecretStorageKey; + let expectedBackupVersion: string; + + test.beforeEach(async ({ page, homeserver, credentials }) => { + const res = await createBot(page, homeserver, credentials); + recoveryKey = res.recoveryKey; + expectedBackupVersion = res.expectedBackupVersion; + }); + + test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => { + const dialog = await util.openEncryptionTab(); + + // The user's device is in an unverified state, therefore the only option available to them here is to verify it + const verifyButton = dialog.getByRole("button", { name: "Verify this device" }); + await expect(verifyButton).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("verify-device-encryption-tab.png"); + await verifyButton.click(); + + await util.verifyDevice(recoveryKey); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png"); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await app.closeDialog(); + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true); + }); + + test( + "should change the recovery key", + { tag: ["@screenshot", "@no-webkit"] }, + async ({ page, app, homeserver, credentials, util, context }) => { + await verifySession(app, "new passphrase"); + const dialog = await util.openEncryptionTab(); + + // The user can only change the recovery key + const changeButton = dialog.getByRole("button", { name: "Change recovery key" }); + await expect(changeButton).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("default-recovery.png"); + await changeButton.click(); + + // Display the new recovery key and click on the copy button + await expect(dialog.getByText("Change recovery key?")).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("change-key-1-encryption-tab.png", { + mask: [dialog.getByTestId("recoveryKey")], + }); + await dialog.getByRole("button", { name: "Copy" }).click(); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Confirm the recovery key + await util.confirmRecoveryKey( + "Enter your new recovery key", + "Confirm new recovery key", + "change-key-2-encryption-tab.png", + ); + }, + ); + + test("should setup the recovery key", { tag: ["@screenshot", "@no-webkit"] }, async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + await util.removeSecretStorageDefaultKeyId(); + + // The key backup is deleted and the user needs to set it up + const dialog = await util.openEncryptionTab(); + const setupButton = dialog.getByRole("button", { name: "Set up recovery" }); + await expect(setupButton).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-recovery.png"); + await setupButton.click(); + + // Display an informative panel about the recovery key + await expect(dialog.getByRole("heading", { name: "Set up recovery" })).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-1-encryption-tab.png"); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Display the new recovery key and click on the copy button + await expect(dialog.getByText("Save your recovery key somewhere safe")).toBeVisible(); + await expect(util.getEncryptionTabContent()).toMatchScreenshot("set-up-key-2-encryption-tab.png", { + mask: [dialog.getByTestId("recoveryKey")], + }); + await dialog.getByRole("button", { name: "Copy" }).click(); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Confirm the recovery key + await util.confirmRecoveryKey( + "Enter your recovery key to confirm", + "Finish set up", + "set-up-key-3-encryption-tab.png", + ); + + // The recovery key is now set up and the user can change it + await expect(dialog.getByRole("button", { name: "Change recovery key" })).toBeVisible(); + + await app.closeDialog(); + // Check that the current device is connected to key backup and the backup version is the expected one + await checkDeviceIsConnectedKeyBackup(page, "1", true); + }); + + // Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB. + // + // This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. + // We simulate this case by deleting the cached secrets in the indexedDB. + test( + "should enter the recovery key when the secrets are not cached", + { tag: "@screenshot" }, + async ({ page, app, util }) => { + await verifySession(app, "new passphrase"); + // We need to delete the cached secrets + await deleteCachedSecrets(page); + + await util.openEncryptionTab(); + // We ask the user to enter the recovery key + const dialog = util.getEncryptionTabContent(); + const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" }); + await expect(enterKeyButton).toBeVisible(); + await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png"); + await enterKeyButton.click(); + + // Fill the recovery key + await util.enterRecoveryKey(recoveryKey); + await expect(dialog).toMatchScreenshot("default-recovery.png"); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + // The backup decryption key should be in cache also, as we got it directly from the 4S + await app.closeDialog(); + await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true); + }, + ); +}); diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts index 5e29f802c2..376412914a 100644 --- a/playwright/e2e/settings/general-room-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts @@ -36,7 +36,7 @@ test.describe("General room settings tab", () => { await expect(settings.getByText("Show more")).toBeVisible(); }); - test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app }) => { + test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app, user }) => { const settings = await app.settings.openRoomSettings("General"); // 1. Set the room-address to be a really long string const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4); @@ -45,7 +45,7 @@ test.describe("General room settings tab", () => { await settings.locator("#roomAliases").getByText("Add", { exact: true }).click(); // 2. wait for the new setting to apply ... - await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`); + await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:${user.homeServer}`); // 3. Check if the dialog overflows const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox(); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 48bcc13c53..37e5606cc1 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -10,6 +10,7 @@ import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import type { Preset, ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; import { ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; async function openSpaceCreateMenu(page: Page): Promise { await page.getByRole("button", { name: "Create a space" }).click(); @@ -50,6 +51,7 @@ function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state" } test.describe("Spaces", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); test.use({ displayName: "Sue", botCreateOpts: { displayName: "BotBob" }, @@ -82,7 +84,7 @@ test.describe("Spaces", () => { // Copy matrix.to link await page.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost"); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#lets-have-a-riot:${user.homeServer}`); // Go to space home await page.getByRole("button", { name: "Go to my first room" }).click(); @@ -169,13 +171,13 @@ test.describe("Spaces", () => { room_alias_name: "space", }); - const menu = await openSpaceContextMenu(page, app, "#space:localhost"); + const menu = await openSpaceContextMenu(page, app, `#space:${user.homeServer}`); await menu.getByRole("menuitem", { name: "Invite" }).click(); const shareDialog = page.locator(".mx_SpacePublicShare"); // Copy link first await shareDialog.getByRole("button", { name: "Share invite link" }).click(); - expect(await app.getClipboardText()).toEqual("https://matrix.to/#/#space:localhost"); + expect(await app.getClipboard()).toEqual(`https://matrix.to/#/#space:${user.homeServer}`); // Start Matrix invite flow await shareDialog.getByRole("button", { name: "Invite people" }).click(); diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index d3d3cb352b..7da6974a92 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -38,11 +38,13 @@ export const test = base.extend<{ room1Name: "Room 1", room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, room2Name: "Room 2", room2: async ({ room2Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await bot.awaitRoomMembership(roomId); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index c45222d035..683577dce4 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -8,8 +8,14 @@ import { expect, test } from "."; import { CommandOrControl } from "../../utils"; +import { isDendrite } from "../../../plugins/homeserver/dendrite"; test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { + test.skip( + isDendrite, + "due to Dendrite lacking full threads support https://github.com/element-hq/dendrite/issues/3283", + ); + test.use({ displayName: "Alice", botCreateOpts: { displayName: "Other User" }, diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index d1bb3dec25..7a5f7d4ea8 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -6,12 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { AccountDataEvents } from "matrix-js-sdk/src/matrix"; -import { test, expect } from "../../element-web-test"; +import type { AccountDataEvents, Visibility } from "matrix-js-sdk/src/matrix"; +import { test as base, expect } from "../../element-web-test"; import { Filter } from "../../pages/Spotlight"; import { Bot } from "../../pages/bot"; import type { Locator, Page } from "@playwright/test"; import type { ElementAppPage } from "../../pages/ElementAppPage"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; function roomHeaderName(page: Page): Locator { return page.locator(".mx_RoomHeader_heading"); @@ -38,41 +39,37 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise { - const bot1Name = "BotBob"; - let bot1: Bot; - - const bot2Name = "ByteBot"; - let bot2: Bot; - - const room1Name = "247"; - let room1Id: string; - - const room2Name = "Lounge"; - let room2Id: string; - - const room3Name = "Public"; - let room3Id: string; - - test.use({ - displayName: "Jim", - }); - - test.beforeEach(async ({ page, homeserver, app, user }) => { - bot1 = new Bot(page, homeserver, { displayName: bot1Name, autoAcceptInvites: true }); - bot2 = new Bot(page, homeserver, { displayName: bot2Name, autoAcceptInvites: true }); - const Visibility = await page.evaluate(() => (window as any).matrixcs.Visibility); - - room1Id = await app.client.createRoom({ name: room1Name, visibility: Visibility.Public }); - - await bot1.joinRoom(room1Id); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); - room2Id = await bot2.createRoom({ name: room2Name, visibility: Visibility.Public }); - await bot2.inviteUser(room2Id, bot1UserId); - - room3Id = await bot2.createRoom({ - name: room3Name, - visibility: Visibility.Public, +type RoomRef = { name: string; roomId: string }; +const test = base.extend<{ + bot1: Bot; + bot2: Bot; + room1: RoomRef; + room2: RoomRef; + room3: RoomRef; +}>({ + bot1: async ({ page, homeserver }, use, testInfo) => { + const bot = new Bot(page, homeserver, { displayName: `BotBob_${testInfo.testId}`, autoAcceptInvites: true }); + await use(bot); + }, + bot2: async ({ page, homeserver }, use, testInfo) => { + const bot = new Bot(page, homeserver, { displayName: `ByteBot_${testInfo.testId}`, autoAcceptInvites: true }); + await use(bot); + }, + room1: async ({ app }, use) => { + const name = "247"; + const roomId = await app.client.createRoom({ name, visibility: "public" as Visibility }); + await use({ name, roomId }); + }, + room2: async ({ bot2 }, use) => { + const name = "Lounge"; + const roomId = await bot2.createRoom({ name, visibility: "public" as Visibility }); + await use({ name, roomId }); + }, + room3: async ({ bot2 }, use) => { + const name = "Public"; + const roomId = await bot2.createRoom({ + name, + visibility: "public" as Visibility, initial_state: [ { type: "m.room.history_visibility", @@ -83,9 +80,27 @@ test.describe("Spotlight", () => { }, ], }); - await bot2.inviteUser(room3Id, bot1UserId); + await use({ name, roomId }); + }, + context: async ({ context, homeserver }, use) => { + // Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts + await homeserver.restart(); + await use(context); + }, +}); - await page.goto("/#/room/" + room1Id); +test.describe("Spotlight", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3488"); + test.use({ + displayName: "Jim", + }); + + test.beforeEach(async ({ page, user, bot1, bot2, room1, room2, room3 }) => { + await bot1.joinRoom(room1.roomId); + await bot2.inviteUser(room2.roomId, bot1.credentials.userId); + await bot2.inviteUser(room3.roomId, bot1.credentials.userId); + + await page.goto(`/#/room/${room1.roomId}`); await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached(); }); @@ -117,69 +132,69 @@ test.describe("Spotlight", () => { await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached(); }); - test("should find joined rooms", async ({ page, app }) => { + test("should find joined rooms", async ({ page, app, room1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle - await spotlight.search(room1Name); + await spotlight.search(room1.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room1Name); + await expect(resultLocator.first()).toContainText(room1.name); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`)); - await expect(roomHeaderName(page)).toContainText(room1Name); + await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`)); + await expect(roomHeaderName(page)).toContainText(room1.name); }); - test("should find known public rooms", async ({ page, app }) => { + test("should find known public rooms", async ({ page, app, room1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room1Name); + await spotlight.search(room1.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room1Name); + await expect(resultLocator.first()).toContainText(room1.name); await expect(resultLocator.first()).toContainText("View"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`)); - await expect(roomHeaderName(page)).toContainText(room1Name); + await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`)); + await expect(roomHeaderName(page)).toContainText(room1.name); }); - test("should find unknown public rooms", async ({ page, app }) => { + test("should find unknown public rooms", async ({ page, app, room2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room2Name); + await spotlight.search(room2.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room2Name); + await expect(resultLocator.first()).toContainText(room2.name); await expect(resultLocator.first()).toContainText("Join"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room2Id}`)); + await expect(page).toHaveURL(new RegExp(`#/room/${room2.roomId}`)); await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1); - await expect(roomHeaderName(page)).toContainText(room2Name); + await expect(roomHeaderName(page)).toContainText(room2.name); }); - test("should find unknown public world readable rooms", async ({ page, app }) => { + test("should find unknown public world readable rooms", async ({ page, app, room3 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room3Name); + await spotlight.search(room3.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room3Name); + await expect(resultLocator.first()).toContainText(room3.name); await expect(resultLocator.first()).toContainText("View"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room3Id}`)); + await expect(page).toHaveURL(new RegExp(`#/room/${room3.roomId}`)); await page.getByRole("button", { name: "Join the discussion" }).click(); - await expect(roomHeaderName(page)).toHaveText(room3Name); + await expect(roomHeaderName(page)).toHaveText(room3.name); }); // TODO: We currently can’t test finding rooms on other homeservers/other protocols // We obviously don’t have federation or bridges in local e2e tests - test.skip("should find unknown public rooms on other homeservers", async ({ page, app }) => { + test.skip("should find unknown public rooms on other homeservers", async ({ page, app, room3 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room3Name); + await spotlight.search(room3.name); await page.locator("[aria-haspopup=true][role=button]").click(); await page @@ -194,20 +209,20 @@ test.describe("Spotlight", () => { const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room3Name); - await expect(resultLocator.first()).toContainText(room3Id); + await expect(resultLocator.first()).toContainText(room3.name); + await expect(resultLocator.first()).toContainText(room3.roomId); }); - test("should find known people", async ({ page, app }) => { + test("should find known people", async ({ page, app, bot1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot1Name); + await spotlight.search(bot1.credentials.displayName); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot1Name); + await expect(resultLocator.first()).toContainText(bot1.credentials.displayName); await resultLocator.first().click(); - await expect(roomHeaderName(page)).toHaveText(bot1Name); + await expect(roomHeaderName(page)).toHaveText(bot1.credentials.displayName); }); /** @@ -217,42 +232,41 @@ test.describe("Spotlight", () => { * * https://github.com/matrix-org/synapse/issues/16472 */ - test("should find unknown people", async ({ page, app }) => { + test("should find unknown people", async ({ page, app, bot2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await resultLocator.first().click(); - await expect(roomHeaderName(page)).toHaveText(bot2Name); + await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName); }); - test("should find group DMs by usernames or user ids", async ({ page, app }) => { + test("should find group DMs by usernames or user ids", async ({ page, app, bot1, bot2, room1 }) => { // First we want to share a room with both bots to ensure we’ve got their usernames cached - const bot2UserId = await bot2.evaluate((client) => client.getUserId()); - await app.client.inviteUser(room1Id, bot2UserId); + await app.client.inviteUser(room1.roomId, bot2.credentials.userId); // Starting a DM with ByteBot (will be turned into a group dm later) let spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); let resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await resultLocator.first().click(); // Send first message to actually start DM - await expect(roomHeaderName(page)).toHaveText(bot2Name); + await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName); const locator = page.getByRole("textbox", { name: "Send a message…" }); await locator.fill("Hey!"); await locator.press("Enter"); // Assert DM exists by checking for the first message and the room being in the room list await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 }); - await expect(page.getByRole("group", { name: "People" })).toContainText(bot2Name); + await expect(page.getByRole("group", { name: "People" })).toContainText(bot2.credentials.displayName); // Invite BotBob into existing DM with ByteBot const dmRooms = await app.client.evaluate((client, userId) => { @@ -260,18 +274,17 @@ test.describe("Spotlight", () => { .getAccountData("m.direct" as keyof AccountDataEvents) ?.getContent>(); return map[userId] ?? []; - }, bot2UserId); + }, bot2.credentials.userId); expect(dmRooms).toHaveLength(1); const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); - await app.client.inviteUser(dmRooms[0], bot1UserId); + await app.client.inviteUser(dmRooms[0], bot1.credentials.userId); await expect(roomHeaderName(page).first()).toContainText(groupDmName); await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName); // Search for BotBob by id, should return group DM and user spotlight = await app.openSpotlight(); await spotlight.filter(Filter.People); - await spotlight.search(bot1UserId); + await spotlight.search(bot1.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(2); @@ -284,7 +297,7 @@ test.describe("Spotlight", () => { // Search for ByteBot by id, should return group DM and user spotlight = await app.openSpotlight(); await spotlight.filter(Filter.People); - await spotlight.search(bot2UserId); + await spotlight.search(bot2.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(2); @@ -297,11 +310,10 @@ test.describe("Spotlight", () => { }); // Test against https://github.com/vector-im/element-web/issues/22851 - test("should show each person result only once", async ({ page, app }) => { + test("should show each person result only once", async ({ page, app, bot1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); // 2 rounds of search to simulate the bug conditions. Specifically, the first search // should have 1 result (not 2) and the second search should also have 1 result (instead @@ -310,24 +322,24 @@ test.describe("Spotlight", () => { // We search for user ID to trigger the profile lookup within the dialog. for (let i = 0; i < 2; i++) { console.log("Iteration: " + i); - await spotlight.search(bot1UserId); + await spotlight.search(bot1.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot1UserId); + await expect(resultLocator.first()).toContainText(bot1.credentials.userId); } }); - test("should allow opening group chat dialog", async ({ page, app }) => { + test("should allow opening group chat dialog", async ({ page, app, bot2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); await page.waitForTimeout(3000); // wait for the dialog to settle const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText( "Start a group chat", @@ -336,18 +348,18 @@ test.describe("Spotlight", () => { await expect(page.getByRole("dialog")).toContainText("Direct Messages"); }); - test("should close spotlight after starting a DM", async ({ page, app }) => { - await startDM(app, page, bot1Name); + test("should close spotlight after starting a DM", async ({ page, app, bot1 }) => { + await startDM(app, page, bot1.credentials.displayName); await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0); }); - test("should show the same user only once", async ({ page, app }) => { - await startDM(app, page, bot1Name); + test("should show the same user only once", async ({ page, app, bot1 }) => { + await startDM(app, page, bot1.credentials.displayName); await page.goto("/#/home"); const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot1Name); + await spotlight.search(bot1.credentials.displayName); await page.waitForTimeout(3000); // wait for the dialog to settle await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached(); const resultLocator = spotlight.results; diff --git a/playwright/e2e/threads/threads.spec.ts b/playwright/e2e/threads/threads.spec.ts index c654792659..89cfe418ba 100644 --- a/playwright/e2e/threads/threads.spec.ts +++ b/playwright/e2e/threads/threads.spec.ts @@ -8,8 +8,10 @@ Please see LICENSE files in the repository root for full details. import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { test, expect } from "../../element-web-test"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; test.describe("Threads", () => { + test.skip(isDendrite, "due to a Dendrite bug https://github.com/element-hq/dendrite/issues/3489"); test.use({ displayName: "Tom", botCreateOpts: { diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 885c15d90f..3de0f7c0f2 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -1195,6 +1195,7 @@ test.describe("Timeline", () => { }); await sendImage(app.client, room.roomId, NEW_AVATAR); + await app.timeline.scrollToBottom(); await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); // Exclude timestamp and read marker from snapshot diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts deleted file mode 100644 index 3c7ef1f171..0000000000 --- a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { test, expect } from "../../element-web-test"; - -test.describe("User Onboarding (new user)", () => { - test.use({ - displayName: "Jane Doe", - }); - - // This first beforeEach happens before the `user` fixture runs - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem("mx_registration_time", "1656633601"); - }); - }); - - test.beforeEach(async ({ page, user }) => { - await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); - await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); - await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); - }); - - test("page is shown and preference exists", { tag: "@screenshot" }, async ({ page, app }) => { - await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot( - "User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png", - ); - await app.settings.openUserSettings("Preferences"); - await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible(); - }); - - test("app download dialog", { tag: "@screenshot" }, async ({ page }) => { - await page.getByRole("button", { name: "Download apps" }).click(); - await expect( - page.getByRole("dialog").getByRole("heading", { level: 1, name: "Download Element" }), - ).toBeVisible(); - await expect(page.locator(".mx_Dialog")).toMatchScreenshot( - "User-Onboarding-new-user-app-download-dialog-1.png", - { - // Set a constant bg behind the modal to ensure screenshot stability - css: ` - .mx_AppDownloadDialog_wrapper { - background: black; - } - `, - }, - ); - }); - - test("using find friends action should increase progress", async ({ page, homeserver }) => { - const bot = await homeserver.registerUser("botbob", "password", "BotBob"); - - const oldProgress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); - await page.getByRole("button", { name: "Find friends" }).click(); - await page.locator(".mx_InviteDialog_editor").getByRole("textbox").fill(bot.userId); - await page.getByRole("button", { name: "Go" }).click(); - await expect(page.locator(".mx_InviteDialog_buttonAndSpinner")).not.toBeVisible(); - - const message = "Hi!"; - const composer = page.getByRole("textbox", { name: "Send a message…" }); - await composer.fill(`${message}`); - await composer.press("Enter"); - await expect(page.locator(".mx_MTextBody.mx_EventTile_content", { hasText: message })).toBeVisible(); - - await page.goto("/#/home"); - await expect(page.locator(".mx_UserOnboardingPage")).toBeVisible(); - await expect(page.getByRole("button", { name: "Welcome" })).toBeVisible(); - await expect(page.locator(".mx_UserOnboardingList")).toBeVisible(); - - await page.waitForTimeout(500); // await progress bar animation - const progress = parseFloat(await page.getByRole("progressbar").getAttribute("value")); - expect(progress).toBeGreaterThan(oldProgress); - }); -}); diff --git a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts deleted file mode 100644 index 8931672b52..0000000000 --- a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { test, expect } from "../../element-web-test"; - -test.describe("User Onboarding (old user)", () => { - test.use({ - displayName: "Jane Doe", - }); - - test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.localStorage.setItem("mx_registration_time", "2"); - }); - }); - - test("page and preference are hidden", async ({ page, user, app }) => { - await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible(); - await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible(); - await app.settings.openUserSettings("Preferences"); - await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible(); - }); -}); diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts new file mode 100644 index 0000000000..9a35d9b9c3 --- /dev/null +++ b/playwright/e2e/voip/pstn.spec.ts @@ -0,0 +1,31 @@ +/* +Copyright 2025 New Vector Ltd. + +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 { test, expect } from "../../element-web-test"; + +test.describe("PSTN", () => { + test.beforeEach(async ({ page }) => { + // Mock the third party protocols endpoint to look like the HS has PSTN support + await page.route("**/_matrix/client/v3/thirdparty/protocols", async (route) => { + await route.fulfill({ + status: 200, + json: { + "im.vector.protocol.pstn": {}, + }, + }); + }); + }); + + test("should render dialpad as expected", { tag: "@screenshot" }, async ({ page, user, toasts }) => { + await toasts.rejectToast("Notifications"); + await toasts.assertNoToasts(); + + await expect(page.locator(".mx_LeftPanel_filterContainer")).toMatchScreenshot("dialpad-trigger.png"); + await page.getByLabel("Open dial pad").click(); + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); + }); +}); diff --git a/playwright/e2e/widgets/stickers.spec.ts b/playwright/e2e/widgets/stickers.spec.ts index 2477efdc6b..601dcd8b79 100644 --- a/playwright/e2e/widgets/stickers.spec.ts +++ b/playwright/e2e/widgets/stickers.spec.ts @@ -88,7 +88,7 @@ async function sendStickerFromPicker(page: Page) { await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible(); } -async function expectTimelineSticker(page: Page, roomId: string, contentUri: string) { +async function expectTimelineSticker(page: Page, serverName: string, roomId: string, contentUri: string) { const contentId = contentUri.split("/").slice(-1)[0]; // Make sure it's in the right room await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`)); @@ -98,7 +98,7 @@ async function expectTimelineSticker(page: Page, roomId: string, contentUri: str // download URL. await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute( "src", - new RegExp(`/localhost/${contentId}`), + new RegExp(`/${serverName}/${contentId}`), ); } @@ -156,7 +156,7 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(page).toHaveURL(`/#/room/${room.roomId}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, room.roomId, contentUri); + await expectTimelineSticker(page, user.homeServer, room.roomId, contentUri); // Ensure that when we switch to a different room that the sticker // goes to the right place @@ -164,7 +164,7 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(page).toHaveURL(`/#/room/${roomId2}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, roomId2, contentUri); + await expectTimelineSticker(page, user.homeServer, roomId2, contentUri); }); test("should handle a sticker picker widget missing creatorUserId", async ({ @@ -183,7 +183,7 @@ test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(page).toHaveURL(`/#/room/${room.roomId}`); await openStickerPicker(app); await sendStickerFromPicker(page); - await expectTimelineSticker(page, room.roomId, contentUri); + await expectTimelineSticker(page, user.homeServer, room.roomId, contentUri); }); test("should render invalid mimetype as a file", async ({ webserver, page, app, user, room }) => { diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 1a43820341..9468ddeec3 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -49,7 +49,7 @@ const CONFIG_JSON: Partial = { }, }; -interface CredentialsWithDisplayName extends Credentials { +export interface CredentialsWithDisplayName extends Credentials { displayName: string; } diff --git a/playwright/flaky-reporter.ts b/playwright/flaky-reporter.ts index e187b3b689..f816d7651e 100644 --- a/playwright/flaky-reporter.ts +++ b/playwright/flaky-reporter.ts @@ -24,16 +24,40 @@ type PaginationLinks = { first?: string; }; +// We see quite a few test flakes which are caused by the app exploding +// so we have some magic strings we check the logs for to better track the flake with its cause +const SPECIAL_CASES = { + "ChunkLoadError": "ChunkLoadError", + "Unreachable code should not be executed": "Rust crypto panic", + "Out of bounds memory access": "Rust crypto memory error", +}; + class FlakyReporter implements Reporter { private flakes = new Map(); public onTestEnd(test: TestCase): void { - const title = `${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`; + // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track + if (["Dendrite", "Pinecone"].includes(test.parent.project()?.name)) return; + let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`]; if (test.outcome() === "flaky") { - if (!this.flakes.has(title)) { - this.flakes.set(title, []); + const timedOutRuns = test.results.filter((result) => result.status === "timedOut"); + const pageLogs = timedOutRuns.flatMap((result) => + result.attachments.filter((attachment) => attachment.name.startsWith("page-")), + ); + // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such. + const specialCases = Object.keys(SPECIAL_CASES).filter((log) => + pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body.includes(log)), + ); + if (specialCases.length > 0) { + failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]); + } + + for (const title of failures) { + if (!this.flakes.has(title)) { + this.flakes.set(title, []); + } + this.flakes.get(title).push(test); } - this.flakes.get(title).push(test); } } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 98d0bf30fb..d530c75b54 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -158,10 +158,6 @@ export class ElementAppPage { return button.click(); } - public async getClipboardText(): Promise { - return this.page.evaluate("navigator.clipboard.readText()"); - } - public async openSpotlight(): Promise { const spotlight = new Spotlight(this.page); await spotlight.open(); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 362915ce71..611a6cef19 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -15,7 +15,6 @@ import type { ICreateRoomOpts, ISendEventResponse, MatrixClient, - Room, MatrixEvent, ReceiptType, IRoomDirectoryOptions, @@ -178,21 +177,12 @@ export class Client { */ public async createRoom(options: ICreateRoomOpts): Promise { const client = await this.prepareClient(); - return await client.evaluate(async (cli, options) => { + const roomId = await client.evaluate(async (cli, options) => { const { room_id: roomId } = await cli.createRoom(options); - if (!cli.getRoom(roomId)) { - await new Promise((resolve) => { - const onRoom = (room: Room) => { - if (room.roomId === roomId) { - cli.off(window.matrixcs.ClientEvent.Room, onRoom); - resolve(); - } - }; - cli.on(window.matrixcs.ClientEvent.Room, onRoom); - }); - } return roomId; }, options); + await this.awaitRoomMembership(roomId); + return roomId; } /** diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index e199de261b..fb3537f417 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -6,34 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { DendriteContainer, PineconeContainer } from "../../../testcontainers/dendrite.ts"; -import { Fixtures } from "../../../element-web-test.ts"; +import { Options } from "../../../services.ts"; -export const dendriteHomeserver: Fixtures = { - _homeserver: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const container = - process.env["PLAYWRIGHT_HOMESERVER"] === "dendrite" ? new DendriteContainer() : new PineconeContainer(); - await use(container); - }, - { scope: "worker" }, - ], - homeserver: [ - async ({ logger, network, _homeserver: homeserver }, use) => { - const container = await homeserver - .withNetwork(network) - .withNetworkAliases("homeserver") - .withLogConsumer(logger.getConsumer("dendrite")) - .start(); - - await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], +export const isDendrite = ({ homeserverType }: Options): boolean => { + return homeserverType === "dendrite" || homeserverType === "pinecone"; }; - -export function isDendrite(): boolean { - return process.env["PLAYWRIGHT_HOMESERVER"] === "dendrite" || process.env["PLAYWRIGHT_HOMESERVER"] === "pinecone"; -} diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 50dea472f7..e5359ad454 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -6,8 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { ClientServerApi } from "../utils/api.ts"; + export interface HomeserverInstance { readonly baseUrl: string; + readonly csApi: ClientServerApi; /** * Register a user on the given Homeserver using the shared registration secret. @@ -43,3 +46,5 @@ export interface Credentials { displayName?: string; username: string; // the localpart of the userId } + +export type HomeserverType = "synapse" | "dendrite" | "pinecone"; diff --git a/playwright/plugins/homeserver/synapse/consentHomeserver.ts b/playwright/plugins/homeserver/synapse/consentHomeserver.ts index a35fa8edc7..e714e8a9c1 100644 --- a/playwright/plugins/homeserver/synapse/consentHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/consentHomeserver.ts @@ -54,4 +54,9 @@ export const consentHomeserver: Fixtures = { }, { scope: "worker" }, ], + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/homeserver/synapse/emailHomeserver.ts b/playwright/plugins/homeserver/synapse/emailHomeserver.ts index 4faaa8ad00..f7dee7b01a 100644 --- a/playwright/plugins/homeserver/synapse/emailHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/emailHomeserver.ts @@ -26,4 +26,9 @@ export const emailHomeserver: Fixtures = { }, { scope: "worker" }, ], + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts b/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts index 1752794f40..7414fdb015 100644 --- a/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts @@ -21,7 +21,8 @@ export const legacyOAuthHomeserver: Fixtures = { }, { scope: "worker" }, ], - context: async ({ context, oAuthServer }, use, testInfo) => { + context: async ({ homeserverType, context, oAuthServer }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support OIDC"); oAuthServer.onTestStarted(testInfo); await use(context); }, diff --git a/playwright/plugins/homeserver/synapse/masHomeserver.ts b/playwright/plugins/homeserver/synapse/masHomeserver.ts index 45169f1c90..d52c446e9b 100644 --- a/playwright/plugins/homeserver/synapse/masHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/masHomeserver.ts @@ -79,4 +79,9 @@ export const masHomeserver: Fixtures = { default_server_config: wellKnown, }); }, + + context: async ({ homeserverType, context }, use, testInfo) => { + testInfo.skip(homeserverType !== "synapse", "does not yet support MAS"); + await use(context); + }, }; diff --git a/playwright/plugins/utils/api.ts b/playwright/plugins/utils/api.ts new file mode 100644 index 0000000000..40dade1302 --- /dev/null +++ b/playwright/plugins/utils/api.ts @@ -0,0 +1,76 @@ +/* +Copyright 2025 New Vector Ltd. + +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 { APIRequestContext } from "@playwright/test"; + +import { Credentials } from "../homeserver"; + +export type Verb = "GET" | "POST" | "PUT" | "DELETE"; + +export class Api { + private _request?: APIRequestContext; + + constructor(private readonly baseUrl: string) {} + + public setRequest(request: APIRequestContext): void { + this._request = request; + } + + public async request(verb: "GET", path: string, token?: string, data?: never): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise { + const url = `${this.baseUrl}${path}`; + const res = await this._request.fetch(url, { + data, + method: verb, + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok()) { + throw new Error( + `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`, + ); + } + + return res.json(); + } +} + +export class ClientServerApi extends Api { + constructor(baseUrl: string) { + super(`${baseUrl}/_matrix/client`); + } + + public async loginUser(userId: string, password: string): Promise { + const json = await this.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>("POST", "/v3/login", undefined, { + type: "m.login.password", + identifier: { + type: "m.id.user", + user: userId, + }, + password: password, + }); + + return { + password, + accessToken: json.access_token, + userId: json.user_id, + deviceId: json.device_id, + homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), + username: userId.slice(1).split(":")[0], + }; + } +} diff --git a/playwright/services.ts b/playwright/services.ts index c5d1e278d1..a501bf6138 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -16,6 +16,8 @@ import { StartedMatrixAuthenticationServiceContainer } from "./testcontainers/ma import { HomeserverContainer, StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts"; import { MailhogContainer, StartedMailhogContainer } from "./testcontainers/mailhog.ts"; import { OAuthServer } from "./plugins/oauth_server"; +import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts"; +import { HomeserverType } from "./plugins/homeserver"; export interface TestFixtures { mailhogClient: mailhog.API; @@ -37,7 +39,9 @@ export interface Services { oAuthServer?: OAuthServer; } -export interface Options {} +export interface Options { + homeserverType: HomeserverType; +} export const test = base.extend({ logger: [ @@ -104,21 +108,36 @@ export const test = base.extend({ }, synapseConfig: [{}, { scope: "worker" }], + homeserverType: ["synapse", { option: true, scope: "worker" }], _homeserver: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const container = new SynapseContainer(); + async ({ homeserverType }, use) => { + let container: HomeserverContainer; + switch (homeserverType) { + case "synapse": + container = new SynapseContainer(); + break; + case "dendrite": + container = new DendriteContainer(); + break; + case "pinecone": + container = new PineconeContainer(); + break; + } + await use(container); }, { scope: "worker" }, ], homeserver: [ - async ({ logger, network, _homeserver: homeserver, synapseConfig, mas }, use) => { + async ({ homeserverType, logger, network, _homeserver: homeserver, synapseConfig, mas }, use) => { + if (homeserver instanceof SynapseContainer) { + homeserver.withConfig(synapseConfig); + } const container = await homeserver .withNetwork(network) .withNetworkAliases("homeserver") - .withLogConsumer(logger.getConsumer("synapse")) - .withConfig(synapseConfig) + .withLogConsumer(logger.getConsumer(homeserverType)) + .withMatrixAuthenticationService(mas) .start(); await use(container); @@ -136,10 +155,19 @@ export const test = base.extend({ { scope: "worker" }, ], - context: async ({ logger, context, request, homeserver, mailhogClient }, use, testInfo) => { + context: async ( + { homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver }, + use, + testInfo, + ) => { + testInfo.skip( + !(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, + `Test specifies Synapse config options so is unsupported with ${homeserverType}`, + ); homeserver.setRequest(request); await logger.onTestStarted(context); await use(context); await logger.onTestFinished(testInfo); + await homeserver.onTestFinished(testInfo); }, }); diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png new file mode 100644 index 0000000000..8e335bd232 Binary files /dev/null and b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png b/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png deleted file mode 100644 index 1dd98b51e1..0000000000 Binary files a/playwright/snapshots/register/register.spec.ts/use-case-selection-linux.png and /dev/null differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 655d45bc4a..12fd5c79d3 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index e2bd16fb5a..ac6f86e81c 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index 75a4852f9b..a8b3ae3ea7 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index 41ffca6c93..5307b7400a 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png new file mode 100644 index 0000000000..b2083a5dd4 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-1-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png new file mode 100644 index 0000000000..0294549459 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/change-key-2-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png new file mode 100644 index 0000000000..971745c412 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/default-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png new file mode 100644 index 0000000000..e6664a5f79 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/out-of-sync-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png new file mode 100644 index 0000000000..1a413094ae Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-1-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png new file mode 100644 index 0000000000..099c0c549e Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-2-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png new file mode 100644 index 0000000000..6cc32cc431 Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-key-3-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png new file mode 100644 index 0000000000..78dcd14aea Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png new file mode 100644 index 0000000000..643fe46a1d Binary files /dev/null and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/verify-device-encryption-tab-linux.png differ diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 7d95205251..62a8c5b8d1 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png deleted file mode 100644 index 024886d01e..0000000000 Binary files a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-app-download-dialog-1-linux.png and /dev/null differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png deleted file mode 100644 index 1042d92e76..0000000000 Binary files a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png and /dev/null differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png new file mode 100644 index 0000000000..3be63e2f50 Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-linux.png differ diff --git a/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png new file mode 100644 index 0000000000..17bea8979d Binary files /dev/null and b/playwright/snapshots/voip/pstn.spec.ts/dialpad-trigger-linux.png differ diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts index 09eea7da77..259ecb7fe0 100644 --- a/playwright/testcontainers/HomeserverContainer.ts +++ b/playwright/testcontainers/HomeserverContainer.ts @@ -6,17 +6,19 @@ Please see LICENSE files in the repository root for full details. */ import { AbstractStartedContainer, GenericContainer } from "testcontainers"; -import { APIRequestContext } from "@playwright/test"; +import { APIRequestContext, TestInfo } from "@playwright/test"; -import { StartedSynapseContainer } from "./synapse.ts"; import { HomeserverInstance } from "../plugins/homeserver"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; export interface HomeserverContainer extends GenericContainer { withConfigField(key: string, value: any): this; withConfig(config: Partial): this; - start(): Promise; + withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this; + start(): Promise; } export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance { setRequest(request: APIRequestContext): void; + onTestFinished(testInfo: TestInfo): Promise; } diff --git a/playwright/testcontainers/dendrite.ts b/playwright/testcontainers/dendrite.ts index 629ea70c65..58ab844a7c 100644 --- a/playwright/testcontainers/dendrite.ts +++ b/playwright/testcontainers/dendrite.ts @@ -13,6 +13,7 @@ import { randB64Bytes } from "../plugins/utils/rand.ts"; import { StartedSynapseContainer } from "./synapse.ts"; import { deepCopy } from "../plugins/utils/object.ts"; import { HomeserverContainer } from "./HomeserverContainer.ts"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; const DEFAULT_CONFIG = { version: 2, @@ -235,7 +236,12 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon return this; } - public override async start(): Promise { + // Dendrite does not support MAS at this time + public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { + return this; + } + + public override async start(): Promise { this.withCopyContentToContainer([ { target: "/etc/dendrite/dendrite.yaml", @@ -244,8 +250,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon ]); const container = await super.start(); - // Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it - return new StartedSynapseContainer( + return new StartedDendriteContainer( container, `http://${container.getHost()}:${container.getMappedPort(8008)}`, this.config.client_api.registration_shared_secret, @@ -258,3 +263,12 @@ export class PineconeContainer extends DendriteContainer { super("matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone"); } } + +// Surprisingly, Dendrite implements the same register user Synapse Admin API, so we can just extend it +export class StartedDendriteContainer extends StartedSynapseContainer { + protected async deletePublicRooms(): Promise { + // Dendrite does not support admin users managing the room directory + // https://github.com/element-hq/dendrite/blob/main/clientapi/routing/directory.go#L365 + return; + } +} diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index 2c795c2c47..9b05b521ba 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait, ExecResult } from "testcontainers"; import { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import * as YAML from "yaml"; import { getFreePort } from "../plugins/utils/port.ts"; import { deepCopy } from "../plugins/utils/object.ts"; +import { Credentials } from "../plugins/homeserver"; const DEFAULT_CONFIG = { http: { @@ -18,18 +19,10 @@ const DEFAULT_CONFIG = { { name: "web", resources: [ - { - name: "discovery", - }, - { - name: "human", - }, - { - name: "oauth", - }, - { - name: "compat", - }, + { name: "discovery" }, + { name: "human" }, + { name: "oauth" }, + { name: "compat" }, { name: "graphql", playground: true, @@ -182,9 +175,12 @@ const DEFAULT_CONFIG = { export class MatrixAuthenticationServiceContainer extends GenericContainer { private config: typeof DEFAULT_CONFIG; + private readonly args = ["-c", "/config/config.yaml"]; constructor(db: StartedPostgreSqlContainer) { - super("ghcr.io/element-hq/matrix-authentication-service:0.12.0"); + // We rely on `mas-cli manage add-email` which isn't in a release yet + // https://github.com/element-hq/matrix-authentication-service/pull/3235 + super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33"); this.config = deepCopy(DEFAULT_CONFIG); this.config.database.username = db.getUsername(); @@ -192,7 +188,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { this.withExposedPorts(8080, 8081) .withWaitStrategy(Wait.forHttp("/health", 8081)) - .withCommand(["server", "--config", "/config/config.yaml"]); + .withCommand(["server", ...this.args]); } public withConfig(config: object): this { @@ -220,15 +216,125 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { }, ]); - return new StartedMatrixAuthenticationServiceContainer(await super.start(), `http://localhost:${port}`); + return new StartedMatrixAuthenticationServiceContainer( + await super.start(), + `http://localhost:${port}`, + this.args, + ); } } export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer { + private adminTokenPromise?: Promise; + constructor( container: StartedTestContainer, public readonly baseUrl: string, + private readonly args: string[], ) { super(container); } + + public async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + private async manage(cmd: string, ...args: string[]): Promise { + const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]); + if (result.exitCode !== 0) { + throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`); + } + return result; + } + + private async manageRegisterUser( + username: string, + password: string, + displayName?: string, + admin = false, + ): Promise { + const args: string[] = []; + if (admin) args.push("-a"); + const result = await this.manage( + "register-user", + ...args, + "-y", + "-p", + password, + "-d", + displayName ?? "", + username, + ); + + const registerLines = result.output.trim().split("\n"); + const userId = registerLines + .find((line) => line.includes("Matrix ID: ")) + ?.split(": ") + .pop(); + + if (!userId) { + throw new Error(`Failed to register user: ${result.output}`); + } + + return userId; + } + + private async manageIssueCompatibilityToken( + username: string, + admin = false, + ): Promise<{ accessToken: string; deviceId: string }> { + const args: string[] = []; + if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges"); + const result = await this.manage("issue-compatibility-token", ...args, username); + + const parts = result.output.trim().split(/\s+/); + const accessToken = parts.find((part) => part.startsWith("mct_")); + const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1]; + + if (!accessToken || !deviceId) { + throw new Error(`Failed to issue compatibility token: ${result.output}`); + } + + return { accessToken, deviceId }; + } + + private async registerUserInternal( + username: string, + password: string, + displayName?: string, + admin = false, + ): Promise { + const userId = await this.manageRegisterUser(username, password, displayName, admin); + const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin); + + return { + userId, + accessToken, + deviceId, + homeServer: userId.slice(1).split(":").slice(1).join(":"), + displayName, + username, + password, + }; + } + + public async registerUser(username: string, password: string, displayName?: string): Promise { + return this.registerUserInternal(username, password, displayName, false); + } + + public async setThreepid(username: string, medium: string, address: string): Promise { + if (medium !== "email") { + throw new Error("Only email threepids are supported by MAS"); + } + + await this.manage("add-email", username, address); + } } diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 3469082d10..4307834b36 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { AbstractStartedContainer, GenericContainer, RestartOptions, StartedTestContainer, Wait } from "testcontainers"; -import { APIRequestContext } from "@playwright/test"; +import { APIRequestContext, TestInfo } from "@playwright/test"; import crypto from "node:crypto"; import * as YAML from "yaml"; import { set } from "lodash"; @@ -16,8 +16,10 @@ import { randB64Bytes } from "../plugins/utils/rand.ts"; import { Credentials } from "../plugins/homeserver"; import { deepCopy } from "../plugins/utils/object.ts"; import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; +import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts"; -const TAG = "develop@sha256:7be2e00da62dfbb2bad071c6d408fecb1fabf740a538d08768b9b3e0a8c45350"; +const TAG = "develop@sha256:5d62b61c4373eaca25df6c6bb99fc1be92f8f40b8abebd8897bf5b2af9eb137a"; const DEFAULT_CONFIG = { server_name: "localhost", @@ -142,6 +144,7 @@ export type SynapseConfig = Partial; export class SynapseContainer extends GenericContainer implements HomeserverContainer { private config: typeof DEFAULT_CONFIG; + private mas?: StartedMatrixAuthenticationServiceContainer; constructor() { super(`ghcr.io/element-hq/synapse:${TAG}`); @@ -201,6 +204,11 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont return this; } + public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { + this.mas = mas; + return this; + } + public override async start(): Promise { // Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually const port = await getFreePort(); @@ -219,17 +227,25 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont }, ]); - return new StartedSynapseContainer( - await super.start(), - `http://localhost:${port}`, - this.config.registration_shared_secret, - ); + const container = await super.start(); + const baseUrl = `http://localhost:${port}`; + if (this.mas) { + return new StartedSynapseWithMasContainer( + container, + baseUrl, + this.config.registration_shared_secret, + this.mas, + ); + } + + return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret); } } export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer { - private adminToken?: string; - private request?: APIRequestContext; + protected adminTokenPromise?: Promise; + protected readonly adminApi: Api; + public readonly csApi: ClientServerApi; constructor( container: StartedTestContainer, @@ -237,15 +253,36 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements private readonly registrationSharedSecret: string, ) { super(container); + this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`); + this.csApi = new ClientServerApi(this.baseUrl); } public restart(options?: Partial): Promise { - this.adminToken = undefined; + this.adminTokenPromise = undefined; return super.restart(options); } public setRequest(request: APIRequestContext): void { - this.request = request; + this.csApi.setRequest(request); + this.adminApi.setRequest(request); + } + + public async onTestFinished(testInfo: TestInfo): Promise { + // Clean up the server to prevent rooms leaking between tests + await this.deletePublicRooms(); + } + + protected async deletePublicRooms(): Promise { + const token = await this.getAdminToken(); + // We hide the rooms from the room directory to save time between tests and for portability between homeservers + const { chunk: rooms } = await this.csApi.request<{ + chunk: { room_id: string }[]; + }>("GET", "/v3/publicRooms", token, {}); + await Promise.all( + rooms.map((room) => + this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }), + ), + ); } private async registerUserInternal( @@ -254,28 +291,26 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements displayName?: string, admin = false, ): Promise { - const url = `${this.baseUrl}/_synapse/admin/v1/register`; - const { nonce } = await this.request.get(url).then((r) => r.json()); + const path = "/v1/register"; + const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {}); const mac = crypto .createHmac("sha1", this.registrationSharedSecret) .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`) .digest("hex"); - const res = await this.request.post(url, { - data: { - nonce, - username, - password, - mac, - admin, - displayname: displayName, - }, + const data = await this.adminApi.request<{ + home_server: string; + access_token: string; + user_id: string; + device_id: string; + }>("POST", path, undefined, { + nonce, + username, + password, + mac, + admin, + displayname: displayName, }); - if (!res.ok()) { - throw await res.json(); - } - - const data = await res.json(); return { homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"), accessToken: data.access_token, @@ -287,57 +322,67 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements }; } + protected async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + private async adminRequest(verb: "GET", path: string, data?: never): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise { + const adminToken = await this.getAdminToken(); + return this.adminApi.request(verb, path, adminToken, data); + } + public registerUser(username: string, password: string, displayName?: string): Promise { return this.registerUserInternal(username, password, displayName, false); } public async loginUser(userId: string, password: string): Promise { - const url = `${this.baseUrl}/_matrix/client/v3/login`; - const res = await this.request.post(url, { - data: { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: userId, - }, - password: password, - }, - }); - const json = await res.json(); - - return { - password, - accessToken: json.access_token, - userId: json.user_id, - deviceId: json.device_id, - homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), - username: userId.slice(1).split(":")[0], - }; + return this.csApi.loginUser(userId, password); } public async setThreepid(userId: string, medium: string, address: string): Promise { - if (this.adminToken === undefined) { - const result = await this.registerUserInternal("admin", "totalyinsecureadminpassword", undefined, true); - this.adminToken = result.accessToken; - } - - const url = `${this.baseUrl}/_synapse/admin/v2/users/${userId}`; - const res = await this.request.put(url, { - data: { - threepids: [ - { - medium, - address, - }, - ], - }, - headers: { - Authorization: `Bearer ${this.adminToken}`, - }, + await this.adminRequest("PUT", `/v2/users/${userId}`, { + threepids: [ + { + medium, + address, + }, + ], }); - - if (!res.ok()) { - throw await res.json(); - } + } +} + +export class StartedSynapseWithMasContainer extends StartedSynapseContainer { + constructor( + container: StartedTestContainer, + baseUrl: string, + registrationSharedSecret: string, + private readonly mas: StartedMatrixAuthenticationServiceContainer, + ) { + super(container, baseUrl, registrationSharedSecret); + } + + protected async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.mas.getAdminToken(); + } + return this.adminTokenPromise; + } + + public registerUser(username: string, password: string, displayName?: string): Promise { + return this.mas.registerUser(username, password, displayName); + } + + public async setThreepid(userId: string, medium: string, address: string): Promise { + return this.mas.setThreepid(userId, medium, address); } } diff --git a/res/css/_common.pcss b/res/css/_common.pcss index ac7c36daa5..fe8eff2286 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -596,7 +596,9 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not( + .mx_EncryptionUserSettingsTab button + ), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -616,8 +618,8 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not( - .mx_ShareDialog button + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not( + .mx_EncryptionUserSettingsTab button ):last-child { margin-right: 0px; } @@ -625,7 +627,9 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not( + .mx_EncryptionUserSettingsTab button + ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -637,7 +641,9 @@ legend { .mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not( + .mx_EncryptionUserSettingsTab button + ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -650,7 +656,7 @@ legend { .mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( .mx_ThemeChoicePanel_CustomTheme button - ):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), + ):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(.mx_EncryptionUserSettingsTab button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -666,7 +672,9 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not( + .mx_EncryptionUserSettingsTab button + ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b966d62ddd..e0f9a5788d 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -126,7 +126,6 @@ @import "./views/context_menus/_RoomNotificationContextMenu.pcss"; @import "./views/dialogs/_AddExistingToSpaceDialog.pcss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss"; -@import "./views/dialogs/_AppDownloadDialog.pcss"; @import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_BulkRedactDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss"; @@ -217,8 +216,6 @@ @import "./views/elements/_TagComposer.pcss"; @import "./views/elements/_TextWithTooltip.pcss"; @import "./views/elements/_ToggleSwitch.pcss"; -@import "./views/elements/_UseCaseSelection.pcss"; -@import "./views/elements/_UseCaseSelectionButton.pcss"; @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; @@ -350,10 +347,14 @@ @import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @import "./views/settings/_SettingsFieldset.pcss"; +@import "./views/settings/_SettingsHeader.pcss"; +@import "./views/settings/_SettingsSubheader.pcss"; @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/_UserProfileSettings.pcss"; +@import "./views/settings/encryption/_ChangeRecoveryKey.pcss"; +@import "./views/settings/encryption/_EncryptionCard.pcss"; @import "./views/settings/tabs/_SettingsBanner.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @@ -379,11 +380,6 @@ @import "./views/toasts/_IncomingLegacyCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; -@import "./views/user-onboarding/_UserOnboardingButton.pcss"; -@import "./views/user-onboarding/_UserOnboardingHeader.pcss"; -@import "./views/user-onboarding/_UserOnboardingList.pcss"; -@import "./views/user-onboarding/_UserOnboardingPage.pcss"; -@import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; @import "./views/voip/_CallDuration.pcss"; diff --git a/res/css/views/dialogs/_AppDownloadDialog.pcss b/res/css/views/dialogs/_AppDownloadDialog.pcss deleted file mode 100644 index e0591ed7e9..0000000000 --- a/res/css/views/dialogs/_AppDownloadDialog.pcss +++ /dev/null @@ -1,77 +0,0 @@ -.mx_AppDownloadDialog { - display: flex; - flex-direction: column; - gap: $spacing-32; - color: $primary-content; - - &.mx_Dialog_fixedWidth { - width: 640px; - } - - .mx_AppDownloadDialog_desktop { - display: flex; - flex-direction: column; - align-items: center; - gap: $spacing-16; - } - - .mx_AppDownloadDialog_mobile { - display: flex; - flex-direction: row; - gap: $spacing-24; - - .mx_AppDownloadDialog_app { - display: flex; - flex-direction: column; - flex-grow: 1; - flex-basis: 50%; - align-items: center; - gap: $spacing-16; - - .mx_QRCode { - /* intentionally hardcoded color to ensure the QR code is readable in any situation */ - background: #ffffff; - - padding: $spacing-24; - border: 1px solid $quinary-content; - border-radius: 4px; - align-self: stretch; - display: flex; - align-items: center; - flex-direction: column; - - .mx_VerificationQRCode { - height: 144px; - width: 144px; - image-rendering: pixelated; - border-radius: 0; - } - } - - .mx_AppDownloadDialog_info { - font-size: $font-12px; - color: $tertiary-content; - } - - .mx_AppDownloadDialog_links { - display: flex; - flex-direction: row; - gap: $spacing-8; - - .mx_AccessibleButton { - svg { - height: 40px; - } - } - } - } - } - - .mx_AppDownloadDialog_legal { - p { - margin: 0; - font-size: $font-12px; - color: $tertiary-content; - } - } -} diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss deleted file mode 100644 index ec577a66bd..0000000000 --- a/res/css/views/elements/_UseCaseSelection.pcss +++ /dev/null @@ -1,122 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UseCaseSelection { - display: grid; - grid-template-rows: 1fr 1fr max-content 2fr; - height: 100%; - grid-gap: $spacing-40; - - .mx_UseCaseSelection_title { - display: flex; - flex-direction: column; - justify-content: flex-end; - - h1 { - font-weight: var(--cpd-font-weight-semibold); - font-size: $font-32px; - text-align: center; - } - } - - .mx_UseCaseSelection_info { - display: flex; - flex-direction: column; - gap: $spacing-8; - align-self: flex-end; - - h2 { - margin: 0; - font-weight: 500; - font-size: $font-24px; - text-align: center; - } - - h3 { - margin: 0; - font-weight: 400; - font-size: $font-16px; - color: $secondary-content; - text-align: center; - } - } - - .mx_UseCaseSelection_options { - display: grid; - grid-template-columns: repeat(auto-fit, 232px); - gap: $spacing-32; - align-self: stretch; - justify-content: center; - } - - .mx_UseCaseSelection_skip { - display: flex; - flex-direction: column; - align-self: flex-start; - } -} - -.mx_UseCaseSelection_slideIn { - animation-delay: 800ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UseCaseSelection_slideInLong; - animation-fill-mode: backwards; - will-change: opacity; -} - -.mx_UseCaseSelection_slideInDelayed { - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UseCaseSelection_slideInShort; - animation-fill-mode: backwards; - will-change: transform, opacity; -} - -.mx_UseCaseSelection_selected { - .mx_UseCaseSelection_slideIn, - .mx_UseCaseSelection_slideInDelayed { - animation-delay: 800ms; - animation-duration: 300ms; - animation-fill-mode: forwards; - animation-name: mx_UseCaseSelection_fadeOut; - will-change: opacity; - } -} - -@keyframes mx_UseCaseSelection_slideInLong { - 0% { - transform: translate(0, 20px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UseCaseSelection_slideInShort { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UseCaseSelection_fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/res/css/views/elements/_UseCaseSelectionButton.pcss b/res/css/views/elements/_UseCaseSelectionButton.pcss deleted file mode 100644 index 9393b8a53c..0000000000 --- a/res/css/views/elements/_UseCaseSelectionButton.pcss +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UseCaseSelectionButton { - display: flex; - flex-direction: column; - align-items: center; - padding: $spacing-24 $spacing-16; - background: $background; - border: 1px solid $quinary-content; - border-radius: 8px; - text-align: center; - position: relative; - transition-property: box-shadow, transform; - transition-duration: 300ms; - - .mx_UseCaseSelectionButton_icon { - /* workaround: design expects a layering of two colors */ - background: linear-gradient(0deg, rgba(172, 59, 168, 0.15), rgba(172, 59, 168, 0.15)), #ffffff; - border-radius: 14px; - padding: $spacing-8; - margin-bottom: $spacing-16; - - &::before { - content: ""; - display: block; - /* this has to remain the same color across all themes, - as its background has a fixed color as well */ - background: #1e1e1e; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - width: 22px; - height: 22px; - } - - &.mx_UseCaseSelectionButton_messaging::before { - mask-image: url("$(res)/img/element-icons/chat-bubble.svg"); - } - - &.mx_UseCaseSelectionButton_work::before { - mask-image: url("$(res)/img/element-icons/view-community.svg"); - } - - &.mx_UseCaseSelectionButton_community::before { - mask-image: url("@vector-im/compound-design-tokens/icons/public.svg"); - mask-size: 24px; - } - } - - &:hover, - &:focus { - box-shadow: 0 $spacing-4 $spacing-8 rgba(0, 0, 0, 0.08); - transform: translate(0, -$spacing-8); - } - - .mx_UseCaseSelectionButton_selectedIcon { - right: -12px; - top: -12px; - position: absolute; - border-radius: 24px; - background: $accent; - padding: 6px; - transition-property: opacity, transform; - transition-duration: 150ms; - opacity: 0; - transform: scale(0.6); - - &::before { - content: ""; - display: block; - background: $background; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - width: 12px; - height: 12px; - - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - } - } - - &.mx_UseCaseSelectionButton_selected { - border: 2px solid $accent; - padding: calc($spacing-24 - 1px) calc($spacing-16 - 1px); - box-shadow: 0 $spacing-4 $spacing-8 rgba(0, 0, 0, 0.08); - - .mx_UseCaseSelectionButton_selectedIcon { - opacity: 1; - transform: scale(1); - } - } -} diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss new file mode 100644 index 0000000000..a705deda6c --- /dev/null +++ b/res/css/views/settings/_SettingsHeader.pcss @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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. + */ + +.mx_SettingsHeader { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + /* Override margin from common.pcss */ + margin: 0; + + > span { + font: var(--cpd-font-body-sm-medium); + color: var(--cpd-color-text-action-accent); + } +} diff --git a/res/css/views/settings/_SettingsSubheader.pcss b/res/css/views/settings/_SettingsSubheader.pcss new file mode 100644 index 0000000000..276421e5be --- /dev/null +++ b/res/css/views/settings/_SettingsSubheader.pcss @@ -0,0 +1,27 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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. + */ + +.mx_SettingsSubheader { + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); + + > span { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + font: var(--cpd-font-body-sm-medium); + } + + .mx_SettingsSubheader_success { + color: var(--cpd-color-text-success-primary); + } + + .mx_SettingsSubheader_error { + color: var(--cpd-color-text-critical-primary); + } +} diff --git a/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss new file mode 100644 index 0000000000..d657743140 --- /dev/null +++ b/res/css/views/settings/encryption/_ChangeRecoveryKey.pcss @@ -0,0 +1,79 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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. + */ + +.mx_ChangeRecoveryKey { + .mx_InformationPanel_description { + text-align: center; + } + + .mx_ChangeRecoveryKey_Form { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + + .mx_ChangeRecoveryKey_footer { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + justify-content: center; + } + } + + .mx_KeyPanel { + display: grid; + grid-template: + "header button" auto + "content button" auto / 1fr; + + column-gap: var(--cpd-space-3x); + row-gap: var(--cpd-space-1x); + align-items: center; + + > span { + grid-area: header; + } + + > div { + grid-area: content; + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); + color: var(--cpd-color-text-secondary); + + .mx_KeyPanel_key { + font-family: Inconsolata, monospace; + /* + * From figma https://www.figma.com/design/qTWRfItpO3RdCjnTKPu4mL/Settings?node-id=375-77471&t=t7lozYrSI1AVZZ3U-4 + */ + height: 70px; + box-sizing: border-box; + border-radius: var(--cpd-space-2x); + padding: var(--cpd-space-3x) var(--cpd-space-4x); + background-color: var(--cpd-color-bg-subtle-secondary); + } + } + + > button { + margin: 0 var(--cpd-space-1x); + grid-area: button; + color: var(--cpd-color-icon-secondary-alpha); + } + } + + .mx_KeyForm { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + } + + .mx_ChangeRecoveryKey_footer { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + justify-content: center; + } +} diff --git a/res/css/views/settings/encryption/_EncryptionCard.pcss b/res/css/views/settings/encryption/_EncryptionCard.pcss new file mode 100644 index 0000000000..f125aea176 --- /dev/null +++ b/res/css/views/settings/encryption/_EncryptionCard.pcss @@ -0,0 +1,33 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * 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. + */ + +.mx_EncryptionCard { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + padding: var(--cpd-space-10x); + border-radius: var(--cpd-space-4x); + /* From figma */ + box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15); + border: 1px solid var(--cpd-color-gray-400); + + .mx_EncryptionCard_header { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + align-items: center; + + > h2 { + margin: 0; + } + + > span { + color: var(--cpd-color-text-secondary); + text-align: center; + } + } +} diff --git a/res/css/views/settings/tabs/_SettingsSection.pcss b/res/css/views/settings/tabs/_SettingsSection.pcss index 1dd1166138..997343190d 100644 --- a/res/css/views/settings/tabs/_SettingsSection.pcss +++ b/res/css/views/settings/tabs/_SettingsSection.pcss @@ -15,6 +15,20 @@ Please see LICENSE files in the repository root for full details. a { color: $links; } + + &.mx_SettingsSection_newUi { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + align-items: start; + } + + .mx_SettingsSection_header { + display: flex; + flex-direction: column; + gap: var(--cpd-space-3x); + color: var(--cpd-color-text-secondary); + } } .mx_SettingsSection_subSections { diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 6055c289fc..e0abf08e83 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details. color: $links; } - form { + form:not(.mx_EncryptionUserSettingsTab form) { display: flex; flex-direction: column; gap: $spacing-8; diff --git a/res/css/views/user-onboarding/_UserOnboardingButton.pcss b/res/css/views/user-onboarding/_UserOnboardingButton.pcss deleted file mode 100644 index 75b1b1eb68..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingButton.pcss +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingButton { - display: flex; - flex-direction: column; - align-content: stretch; - align-items: stretch; - border-radius: 8px; - margin: $spacing-8 $spacing-8 0; - padding: $spacing-12; - - &.mx_UserOnboardingButton_selected, - &:hover, - &:focus-within { - background-color: $panel-actions; - } - - .mx_UserOnboardingButton_content { - display: flex; - flex-direction: row; - gap: 5px; - align-items: center; - - .mx_Heading_h4 { - margin-right: auto; - font: var(--cpd-font-body-md-regular); - color: $primary-content; - } - - .mx_UserOnboardingButton_percentage { - font-size: $font-12px; - color: $secondary-content; - } - - .mx_UserOnboardingButton_close { - position: relative; - box-sizing: border-box; - width: 14px; - height: 14px; - border-radius: 7px; - border: 1px solid $secondary-content; - flex-shrink: 0; - - &::before { - background-color: $secondary-content; - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 12px; - width: inherit; - height: inherit; - position: absolute; - left: -1px; - top: -1px; - mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); - } - } - } - - .mx_ProgressBar { - width: auto; - margin-top: $spacing-8; - background: $background; - } - - &.mx_UserOnboardingButton_completed .mx_ProgressBar { - display: none; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingHeader.pcss b/res/css/views/user-onboarding/_UserOnboardingHeader.pcss deleted file mode 100644 index 6402e8c859..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingHeader.pcss +++ /dev/null @@ -1,93 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingHeader { - display: flex; - flex-direction: row; - padding: $spacing-32; - border-radius: 16px; - background: $system; - gap: $spacing-64; - - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingHeader_slideIn; - animation-fill-mode: backwards; - will-change: opacity, transform; - - @media (max-width: 1280px) { - margin: $spacing-32; - } - - .mx_UserOnboardingHeader_dot { - color: $accent; - } - - .mx_UserOnboardingHeader_content { - display: flex; - flex-direction: column; - flex-basis: 50%; - flex-shrink: 1; - flex-grow: 1; - min-width: 0; - gap: $spacing-24; - margin-right: auto; - - p { - margin: 0; - } - - .mx_AccessibleButton { - margin-top: auto; - align-self: flex-start; - padding: $spacing-12 $spacing-24; - } - } - - .mx_UserOnboardingHeader_image { - flex-basis: 30%; - flex-shrink: 1; - flex-grow: 1; - align-self: center; - height: calc(100% + $spacing-64 + $spacing-64); - aspect-ratio: 4 / 3; - object-fit: contain; - min-width: 0; - min-height: 0; - margin-top: -$spacing-64; - margin-bottom: -$spacing-64; - - animation-delay: 1500ms; - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingHeader_slideInLong; - animation-fill-mode: backwards; - will-change: opacity, transform; - } -} - -@keyframes mx_UserOnboardingHeader_slideIn { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} - -@keyframes mx_UserOnboardingHeader_slideInLong { - 0% { - transform: translate(0, 32px); - } - 100% { - transform: translate(0, 0); - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingList.pcss b/res/css/views/user-onboarding/_UserOnboardingList.pcss deleted file mode 100644 index bd198de2fe..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingList.pcss +++ /dev/null @@ -1,67 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingList { - display: flex; - flex-direction: column; - margin: 0 $spacing-32; - - animation-duration: 300ms; - animation-timing-function: cubic-bezier(0, 0, 0.58, 1); - animation-name: mx_UserOnboardingList_slideIn; - animation-fill-mode: backwards; - will-change: opacity; - - .mx_UserOnboardingList_header { - display: flex; - flex-direction: row; - gap: 12px; - align-items: center; - - .mx_UserOnboardingList_hint { - color: $secondary-content; - } - } - - .mx_UserOnboardingList_progress { - display: flex; - flex-direction: column; - counter-reset: user-onboarding; - - .mx_ProgressBar { - width: auto; - margin-top: $spacing-16; - height: 16px; - - @mixin ProgressBarBorderRadius 16px; - } - } - - .mx_UserOnboardingList_list { - display: grid; - grid-template-columns: max-content 1fr max-content; - - appearance: none; - list-style: none; - margin: $spacing-32 0 0; - padding: 0; - - grid-gap: $spacing-24; - } -} - -@keyframes mx_UserOnboardingList_slideIn { - 0% { - transform: translate(0, 8px); - opacity: 0; - } - 100% { - transform: translate(0, 0); - opacity: 1; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingPage.pcss b/res/css/views/user-onboarding/_UserOnboardingPage.pcss deleted file mode 100644 index 285a1b34d4..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingPage.pcss +++ /dev/null @@ -1,27 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingPage { - width: 100%; - height: 100%; - - align-self: stretch; - max-width: 1200px; - margin: 0 auto auto; - - display: flex; - flex-direction: column; - box-sizing: border-box; - - gap: $spacing-64; - padding: $spacing-64 100px; - - @media (max-width: 1280px) { - padding: $spacing-48 $spacing-32; - } -} diff --git a/res/css/views/user-onboarding/_UserOnboardingTask.pcss b/res/css/views/user-onboarding/_UserOnboardingTask.pcss deleted file mode 100644 index 756a9d3604..0000000000 --- a/res/css/views/user-onboarding/_UserOnboardingTask.pcss +++ /dev/null @@ -1,112 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_UserOnboardingTask { - display: contents; - - .mx_UserOnboardingTask_number { - counter-increment: user-onboarding; - grid-column: 1; - color: $secondary-content; - width: 32px; - height: 32px; - text-align: center; - border: 2px solid $quinary-content; - border-radius: 32px; - line-height: 32px; - align-self: center; - position: relative; - - &::before { - content: counter(user-onboarding); - } - } - - .mx_UserOnboardingTask_content { - grid-column: 2; - display: flex; - flex-direction: column; - flex-grow: 1; - flex-shrink: 1; - - transition: all 500ms; - - .mx_UserOnboardingTask_title { - font: var(--cpd-font-body-md-medium); - } - - .mx_UserOnboardingTask_description { - font-size: $font-12px; - } - } - - .mx_UserOnboardingTask_action.mx_AccessibleButton { - grid-column: 3; - min-width: 180px; - - @media (max-width: 800px) { - grid-column: 2; - margin-top: -16px; - } - } - - &.mx_UserOnboardingTask_completed { - .mx_UserOnboardingTask_number { - &::before { - content: ""; - position: absolute; - inset: -2px; - background: var(--cpd-color-icon-accent-tertiary); - border-radius: 32px; - - animation-duration: 300ms; - animation-fill-mode: both; - animation-name: mx_UserOnboardingTask_spring; - will-change: opacity, transform; - } - - &::after { - background-color: var(--cpd-color-icon-on-solid-primary); - content: ""; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 24px; - width: inherit; - height: inherit; - position: absolute; - left: 0; - top: 0; - mask-image: url("@vector-im/compound-design-tokens/icons/check.svg"); - - animation-duration: 300ms; - animation-fill-mode: both; - animation-name: mx_UserOnboardingTask_spring; - will-change: opacity, transform; - } - } - - .mx_UserOnboardingTask_content { - opacity: 0.6; - } - } -} - -@keyframes mx_UserOnboardingTask_spring { - 0% { - opacity: 0; - transform: scale(0.6); - } - 50% { - opacity: 1; - transform: scale(1.2); - } - 100% { - opacity: 1; - transform: scale(1); - } -} diff --git a/res/img/badges/f-droid.svg b/res/img/badges/f-droid.svg deleted file mode 100644 index d97143c42b..0000000000 --- a/res/img/badges/f-droid.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/badges/google-play.svg b/res/img/badges/google-play.svg deleted file mode 100644 index 973d9d3afc..0000000000 --- a/res/img/badges/google-play.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/badges/ios.svg b/res/img/badges/ios.svg deleted file mode 100644 index e723d1cc04..0000000000 --- a/res/img/badges/ios.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/user-onboarding/CommunityMessaging.png b/res/img/user-onboarding/CommunityMessaging.png deleted file mode 100644 index ec13eef8d6..0000000000 Binary files a/res/img/user-onboarding/CommunityMessaging.png and /dev/null differ diff --git a/res/img/user-onboarding/PersonalMessaging.png b/res/img/user-onboarding/PersonalMessaging.png deleted file mode 100644 index 8dce18ad90..0000000000 Binary files a/res/img/user-onboarding/PersonalMessaging.png and /dev/null differ diff --git a/res/img/user-onboarding/WorkMessaging.png b/res/img/user-onboarding/WorkMessaging.png deleted file mode 100644 index 7c3b813a84..0000000000 Binary files a/res/img/user-onboarding/WorkMessaging.png and /dev/null differ diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 28bb5f655e..e50f0d3f9b 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -34,11 +34,9 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import { accessSecretStorage, isSecretStorageBeingAccessed } from "./SecurityManager"; -import { isSecureBackupRequired } from "./utils/WellKnownUtils"; +import { isSecretStorageBeingAccessed } from "./SecurityManager"; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; -import { isLoggedIn } from "./utils/login"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation"; @@ -283,7 +281,21 @@ export default class DeviceListener { const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); - const allSystemsReady = crossSigningReady && secretStorageReady; + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + + const defaultKeyId = await cli.secretStorage.getDefaultKeyId(); + + const isCurrentDeviceTrusted = + crossSigningReady && + Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); + + const allSystemsReady = crossSigningReady && secretStorageReady && allCrossSigningSecretsCached; await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { @@ -294,31 +306,31 @@ export default class DeviceListener { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); - // cross signing isn't enabled - nag to enable it - // There are 3 different toasts for: - if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { - // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session) + if (!crossSigningReady) { + // This account is legacy and doesn't have cross-signing set up at all. + // Prompt the user to set it up. + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } else if (!isCurrentDeviceTrusted) { + // cross signing is ready but the current device is not trusted: prompt the user to verify showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); - this.checkKeyBackupStatus(); + } else if (!allCrossSigningSecretsCached) { + // cross signing ready & device trusted, but we are missing secrets from our local cache. + // prompt the user to enter their recovery key. + showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + } else if (defaultKeyId === null) { + // the user just hasn't set up 4S yet: prompt them to do so + showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); } else { - const backupInfo = await this.getKeyBackupInfo(); - if (backupInfo) { - // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery. - // Since we now enable key backup at registration time, this will be the common case for - // new users. - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); - } else { - // Toast 3: No cross-signing or key backup on account (set up encryption) - await cli.waitForClientWellKnown(); - if (isSecureBackupRequired(cli) && isLoggedIn()) { - // If we're meant to set up, and Secure Backup is required, - // trigger the flow directly without a toast once logged in. - hideSetupEncryptionToast(); - accessSecretStorage(); - } else { - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); - } - } + // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did + // in 'other' situations. Possibly we should consider prompting for a full reset in this case? + logger.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", { + crossSigningReady, + secretStorageReady, + allCrossSigningSecretsCached, + isCurrentDeviceTrusted, + defaultKeyId, + }); + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); } } @@ -334,12 +346,6 @@ export default class DeviceListener { // Unverified devices that have appeared since then const newUnverifiedDeviceIds = new Set(); - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); - // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts if (crossSigningReady) { diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index c2df066fa2..8aaa32f471 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixError, RuleId, TweakName, SyncState } from "matrix-js-sdk/src/matrix"; +import { MatrixError, RuleId, TweakName, SyncState, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { CallError, CallErrorCode, @@ -22,7 +22,6 @@ import { MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import EventEmitter from "events"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; @@ -137,14 +136,23 @@ export enum LegacyCallHandlerEvent { CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", + ProtocolSupport = "protocol_support", } +type EventEmitterMap = { + [LegacyCallHandlerEvent.CallsChanged]: (calls: Map) => void; + [LegacyCallHandlerEvent.CallChangeRoom]: (call: MatrixCall) => void; + [LegacyCallHandlerEvent.SilencedCallsChanged]: (calls: Set) => void; + [LegacyCallHandlerEvent.CallState]: (mappedRoomId: string | null, status: CallState) => void; + [LegacyCallHandlerEvent.ProtocolSupport]: () => void; +}; + /** * LegacyCallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ -export default class LegacyCallHandler extends EventEmitter { +export default class LegacyCallHandler extends TypedEventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -271,15 +279,13 @@ export default class LegacyCallHandler extends EventEmitter { this.supportsPstnProtocol = null; } - dis.dispatch({ action: Action.PstnSupportUpdated }); - if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL], ); } - dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); + this.emit(LegacyCallHandlerEvent.ProtocolSupport); } catch (e) { if (maxTries === 1) { logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); @@ -296,8 +302,8 @@ export default class LegacyCallHandler extends EventEmitter { return !!SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } - public getSupportsPstnProtocol(): boolean | null { - return this.supportsPstnProtocol; + public getSupportsPstnProtocol(): boolean { + return this.supportsPstnProtocol ?? false; } public getSupportsVirtualRooms(): boolean | null { @@ -568,6 +574,7 @@ export default class LegacyCallHandler extends EventEmitter { if (!mappedRoomId || !this.matchesCallForThisRoom(call)) return; this.setCallState(call, newState); + // XXX: this is used by the IPC into Electron to keep device awake dis.dispatch({ action: "call_state", room_id: mappedRoomId, diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index cdb7d39115..80b08c8840 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -1049,9 +1049,9 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise, ScreenName> = { [Views.WELCOME]: "Welcome", [Views.LOGIN]: "Login", [Views.REGISTER]: "Register", - [Views.USE_CASE_SELECTION]: "UseCaseSelection", [Views.FORGOT_PASSWORD]: "ForgotPassword", [Views.COMPLETE_SECURITY]: "CompleteSecurity", [Views.E2E_SETUP]: "E2ESetup", diff --git a/src/Views.ts b/src/Views.ts index 90480b2669..6c0df53a66 100644 --- a/src/Views.ts +++ b/src/Views.ts @@ -33,9 +33,6 @@ enum Views { // flow to setup SSSS / cross-signing on this account E2E_SETUP, - // screen that allows users to select which use case they’ll use matrix for - USE_CASE_SELECTION, - // we are logged in with an active matrix client. The logged_in state also // includes guests users as they too are logged in at the client level. LOGGED_IN, diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index dada99b3e7..e2227ea42d 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -392,6 +392,7 @@ export const useRovingTabIndex = ( }); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler const isActive = context.state.activeNode === nodeRef.current; return [onFocus, isActive, ref, nodeRef]; }; diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index b25e93bc75..25e0d7d1a1 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -142,6 +142,7 @@ export const AutocompleteInput: React.FC = ({ {isFocused && suggestions.length ? (
diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 3d0c169267..51aef8f454 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -607,6 +607,7 @@ export const useContextMenu = (inputRef?: RefObject setIsOpen(false); }; + // eslint-disable-next-line react-compiler/react-compiler return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 32e5bbc519..c1eb34597f 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -286,9 +286,7 @@ class FilePanel extends React.Component { ref={this.card} header={_t("right_panel|files_button")} > - {this.card.current && ( - - )} + { @@ -66,6 +66,7 @@ export default class LeftPanel extends React.Component { this.state = { activeSpace: SpaceStore.instance.activeSpace, showBreadcrumbs: LeftPanel.breadcrumbsMode, + supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol(), }; } @@ -77,6 +78,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.on(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); if (this.listContainerRef.current) { UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -91,6 +93,7 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + LegacyCallHandler.instance.off(LegacyCallHandlerEvent.ProtocolSupport, this.updateProtocolSupport); UIStore.instance.stopTrackingElementDimensions("ListContainer"); UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); @@ -102,6 +105,10 @@ export default class LeftPanel extends React.Component { } } + private updateProtocolSupport = (): void => { + this.setState({ supportsPstnProtocol: LegacyCallHandler.instance.getSupportsPstnProtocol() }); + }; + private updateActiveSpace = (activeSpace: SpaceKey): void => { this.setState({ activeSpace }); }; @@ -331,9 +338,8 @@ export default class LeftPanel extends React.Component { private renderSearchDialExplore(): React.ReactNode { let dialPadButton: JSX.Element | undefined; - // If we have dialer support, show a button to bring up the dial pad - // to start a new call - if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + // If we have dialer support, show a button to bring up the dial pad to start a new call + if (this.state.supportsPstnProtocol) { dialPadButton = ( { {shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()} {this.renderBreadcrumbs()} {!this.props.isMinimized && } -