Merge remote-tracking branch 'origin/develop' into develop

pull/28789/head
Michael Telatynski 2024-12-20 11:22:31 +00:00
commit 179b17434e
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
67 changed files with 509 additions and 522 deletions

View File

@ -3,6 +3,9 @@
# as an artifact and run end-to-end tests. # as an artifact and run end-to-end tests.
name: End to End Tests name: End to End Tests
on: on:
# CRON to run all Projects at 6am UTC
schedule:
- cron: "0 6 * * *"
pull_request: {} pull_request: {}
merge_group: merge_group:
types: [checks_requested] types: [checks_requested]
@ -32,6 +35,8 @@ concurrency:
env: env:
# fetchdep.sh needs to know our PR number # fetchdep.sh needs to know our PR number
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
# Use 6 runners in the default case, but 4 when running on a schedule where we run all 5 projects (20 runners total)
NUM_RUNNERS: ${{ github.event_name == 'schedule' && 4 || 6 }}
permissions: {} # No permissions required permissions: {} # No permissions required
@ -40,6 +45,9 @@ jobs:
name: "Build Element-Web" name: "Build Element-Web"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
if: inputs.skip != true if: inputs.skip != true
outputs:
num-runners: ${{ env.NUM_RUNNERS }}
runners-matrix: ${{ steps.runner-vars.outputs.matrix }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -79,8 +87,17 @@ jobs:
path: webapp path: webapp
retention-days: 1 retention-days: 1
- name: Calculate runner variables
id: runner-vars
uses: actions/github-script@v7
with:
script: |
const numRunners = parseInt(process.env.NUM_RUNNERS, 10);
const matrix = Array.from({ length: numRunners }, (_, i) => i + 1);
core.setOutput("matrix", JSON.stringify(matrix));
playwright: playwright:
name: "Run Tests ${{ matrix.runner }}/${{ strategy.job-total }}" name: "Run Tests [${{ matrix.project }}] ${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}"
needs: build needs: build
if: inputs.skip != true if: inputs.skip != true
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@ -92,7 +109,19 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
# Run multiple instances in parallel to speed up the tests # Run multiple instances in parallel to speed up the tests
runner: [1, 2, 3, 4, 5, 6] runner: ${{ fromJSON(needs.build.outputs.runners-matrix) }}
project:
- Chrome
- Firefox
- WebKit
isCron:
- ${{ github.event_name == 'schedule' }}
# Skip the Firefox & Safari runs unless this was a cron trigger
exclude:
- isCron: false
project: Firefox
- isCron: false
project: WebKit
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@ -124,24 +153,30 @@ jobs:
with: with:
path: | path: |
~/.cache/ms-playwright ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-chromium key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
- name: Install Playwright browser - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps --no-shell chromium run: yarn playwright install --with-deps --no-shell
- name: Install system dependencies for WebKit
# Some WebKit dependencies seem to lay outside the cache and will need to be installed separately
if: matrix.project == 'WebKit' && steps.playwright-cache.outputs.cache-hit == 'true'
run: yarn playwright install-deps webkit
# We skip tests tagged with @mergequeue when running on PRs, but run them in MQ and everywhere else # We skip tests tagged with @mergequeue when running on PRs, but run them in MQ and everywhere else
- name: Run Playwright tests - name: Run Playwright tests
run: | run: |
yarn playwright test \ yarn playwright test \
--shard "${{ matrix.runner }}/${{ strategy.job-total }}" \ --shard "${{ matrix.runner }}/${{ needs.build.outputs.num-runners }}" \
--project="${{ matrix.project }}" \
${{ github.event_name == 'pull_request' && '--grep-invert @mergequeue' || '' }} ${{ github.event_name == 'pull_request' && '--grep-invert @mergequeue' || '' }}
- name: Upload blob report to GitHub Actions Artifacts - name: Upload blob report to GitHub Actions Artifacts
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: all-blob-reports-${{ matrix.runner }} name: all-blob-reports-${{ matrix.project }}-${{ matrix.runner }}
path: blob-report path: blob-report
retention-days: 1 retention-days: 1

View File

@ -53,15 +53,11 @@ yarn run test:playwright:open --headed --debug
See more command line options at <https://playwright.dev/docs/test-cli>. See more command line options at <https://playwright.dev/docs/test-cli>.
### Running with Rust cryptography ## Projects
`matrix-js-sdk` is currently in the By default, Playwright will run all "Projects", this means tests will run against Chrome, Firefox and "Safari" (Webkit).
[process](https://github.com/vector-im/element-web/issues/21972) of being We only run tests against Chrome in pull request CI, but all projects in the merge queue.
updated to replace its end-to-end encryption implementation to use the [Matrix Some tests are excluded from running on certain browsers due to incompatibilities in the test harness.
Rust SDK](https://github.com/matrix-org/matrix-rust-sdk). This is not currently
enabled by default, but it is possible to have Playwright configure Element to use
the Rust crypto implementation by passing `--project="Rust Crypto"` or using
the top left options in open mode.
## How the Tests Work ## How the Tests Work
@ -224,3 +220,14 @@ We use test tags to categorise tests for running subsets more efficiently.
- `@mergequeue`: Tests that are slow or flaky and cover areas of the app we update seldom, should not be run on every PR commit but will be run in the Merge Queue. - `@mergequeue`: Tests that are slow or flaky and cover areas of the app we update seldom, should not be run on every PR commit but will be run in the Merge Queue.
- `@screenshot`: Tests that use `toMatchScreenshot` to speed up a run of `test:playwright:screenshots`. A test with this tag must not also have the `@mergequeue` tag as this would cause false positives in the stale screenshot detection. - `@screenshot`: Tests that use `toMatchScreenshot` to speed up a run of `test:playwright:screenshots`. A test with this tag must not also have the `@mergequeue` tag as this would cause false positives in the stale screenshot detection.
- `@no-$project`: Tests which are unsupported in $Project. These tests will be skipped when running in $Project.
Anything testing Matrix media will need to have `@no-firefox` and `@no-webkit` as those rely on the service worker which
has to be disabled in Playwright on Firefox & Webkit to retain routing functionality.
Anything testing VoIP/microphone will need to have `@no-webkit` as fake microphone functionality is not available
there at this time.
## Colima
If you are running under Colima, you may need to set the environment variable `TMPDIR` to `/tmp/colima` or a path
within `$HOME` to allow bind mounting temporary directories into the Docker containers.

View File

@ -64,7 +64,7 @@
"test:playwright:open": "yarn test:playwright --ui", "test:playwright:open": "yarn test:playwright --ui",
"test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run", "test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run",
"test:playwright:screenshots:build": "docker build playwright -t element-web-playwright", "test:playwright:screenshots:build": "docker build playwright -t element-web-playwright",
"test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot", "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome",
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts", "analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
@ -282,7 +282,7 @@
"terser-webpack-plugin": "^5.3.9", "terser-webpack-plugin": "^5.3.9",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"ts-prune": "^0.10.3", "ts-prune": "^0.10.3",
"typescript": "5.6.3", "typescript": "5.7.2",
"util": "^0.12.5", "util": "^0.12.5",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",

View File

@ -11,16 +11,49 @@ import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080"; const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
export default defineConfig({ export default defineConfig({
projects: [{ name: "Chrome", use: { ...devices["Desktop Chrome"], channel: "chromium" } }], 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"],
},
},
},
{
name: "Firefox",
use: {
...devices["Desktop Firefox"],
launchOptions: {
firefoxUserPrefs: {
"permissions.default.microphone": 1,
},
},
// This is needed to work around an issue between Playwright routes, Firefox, and Service workers
// https://github.com/microsoft/playwright/issues/33561#issuecomment-2471642120
serviceWorkers: "block",
},
ignoreSnapshots: true,
},
{
name: "WebKit",
use: {
...devices["Desktop Safari"],
// Seemingly WebKit has the same issue as Firefox in Playwright routes not working
// https://playwright.dev/docs/network#missing-network-events-and-service-workers
serviceWorkers: "block",
},
ignoreSnapshots: true,
},
],
use: { use: {
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
video: "retain-on-failure", video: "retain-on-failure",
baseURL, baseURL,
permissions: ["clipboard-write", "clipboard-read", "microphone"],
launchOptions: {
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
},
trace: "on-first-retry", trace: "on-first-retry",
}, },
webServer: { webServer: {

View File

@ -13,7 +13,7 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout"; import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage"; import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Audio player", () => { test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.use({ test.use({
displayName: "Hanako", displayName: "Hanako",
}); });

View File

@ -51,90 +51,94 @@ test.describe("Backups", () => {
displayName: "Hanako", displayName: "Hanako",
}); });
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => { test(
// Create a backup "Create, delete and recreate a keys backup",
const securityTab = await app.settings.openUserSettings("Security & Privacy"); { tag: "@no-webkit" },
async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog"); const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one // It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later // copy the recovery key to use it later
const securityKey = await app.getClipboard(); const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again // Open the settings again
await app.settings.openUserSettings("Security & Privacy"); await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports // expand the advanced section to see the active version in the reports
await page await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced") .locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..") .locator("..")
.click(); .click();
await expectBackupVersionToBe(page, "1"); await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click(); await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it // Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup" await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another // Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey); await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful // Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click(); await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again // Open the settings again
await app.settings.openUserSettings("Security & Privacy"); await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible(); await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports // expand the advanced section to see the active version in the reports
await page await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced") .locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..") .locator("..")
.click(); .click();
await expectBackupVersionToBe(page, "2"); await expectBackupVersionToBe(page, "2");
// == // ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created // Ensure that if you don't have the secret storage passphrase the backup won't be created
// == // ==
// First delete version 2 // First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click(); await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup" // Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click(); await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another // Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click(); await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase // But cancel the security key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click(); await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible(); await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed // check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible(); await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel // cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click(); await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there) // go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy"); await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible(); await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
}); },
);
}); });

