From 79c905de45fa76c45420160abc1e25aea6749f69 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 11 Apr 2025 11:52:41 +0100 Subject: [PATCH] Delegate to ResetIdentityDialog from SetupEncryptionBody --- playwright/e2e/crypto/dehydration.spec.ts | 38 ++++-- .../structures/auth/CompleteSecurity.tsx | 5 +- .../structures/auth/SetupEncryptionBody.tsx | 41 +++--- .../views/dialogs/ResetIdentityDialog.tsx | 26 ++-- .../settings/encryption/ResetIdentityBody.tsx | 7 +- .../encryption/ResetIdentityPanel.tsx | 7 +- .../tabs/user/EncryptionUserSettingsTab.tsx | 2 +- src/i18n/strings/en_EN.json | 3 - .../security/ResetIdentityDialog-test.tsx | 62 +++++++++ .../security/SetupEncryptionDialog-test.tsx | 118 ++++++++++++++++++ .../structures/auth/CompleteSecurity-test.tsx | 17 ++- .../encryption/ResetIdentityPanel-test.tsx | 14 +-- 12 files changed, 263 insertions(+), 77 deletions(-) create mode 100644 test/components/views/dialogs/security/ResetIdentityDialog-test.tsx create mode 100644 test/components/views/dialogs/security/SetupEncryptionDialog-test.tsx diff --git a/playwright/e2e/crypto/dehydration.spec.ts b/playwright/e2e/crypto/dehydration.spec.ts index 2f545b8d14..d8a26071b7 100644 --- a/playwright/e2e/crypto/dehydration.spec.ts +++ b/playwright/e2e/crypto/dehydration.spec.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { test, expect } from "../../element-web-test"; import { isDendrite } from "../../plugins/homeserver/dendrite"; -import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts"; +import { createBot, logIntoElement } from "./utils.ts"; import { type Client } from "../../pages/client.ts"; import { type ElementAppPage } from "../../pages/ElementAppPage.ts"; @@ -35,14 +35,20 @@ test.describe("Dehydration", () => { await app.closeDialog(); - // Verify the device by resetting the key + // Reset the key const settings = await app.settings.openUserSettings("Encryption"); await settings.getByRole("button", { name: "Verify this device" }).click(); await page.getByRole("button", { name: "Proceed with reset" }).click(); await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("button", { name: "Copy" }).click(); + + // Set up recovery + await page.getByRole("button", { name: "Set up recovery" }).click(); await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("button", { name: "Done" }).click(); + const recoveryKey = await page.getByTestId("recoveryKey").innerText(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("textbox").fill(recoveryKey); + await page.getByRole("button", { name: "Finish set up" }).click(); + await page.getByRole("button", { name: "Close" }).click(); await expectDehydratedDeviceEnabled(app); @@ -80,7 +86,7 @@ test.describe("Dehydration", () => { await expectDehydratedDeviceEnabled(app); }); - test("Reset recovery key during login re-creates dehydrated device", async ({ + test("Reset identity during login and set up recovery re-creates dehydrated device", async ({ page, homeserver, app, @@ -99,16 +105,26 @@ test.describe("Dehydration", () => { // Log in our client await logIntoElement(page, credentials); - // Oh no, we forgot our recovery key + // Oh no, we forgot our recovery key - reset our identity await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click(); - await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click(); + await expect( + page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByPlaceholder("Password").fill(credentials.password); + await page.getByRole("button", { name: "Continue" }).click(); - await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password }); + // And set up recovery + const settings = await app.settings.openUserSettings("Encryption"); + await settings.getByRole("button", { name: "Set up recovery" }).click(); + await settings.getByRole("button", { name: "Continue" }).click(); + const recoveryKey = await settings.getByTestId("recoveryKey").innerText(); + await settings.getByRole("button", { name: "Continue" }).click(); + await settings.getByRole("textbox").fill(recoveryKey); + await settings.getByRole("button", { name: "Finish set up" }).click(); // There should be a brand new dehydrated device - const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client); - expect(dehydratedDeviceIds.length).toBe(1); - expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]); + await expectDehydratedDeviceEnabled(app); }); test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => { diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index a0f2be8836..25e82f7247 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -75,12 +75,9 @@ export default class CompleteSecurity extends React.Component { } else if (phase === Phase.ConfirmSkip) { icon = ; title = _t("common|are_you_sure"); - } else if (phase === Phase.Busy) { + } else if (phase === Phase.Busy || phase === Phase.ConfirmReset) { icon = ; title = _t("encryption|verification|after_new_login|verify_this_device"); - } else if (phase === Phase.ConfirmReset) { - icon = ; - title = _t("encryption|verification|after_new_login|reset_confirmation"); } else if (phase === Phase.Finished) { // SetupEncryptionBody will take care of calling onFinished, we don't need to do anything } else { diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index b91b99c921..e56bffdef2 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2020, 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -19,6 +19,7 @@ import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStor import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import Spinner from "../../views/elements/Spinner"; +import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog"; function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean { return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations); @@ -114,12 +115,18 @@ export default class SetupEncryptionBody extends React.Component ev.preventDefault(); const store = SetupEncryptionStore.sharedInstance(); store.reset(); - }; - - private onResetConfirmClick = (): void => { - this.props.onFinished(); - const store = SetupEncryptionStore.sharedInstance(); - store.resetConfirm(); + Modal.createDialog(ResetIdentityDialog, { + onReset: () => { + // The user completed the reset process - close this dialog + this.props.onFinished(); + this.onDoneClick(); + }, + onFinished: () => { + // The user cancelled the reset dialog or click away - go back a step + this.onResetBackClick(); + }, + variant: "confirm", + }); }; private onResetBackClick = (): void => { @@ -157,7 +164,7 @@ export default class SetupEncryptionBody extends React.Component

{_t("encryption|verification|no_key_or_device")}

- + {_t("encryption|verification|reset_proceed_prompt")}
@@ -246,23 +253,7 @@ export default class SetupEncryptionBody extends React.Component ); - } else if (phase === Phase.ConfirmReset) { - return ( -
-

{_t("encryption|verification|verify_reset_warning_1")}

-

{_t("encryption|verification|verify_reset_warning_2")}

- -
- - {_t("encryption|verification|reset_proceed_prompt")} - - - {_t("action|go_back")} - -
-
- ); - } else if (phase === Phase.Busy || phase === Phase.Loading) { + } else if (phase === Phase.Busy || phase === Phase.Loading || phase == Phase.ConfirmReset) { return ; } else { logger.log(`SetupEncryptionBody: Unknown phase ${phase}`); diff --git a/src/components/views/dialogs/ResetIdentityDialog.tsx b/src/components/views/dialogs/ResetIdentityDialog.tsx index 54ef736ba1..07b261d1e1 100644 --- a/src/components/views/dialogs/ResetIdentityDialog.tsx +++ b/src/components/views/dialogs/ResetIdentityDialog.tsx @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type MouseEventHandler } from "react"; +import React, { type JSX, type MouseEventHandler } from "react"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -16,14 +16,11 @@ interface ResetIdentityDialogProps { * Called when the dialog closes. */ onFinished: () => void; + /** - * Called when the identity is reset. + * Called when the identity is reset (before onFinished is called). */ - onResetFinished: MouseEventHandler; - /** - * Called when the cancel button is clicked. - */ - onCancelClick: () => void; + onReset: MouseEventHandler; /** * Which variant of this dialog to show. @@ -34,28 +31,19 @@ interface ResetIdentityDialogProps { /** * The dialog for resetting the identity of the current user. */ -export function ResetIdentityDialog({ - onFinished, - onCancelClick, - onResetFinished, - variant, -}: ResetIdentityDialogProps): JSX.Element { +export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element { const matrixClient = MatrixClientPeg.safeGet(); // Wrappers for ResetIdentityBody's callbacks so that onFinish gets called // whenever the reset is done, whether by completing successfully, or by // being cancelled const onResetWrapper: MouseEventHandler = (...args) => { + onReset(...args); onFinished(); - onResetFinished(...args); - }; - const onCancelWrapper: () => void = () => { - onFinished(); - onCancelClick(); }; return ( - + ); } diff --git a/src/components/views/settings/encryption/ResetIdentityBody.tsx b/src/components/views/settings/encryption/ResetIdentityBody.tsx index dabf61f915..f5e2097072 100644 --- a/src/components/views/settings/encryption/ResetIdentityBody.tsx +++ b/src/components/views/settings/encryption/ResetIdentityBody.tsx @@ -22,7 +22,8 @@ interface ResetIdentityBodyProps { /** * Called when the identity is reset. */ - onFinish: MouseEventHandler; + onReset: MouseEventHandler; + /** * Called when the cancel button is clicked. */ @@ -53,7 +54,7 @@ export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" * * Used by {@link ResetIdentityPanel}. */ -export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIdentityBodyProps): JSX.Element { +export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element { const matrixClient = useMatrixClientContext(); // After the user clicks "Continue", we disable the button so it can't be @@ -85,7 +86,7 @@ export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIde await matrixClient .getCrypto() ?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest)); - onFinish(evt); + onReset(evt); }} > {inProgress ? ( diff --git a/src/components/views/settings/encryption/ResetIdentityPanel.tsx b/src/components/views/settings/encryption/ResetIdentityPanel.tsx index dc733a1967..a721e48888 100644 --- a/src/components/views/settings/encryption/ResetIdentityPanel.tsx +++ b/src/components/views/settings/encryption/ResetIdentityPanel.tsx @@ -15,7 +15,8 @@ interface ResetIdentityPanelProps { /** * Called when the identity is reset. */ - onFinish: MouseEventHandler; + onReset: MouseEventHandler; + /** * Called when the cancel button is clicked or when we go back in the breadcrumbs. */ @@ -32,7 +33,7 @@ interface ResetIdentityPanelProps { * * A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs. */ -export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element { +export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIdentityPanelProps): JSX.Element { return ( <> - + ); } diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index 7cd0c1d7b5..2303a1e50c 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -128,7 +128,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): ); break; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 37a3a0718e..987e84f85c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -992,7 +992,6 @@ "accepting": "Accepting…", "after_new_login": { "device_verified": "Device verified", - "reset_confirmation": "Really reset verification keys?", "skip_verification": "Skip verification for now", "unable_to_verify": "Unable to verify this device", "verify_this_device": "Verify this device" @@ -1063,8 +1062,6 @@ "verify_emoji_prompt": "Verify by comparing unique emoji.", "verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.", "verify_later": "I'll verify later", - "verify_reset_warning_1": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.", - "verify_reset_warning_2": "Please only proceed if you're sure you've lost all of your other devices and your Recovery Key.", "verify_using_device": "Verify with another device", "verify_using_key": "Verify with Recovery Key", "verify_using_key_or_phrase": "Verify with Recovery Key or Phrase", diff --git a/test/components/views/dialogs/security/ResetIdentityDialog-test.tsx b/test/components/views/dialogs/security/ResetIdentityDialog-test.tsx new file mode 100644 index 0000000000..dd07ed6ac1 --- /dev/null +++ b/test/components/views/dialogs/security/ResetIdentityDialog-test.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2018-2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { act } from "react"; +import { render } from "jest-matrix-react"; +import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; +import { type Mocked } from "jest-mock"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { getMockClientWithEventEmitter } from "../../../../test-utils"; +import { ResetIdentityDialog } from "../../../../../src/components/views/dialogs/ResetIdentityDialog"; + +describe("ResetIdentityDialog", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("should call onReset and onFinished when we click Continue", async () => { + const client = mockClient(); + + const onFinished = jest.fn(); + const onReset = jest.fn(); + const dialog = render(); + + await act(async () => dialog.getByRole("button", { name: "Continue" }).click()); + + expect(onReset).toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalled(); + expect(client.getCrypto()?.resetEncryption).toHaveBeenCalled(); + }); + + it("should just call onFinished when we click Cancel", async () => { + const client = mockClient(); + + const onFinished = jest.fn(); + const onReset = jest.fn(); + const dialog = render(); + + await act(async () => dialog.getByRole("button", { name: "Cancel" }).click()); + + expect(onFinished).toHaveBeenCalled(); + + expect(onReset).not.toHaveBeenCalled(); + expect(client.getCrypto()?.resetEncryption).not.toHaveBeenCalled(); + }); +}); + +function mockClient(): Mocked { + const mockCrypto = { + resetEncryption: jest.fn().mockResolvedValue(null), + } as unknown as Mocked; + + return getMockClientWithEventEmitter({ + getCrypto: jest.fn().mockReturnValue(mockCrypto), + }); +} diff --git a/test/components/views/dialogs/security/SetupEncryptionDialog-test.tsx b/test/components/views/dialogs/security/SetupEncryptionDialog-test.tsx new file mode 100644 index 0000000000..9b2061666f --- /dev/null +++ b/test/components/views/dialogs/security/SetupEncryptionDialog-test.tsx @@ -0,0 +1,118 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2018-2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { act } from "react"; +import { render, screen } from "jest-matrix-react"; +import { type Mocked } from "jest-mock"; +import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; + +import SetupEncryptionDialog from "../../../../../src/components/views/dialogs/security/SetupEncryptionDialog"; +import { getMockClientWithEventEmitter } from "../../../../test-utils"; +import { Phase, SetupEncryptionStore } from "../../../../../src/stores/SetupEncryptionStore"; +import Modal from "../../../../../src/Modal"; + +describe("SetupEncryptionDialog", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("should launch a dialog when I say Proceed, then be finished when I reset", async () => { + mockClient(); + const store = new SetupEncryptionStore(); + jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store); + + // Given when you open the reset dialog we immediately reset + jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => { + // Simulate doing the reset in the dialog + props?.onReset(); + + return { + close: jest.fn(), + finished: Promise.resolve([]), + }; + }); + + // When we launch the dialog and set it ready to start + const onFinished = jest.fn(); + render(); + await act(async () => await store.fetchKeyInfo()); + expect(store.phase).toBe(Phase.Intro); + + // And we hit the Proceed with reset button. + // (The createDialog mock above simulates the user doing the reset) + await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click()); + + // Then the phase has been set to Finished + expect(store.phase).toBe(Phase.Finished); + }); + + it("should launch a dialog when I say Proceed, then be ready when I cancel", async () => { + mockClient(); + const store = new SetupEncryptionStore(); + jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store); + + // Given when you open the reset dialog we immediately reset + jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => { + // Simulate doing the reset in the dialog + props?.onFinished(); + + return { + close: jest.fn(), + finished: Promise.resolve([]), + }; + }); + + // When we launch the dialog and set it ready to start + const onFinished = jest.fn(); + render(); + await act(async () => await store.fetchKeyInfo()); + expect(store.phase).toBe(Phase.Intro); + + // And we hit the Proceed with reset button. + // (The createDialog mock above simulates the user hitting cancel) + await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click()); + + // Then the phase has been set to Finished + expect(store.phase).toBe(Phase.Intro); + }); +}); + +function mockClient() { + const mockCrypto = { + getDeviceVerificationStatus: jest.fn().mockResolvedValue({ + crossSigningVerified: false, + }), + getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), + isCrossSigningReady: jest.fn().mockResolvedValue(true), + isSecretStorageReady: jest.fn().mockResolvedValue(true), + userHasCrossSigningKeys: jest.fn(), + getActiveSessionBackupVersion: jest.fn(), + getCrossSigningStatus: jest.fn().mockReturnValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: true, + privateKeysCachedLocally: { + masterKey: true, + selfSigningKey: true, + userSigningKey: true, + }, + }), + getSessionBackupPrivateKey: jest.fn(), + isEncryptionEnabledInRoom: jest.fn(), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), + getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), + } as unknown as Mocked; + + const userId = "@user:server"; + + getMockClientWithEventEmitter({ + getCrypto: jest.fn().mockReturnValue(mockCrypto), + getUserId: jest.fn().mockReturnValue(userId), + secretStorage: { isStored: jest.fn().mockReturnValue({}) }, + }); +} diff --git a/test/unit-tests/components/structures/auth/CompleteSecurity-test.tsx b/test/unit-tests/components/structures/auth/CompleteSecurity-test.tsx index 1c9cd97ce2..be4c297192 100644 --- a/test/unit-tests/components/structures/auth/CompleteSecurity-test.tsx +++ b/test/unit-tests/components/structures/auth/CompleteSecurity-test.tsx @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, screen } from "jest-matrix-react"; +import { act, render, screen } from "jest-matrix-react"; import { mocked } from "jest-mock"; import EventEmitter from "events"; @@ -76,4 +76,19 @@ describe("CompleteSecurity", () => { expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument(); }); + + it("Renders a warning if user hits Reset", async () => { + // Given a store and a dialog based on it + const store = new SetupEncryptionStore(); + jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store); + const panel = await act(() => render( {}} />)); + + // When we hit reset + await act(async () => panel.getByRole("button", { name: "Proceed with reset" }).click()); + //await act(async () => store.reset()); + + // Then the title and button update + expect(screen.getByRole("heading", { name: "Verify this device" })).toBeInTheDocument(); + expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument(); + }); }); diff --git a/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx index 07c6c3d930..4dd538547c 100644 --- a/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx @@ -24,9 +24,9 @@ describe("", () => { it("should reset the encryption when the continue button is clicked", async () => { const user = userEvent.setup(); - const onFinish = jest.fn(); + const onReset = jest.fn(); const { asFragment } = render( - , + , withClientContextRenderOptions(matrixClient), ); expect(asFragment()).toMatchSnapshot(); @@ -43,22 +43,22 @@ describe("", () => { await sleep(0); expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled(); - expect(onFinish).toHaveBeenCalled(); + expect(onReset).toHaveBeenCalled(); }); it("should display the 'forgot recovery key' variant correctly", async () => { - const onFinish = jest.fn(); + const onReset = jest.fn(); const { asFragment } = render( - , + , withClientContextRenderOptions(matrixClient), ); expect(asFragment()).toMatchSnapshot(); }); it("should display the 'sync failed' variant correctly", async () => { - const onFinish = jest.fn(); + const onReset = jest.fn(); const { asFragment } = render( - , + , withClientContextRenderOptions(matrixClient), ); expect(asFragment()).toMatchSnapshot();