View File

@ -81,7 +81,7 @@ test.describe("Cryptography", function () {
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType * @param keyType
*/ */
async function verifyKey(app: ElementAppPage, keyType: string) { async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate( const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType, keyType,

View File

@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details.
import { Locator, type Page } from "@playwright/test"; import { Locator, type Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test"; import { test as base, expect, Fixtures } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils"; import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
const test = base.extend({ const test = base.extend<Fixtures>({
// eslint-disable-next-line no-empty-pattern // eslint-disable-next-line no-empty-pattern
startHomeserverOpts: async ({}, use) => { startHomeserverOpts: async ({}, use) => {
await use("dehydration"); await use("dehydration");
@ -50,8 +50,6 @@ test.describe("Dehydration", () => {
}); });
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => { test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
// Create a backup (which will create SSSS, and dehydrated device) // Create a backup (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy"); const securityTab = await app.settings.openUserSettings("Security & Privacy");

View File

@ -21,7 +21,7 @@ import {
} from "./utils"; } from "./utils";
import { Bot } from "../../pages/bot"; import { Bot } from "../../pages/bot";
test.describe("Device verification", () => { test.describe("Device verification", { tag: "@no-webkit" }, () => {
let aliceBotClient: Bot; let aliceBotClient: Bot;
/** The backup version that was set up by the bot client. */ /** The backup version that was set up by the bot client. */

View File

@ -133,8 +133,7 @@ test.describe("Cryptography", function () {
"Encrypted by a device not verified by its owner.", "Encrypted by a device not verified by its owner.",
); );
/* In legacy crypto: should show a grey padlock for a message from a deleted device. /* Should show a red padlock for a message from an unverified device.
* In rust crypto: should show a red padlock for a message from an unverified device.
* Rust crypto remembers the verification state of the sending device, so it will know that the device was * Rust crypto remembers the verification state of the sending device, so it will know that the device was
* unverified, even if it gets deleted. */ * unverified, even if it gets deleted. */
// bob deletes his second device // bob deletes his second device
@ -168,9 +167,7 @@ test.describe("Cryptography", function () {
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus(); await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
workerInfo.project.name === "Legacy Crypto" "Encrypted by a device not verified by its owner.",
? "Encrypted by an unknown or deleted device."
: "Encrypted by a device not verified by its owner.",
); );
}); });

View File

@ -9,9 +9,9 @@ Please see LICENSE files in the repository root for full details.
import path from "path"; import path from "path";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { expect, test as base } from "../../element-web-test"; import { expect, Fixtures, test as base } from "../../element-web-test";
const test = base.extend({ const test = base.extend<Fixtures>({
// Replace the `user` fixture with one which populates the indexeddb data before starting the app. // Replace the `user` fixture with one which populates the indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => { user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => { await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
@ -25,11 +25,10 @@ const test = base.extend({
}, },
}); });
test.describe("migration", function () { test.describe("migration", { tag: "@no-webkit" }, function () {
test.use({ displayName: "Alice" }); test.use({ displayName: "Alice" });
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => { test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
test.slow(); test.slow();
// We should see a migration progress bar // We should see a migration progress bar

View File

@ -220,11 +220,7 @@ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Ver
for (let i = 0; i < emojis.length; i++) { for (let i = 0; i < emojis.length; i++) {
const emoji = emojis[i]; const emoji = emojis[i];
const emojiBlock = emojiBlocks.nth(i); const emojiBlock = emojiBlocks.nth(i);
const textContent = await emojiBlock.textContent(); await expect(emojiBlock).toHaveText(emoji[0] + emoji[1]);
// VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before
// displaying them. Once we drop support for legacy crypto, that code can go away, and so can the
// case-munging here.
expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase());
} }
} }

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils"; import { openIntegrationManager } from "./utils";
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
const ROOM_NAME = "Integration Manager Test"; const ROOM_NAME = "Integration Manager Test";
@ -92,7 +93,7 @@ test.describe("Integration Manager: Get OpenID Token", () => {
}, },
}, },
id: "integration-manager", id: "integration-manager",
}, } as unknown as UserWidget,
}); });
// Succeed when checking the token is valid // Succeed when checking the token is valid

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils"; import { openIntegrationManager } from "./utils";
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
const ROOM_NAME = "Integration Manager Test"; const ROOM_NAME = "Integration Manager Test";
const USER_DISPLAY_NAME = "Alice"; const USER_DISPLAY_NAME = "Alice";
@ -136,7 +137,7 @@ test.describe("Integration Manager: Kick", () => {
}, },
}, },
id: "integration-manager", id: "integration-manager",
}, } as unknown as UserWidget,
}); });
// Succeed when checking the token is valid // Succeed when checking the token is valid

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils"; import { openIntegrationManager } from "./utils";
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
const ROOM_NAME = "Integration Manager Test"; const ROOM_NAME = "Integration Manager Test";
@ -107,7 +108,7 @@ test.describe("Integration Manager: Read Events", () => {
}, },
}, },
id: "integration-manager", id: "integration-manager",
}, } as unknown as UserWidget,
}); });
// Succeed when checking the token is valid // Succeed when checking the token is valid

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils"; import { openIntegrationManager } from "./utils";
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
const ROOM_NAME = "Integration Manager Test"; const ROOM_NAME = "Integration Manager Test";
@ -113,7 +114,7 @@ test.describe("Integration Manager: Send Event", () => {
}, },
}, },
id: "integration-manager", id: "integration-manager",
}, } as unknown as UserWidget,
}); });
// Succeed when checking the token is valid // Succeed when checking the token is valid

View File

@ -10,7 +10,8 @@ import { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
test.describe("Location sharing", () => { // Firefox headless lacks WebGL support https://bugzilla.mozilla.org/show_bug.cgi?id=1375585
test.describe("Location sharing", { tag: "@no-firefox" }, () => {
const selectLocationShareTypeOption = (page: Page, shareType: string): Locator => { const selectLocationShareTypeOption = (page: Page, shareType: string): Locator => {
return page.getByTestId(`share-location-option-${shareType}`); return page.getByTestId(`share-location-option-${shareType}`);
}; };

View File

@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { test, expect, registerAccountMas } from "."; import { test, expect, registerAccountMas } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("OIDC Aware", () => { test.describe("OIDC Aware", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.skip(isDendrite, "does not yet support MAS"); test.skip(isDendrite, "does not yet support MAS");
test.slow(); // trace recording takes a while here test.slow(); // trace recording takes a while here

View File

@ -10,7 +10,7 @@ import { test, expect, registerAccountMas } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
import { ElementAppPage } from "../../pages/ElementAppPage.ts"; import { ElementAppPage } from "../../pages/ElementAppPage.ts";
test.describe("OIDC Native", () => { test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.skip(isDendrite, "does not yet support MAS"); test.skip(isDendrite, "does not yet support MAS");
test.slow(); // trace recording takes a while here test.slow(); // trace recording takes a while here

View File

@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
test.describe("Registration", () => { test.describe("Registration", () => {
test.use({ startHomeserverOpts: "consent" }); test.use({
startHomeserverOpts: "consent",
});
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/#/register"); await page.goto("/#/register");

View File

@ -39,7 +39,7 @@ test.describe("FilePanel", () => {
await expect(page.locator(".mx_FilePanel")).toBeVisible(); await expect(page.locator(".mx_FilePanel")).toBeVisible();
}); });
test.describe("render", () => { test.describe("render", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test("should render empty state", { tag: "@screenshot" }, async ({ page }) => { test("should render empty state", { tag: "@screenshot" }, async ({ page }) => {
// Wait until the information about the empty state is rendered // Wait until the information about the empty state is rendered
await expect(page.locator(".mx_EmptyState")).toBeVisible(); await expect(page.locator(".mx_EmptyState")).toBeVisible();

View File

@ -15,37 +15,43 @@ test.describe("Room Directory", () => {
botCreateOpts: { displayName: "Paul" }, botCreateOpts: { displayName: "Paul" },
}); });
test("should allow admin to add alias & publish room to directory", async ({ page, app, user, bot }) => { test(
const roomId = await app.client.createRoom({ "should allow admin to add alias & publish room to directory",
name: "Gaming", { tag: "@no-webkit" },
preset: "public_chat" as Preset, async ({ page, app, user, bot }) => {
}); const roomId = await app.client.createRoom({
name: "Gaming",
preset: "public_chat" as Preset,
});
await app.viewRoomByName("Gaming"); await app.viewRoomByName("Gaming");
await app.settings.openRoomSettings(); await app.settings.openRoomSettings();
// First add a local address `gaming` // First add a local address `gaming`
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" }); const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill("gaming"); await localAddresses.getByRole("textbox").fill("gaming");
await localAddresses.getByRole("button", { name: "Add" }).click(); await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item"); await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item");
// Publish into the public rooms directory // Publish into the public rooms directory
const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" }); const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" });
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost"); await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost");
const checkbox = publishedAddresses const checkbox = publishedAddresses
.locator(".mx_SettingsFlag", { hasText: "Publish this room to the public in localhost's room directory?" }) .locator(".mx_SettingsFlag", {
.getByRole("switch"); hasText: "Publish this room to the public in localhost's room directory?",
await checkbox.check(); })
await expect(checkbox).toBeChecked(); .getByRole("switch");
await checkbox.check();
await expect(checkbox).toBeChecked();
await app.closeDialog(); await app.closeDialog();
const resp = await bot.publicRooms({}); const resp = await bot.publicRooms({});
expect(resp.total_room_count_estimate).toEqual(1); expect(resp.total_room_count_estimate).toEqual(1);
expect(resp.chunk).toHaveLength(1); expect(resp.chunk).toHaveLength(1);
expect(resp.chunk[0].room_id).toEqual(roomId); expect(resp.chunk[0].room_id).toEqual(roomId);
}); },
);
test( test(
"should allow finding published rooms in directory", "should allow finding published rooms in directory",

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import type { EventType } from "matrix-js-sdk/src/matrix"; import type { AccountDataEvents } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot"; import { Bot } from "../../pages/bot";
@ -28,7 +28,7 @@ test.describe("Room Directory", () => {
const charlieRoom = await cli.createRoom({ is_direct: true }); const charlieRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob); await cli.invite(bobRoom.room_id, bob);
await cli.invite(charlieRoom.room_id, charlie); await cli.invite(charlieRoom.room_id, charlie);
await cli.setAccountData("m.direct" as EventType, { await cli.setAccountData("m.direct" as keyof AccountDataEvents, {
[bob]: [bobRoom.room_id], [bob]: [bobRoom.room_id],
[charlie]: [charlieRoom.room_id], [charlie]: [charlieRoom.room_id],
}); });

View File

@ -36,7 +36,7 @@ test.describe("General room settings tab", () => {
await expect(settings.getByText("Show more")).toBeVisible(); await expect(settings.getByText("Show more")).toBeVisible();
}); });
test("long address should not cause dialog to overflow", async ({ page, app }) => { test("long address should not cause dialog to overflow", { tag: "@no-webkit" }, async ({ page, app }) => {
const settings = await app.settings.openRoomSettings("General"); const settings = await app.settings.openRoomSettings("General");
// 1. Set the room-address to be a really long string // 1. Set the room-address to be a really long string
const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4); const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4);

View File

@ -31,7 +31,7 @@ test.describe("Preferences user settings tab", () => {
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png"); await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
}); });
test("should be able to change the app language", async ({ uut, user }) => { test("should be able to change the app language", { tag: ["@no-firefox", "@no-webkit"] }, async ({ uut, user }) => {
// Check language and region setting dropdown // Check language and region setting dropdown
const languageInput = uut.getByRole("button", { name: "Language Dropdown" }); const languageInput = uut.getByRole("button", { name: "Language Dropdown" });
await languageInput.scrollIntoViewIfNeeded(); await languageInput.scrollIntoViewIfNeeded();

View File

@ -55,38 +55,44 @@ test.describe("Spaces", () => {
botCreateOpts: { displayName: "BotBob" }, botCreateOpts: { displayName: "BotBob" },
}); });
test("should allow user to create public space", { tag: "@screenshot" }, async ({ page, app, user }) => { test(
const contextMenu = await openSpaceCreateMenu(page); "should allow user to create public space",
await expect(contextMenu).toMatchScreenshot("space-create-menu.png"); { tag: ["@screenshot", "@no-webkit"] },
async ({ page, app, user }) => {
const contextMenu = await openSpaceCreateMenu(page);
await expect(contextMenu).toMatchScreenshot("space-create-menu.png");
await contextMenu.getByRole("button", { name: /Public/ }).click(); await contextMenu.getByRole("button", { name: /Public/ }).click();
await contextMenu await contextMenu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') .locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png"); .setInputFiles("playwright/sample-files/riot.png");
await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot"); await contextMenu.getByRole("textbox", { name: "Name" }).fill("Let's have a Riot");
await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot"); await expect(contextMenu.getByRole("textbox", { name: "Address" })).toHaveValue("lets-have-a-riot");
await contextMenu.getByRole("textbox", { name: "Description" }).fill("This is a space to reminisce Riot.im!"); await contextMenu
await contextMenu.getByRole("button", { name: "Create" }).click(); .getByRole("textbox", { name: "Description" })
.fill("This is a space to reminisce Riot.im!");
await contextMenu.getByRole("button", { name: "Create" }).click();
// Create the default General & Random rooms, as well as a custom "Jokes" room // Create the default General & Random rooms, as well as a custom "Jokes" room
await expect(page.getByPlaceholder("General")).toBeVisible(); await expect(page.getByPlaceholder("General")).toBeVisible();
await expect(page.getByPlaceholder("Random")).toBeVisible(); await expect(page.getByPlaceholder("Random")).toBeVisible();
await page.getByPlaceholder("Support").fill("Jokes"); await page.getByPlaceholder("Support").fill("Jokes");
await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("button", { name: "Continue" }).click();
// Copy matrix.to link // Copy matrix.to link
await page.getByRole("button", { name: "Share invite link" }).click(); 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.getClipboardText()).toEqual("https://matrix.to/#/#lets-have-a-riot:localhost");
// Go to space home // Go to space home
await page.getByRole("button", { name: "Go to my first room" }).click(); await page.getByRole("button", { name: "Go to my first room" }).click();
// Assert rooms exist in the room list // Assert rooms exist in the room list
await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible(); await expect(page.getByRole("treeitem", { name: "General" })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible(); await expect(page.getByRole("treeitem", { name: "Random" })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible(); await expect(page.getByRole("treeitem", { name: "Jokes" })).toBeVisible();
}); },
);
test("should allow user to create private space", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should allow user to create private space", { tag: "@screenshot" }, async ({ page, app, user }) => {
const menu = await openSpaceCreateMenu(page); const menu = await openSpaceCreateMenu(page);
@ -157,7 +163,7 @@ test.describe("Spaces", () => {
).toBeVisible(); ).toBeVisible();
}); });
test("should allow user to invite another to a space", async ({ page, app, user, bot }) => { test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({ await app.client.createSpace({
visibility: "public" as any, visibility: "public" as any,
room_alias_name: "space", room_alias_name: "space",

View File

@ -9,7 +9,7 @@
import { expect, test } from "."; import { expect, test } from ".";
import { CommandOrControl } from "../../utils"; import { CommandOrControl } from "../../utils";
test.describe("Threads Activity Centre", () => { test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test.use({ test.use({
displayName: "Alice", displayName: "Alice",
botCreateOpts: { displayName: "Other User" }, botCreateOpts: { displayName: "Other User" },

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. 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 { test, expect } from "../../element-web-test";
import { Filter } from "../../pages/Spotlight"; import { Filter } from "../../pages/Spotlight";
import { Bot } from "../../pages/bot"; import { Bot } from "../../pages/bot";
@ -255,7 +256,9 @@ test.describe("Spotlight", () => {
// Invite BotBob into existing DM with ByteBot // Invite BotBob into existing DM with ByteBot
const dmRooms = await app.client.evaluate((client, userId) => { const dmRooms = await app.client.evaluate((client, userId) => {
const map = client.getAccountData("m.direct")?.getContent<Record<string, string[]>>(); const map = client
.getAccountData("m.direct" as keyof AccountDataEvents)
?.getContent<Record<string, string[]>>();
return map[userId] ?? []; return map[userId] ?? [];
}, bot2UserId); }, bot2UserId);
expect(dmRooms).toHaveLength(1); expect(dmRooms).toHaveLength(1);

View File

@ -324,7 +324,7 @@ test.describe("Threads", () => {
}); });
}); });
test("can send voice messages", async ({ page, app, user }) => { test("can send voice messages", { tag: ["@no-firefox", "@no-webkit"] }, async ({ page, app, user }) => {
// Increase right-panel size, so that voice messages fit // Increase right-panel size, so that voice messages fit
await page.evaluate(() => { await page.evaluate(() => {
window.localStorage.setItem("mx_rhs_size", "600"); window.localStorage.setItem("mx_rhs_size", "600");
@ -353,7 +353,7 @@ test.describe("Threads", () => {
test( test(
"should send location and reply to the location on ThreadView", "should send location and reply to the location on ThreadView",
{ tag: "@screenshot" }, { tag: ["@screenshot", "@no-firefox"] },
async ({ page, app, bot }) => { async ({ page, app, bot }) => {
const roomId = await app.client.createRoom({}); const roomId = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId); await app.client.inviteUser(roomId, bot.credentials.userId);

View File

@ -90,7 +90,7 @@ test.describe("Timeline", () => {
let oldAvatarUrl: string; let oldAvatarUrl: string;
let newAvatarUrl: string; let newAvatarUrl: string;
test.describe("useOnlyCurrentProfiles", () => { test.describe("useOnlyCurrentProfiles", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.beforeEach(async ({ app, user }) => { test.beforeEach(async ({ app, user }) => {
({ content_uri: oldAvatarUrl } = await app.client.uploadContent(OLD_AVATAR, { type: "image/png" })); ({ content_uri: oldAvatarUrl } = await app.client.uploadContent(OLD_AVATAR, { type: "image/png" }));
await app.client.setAvatarUrl(oldAvatarUrl); await app.client.setAvatarUrl(oldAvatarUrl);
@ -876,7 +876,7 @@ test.describe("Timeline", () => {
}); });
}); });
test.describe("message sending", () => { test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const MESSAGE = "Hello world"; const MESSAGE = "Hello world";
const reply = "Reply"; const reply = "Reply";
const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => { const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => {
@ -914,7 +914,6 @@ test.describe("Timeline", () => {
}); });
test("can reply with a voice message", async ({ page, app, room, context }) => { test("can reply with a voice message", async ({ page, app, room, context }) => {
await context.grantPermissions(["microphone"]);
await viewRoomSendMessageAndSetupReply(page, app, room.roomId); await viewRoomSendMessageAndSetupReply(page, app, room.roomId);
const composerOptions = await app.openMessageComposerOptions(); const composerOptions = await app.openMessageComposerOptions();

View File

@ -12,6 +12,7 @@ import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage"; import { ElementAppPage } from "../../pages/ElementAppPage";
import { Credentials } from "../../plugins/homeserver"; import { Credentials } from "../../plugins/homeserver";
import type { UserWidget } from "../../../src/utils/WidgetUtils-types.ts";
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
@ -123,11 +124,11 @@ async function setWidgetAccountData(
state_key: STICKER_PICKER_WIDGET_ID, state_key: STICKER_PICKER_WIDGET_ID,
type: "m.widget", type: "m.widget",
id: STICKER_PICKER_WIDGET_ID, id: STICKER_PICKER_WIDGET_ID,
}, } as unknown as UserWidget,
}); });
} }
test.describe("Stickers", () => { test.describe("Stickers", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.use({ test.use({
displayName: "Sally", displayName: "Sally",
room: async ({ app }, use) => { room: async ({ app }, use) => {

View File

@ -60,7 +60,7 @@ interface CredentialsWithDisplayName extends Credentials {
displayName: string; displayName: string;
} }
export const test = base.extend<{ export interface Fixtures {
axe: AxeBuilder; axe: AxeBuilder;
checkA11y: () => Promise<void>; checkA11y: () => Promise<void>;
@ -124,7 +124,17 @@ export const test = base.extend<{
slidingSyncProxy: ProxyInstance; slidingSyncProxy: ProxyInstance;
labsFlags: string[]; labsFlags: string[];
webserver: Webserver; webserver: Webserver;
}>({ }
export const test = base.extend<Fixtures>({
context: async ({ context }, use, testInfo) => {
// We skip tests instead of using grep-invert to still surface the counts in the html report
test.skip(
testInfo.tags.includes(`@no-${testInfo.project.name.toLowerCase()}`),
`Test does not work on ${testInfo.project.name}`,
);
await use(context);
},
config: CONFIG_JSON, config: CONFIG_JSON,
page: async ({ context, page, config, labsFlags }, use) => { page: async ({ context, page, config, labsFlags }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => { await context.route(`http://localhost:8080/config.json*`, async (route) => {

View File

@ -25,6 +25,7 @@ import type {
Upload, Upload,
StateEvents, StateEvents,
TimelineEvents, TimelineEvents,
AccountDataEvents,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { Credentials } from "../plugins/homeserver"; import { Credentials } from "../plugins/homeserver";
@ -439,11 +440,14 @@ export class Client {
* @param type The type of account data to set * @param type The type of account data to set
* @param content The content to set * @param content The content to set
*/ */
public async setAccountData(type: string, content: IContent): Promise<void> { public async setAccountData<T extends keyof AccountDataEvents>(
type: T,
content: AccountDataEvents[T],
): Promise<void> {
const client = await this.prepareClient(); const client = await this.prepareClient();
return client.evaluate( return client.evaluate(
async (client, { type, content }) => { async (client, { type, content }) => {
await client.setAccountData(type, content); await client.setAccountData(type as T, content as AccountDataEvents[T]);
}, },
{ type, content }, { type, content },
); );

View File

@ -140,8 +140,12 @@ export class Docker {
* Detects whether the docker command is actually podman. * Detects whether the docker command is actually podman.
* To do this, it looks for "podman" in the output of "docker --help". * To do this, it looks for "podman" in the output of "docker --help".
*/ */
static _isPodman?: boolean;
static async isPodman(): Promise<boolean> { static async isPodman(): Promise<boolean> {
const { stdout } = await exec("docker", ["--help"], true); if (Docker._isPodman === undefined) {
return stdout.toLowerCase().includes("podman"); const { stdout } = await exec("docker", ["--help"], true);
Docker._isPodman = stdout.toLowerCase().includes("podman");
}
return Docker._isPodman;
} }
} }

View File

@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for synapse docker image. // Docker tag to use for synapse docker image.
// We target a specific digest as every now and then a Synapse update will break our CI. // We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically. // This digest is updated by the playwright-image-updates.yaml workflow periodically.
const DOCKER_TAG = "develop@sha256:737711309e64119facbc615d703c33d7e57c3d2789a0d6d12955529902276a99"; const DOCKER_TAG = "develop@sha256:c965896a4865479ab2628807ebf6d9c742586f3b6185a56f10077a408f1c7c3b";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> { async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template); const templateDir = path.join(__dirname, "templates", opts.template);

View File

@ -11,6 +11,8 @@ import type { BLURHASH_FIELD } from "../utils/image-media";
import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types"; import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types";
import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types"; import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types";
import type { EncryptedFile } from "matrix-js-sdk/src/types"; import type { EncryptedFile } from "matrix-js-sdk/src/types";
import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" { declare module "matrix-js-sdk/src/types" {
@ -57,6 +59,35 @@ declare module "matrix-js-sdk/src/types" {
}; };
} }
export interface AccountDataEvents {
// Analytics account data event
"im.vector.analytics": {
id: string;
pseudonymousAnalyticsOptIn?: boolean;
};
// Device client information account data event
[key: `io.element.matrix_client_information.${string}`]: DeviceClientInformation;
// Element settings account data events
"im.vector.setting.breadcrumbs": { recent_rooms: string[] };
"io.element.recent_emoji": { recent_emoji: string[] };
"im.vector.setting.integration_provisioning": { enabled: boolean };
"im.vector.riot.breadcrumb_rooms": { recent_rooms: string[] };
"im.vector.web.settings": Record<string, any>;
// URL preview account data event
"org.matrix.preview_urls": { disable: boolean };
// This is not yet in the Matrix spec yet is being used as if it was
"m.widgets": {
[widgetId: string]: UserWidget;
};
// This is not in the Matrix spec yet seems to use an `m.` prefix
"m.accepted_terms": {
accepted: string[];
};
}
export interface AudioContent { export interface AudioContent {
// MSC1767 + Ideals of MSC2516 as MSC3245 // MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245 // https://github.com/matrix-org/matrix-doc/pull/3245

View File

@ -7,60 +7,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/src/logger"; import { AuthDict, MatrixClient, MatrixError, UIAResponse } from "matrix-js-sdk/src/matrix";
import { AuthDict, CrossSigningKeys, MatrixClient, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix";
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
import Modal from "./Modal"; import Modal from "./Modal";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
/**
* Determine if the homeserver allows uploading device keys with only password auth, or with no auth at
* all (ie. if the homeserver supports MSC3967).
* @param cli The Matrix Client to use
* @returns True if the homeserver allows uploading device keys with only password auth or with no auth
* at all, otherwise false
*/
async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise<boolean> {
try {
await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
// If we get here, it's because the server is allowing us to upload keys without
// auth the first time due to MSC3967. Therefore, yes, we can upload keys
// (with or without password, technically, but that's fine).
return true;
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
logger.log("uploadDeviceSigningKeys advertised no flows!");
return false;
}
const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => {
return f.stages.length === 1 && f.stages[0] === "m.login.password";
});
return canUploadKeysWithPasswordOnly;
}
}
/** /**
* Ensures that cross signing keys are created and uploaded for the user. * Ensures that cross signing keys are created and uploaded for the user.
* The homeserver may require user-interactive auth to upload the keys, in * The homeserver may require user-interactive auth to upload the keys, in
* which case the user will be prompted to authenticate. If the homeserver * which case the user will be prompted to authenticate.
* allows uploading keys with just an account password and one is provided,
* the keys will be uploaded without user interaction.
* *
* This function does not set up backups of the created cross-signing keys * This function does not set up backups of the created cross-signing keys
* (or message keys): the cross-signing keys are stored locally and will be * (or message keys): the cross-signing keys are stored locally and will be
* lost requiring a crypto reset, if the user logs out or loses their session. * lost requiring a crypto reset, if the user logs out or loses their session.
* *
* @param cli The Matrix Client to use * @param cli The Matrix Client to use
* @param isTokenLogin True if the user logged in via a token login, otherwise false
* @param accountPassword The password that the user logged in with
*/ */
export async function createCrossSigning( export async function createCrossSigning(cli: MatrixClient): Promise<void> {
cli: MatrixClient,
isTokenLogin: boolean,
accountPassword?: string,
): Promise<void> {
const cryptoApi = cli.getCrypto(); const cryptoApi = cli.getCrypto();
if (!cryptoApi) { if (!cryptoApi) {
throw new Error("No crypto API found!"); throw new Error("No crypto API found!");
@ -69,19 +34,14 @@ export async function createCrossSigning(
const doBootstrapUIAuth = async ( const doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>, makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => { ): Promise<void> => {
if (accountPassword && (await canUploadKeysWithPasswordOnly(cli))) { try {
await makeRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: cli.getUserId(),
},
password: accountPassword,
});
} else if (isTokenLogin) {
// We are hoping the grace period is active
await makeRequest({}); await makeRequest({});
} else { } catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
// Not a UIA response
throw error;
}
const dialogAesthetics = { const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: { [SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"), title: _t("auth|uia|sso_title"),

View File

@ -191,8 +191,6 @@ export interface AccessSecretStorageOpts {
forceReset?: boolean; forceReset?: boolean;
/** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */ /** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
resetCrossSigning?: boolean; resetCrossSigning?: boolean;
/** The cached account password, if available. */
accountPassword?: string;
} }
/** /**

View File

@ -16,6 +16,7 @@ import {
IUsageLimit, IUsageLimit,
SyncStateData, SyncStateData,
SyncState, SyncState,
EventType,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import classNames from "classnames"; import classNames from "classnames";
@ -161,7 +162,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.on(ClientEvent.AccountData, this.onAccountData); this._matrixClient.on(ClientEvent.AccountData, this.onAccountData);
// check push rules on start up as well // check push rules on start up as well
monitorSyncedPushRules(this._matrixClient.getAccountData("m.push_rules"), this._matrixClient); monitorSyncedPushRules(this._matrixClient.getAccountData(EventType.PushRules), this._matrixClient);
this._matrixClient.on(ClientEvent.Sync, this.onSync); this._matrixClient.on(ClientEvent.Sync, this.onSync);
// Call `onSync` with the current state as well // Call `onSync` with the current state as well
this.onSync(this._matrixClient.getSyncState(), null, this._matrixClient.getSyncStateData() ?? undefined); this.onSync(this._matrixClient.getSyncState(), null, this._matrixClient.getSyncStateData() ?? undefined);

View File

@ -431,8 +431,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// if cross-signing is not yet set up, do so now if possible. // if cross-signing is not yet set up, do so now if possible.
InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup( InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup(
cli, cli,
Boolean(this.tokenLogin),
this.stores,
this.onCompleteSecurityE2eSetupFinished, this.onCompleteSecurityE2eSetupFinished,
); );
this.setStateForNewView({ view: Views.E2E_SETUP }); this.setStateForNewView({ view: Views.E2E_SETUP });
@ -504,8 +502,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
UIStore.destroy(); UIStore.destroy();
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
window.removeEventListener("resize", this.onWindowResized); window.removeEventListener("resize", this.onWindowResized);
this.stores.accountPasswordStore.clearPassword();
} }
private onWindowResized = (): void => { private onWindowResized = (): void => {
@ -1935,8 +1931,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreen("forgot_password"); this.showScreen("forgot_password");
}; };
private onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string): Promise<void> => { private onRegisterFlowComplete = (credentials: IMatrixClientCreds): Promise<void> => {
return this.onUserCompletedLoginFlow(credentials, password); return this.onUserCompletedLoginFlow(credentials);
}; };
// returns a promise which resolves to the new MatrixClient // returns a promise which resolves to the new MatrixClient
@ -2003,9 +1999,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Note: SSO users (and any others using token login) currently do not pass through * Note: SSO users (and any others using token login) currently do not pass through
* this, as they instead jump straight into the app after `attemptTokenLogin`. * this, as they instead jump straight into the app after `attemptTokenLogin`.
*/ */
private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string): Promise<void> => { private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds): Promise<void> => {
this.stores.accountPasswordStore.setPassword(password);
// Create and start the client // Create and start the client
await Lifecycle.setLoggedIn(credentials); await Lifecycle.setLoggedIn(credentials);
await this.postLoginSetup(); await this.postLoginSetup();

View File

@ -48,10 +48,7 @@ interface IProps {
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - The object returned by the login API // - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a onLoggedIn(data: IMatrixClientCreds): void;
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(data: IMatrixClientCreds, password: string): void;
// login shouldn't know or care how registration, password recovery, etc is done. // login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void; onRegisterClick(): void;
@ -199,7 +196,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.loginLogic.loginViaPassword(username, phoneCountry, phoneNumber, password).then( this.loginLogic.loginViaPassword(username, phoneCountry, phoneNumber, password).then(
(data) => { (data) => {
this.setState({ serverIsAlive: true }); // it must be, we logged in. this.setState({ serverIsAlive: true }); // it must be, we logged in.
this.props.onLoggedIn(data, password); this.props.onLoggedIn(data);
}, },
(error) => { (error) => {
if (this.unmounted) return; if (this.unmounted) return;

View File

@ -72,10 +72,7 @@ interface IProps {
mobileRegister?: boolean; mobileRegister?: boolean;
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory onLoggedIn(params: IMatrixClientCreds): Promise<void>;
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(params: IMatrixClientCreds, password: string): Promise<void>;
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick(): void; onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void; onServerConfigChange(config: ValidatedServerConfig): void;
@ -431,16 +428,13 @@ export default class Registration extends React.Component<IProps, IState> {
newState.busy = false; newState.busy = false;
newState.completedNoSignin = true; newState.completedNoSignin = true;
} else { } else {
await this.props.onLoggedIn( await this.props.onLoggedIn({
{ userId,
userId, deviceId: (response as RegisterResponse).device_id!,
deviceId: (response as RegisterResponse).device_id!, homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
homeserverUrl: this.state.matrixClient.getHomeserverUrl(), identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), accessToken,
accessToken, });
},
this.state.formVals.password!,
);
this.setupPushers(); this.setupPushers();
} }

View File

@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { useContext, useMemo, useState } from "react"; import React, { useContext, useMemo, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { AccountDataEvents, IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../../contexts/MatrixClientContext";
@ -21,7 +21,7 @@ export const AccountDataEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack
const fields = useMemo(() => [eventTypeField(mxEvent?.getType())], [mxEvent]); const fields = useMemo(() => [eventTypeField(mxEvent?.getType())], [mxEvent]);
const onSend = async ([eventType]: string[], content?: IContent): Promise<void> => { const onSend = async ([eventType]: Array<keyof AccountDataEvents>, content?: IContent): Promise<void> => {
await cli.setAccountData(eventType, content || {}); await cli.setAccountData(eventType, content || {});
}; };

View File

@ -214,7 +214,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
<SettingsSubsectionText> <SettingsSubsectionText>
{this.state.enabling ? <InlineSpinner /> : _t("settings|security|message_search_failed")} {this.state.enabling ? <InlineSpinner /> : _t("settings|security|message_search_failed")}
</SettingsSubsectionText> </SettingsSubsectionText>
{EventIndexPeg.error && ( {EventIndexPeg.error ? (
<SettingsSubsectionText> <SettingsSubsectionText>
<details> <details>
<summary>{_t("common|advanced")}</summary> <summary>{_t("common|advanced")}</summary>
@ -230,7 +230,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
</p> </p>
</details> </details>
</SettingsSubsectionText> </SettingsSubsectionText>
)} ) : undefined}
</> </>
); );
} }

View File

@ -116,7 +116,7 @@ export const useOwnDevices = (): DevicesState => {
const notificationSettings = new Map<string, LocalNotificationSettings>(); const notificationSettings = new Map<string, LocalNotificationSettings>();
Object.keys(devices).forEach((deviceId) => { Object.keys(devices).forEach((deviceId) => {
const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}` as const;
const event = matrixClient.getAccountData(eventType); const event = matrixClient.getAccountData(eventType);
if (event) { if (event) {
notificationSettings.set(deviceId, event.getContent()); notificationSettings.set(deviceId, event.getContent());

View File

@ -13,7 +13,6 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import LegacyCallHandler from "../LegacyCallHandler"; import LegacyCallHandler from "../LegacyCallHandler";
import { PosthogAnalytics } from "../PosthogAnalytics"; import { PosthogAnalytics } from "../PosthogAnalytics";
import { SlidingSyncManager } from "../SlidingSyncManager"; import { SlidingSyncManager } from "../SlidingSyncManager";
import { AccountPasswordStore } from "../stores/AccountPasswordStore";
import { MemberListStore } from "../stores/MemberListStore"; import { MemberListStore } from "../stores/MemberListStore";
import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore";
import RightPanelStore from "../stores/right-panel/RightPanelStore"; import RightPanelStore from "../stores/right-panel/RightPanelStore";
@ -63,7 +62,6 @@ export class SdkContextClass {
protected _SpaceStore?: SpaceStoreClass; protected _SpaceStore?: SpaceStoreClass;
protected _LegacyCallHandler?: LegacyCallHandler; protected _LegacyCallHandler?: LegacyCallHandler;
protected _TypingStore?: TypingStore; protected _TypingStore?: TypingStore;
protected _AccountPasswordStore?: AccountPasswordStore;
protected _UserProfilesStore?: UserProfilesStore; protected _UserProfilesStore?: UserProfilesStore;
protected _OidcClientStore?: OidcClientStore; protected _OidcClientStore?: OidcClientStore;
@ -149,13 +147,6 @@ export class SdkContextClass {
return this._TypingStore; return this._TypingStore;
} }
public get accountPasswordStore(): AccountPasswordStore {
if (!this._AccountPasswordStore) {
this._AccountPasswordStore = new AccountPasswordStore();
}
return this._AccountPasswordStore;
}
public get userProfilesStore(): UserProfilesStore { public get userProfilesStore(): UserProfilesStore {
if (!this.client) { if (!this.client) {
throw new Error("Unable to create UserProfilesStore without a client"); throw new Error("Unable to create UserProfilesStore without a client");

View File

@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { AccountDataEvents, ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { useTypedEventEmitter } from "./useEventEmitter"; import { useTypedEventEmitter } from "./useEventEmitter";
const tryGetContent = <T extends {}>(ev?: MatrixEvent): T | undefined => ev?.getContent<T>(); const tryGetContent = <T extends {}>(ev?: MatrixEvent): T | undefined => ev?.getContent<T>();
// Hook to simplify listening to Matrix account data // Hook to simplify listening to Matrix account data
export const useAccountData = <T extends {}>(cli: MatrixClient, eventType: string): T => { export const useAccountData = <T extends {}>(cli: MatrixClient, eventType: keyof AccountDataEvents): T => {
const [value, setValue] = useState<T | undefined>(() => tryGetContent<T>(cli.getAccountData(eventType))); const [value, setValue] = useState<T | undefined>(() => tryGetContent<T>(cli.getAccountData(eventType)));
const handler = useCallback( const handler = useCallback(

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { AccountDataEvents, ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils"; import { defer } from "matrix-js-sdk/src/utils";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
@ -140,11 +140,11 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
} }
// helper function to set account data then await it being echoed back // helper function to set account data then await it being echoed back
private async setAccountData( private async setAccountData<K extends keyof AccountDataEvents, F extends keyof AccountDataEvents[K]>(
eventType: string, eventType: K,
field: string, field: F,
value: any, value: AccountDataEvents[K][F],
legacyEventType?: string, legacyEventType?: keyof AccountDataEvents,
): Promise<void> { ): Promise<void> {
let content = this.getSettings(eventType); let content = this.getSettings(eventType);
if (legacyEventType && !content?.[field]) { if (legacyEventType && !content?.[field]) {
@ -161,7 +161,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
// which race between different lines. // which race between different lines.
const deferred = defer<void>(); const deferred = defer<void>();
const handler = (event: MatrixEvent): void => { const handler = (event: MatrixEvent): void => {
if (event.getType() !== eventType || !isEqual(event.getContent()[field], value)) return; if (event.getType() !== eventType || !isEqual(event.getContent<AccountDataEvents[K]>()[field], value))
return;
this.client.off(ClientEvent.AccountData, handler); this.client.off(ClientEvent.AccountData, handler);
deferred.resolve(); deferred.resolve();
}; };
@ -212,7 +213,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
return this.client && !this.client.isGuest(); return this.client && !this.client.isGuest();
} }
private getSettings(eventType = "im.vector.web.settings"): any { private getSettings(eventType: keyof AccountDataEvents = "im.vector.web.settings"): any {
// TODO: [TS] Types on return // TODO: [TS] Types on return
if (!this.client) return null; if (!this.client) return null;

View File

@ -1,35 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
const PASSWORD_TIMEOUT = 5 * 60 * 1000; // five minutes
/**
* Store for the account password.
* This password can be used for a short time after login
* to avoid requestin the password all the time for instance during e2ee setup.
*/
export class AccountPasswordStore {
private password?: string;
private passwordTimeoutId?: ReturnType<typeof setTimeout>;
public setPassword(password: string): void {
this.password = password;
clearTimeout(this.passwordTimeoutId);
this.passwordTimeoutId = setTimeout(this.clearPassword, PASSWORD_TIMEOUT);
}
public getPassword(): string | undefined {
return this.password;
}
public clearPassword = (): void => {
clearTimeout(this.passwordTimeoutId);
this.passwordTimeoutId = undefined;
this.password = undefined;
};
}

View File

@ -11,7 +11,6 @@ import { logger } from "matrix-js-sdk/src/logger";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createCrossSigning } from "../CreateCrossSigning"; import { createCrossSigning } from "../CreateCrossSigning";
import { SdkContextClass } from "../contexts/SDKContext";
type Status = "in_progress" | "complete" | "error" | undefined; type Status = "in_progress" | "complete" | "error" | undefined;
@ -45,8 +44,6 @@ export class InitialCryptoSetupStore extends EventEmitter {
private status: Status = undefined; private status: Status = undefined;
private client?: MatrixClient; private client?: MatrixClient;
private isTokenLogin?: boolean;
private stores?: SdkContextClass;
private onFinished?: (success: boolean) => void; private onFinished?: (success: boolean) => void;
public static sharedInstance(): InitialCryptoSetupStore { public static sharedInstance(): InitialCryptoSetupStore {
@ -62,18 +59,9 @@ export class InitialCryptoSetupStore extends EventEmitter {
* Start the initial crypto setup process. * Start the initial crypto setup process.
* *
* @param {MatrixClient} client The client to use for the setup * @param {MatrixClient} client The client to use for the setup
* @param {boolean} isTokenLogin True if the user logged in via a token login, otherwise false
* @param {SdkContextClass} stores The stores to use for the setup
*/ */
public startInitialCryptoSetup( public startInitialCryptoSetup(client: MatrixClient, onFinished: (success: boolean) => void): void {
client: MatrixClient,
isTokenLogin: boolean,
stores: SdkContextClass,
onFinished: (success: boolean) => void,
): void {
this.client = client; this.client = client;
this.isTokenLogin = isTokenLogin;
this.stores = stores;
this.onFinished = onFinished; this.onFinished = onFinished;
// We just start this process: it's progress is tracked by the events rather // We just start this process: it's progress is tracked by the events rather
@ -89,7 +77,7 @@ export class InitialCryptoSetupStore extends EventEmitter {
* @returns {boolean} True if a retry was initiated, otherwise false * @returns {boolean} True if a retry was initiated, otherwise false
*/ */
public retry(): boolean { public retry(): boolean {
if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) return false; if (this.client === undefined) return false;
this.doSetup().catch(() => logger.error("Initial crypto setup failed")); this.doSetup().catch(() => logger.error("Initial crypto setup failed"));
@ -98,12 +86,10 @@ export class InitialCryptoSetupStore extends EventEmitter {
private reset(): void { private reset(): void {
this.client = undefined; this.client = undefined;
this.isTokenLogin = undefined;
this.stores = undefined;
} }
private async doSetup(): Promise<void> { private async doSetup(): Promise<void> {
if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) { if (this.client === undefined) {
throw new Error("No setup is in progress"); throw new Error("No setup is in progress");
} }
@ -115,7 +101,7 @@ export class InitialCryptoSetupStore extends EventEmitter {
try { try {
// Create the user's cross-signing keys // Create the user's cross-signing keys
await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword()); await createCrossSigning(this.client);
// Check for any existing backup and enable key backup if there isn't one // Check for any existing backup and enable key backup if there isn't one
const currentKeyBackup = await cryptoApi.checkKeyBackupAndEnable(); const currentKeyBackup = await cryptoApi.checkKeyBackupAndEnable();
@ -129,16 +115,6 @@ export class InitialCryptoSetupStore extends EventEmitter {
this.emit("update"); this.emit("update");
this.onFinished?.(true); this.onFinished?.(true);
} catch (e) { } catch (e) {
if (this.isTokenLogin) {
// ignore any failures, we are relying on grace period here
this.reset();
this.status = "complete";
this.emit("update");
this.onFinished?.(true);
return;
}
logger.error("Error bootstrapping cross-signing", e); logger.error("Error bootstrapping cross-signing", e);
this.status = "error"; this.status = "error";
this.emit("update"); this.emit("update");

View File

@ -19,7 +19,6 @@ import { Device, SecretStorage } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import { SdkContextClass } from "../contexts/SDKContext";
import { asyncSome } from "../utils/arrays"; import { asyncSome } from "../utils/arrays";
import { initialiseDehydration } from "../utils/device/dehydration"; import { initialiseDehydration } from "../utils/device/dehydration";
@ -239,7 +238,6 @@ export class SetupEncryptionStore extends EventEmitter {
{ {
forceReset: true, forceReset: true,
resetCrossSigning: true, resetCrossSigning: true,
accountPassword: SdkContextClass.instance.accountPasswordStore.getPassword(),
}, },
); );
} catch (e) { } catch (e) {

View File

@ -17,17 +17,11 @@ import WidgetEchoStore from "../stores/WidgetEchoStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import { UPDATE_EVENT } from "./AsyncStore"; import { UPDATE_EVENT } from "./AsyncStore";
import { IApp } from "../utils/WidgetUtils-types";
interface IState {} interface IState {}
export interface IApp extends IWidget { export type { IApp };
"roomId": string;
"eventId"?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase
"avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
// Whether the widget was created from `widget_build_url` and thus is a call widget of some kind
"io.element.managed_hybrid"?: boolean;
}
export function isAppWidget(widget: IWidget | IApp): widget is IApp { export function isAppWidget(widget: IWidget | IApp): widget is IApp {
return "roomId" in widget && typeof widget.roomId === "string"; return "roomId" in widget && typeof widget.roomId === "string";

View File

@ -20,7 +20,7 @@ export function setToDefaultIdentityServer(matrixClient: MatrixClient): void {
const url = getDefaultIdentityServerUrl(); const url = getDefaultIdentityServerUrl();
// Account data change will update localstorage, client, etc through dispatcher // Account data change will update localstorage, client, etc through dispatcher
matrixClient.setAccountData("m.identity_server", { matrixClient.setAccountData("m.identity_server", {
base_url: url, base_url: url ?? null,
}); });
} }

View File

@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017-2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Travis Ralston
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { IWidget } from "matrix-widget-api";
export interface IApp extends IWidget {
"roomId": string;
"eventId"?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase
"avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
// Whether the widget was created from `widget_build_url` and thus is a call widget of some kind
"io.element.managed_hybrid"?: boolean;
}
export interface IWidgetEvent {
id: string;
type: string;
sender: string;
// eslint-disable-next-line camelcase
state_key: string;
content: IApp;
}
export interface UserWidget extends Omit<IWidgetEvent, "content"> {
content: IWidget & Partial<IApp>;
}

View File

@ -29,23 +29,13 @@ import WidgetStore, { IApp, isAppWidget } from "../stores/WidgetStore";
import { parseUrl } from "./UrlUtils"; import { parseUrl } from "./UrlUtils";
import { useEventEmitter } from "../hooks/useEventEmitter"; import { useEventEmitter } from "../hooks/useEventEmitter";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import { IWidgetEvent, UserWidget } from "./WidgetUtils-types";
// How long we wait for the state event echo to come back from the server // How long we wait for the state event echo to come back from the server
// before waitFor[Room/User]Widget rejects its promise // before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000; const WIDGET_WAIT_TIME = 20000;
export interface IWidgetEvent { export type { IWidgetEvent, UserWidget };
id: string;
type: string;
sender: string;
// eslint-disable-next-line camelcase
state_key: string;
content: IApp;
}
export interface UserWidget extends Omit<IWidgetEvent, "content"> {
content: IWidget & Partial<IApp>;
}
export default class WidgetUtils { export default class WidgetUtils {
/** /**

View File

@ -6,17 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { AccountDataEvents, MatrixClient } from "matrix-js-sdk/src/matrix";
import BasePlatform from "../../BasePlatform"; import BasePlatform from "../../BasePlatform";
import { IConfigOptions } from "../../IConfigOptions"; import { IConfigOptions } from "../../IConfigOptions";
import { DeepReadonly } from "../../@types/common"; import { DeepReadonly } from "../../@types/common";
import { DeviceClientInformation } from "./types";
export type DeviceClientInformation = { export type { DeviceClientInformation };
name?: string;
version?: string;
url?: string;
};
const formatUrl = (): string | undefined => { const formatUrl = (): string | undefined => {
// don't record url for electron clients // don't record url for electron clients
@ -34,7 +31,8 @@ const formatUrl = (): string | undefined => {
}; };
const clientInformationEventPrefix = "io.element.matrix_client_information."; const clientInformationEventPrefix = "io.element.matrix_client_information.";
export const getClientInformationEventType = (deviceId: string): string => `${clientInformationEventPrefix}${deviceId}`; export const getClientInformationEventType = (deviceId: string): `${typeof clientInformationEventPrefix}${string}` =>
`${clientInformationEventPrefix}${deviceId}`;
/** /**
* Record extra client information for the current device * Record extra client information for the current device
@ -70,7 +68,7 @@ export const pruneClientInformation = (validDeviceIds: string[], matrixClient: M
} }
const [, deviceId] = event.getType().split(clientInformationEventPrefix); const [, deviceId] = event.getType().split(clientInformationEventPrefix);
if (deviceId && !validDeviceIds.includes(deviceId)) { if (deviceId && !validDeviceIds.includes(deviceId)) {
matrixClient.deleteAccountData(event.getType()); matrixClient.deleteAccountData(event.getType() as keyof AccountDataEvents);
} }
}); });
}; };

13
src/utils/device/types.ts Normal file
View File

@ -0,0 +1,13 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
export type DeviceClientInformation = {
name?: string;
version?: string;
url?: string;
};

View File

@ -40,7 +40,9 @@ export const deviceNotificationSettingsKeys = [
"audioNotificationsEnabled", "audioNotificationsEnabled",
]; ];
export function getLocalNotificationAccountDataEventType(deviceId: string | null): string { export function getLocalNotificationAccountDataEventType(
deviceId: string | null,
): `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}` {
return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
} }

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { HTTPError, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { createCrossSigning } from "../src/CreateCrossSigning"; import { createCrossSigning } from "../src/CreateCrossSigning";
@ -21,14 +21,14 @@ describe("CreateCrossSigning", () => {
}); });
it("should call bootstrapCrossSigning with an authUploadDeviceSigningKeys function", async () => { it("should call bootstrapCrossSigning with an authUploadDeviceSigningKeys function", async () => {
await createCrossSigning(client, false, "password"); await createCrossSigning(client);
expect(client.getCrypto()?.bootstrapCrossSigning).toHaveBeenCalledWith({ expect(client.getCrypto()?.bootstrapCrossSigning).toHaveBeenCalledWith({
authUploadDeviceSigningKeys: expect.any(Function), authUploadDeviceSigningKeys: expect.any(Function),
}); });
}); });
it("should upload with password auth if possible", async () => { it("should upload", async () => {
client.uploadDeviceSigningKeys = jest.fn().mockRejectedValueOnce( client.uploadDeviceSigningKeys = jest.fn().mockRejectedValueOnce(
new MatrixError({ new MatrixError({
flows: [ flows: [
@ -39,24 +39,7 @@ describe("CreateCrossSigning", () => {
}), }),
); );
await createCrossSigning(client, false, "password"); await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const makeRequest = jest.fn();
await authUploadDeviceSigningKeys!(makeRequest);
expect(makeRequest).toHaveBeenCalledWith({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: client.getUserId(),
},
password: "password",
});
});
it("should attempt to upload keys without auth if using token login", async () => {
await createCrossSigning(client, true, undefined);
const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0]; const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
@ -65,7 +48,7 @@ describe("CreateCrossSigning", () => {
expect(makeRequest).toHaveBeenCalledWith({}); expect(makeRequest).toHaveBeenCalledWith({});
}); });
it("should prompt user if password upload not possible", async () => { it("should prompt user if upload failed with UIA", async () => {
const createDialog = jest.spyOn(Modal, "createDialog").mockReturnValue({ const createDialog = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true]), finished: Promise.resolve([true]),
close: jest.fn(), close: jest.fn(),
@ -81,13 +64,32 @@ describe("CreateCrossSigning", () => {
}), }),
); );
await createCrossSigning(client, false, "password"); await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0]; const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const makeRequest = jest.fn(); const makeRequest = jest.fn().mockRejectedValue(
new MatrixError({
flows: [
{
stages: ["dummy.mystery_flow_nobody_knows"],
},
],
}),
);
await authUploadDeviceSigningKeys!(makeRequest); await authUploadDeviceSigningKeys!(makeRequest);
expect(makeRequest).not.toHaveBeenCalledWith(); expect(makeRequest).not.toHaveBeenCalledWith();
expect(createDialog).toHaveBeenCalled(); expect(createDialog).toHaveBeenCalled();
}); });
it("should throw error if server fails with something other than UIA", async () => {
await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const error = new HTTPError("Internal Server Error", 500);
const makeRequest = jest.fn().mockRejectedValue(error);
await expect(authUploadDeviceSigningKeys!(makeRequest)).rejects.toThrow(error);
expect(makeRequest).not.toHaveBeenCalledWith();
});
}); });

View File

@ -16,6 +16,7 @@ import {
IContent, IContent,
MatrixEvent, MatrixEvent,
SyncState, SyncState,
AccountDataEvents,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react"; import { waitFor } from "jest-matrix-react";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
@ -69,7 +70,7 @@ describe("Notifier", () => {
let MockPlatform: MockedObject<BasePlatform>; let MockPlatform: MockedObject<BasePlatform>;
let mockClient: MockedObject<MatrixClient>; let mockClient: MockedObject<MatrixClient>;
let testRoom: Room; let testRoom: Room;
let accountDataEventKey: string; let accountDataEventKey: keyof AccountDataEvents;
let accountDataStore: Record<string, MatrixEvent | undefined> = {}; let accountDataStore: Record<string, MatrixEvent | undefined> = {};
let mockSettings: Record<string, boolean> = {}; let mockSettings: Record<string, boolean> = {};

View File

@ -338,19 +338,18 @@ describe("<RoomSummaryCard />", () => {
}); });
it("does not show public room label for a DM", async () => { it("does not show public room label for a DM", async () => {
mockClient.getAccountData.mockImplementation( mockClient.getAccountData.mockImplementation((eventType) => {
(eventType) => if (eventType === EventType.Direct) {
({ return new MatrixEvent({
[EventType.Direct]: new MatrixEvent({ type: EventType.Direct,
type: EventType.Direct, content: {
content: { "@bob:sesame.st": ["some-room-id"],
"@bob:sesame.st": ["some-room-id"], // this room is a DM with ernie
// this room is a DM with ernie "@ernie:sesame.st": ["some-other-room-id", room.roomId],
"@ernie:sesame.st": ["some-other-room-id", room.roomId], },
}, });
}), }
})[eventType], });
);
getComponent(); getComponent();
await flushPromises(); await flushPromises();

View File

@ -11,7 +11,6 @@ import { fireEvent, render, screen, waitFor, within } from "jest-matrix-react";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { UIFeature } from "../../../../../src/settings/UIFeature"; import { UIFeature } from "../../../../../src/settings/UIFeature";
import { import {
@ -35,13 +34,9 @@ describe("SetIntegrationManager", () => {
deleteThreePid: jest.fn(), deleteThreePid: jest.fn(),
}); });
let stores!: SdkContextClass;
const getComponent = () => ( const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<SDKContext.Provider value={stores}> <SetIntegrationManager />
<SetIntegrationManager />
</SDKContext.Provider>
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );

View File

@ -1,53 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { AccountPasswordStore } from "../../../src/stores/AccountPasswordStore";
jest.useFakeTimers();
describe("AccountPasswordStore", () => {
let accountPasswordStore: AccountPasswordStore;
beforeEach(() => {
accountPasswordStore = new AccountPasswordStore();
});
it("should not have a password by default", () => {
expect(accountPasswordStore.getPassword()).toBeUndefined();
});
describe("when setting a password", () => {
beforeEach(() => {
accountPasswordStore.setPassword("pass1");
});
it("should return the password", () => {
expect(accountPasswordStore.getPassword()).toBe("pass1");
});
describe("and the password timeout exceed", () => {
beforeEach(() => {
jest.advanceTimersToNextTimer();
});
it("should clear the password", () => {
expect(accountPasswordStore.getPassword()).toBeUndefined();
});
});
describe("and setting another password", () => {
beforeEach(() => {
accountPasswordStore.setPassword("pass2");
});
it("should return the other password", () => {
expect(accountPasswordStore.getPassword()).toBe("pass2");
});
});
});
});

View File

@ -8,12 +8,11 @@ Please see LICENSE files in the repository root for full details.
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react"; import { waitFor } from "jest-matrix-react";
import { sleep } from "matrix-js-sdk/src/utils";
import { createCrossSigning } from "../../../src/CreateCrossSigning"; import { createCrossSigning } from "../../../src/CreateCrossSigning";
import { InitialCryptoSetupStore } from "../../../src/stores/InitialCryptoSetupStore"; import { InitialCryptoSetupStore } from "../../../src/stores/InitialCryptoSetupStore";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { createTestClient } from "../../test-utils"; import { createTestClient } from "../../test-utils";
import { AccountPasswordStore } from "../../../src/stores/AccountPasswordStore";
jest.mock("../../../src/CreateCrossSigning", () => ({ jest.mock("../../../src/CreateCrossSigning", () => ({
createCrossSigning: jest.fn(), createCrossSigning: jest.fn(),
@ -22,7 +21,6 @@ jest.mock("../../../src/CreateCrossSigning", () => ({
describe("InitialCryptoSetupStore", () => { describe("InitialCryptoSetupStore", () => {
let testStore: InitialCryptoSetupStore; let testStore: InitialCryptoSetupStore;
let client: MatrixClient; let client: MatrixClient;
let stores: SdkContextClass;
let createCrossSigningResolve: () => void; let createCrossSigningResolve: () => void;
let createCrossSigningReject: (e: Error) => void; let createCrossSigningReject: (e: Error) => void;
@ -30,11 +28,6 @@ describe("InitialCryptoSetupStore", () => {
beforeEach(() => { beforeEach(() => {
testStore = new InitialCryptoSetupStore(); testStore = new InitialCryptoSetupStore();
client = createTestClient(); client = createTestClient();
stores = {
accountPasswordStore: {
getPassword: jest.fn(),
} as unknown as AccountPasswordStore,
} as unknown as SdkContextClass;
mocked(createCrossSigning).mockImplementation(() => { mocked(createCrossSigning).mockImplementation(() => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
@ -45,7 +38,7 @@ describe("InitialCryptoSetupStore", () => {
}); });
it("should call createCrossSigning when startInitialCryptoSetup is called", async () => { it("should call createCrossSigning when startInitialCryptoSetup is called", async () => {
testStore.startInitialCryptoSetup(client, false, stores, jest.fn()); testStore.startInitialCryptoSetup(client, jest.fn());
await waitFor(() => expect(createCrossSigning).toHaveBeenCalled()); await waitFor(() => expect(createCrossSigning).toHaveBeenCalled());
}); });
@ -54,7 +47,7 @@ describe("InitialCryptoSetupStore", () => {
const updateSpy = jest.fn(); const updateSpy = jest.fn();
testStore.on("update", updateSpy); testStore.on("update", updateSpy);
testStore.startInitialCryptoSetup(client, false, stores, jest.fn()); testStore.startInitialCryptoSetup(client, jest.fn());
createCrossSigningResolve(); createCrossSigningResolve();
await waitFor(() => expect(updateSpy).toHaveBeenCalled()); await waitFor(() => expect(updateSpy).toHaveBeenCalled());
@ -65,21 +58,28 @@ describe("InitialCryptoSetupStore", () => {
const updateSpy = jest.fn(); const updateSpy = jest.fn();
testStore.on("update", updateSpy); testStore.on("update", updateSpy);
testStore.startInitialCryptoSetup(client, false, stores, jest.fn()); testStore.startInitialCryptoSetup(client, jest.fn());
createCrossSigningReject(new Error("Test error")); createCrossSigningReject(new Error("Test error"));
await waitFor(() => expect(updateSpy).toHaveBeenCalled()); await waitFor(() => expect(updateSpy).toHaveBeenCalled());
expect(testStore.getStatus()).toBe("error"); expect(testStore.getStatus()).toBe("error");
}); });
it("should ignore failures if tokenLogin is true", async () => { it("should fail to retry once complete", async () => {
const updateSpy = jest.fn(); testStore.startInitialCryptoSetup(client, jest.fn());
testStore.on("update", updateSpy);
testStore.startInitialCryptoSetup(client, true, stores, jest.fn()); await waitFor(() => expect(createCrossSigning).toHaveBeenCalled());
createCrossSigningResolve();
await sleep(0); // await the next tick
expect(testStore.retry()).toBeFalsy();
});
it("should retry if initial attempt failed", async () => {
testStore.startInitialCryptoSetup(client, jest.fn());
await waitFor(() => expect(createCrossSigning).toHaveBeenCalled());
createCrossSigningReject(new Error("Test error")); createCrossSigningReject(new Error("Test error"));
await sleep(0); // await the next tick
await waitFor(() => expect(updateSpy).toHaveBeenCalled()); expect(testStore.retry()).toBeTruthy();
expect(testStore.getStatus()).toBe("complete");
}); });
}); });

View File

@ -11,7 +11,6 @@ import { MatrixClient, Device } from "matrix-js-sdk/src/matrix";
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage"; import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage";
import { BootstrapCrossSigningOpts, CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { BootstrapCrossSigningOpts, CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { accessSecretStorage } from "../../../src/SecurityManager"; import { accessSecretStorage } from "../../../src/SecurityManager";
import { SetupEncryptionStore } from "../../../src/stores/SetupEncryptionStore"; import { SetupEncryptionStore } from "../../../src/stores/SetupEncryptionStore";
import { emitPromise, stubClient } from "../../test-utils"; import { emitPromise, stubClient } from "../../test-utils";
@ -21,7 +20,6 @@ jest.mock("../../../src/SecurityManager", () => ({
})); }));
describe("SetupEncryptionStore", () => { describe("SetupEncryptionStore", () => {
const cachedPassword = "p4assword";
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>; let mockCrypto: Mocked<CryptoApi>;
let mockSecretStorage: Mocked<ServerSideSecretStorage>; let mockSecretStorage: Mocked<ServerSideSecretStorage>;
@ -47,11 +45,6 @@ describe("SetupEncryptionStore", () => {
Object.defineProperty(client, "secretStorage", { value: mockSecretStorage }); Object.defineProperty(client, "secretStorage", { value: mockSecretStorage });
setupEncryptionStore = new SetupEncryptionStore(); setupEncryptionStore = new SetupEncryptionStore();
SdkContextClass.instance.accountPasswordStore.setPassword(cachedPassword);
});
afterEach(() => {
SdkContextClass.instance.accountPasswordStore.clearPassword();
}); });
describe("start", () => { describe("start", () => {
@ -172,7 +165,6 @@ describe("SetupEncryptionStore", () => {
await setupEncryptionStore.resetConfirm(); await setupEncryptionStore.resetConfirm();
expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), { expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), {
accountPassword: cachedPassword,
forceReset: true, forceReset: true,
resetCrossSigning: true, resetCrossSigning: true,
}); });

View File

@ -6,7 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { MatrixEvent, NotificationCountType, Room, MatrixClient, ReceiptType } from "matrix-js-sdk/src/matrix"; import {
MatrixEvent,
NotificationCountType,
Room,
MatrixClient,
ReceiptType,
AccountDataEvents,
} from "matrix-js-sdk/src/matrix";
import { Mocked, mocked } from "jest-mock"; import { Mocked, mocked } from "jest-mock";
import { import {
@ -32,7 +39,7 @@ jest.mock("../../../src/settings/SettingsStore");
describe("notifications", () => { describe("notifications", () => {
let accountDataStore: Record<string, MatrixEvent> = {}; let accountDataStore: Record<string, MatrixEvent> = {};
let mockClient: Mocked<MatrixClient>; let mockClient: Mocked<MatrixClient>;
let accountDataEventKey: string; let accountDataEventKey: keyof AccountDataEvents;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();

View File

@ -11539,10 +11539,10 @@ typed-array-length@^1.0.6:
possible-typed-array-names "^1.0.0" possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6" reflect.getprototypeof "^1.0.6"
typescript@5.6.3: typescript@5.7.2:
version "5.6.3" version "5.7.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6"
integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==
ua-parser-js@^1.0.2: ua-parser-js@^1.0.2:
version "1.0.39" version "1.0.39"