Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -0,0 +1,153 @@
/*
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.
*/
import React from "react";
import { act, fireEvent, render, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { RoomMember, EventType } from "matrix-js-sdk/src/matrix";
import { getMockClientWithEventEmitter, makeRoomWithStateEvents, mkEvent } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import {
AddPrivilegedUsers,
getUserIdsFromCompletions,
hasLowerOrEqualLevelThanDefaultLevel,
} from "../../../../../src/components/views/settings/AddPrivilegedUsers";
import UserProvider from "../../../../../src/autocomplete/UserProvider";
import { ICompletion } from "../../../../../src/autocomplete/Autocompleter";
jest.mock("../../../../../src/autocomplete/UserProvider");
jest.mock("../../../../../src/stores/WidgetStore");
jest.mock("../../../../../src/stores/widgets/WidgetLayoutStore");
const completions: ICompletion[] = [
{
component: <div />,
type: "user",
completion: "user_1",
completionId: "@user_1:host.local",
range: { start: 1, end: 1 },
},
{
component: <div />,
type: "user",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
},
{ component: <div />, type: "user", completion: "user_without_completion_id", range: { start: 1, end: 1 } },
];
describe("<AddPrivilegedUsers />", () => {
const provider = mocked(UserProvider, { shallow: true });
provider.prototype.getCompletions.mockResolvedValue(completions);
const mockClient = getMockClientWithEventEmitter({
// `makeRoomWithStateEvents` only work's if `getRoom` is present.
getRoom: jest.fn(),
setPowerLevel: jest.fn(),
});
const room = makeRoomWithStateEvents([], { roomId: "room_id", mockClient: mockClient });
room.getMember = (userId: string) => {
const member = new RoomMember("room_id", userId);
member.powerLevel = 0;
return member;
};
(room.currentState.getStateEvents as unknown) = (_eventType: string, _stateKey: string) => {
return mkEvent({
type: EventType.RoomPowerLevels,
content: {},
user: "user_id",
});
};
const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}>
<AddPrivilegedUsers room={room} defaultUserLevel={0} />
</MatrixClientContext.Provider>
);
it("checks whether form submit works as intended", async () => {
const { getByTestId, queryAllByTestId } = render(getComponent());
// Verify that the submit button is disabled initially.
const submitButton = getByTestId("add-privileged-users-submit-button");
expect(submitButton).toBeDisabled();
// Find some suggestions and select them.
const autocompleteInput = getByTestId("autocomplete-input");
act(() => {
fireEvent.focus(autocompleteInput);
fireEvent.change(autocompleteInput, { target: { value: "u" } });
});
await waitFor(() => expect(provider.mock.instances[0].getCompletions).toHaveBeenCalledTimes(1));
const matchOne = getByTestId("autocomplete-suggestion-item-@user_1:host.local");
const matchTwo = getByTestId("autocomplete-suggestion-item-@user_2:host.local");
act(() => {
fireEvent.mouseDown(matchOne);
});
act(() => {
fireEvent.mouseDown(matchTwo);
});
// Check that `defaultUserLevel` is initially set and select a higher power level.
expect((getByTestId("power-level-option-0") as HTMLOptionElement).selected).toBeTruthy();
expect((getByTestId("power-level-option-50") as HTMLOptionElement).selected).toBeFalsy();
expect((getByTestId("power-level-option-100") as HTMLOptionElement).selected).toBeFalsy();
const powerLevelSelect = getByTestId("power-level-select-element");
await userEvent.selectOptions(powerLevelSelect, "100");
expect((getByTestId("power-level-option-0") as HTMLOptionElement).selected).toBeFalsy();
expect((getByTestId("power-level-option-50") as HTMLOptionElement).selected).toBeFalsy();
expect((getByTestId("power-level-option-100") as HTMLOptionElement).selected).toBeTruthy();
// The submit button should be enabled now.
expect(submitButton).toBeEnabled();
// Submit the form.
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1));
// Verify that the submit button is disabled again.
expect(submitButton).toBeDisabled();
// Verify that previously selected items are reset.
const selectionItems = queryAllByTestId("autocomplete-selection-item", { exact: false });
expect(selectionItems).toHaveLength(0);
// Verify that power level select is reset to `defaultUserLevel`.
expect((getByTestId("power-level-option-0") as HTMLOptionElement).selected).toBeTruthy();
expect((getByTestId("power-level-option-50") as HTMLOptionElement).selected).toBeFalsy();
expect((getByTestId("power-level-option-100") as HTMLOptionElement).selected).toBeFalsy();
});
it("getUserIdsFromCompletions() should map completions to user id's", () => {
expect(getUserIdsFromCompletions(completions)).toStrictEqual(["@user_1:host.local", "@user_2:host.local"]);
});
it.each([
{ defaultUserLevel: -50, expectation: false },
{ defaultUserLevel: 0, expectation: true },
{ defaultUserLevel: 50, expectation: true },
])(
"hasLowerOrEqualLevelThanDefaultLevel() should return $expectation for default level $defaultUserLevel",
({ defaultUserLevel, expectation }) => {
expect(hasLowerOrEqualLevelThanDefaultLevel(room, completions[0], defaultUserLevel)).toBe(expectation);
},
);
});

View file

@ -0,0 +1,535 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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 { render, screen, waitFor } from "jest-matrix-react";
import { MatrixClient, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import React from "react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { AddRemoveThreepids } from "../../../../../src/components/views/settings/AddRemoveThreepids";
import { clearAllModals, stubClient } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../src/Modal";
const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token";
const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_IDENTITY_ACCESS_TOKEN);
jest.mock("../../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
const EMAIL1 = {
medium: ThreepidMedium.Email,
address: "alice@nowhere.dummy",
};
const PHONE1 = {
medium: ThreepidMedium.Phone,
address: "447700900000",
};
const PHONE1_LOCALNUM = "07700900000";
describe("AddRemoveThreepids", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
afterEach(() => {
jest.restoreAllMocks();
clearAllModals();
});
const clientProviderWrapper: React.FC = ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
);
it("should render a loader while loading", async () => {
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={true}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
});
it("should render email addresses", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should render phone numbers", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[PHONE1]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should handle no email addresses", async () => {
const { container } = render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={() => {}}
/>,
);
expect(container).toMatchSnapshot();
});
it("should add an email address", async () => {
const onChangeFn = jest.fn();
mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" });
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Email Address" });
await userEvent.type(input, EMAIL1.address);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
expect(client.requestAdd3pidEmailToken).toHaveBeenCalledWith(EMAIL1.address, client.generateClientSecret(), 1);
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
await userEvent.click(continueButton);
expect(client.addThreePidOnly).toHaveBeenCalledWith({
client_secret: client.generateClientSecret(),
sid: "1",
auth: undefined,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should display an error if the link has not been clicked", async () => {
const onChangeFn = jest.fn();
const createDialogFn = jest.spyOn(Modal, "createDialog");
mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" });
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Email Address" });
await userEvent.type(input, EMAIL1.address);
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton);
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(continueButton).toBeEnabled();
mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized"));
await userEvent.click(continueButton);
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
description: "Unauthorized",
title: "Unable to verify email address.",
});
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should add a phone number", async () => {
const onChangeFn = jest.fn();
mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
success: true,
submit_url: "https://example.dummy",
});
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const countryDropdown = screen.getByRole("button", { name: /Country Dropdown/ });
await userEvent.click(countryDropdown);
const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" });
await userEvent.click(gbOption);
const input = screen.getByRole("textbox", { name: "Phone Number" });
await userEvent.type(input, PHONE1_LOCALNUM);
const addButton = screen.getByRole("button", { name: /Add/ });
userEvent.click(addButton);
const continueButton = await screen.findByRole("button", { name: /Continue/ });
await expect(continueButton).toHaveAttribute("aria-disabled", "true");
await expect(
await screen.findByText(
`A text message has been sent to +${PHONE1.address}. Please enter the verification code it contains.`,
),
).toBeInTheDocument();
expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith(
"GB",
PHONE1_LOCALNUM,
client.generateClientSecret(),
1,
);
const verificationInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(verificationInput, "123456");
expect(continueButton).not.toHaveAttribute("aria-disabled", "true");
userEvent.click(continueButton);
await waitFor(() => expect(continueButton).toHaveAttribute("aria-disabled", "true"));
expect(client.addThreePidOnly).toHaveBeenCalledWith({
client_secret: client.generateClientSecret(),
sid: "1",
auth: undefined,
});
expect(onChangeFn).toHaveBeenCalled();
}, 10000);
it("should display an error if the code is incorrect", async () => {
const onChangeFn = jest.fn();
const createDialogFn = jest.spyOn(Modal, "createDialog");
mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
success: true,
submit_url: "https://example.dummy",
});
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const input = screen.getByRole("textbox", { name: "Phone Number" });
await userEvent.type(input, PHONE1_LOCALNUM);
const countryDropdown = screen.getByRole("button", { name: /Country Dropdown/ });
await userEvent.click(countryDropdown);
const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" });
await userEvent.click(gbOption);
const addButton = screen.getByRole("button", { name: /Add/ });
await userEvent.click(addButton);
mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized"));
const verificationInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(verificationInput, "123457");
const continueButton = screen.getByRole("button", { name: /Continue/ });
await userEvent.click(continueButton);
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
description: "Unauthorized",
title: "Unable to verify phone number.",
});
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should remove an email address", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: /Remove/ });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: /Remove/ });
await userEvent.click(confirmRemoveButton);
expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should return to default view if adding is cancelled", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={[EMAIL1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: /Remove/ });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: /Cancel/ });
await userEvent.click(confirmRemoveButton);
expect(screen.queryByText(`Remove ${EMAIL1.address}?`)).not.toBeInTheDocument();
expect(client.deleteThreePid).not.toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).not.toHaveBeenCalled();
});
it("should remove a phone number", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={[PHONE1]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
const removeButton = screen.getByRole("button", { name: /Remove/ });
await userEvent.click(removeButton);
expect(screen.getByText(`Remove ${PHONE1.address}?`)).toBeVisible();
const confirmRemoveButton = screen.getByRole("button", { name: /Remove/ });
await userEvent.click(confirmRemoveButton);
expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should bind an email address", async () => {
mocked(client).requestEmailToken.mockResolvedValue({ sid: "1" });
mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy");
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={[Object.assign({}, EMAIL1, { bound: false })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(EMAIL1.address)).toBeVisible();
const shareButton = screen.getByRole("button", { name: /Share/ });
await userEvent.click(shareButton);
expect(screen.getByText("Verify the link in your inbox")).toBeVisible();
expect(client.requestEmailToken).toHaveBeenCalledWith(
EMAIL1.address,
client.generateClientSecret(),
1,
undefined,
MOCK_IDENTITY_ACCESS_TOKEN,
);
const completeButton = screen.getByRole("button", { name: /Complete/ });
await userEvent.click(completeButton);
expect(client.bindThreePid).toHaveBeenCalledWith({
sid: "1",
client_secret: client.generateClientSecret(),
id_server: "https://the_best_id_server.dummy",
id_access_token: MOCK_IDENTITY_ACCESS_TOKEN,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should bind a phone number", async () => {
mocked(client).requestMsisdnToken.mockResolvedValue({
success: true,
sid: "1",
msisdn: PHONE1.address,
intl_fmt: "+" + PHONE1.address,
});
mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy");
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={[Object.assign({}, PHONE1, { bound: false })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(PHONE1.address)).toBeVisible();
const shareButton = screen.getByRole("button", { name: /Share/ });
await userEvent.click(shareButton);
expect(screen.getByText("Please enter verification code sent via text.")).toBeVisible();
expect(client.requestMsisdnToken).toHaveBeenCalledWith(
null,
"+" + PHONE1.address,
client.generateClientSecret(),
1,
undefined,
MOCK_IDENTITY_ACCESS_TOKEN,
);
const codeInput = screen.getByRole("textbox", { name: "Verification code" });
await userEvent.type(codeInput, "123456");
await userEvent.keyboard("{Enter}");
expect(client.bindThreePid).toHaveBeenCalledWith({
sid: "1",
client_secret: client.generateClientSecret(),
id_server: "https://the_best_id_server.dummy",
id_access_token: MOCK_IDENTITY_ACCESS_TOKEN,
});
expect(onChangeFn).toHaveBeenCalled();
});
it("should revoke a bound email address", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={[Object.assign({}, EMAIL1, { bound: true })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(EMAIL1.address)).toBeVisible();
const revokeButton = screen.getByRole("button", { name: /Revoke/ });
await userEvent.click(revokeButton);
expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address);
expect(onChangeFn).toHaveBeenCalled();
});
it("should revoke a bound phone number", async () => {
const onChangeFn = jest.fn();
render(
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={[Object.assign({}, PHONE1, { bound: true })]}
isLoading={false}
onChange={onChangeFn}
/>,
{
wrapper: clientProviderWrapper,
},
);
expect(screen.getByText(PHONE1.address)).toBeVisible();
const revokeButton = screen.getByRole("button", { name: /Revoke/ });
await userEvent.click(revokeButton);
expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address);
expect(onChangeFn).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,73 @@
/*
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 React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import AvatarSetting from "../../../../../src/components/views/settings/AvatarSetting";
import { stubClient } from "../../../../test-utils";
const BASE64_GIF = "R0lGODlhAQABAAAAACw=";
const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", {
type: "image/gif",
});
describe("<AvatarSetting />", () => {
beforeEach(() => {
stubClient();
});
it("renders avatar with specified alt text", async () => {
const { queryByAltText } = render(
<AvatarSetting
placeholderId="blee"
placeholderName="boo"
avatarAltText="Avatar of Peter Fox"
avatar="mxc://example.org/my-avatar"
/>,
);
const imgElement = queryByAltText("Avatar of Peter Fox");
expect(imgElement).toBeInTheDocument();
});
it("renders a file as the avatar when supplied", async () => {
render(
<AvatarSetting
placeholderId="blee"
placeholderName="boo"
avatarAltText="Avatar of Peter Fox"
avatar={AVATAR_FILE}
/>,
);
const imgElement = await screen.findByRole("button", { name: "Avatar of Peter Fox" });
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute("src", "data:image/gif;base64," + BASE64_GIF);
});
it("calls onChange when a file is uploaded", async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(
<AvatarSetting
placeholderId="blee"
placeholderName="boo"
avatar="mxc://example.org/my-avatar"
avatarAltText="Avatar of Peter Fox"
onChange={onChange}
/>,
);
const fileInput = screen.getByAltText("Upload");
await user.upload(fileInput, AVATAR_FILE);
expect(onChange).toHaveBeenCalledWith(AVATAR_FILE);
});
});

View file

@ -0,0 +1,78 @@
/*
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 React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import ChangePassword from "../../../../../src/components/views/settings/ChangePassword";
import { stubClient } from "../../../../test-utils";
describe("<ChangePassword />", () => {
it("renders expected fields", () => {
const onFinished = jest.fn();
const onError = jest.fn();
const { asFragment } = render(<ChangePassword onFinished={onFinished} onError={onError} />);
expect(asFragment()).toMatchSnapshot();
});
it("should show validation tooltip if passwords do not match", async () => {
const onFinished = jest.fn();
const onError = jest.fn();
const { getByLabelText, getByText } = render(<ChangePassword onFinished={onFinished} onError={onError} />);
const currentPasswordField = getByLabelText("Current password");
await userEvent.type(currentPasswordField, "CurrentPassword1234");
const newPasswordField = getByLabelText("New Password");
await userEvent.type(newPasswordField, "$%newPassword1234");
const confirmPasswordField = getByLabelText("Confirm password");
await userEvent.type(confirmPasswordField, "$%newPassword1235");
await userEvent.click(getByText("Change Password"));
await expect(screen.findByText("Passwords don't match")).resolves.toBeInTheDocument();
});
it("should call MatrixClient::setPassword with expected parameters", async () => {
const cli = stubClient();
mocked(cli.setPassword).mockResolvedValue({});
const onFinished = jest.fn();
const onError = jest.fn();
const { getByLabelText, getByText } = render(<ChangePassword onFinished={onFinished} onError={onError} />);
const currentPasswordField = getByLabelText("Current password");
await userEvent.type(currentPasswordField, "CurrentPassword1234");
const newPasswordField = getByLabelText("New Password");
await userEvent.type(newPasswordField, "$%newPassword1234");
const confirmPasswordField = getByLabelText("Confirm password");
await userEvent.type(confirmPasswordField, "$%newPassword1234");
await userEvent.click(getByText("Change Password"));
await waitFor(() => {
expect(cli.setPassword).toHaveBeenCalledWith(
expect.objectContaining({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: cli.getUserId(),
},
password: "CurrentPassword1234",
}),
"$%newPassword1234",
false,
);
});
expect(onFinished).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,134 @@
/*
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 React from "react";
import { render, screen } from "jest-matrix-react";
import { Mocked, mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import CrossSigningPanel from "../../../../../src/components/views/settings/CrossSigningPanel";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../../../test-utils";
import Modal from "../../../../../src/Modal";
import ConfirmDestroyCrossSigningDialog from "../../../../../src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog";
describe("<CrossSigningPanel />", () => {
const userId = "@alice:server.org";
let mockClient: Mocked<MatrixClient>;
const getComponent = () => render(<CrossSigningPanel />);
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsCrypto(),
doesServerSupportUnstableFeature: jest.fn(),
});
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
mockClient.isCrossSigningReady.mockResolvedValue(false);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should render a spinner while loading", () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("should render when homeserver does not support cross-signing", async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);
getComponent();
await flushPromises();
expect(screen.getByText("Your homeserver does not support cross-signing.")).toBeInTheDocument();
});
describe("when cross signing is ready", () => {
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"⚠️ Cross-signing is ready but keys are not backed up.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
it("should render when keys are backed up", async () => {
mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
it("should allow reset of cross-signing", async () => {
mockClient.getCrypto()!.bootstrapCrossSigning = jest.fn().mockResolvedValue(undefined);
getComponent();
await flushPromises();
const modalSpy = jest.spyOn(Modal, "createDialog");
screen.getByRole("button", { name: "Reset" }).click();
expect(modalSpy).toHaveBeenCalledWith(ConfirmDestroyCrossSigningDialog, expect.any(Object));
modalSpy.mock.lastCall![1]!.onFinished(true);
expect(mockClient.getCrypto()!.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({ setupNewCrossSigning: true }),
);
});
});
describe("when cross signing is not ready", () => {
beforeEach(() => {
mocked(mockClient.getCrypto()!.isCrossSigningReady).mockResolvedValue(false);
});
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("Cross-signing is not set up.");
});
it("should render when keys are backed up", async () => {
mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,68 @@
/*
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.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as TestUtils from "../../../../test-utils";
import CryptographyPanel from "../../../../../src/components/views/settings/CryptographyPanel";
describe("CryptographyPanel", () => {
it("shows the session ID and key", async () => {
const sessionId = "ABCDEFGHIJ";
const sessionKey = "AbCDeFghIJK7L/m4nOPqRSTUVW4xyzaBCDef6gHIJkl";
const sessionKeyFormatted = "<strong>AbCD eFgh IJK7 L/m4 nOPq RSTU VW4x yzaB CDef 6gHI Jkl</strong>";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({ ed25519: sessionKey, curve25519: "1234" });
// When we render the CryptographyPanel
const rendered = render(<CryptographyPanel />);
// Then it displays info about the user's session
const codes = rendered.container.querySelectorAll("code");
expect(codes.length).toEqual(2);
expect(codes[0].innerHTML).toEqual(sessionId);
// Initially a placeholder
expect(codes[1].innerHTML).toEqual("<strong>...</strong>");
// Then the actual key
await TestUtils.flushPromises();
expect(codes[1].innerHTML).toEqual(sessionKeyFormatted);
});
it("handles errors fetching session key", async () => {
const sessionId = "ABCDEFGHIJ";
TestUtils.stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
client.deviceId = sessionId;
mocked(client.getCrypto()!.getOwnDeviceKeys).mockRejectedValue(new Error("bleh"));
// When we render the CryptographyPanel
const rendered = render(<CryptographyPanel />);
// Then it displays info about the user's session
const codes = rendered.container.querySelectorAll("code");
// Initially a placeholder
expect(codes[1].innerHTML).toEqual("<strong>...</strong>");
// Then "not supported key
await TestUtils.flushPromises();
expect(codes[1].innerHTML).toEqual("<strong>&lt;not supported&gt;</strong>");
});
});

View file

@ -0,0 +1,193 @@
/*
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 React from "react";
import { fireEvent, render, screen, within } from "jest-matrix-react";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import EventIndexPanel from "../../../../../src/components/views/settings/EventIndexPanel";
import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg";
import EventIndex from "../../../../../src/indexing/EventIndex";
import { clearAllModals, flushPromises, getMockClientWithEventEmitter } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
describe("<EventIndexPanel />", () => {
getMockClientWithEventEmitter({
getRooms: jest.fn().mockReturnValue([]),
});
const getComponent = () => render(<EventIndexPanel />);
beforeEach(() => {
jest.spyOn(EventIndexPeg, "get").mockRestore();
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "initEventIndex").mockClear().mockResolvedValue(true);
jest.spyOn(EventIndexPeg, "deleteEventIndex").mockClear();
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(false);
jest.spyOn(SettingsStore, "setValue").mockClear();
// @ts-ignore private property
EventIndexPeg.error = null;
});
afterEach(async () => {
await clearAllModals();
});
describe("when event index is initialised", () => {
it("renders event index information", () => {
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("opens event index management dialog", async () => {
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
getComponent();
fireEvent.click(screen.getByText("Manage"));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Message search")).toBeInTheDocument();
// close the modal
fireEvent.click(within(dialog).getByText("Done"));
});
});
describe("when event indexing is fully supported and enabled but not initialised", () => {
beforeEach(() => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(true);
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
// @ts-ignore private property
EventIndexPeg.error = new Error("Test error message");
});
it("displays an error when no event index is found and enabling not in progress", () => {
getComponent();
expect(screen.getByText("Message search initialisation failed")).toBeInTheDocument();
});
it("displays an error from the event index", () => {
getComponent();
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("asks for confirmation when resetting seshat", async () => {
getComponent();
fireEvent.click(screen.getByText("Reset"));
// wait for reset modal to open
await screen.findByText("Reset event store?");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Reset event store?")).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("Cancel"));
// didn't reset
expect(SettingsStore.setValue).not.toHaveBeenCalled();
expect(EventIndexPeg.deleteEventIndex).not.toHaveBeenCalled();
});
it("resets seshat", async () => {
getComponent();
fireEvent.click(screen.getByText("Reset"));
// wait for reset modal to open
await screen.findByText("Reset event store?");
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Reset event store"));
await flushPromises();
expect(SettingsStore.setValue).toHaveBeenCalledWith(
"enableEventIndexing",
null,
SettingLevel.DEVICE,
false,
);
expect(EventIndexPeg.deleteEventIndex).toHaveBeenCalled();
await clearAllModals();
});
});
describe("when event indexing is supported but not enabled", () => {
it("renders enable text", () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
getComponent();
expect(
screen.getByText("Securely cache encrypted messages locally for them to appear in search results."),
).toBeInTheDocument();
});
it("enables event indexing on enable button click", async () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
let deferredInitEventIndex: IDeferred<boolean> | undefined;
jest.spyOn(EventIndexPeg, "initEventIndex").mockImplementation(() => {
deferredInitEventIndex = defer<boolean>();
return deferredInitEventIndex.promise;
});
getComponent();
fireEvent.click(screen.getByText("Enable"));
await flushPromises();
// spinner shown while enabling
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
// add an event indx to the peg and resolve the init promise
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
expect(EventIndexPeg.initEventIndex).toHaveBeenCalled();
deferredInitEventIndex!.resolve(true);
await flushPromises();
expect(SettingsStore.setValue).toHaveBeenCalledWith("enableEventIndexing", null, SettingLevel.DEVICE, true);
// message for enabled event index
expect(
screen.getByText(
"Securely cache encrypted messages locally for them to appear in search results, using 0 Bytes to store messages from 0 rooms.",
),
).toBeInTheDocument();
});
});
describe("when event indexing is supported but not installed", () => {
it("renders link to install seshat", () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(true);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
describe("when event indexing is not supported", () => {
it("renders link to download a desktop client", () => {
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(false);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,21 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 React from "react";
import { render } from "jest-matrix-react";
import * as TestUtils from "../../../../test-utils";
import FontScalingPanel from "../../../../../src/components/views/settings/FontScalingPanel";
describe("FontScalingPanel", () => {
it("renders the font scaling UI", () => {
TestUtils.stubClient();
const { asFragment } = render(<FontScalingPanel />);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,345 @@
/*
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 React from "react";
import { act, fireEvent, render, screen, waitFor, within } from "jest-matrix-react";
import {
EventType,
GuestAccess,
HistoryVisibility,
JoinRule,
MatrixEvent,
Room,
ClientEvent,
RoomMember,
MatrixError,
Visibility,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import {
clearAllModals,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsUser,
} from "../../../../test-utils";
import { filterBoolean } from "../../../../../src/utils/arrays";
import JoinRuleSettings, { JoinRuleSettingsProps } from "../../../../../src/components/views/settings/JoinRuleSettings";
import { PreferredRoomVersions } from "../../../../../src/utils/PreferredRoomVersions";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import SettingsStore from "../../../../../src/settings/SettingsStore";
describe("<JoinRuleSettings />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
getDomain: jest.fn(),
getLocalAliases: jest.fn().mockReturnValue([]),
sendStateEvent: jest.fn(),
upgradeRoom: jest.fn(),
getProfileInfo: jest.fn(),
invite: jest.fn().mockResolvedValue(undefined),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getRoomDirectoryVisibility: jest.fn(),
setRoomDirectoryVisibility: jest.fn(),
});
const roomId = "!room:server.org";
const newRoomId = "!roomUpgraded:server.org";
const defaultProps = {
room: new Room(roomId, client, userId),
closeSettingsFn: jest.fn(),
onError: jest.fn(),
};
const getComponent = (props: Partial<JoinRuleSettingsProps> = {}) =>
render(<JoinRuleSettings {...defaultProps} {...props} />);
const setRoomStateEvents = (
room: Room,
roomVersion: string,
joinRule?: JoinRule,
guestAccess?: GuestAccess,
history?: HistoryVisibility,
): void => {
const events = filterBoolean<MatrixEvent>([
new MatrixEvent({
type: EventType.RoomCreate,
content: { room_version: roomVersion },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
guestAccess &&
new MatrixEvent({
type: EventType.RoomGuestAccess,
content: { guest_access: guestAccess },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
history &&
new MatrixEvent({
type: EventType.RoomHistoryVisibility,
content: { history_visibility: history },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
joinRule &&
new MatrixEvent({
type: EventType.RoomJoinRules,
content: { join_rule: joinRule },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
]);
room.currentState.setStateEvents(events);
};
beforeEach(() => {
client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" });
client.isRoomEncrypted.mockReturnValue(false);
client.upgradeRoom.mockResolvedValue({ replacement_room: newRoomId });
client.getRoom.mockReturnValue(null);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join");
});
type TestCase = [string, { label: string; unsupportedRoomVersion: string; preferredRoomVersion: string }];
const testCases: TestCase[] = [
[
JoinRule.Knock,
{
label: "Ask to join",
unsupportedRoomVersion: "6",
preferredRoomVersion: PreferredRoomVersions.KnockRooms,
},
],
[
JoinRule.Restricted,
{
label: "Space members",
unsupportedRoomVersion: "8",
preferredRoomVersion: PreferredRoomVersions.RestrictedRooms,
},
],
];
describe.each(testCases)("%s rooms", (joinRule, { label, unsupportedRoomVersion, preferredRoomVersion }) => {
afterEach(async () => {
await clearAllModals();
});
describe(`when room does not support join rule ${joinRule}`, () => {
it(`should not show ${joinRule} room join rule when upgrade is disabled`, () => {
// room that doesn't support the join rule
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, unsupportedRoomVersion);
getComponent({ room: room, promptUpgrade: false });
expect(screen.queryByText(label)).not.toBeInTheDocument();
});
it(`should show ${joinRule} room join rule when upgrade is enabled`, () => {
// room that doesn't support the join rule
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, unsupportedRoomVersion);
getComponent({ room: room, promptUpgrade: true });
expect(within(screen.getByText(label)).getByText("Upgrade required")).toBeInTheDocument();
});
it(`upgrades room when changing join rule to ${joinRule}`, async () => {
const deferredInvites: IDeferred<any>[] = [];
// room that doesn't support the join rule
const room = new Room(roomId, client, userId);
const parentSpace = new Room("!parentSpace:server.org", client, userId);
jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parentSpace.roomId]));
setRoomStateEvents(room, unsupportedRoomVersion);
const memberAlice = new RoomMember(roomId, "@alice:server.org");
const memberBob = new RoomMember(roomId, "@bob:server.org");
const memberCharlie = new RoomMember(roomId, "@charlie:server.org");
jest.spyOn(room, "getMembersWithMembership").mockImplementation((membership) =>
membership === KnownMembership.Join ? [memberAlice, memberBob] : [memberCharlie],
);
const upgradedRoom = new Room(newRoomId, client, userId);
setRoomStateEvents(upgradedRoom, preferredRoomVersion);
client.getRoom.mockImplementation((id) => {
if (roomId === id) return room;
if (parentSpace.roomId === id) return parentSpace;
return null;
});
// resolve invites by hand
// flushPromises is too blunt to test reliably
client.invite.mockImplementation(() => {
const p = defer<{}>();
deferredInvites.push(p);
return p.promise;
});
getComponent({ room: room, promptUpgrade: true });
fireEvent.click(screen.getByText(label));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Upgrade"));
expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion);
expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument();
await flushPromises();
expect(within(dialog).getByText("Loading new room")).toBeInTheDocument();
// "create" our new room, have it come thru sync
client.getRoom.mockImplementation((id) => {
if (roomId === id) return room;
if (newRoomId === id) return upgradedRoom;
if (parentSpace.roomId === id) return parentSpace;
return null;
});
client.emit(ClientEvent.Room, upgradedRoom);
// invite users
expect(await screen.findByText("Sending invites... (0 out of 2)")).toBeInTheDocument();
deferredInvites.pop()!.resolve({});
expect(await screen.findByText("Sending invites... (1 out of 2)")).toBeInTheDocument();
deferredInvites.pop()!.resolve({});
// Usually we see "Updating space..." in the UI here, but we
// removed the assertion about it, because it sometimes fails,
// presumably because it disappeared too quickly to be visible.
await flushPromises();
// done, modal closed
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it(`upgrades room with no parent spaces or members when changing join rule to ${joinRule}`, async () => {
// room that doesn't support the join rule
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, unsupportedRoomVersion);
const upgradedRoom = new Room(newRoomId, client, userId);
setRoomStateEvents(upgradedRoom, preferredRoomVersion);
getComponent({ room: room, promptUpgrade: true });
fireEvent.click(screen.getByText(label));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Upgrade"));
expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, preferredRoomVersion);
expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument();
await flushPromises();
expect(within(dialog).getByText("Loading new room")).toBeInTheDocument();
// "create" our new room, have it come thru sync
client.getRoom.mockImplementation((id) => {
if (roomId === id) return room;
if (newRoomId === id) return upgradedRoom;
return null;
});
client.emit(ClientEvent.Room, upgradedRoom);
await flushPromises();
await flushPromises();
// done, modal closed
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});
describe("knock rooms directory visibility", () => {
const getCheckbox = () => screen.getByRole("checkbox");
let room: Room;
beforeEach(() => (room = new Room(roomId, client, userId)));
describe("when join rule is knock", () => {
beforeEach(() => setRoomStateEvents(room, PreferredRoomVersions.KnockRooms, JoinRule.Knock));
it("should set the visibility to public", async () => {
jest.spyOn(client, "getRoomDirectoryVisibility").mockResolvedValue({ visibility: Visibility.Private });
jest.spyOn(client, "setRoomDirectoryVisibility").mockResolvedValue({});
getComponent({ room });
fireEvent.click(getCheckbox());
await act(async () => await flushPromises());
expect(client.setRoomDirectoryVisibility).toHaveBeenCalledWith(roomId, Visibility.Public);
expect(getCheckbox()).toBeChecked();
});
it("should set the visibility to private", async () => {
jest.spyOn(client, "getRoomDirectoryVisibility").mockResolvedValue({ visibility: Visibility.Public });
jest.spyOn(client, "setRoomDirectoryVisibility").mockResolvedValue({});
getComponent({ room });
await act(async () => await flushPromises());
fireEvent.click(getCheckbox());
await act(async () => await flushPromises());
expect(client.setRoomDirectoryVisibility).toHaveBeenCalledWith(roomId, Visibility.Private);
expect(getCheckbox()).not.toBeChecked();
});
it("should call onError if setting visibility fails", async () => {
const error = new MatrixError();
jest.spyOn(client, "getRoomDirectoryVisibility").mockResolvedValue({ visibility: Visibility.Private });
jest.spyOn(client, "setRoomDirectoryVisibility").mockRejectedValue(error);
getComponent({ room });
fireEvent.click(getCheckbox());
await act(async () => await flushPromises());
expect(getCheckbox()).not.toBeChecked();
expect(defaultProps.onError).toHaveBeenCalledWith(error);
});
});
describe("when the room version is unsupported and upgrade is enabled", () => {
it("should disable the checkbox", () => {
setRoomStateEvents(room, "6", JoinRule.Invite);
getComponent({ promptUpgrade: true, room });
expect(getCheckbox()).toBeDisabled();
});
});
describe("when join rule is not knock", () => {
beforeEach(() => {
setRoomStateEvents(room, PreferredRoomVersions.KnockRooms, JoinRule.Invite);
getComponent({ room });
});
it("should disable the checkbox", () => {
expect(getCheckbox()).toBeDisabled();
});
it("should set the visibility to private by default", () => {
expect(getCheckbox()).not.toBeChecked();
});
});
});
it("should not show knock room join rule", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const room = new Room(newRoomId, client, userId);
setRoomStateEvents(room, PreferredRoomVersions.KnockRooms);
getComponent({ room });
expect(screen.queryByText("Ask to join")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { Key } from "../../../../../src/Keyboard";
import { mockPlatformPeg, unmockPlatformPeg } from "../../../../test-utils/platform";
import { KeyboardKey, KeyboardShortcut } from "../../../../../src/components/views/settings/KeyboardShortcut";
const renderKeyboardShortcut = (Component: React.FunctionComponent<any>, props: Record<string, any>) => {
return render(<Component {...props} />).container;
};
describe("KeyboardShortcut", () => {
beforeEach(() => {
jest.resetModules();
unmockPlatformPeg();
});
it("renders key icon", () => {
const body = renderKeyboardShortcut(KeyboardKey, { name: Key.ARROW_DOWN });
expect(body).toMatchSnapshot();
});
it("renders alternative key name", () => {
const body = renderKeyboardShortcut(KeyboardKey, { name: Key.PAGE_DOWN });
expect(body).toMatchSnapshot();
});
it("doesn't render + if last", () => {
const body = renderKeyboardShortcut(KeyboardKey, { name: Key.A, last: true });
expect(body).toMatchSnapshot();
});
it("doesn't render same modifier twice", () => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const body1 = renderKeyboardShortcut(KeyboardShortcut, {
value: {
key: Key.A,
ctrlOrCmdKey: true,
metaKey: true,
},
});
expect(body1).toMatchSnapshot();
const body2 = renderKeyboardShortcut(KeyboardShortcut, {
value: {
key: Key.A,
ctrlOrCmdKey: true,
ctrlKey: true,
},
});
expect(body2).toMatchSnapshot();
jest.resetModules();
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 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 React from "react";
import { act, render, screen, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { LayoutSwitcher } from "../../../../../src/components/views/settings/LayoutSwitcher";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { stubClient } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { Layout } from "../../../../../src/settings/enums/Layout";
describe("<LayoutSwitcher />", () => {
const matrixClient = stubClient();
const profileInfo = {
displayname: "Alice",
};
async function renderLayoutSwitcher() {
const renderResult = render(
<MatrixClientContext.Provider value={matrixClient}>
<LayoutSwitcher />
</MatrixClientContext.Provider>,
);
// Wait for the profile info to be displayed in the event tile preview
// Also avoid act warning
await waitFor(() => expect(screen.getAllByText(profileInfo.displayname).length).toBe(3));
return renderResult;
}
beforeEach(async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
mocked(matrixClient).getProfileInfo.mockResolvedValue(profileInfo);
});
it("should render", async () => {
const { asFragment } = await renderLayoutSwitcher();
expect(asFragment()).toMatchSnapshot();
});
describe("layout selection", () => {
it("should display the modern layout", async () => {
await renderLayoutSwitcher();
expect(screen.getByRole("radio", { name: "Modern" })).toBeChecked();
});
it("should change the layout when selected", async () => {
await renderLayoutSwitcher();
act(() => screen.getByRole("radio", { name: "Message bubbles" }).click());
expect(screen.getByRole("radio", { name: "Message bubbles" })).toBeChecked();
await waitFor(() => expect(SettingsStore.getValue<boolean>("layout")).toBe(Layout.Bubble));
});
});
describe("compact layout", () => {
beforeEach(async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, false);
});
it("should be enabled", async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeChecked();
});
it("should change the setting when toggled", async () => {
await renderLayoutSwitcher();
act(() => screen.getByRole("checkbox", { name: "Show compact text and messages" }).click());
await waitFor(() => expect(SettingsStore.getValue<boolean>("useCompactLayout")).toBe(true));
});
it("should be disabled when the modern layout is not enabled", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeDisabled();
});
});
});

View file

@ -0,0 +1,930 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 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 React from "react";
import {
IPushRule,
IPushRules,
RuleId,
IPusher,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixEvent,
Room,
PushRuleActionName,
TweakName,
ConditionKind,
IPushRuleCondition,
PushRuleKind,
IThreepid,
ThreepidMedium,
} from "matrix-js-sdk/src/matrix";
import { randomString } from "matrix-js-sdk/src/randomstring";
import {
act,
fireEvent,
getByTestId,
render,
screen,
waitFor,
waitForElementToBeRemoved,
within,
} from "jest-matrix-react";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import Notifications from "../../../../../src/components/views/settings/Notifications";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { StandardActions } from "../../../../../src/notifications/StandardActions";
import {
clearAllModals,
getMockClientWithEventEmitter,
mkMessage,
mockClientMethodsUser,
} from "../../../../test-utils";
// don't pollute test output with error logs from mock rejections
jest.mock("matrix-js-sdk/src/logger");
// Avoid indirectly importing any eagerly created stores that would require extra setup
jest.mock("../../../../../src/Notifier");
const masterRule: IPushRule = {
actions: [PushRuleActionName.DontNotify],
conditions: [],
default: true,
enabled: false,
rule_id: RuleId.Master,
};
const oneToOneRule: IPushRule = {
conditions: [
{ kind: ConditionKind.RoomMemberCount, is: "2" },
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" },
],
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const encryptedOneToOneRule: IPushRule = {
conditions: [
{ kind: ConditionKind.RoomMemberCount, is: "2" },
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.encrypted" },
],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" },
{ set_tweak: TweakName.Highlight, value: false },
],
rule_id: ".m.rule.encrypted_room_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const groupRule = {
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" }],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" },
{ set_tweak: TweakName.Highlight, value: false },
],
rule_id: ".m.rule.message",
default: true,
enabled: true,
};
const encryptedGroupRule: IPushRule = {
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.encrypted" }],
actions: [PushRuleActionName.DontNotify],
rule_id: ".m.rule.encrypted",
default: true,
enabled: true,
} as IPushRule;
const bananaRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
pattern: "banana",
rule_id: "banana",
default: false,
enabled: true,
} as IPushRule;
const pushRules: IPushRules = {
global: {
underride: [
{
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.call.invite" }],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "ring" },
{ set_tweak: TweakName.Highlight, value: false },
],
rule_id: ".m.rule.call",
default: true,
enabled: true,
},
oneToOneRule,
encryptedOneToOneRule,
groupRule,
encryptedGroupRule,
{
conditions: [
{ kind: ConditionKind.EventMatch, key: "type", pattern: "im.vector.modular.widgets" },
{ kind: ConditionKind.EventMatch, key: "content.type", pattern: "jitsi" },
{ kind: ConditionKind.EventMatch, key: "state_key", pattern: "*" },
],
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
rule_id: ".im.vector.jitsi",
default: true,
enabled: true,
},
],
sender: [],
room: [
{
actions: [PushRuleActionName.DontNotify],
rule_id: "!zJPyWqpMorfCcWObge:matrix.org",
default: false,
enabled: true,
},
],
content: [
bananaRule,
{
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" },
{ set_tweak: TweakName.Highlight },
],
pattern: "kadev1",
rule_id: ".m.rule.contains_user_name",
default: true,
enabled: true,
},
],
override: [
{
conditions: [],
actions: [PushRuleActionName.DontNotify],
rule_id: ".m.rule.master",
default: true,
enabled: false,
},
{
conditions: [{ kind: ConditionKind.EventMatch, key: "content.msgtype", pattern: "m.notice" }],
actions: [PushRuleActionName.DontNotify],
rule_id: ".m.rule.suppress_notices",
default: true,
enabled: true,
},
{
conditions: [
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.member" },
{ kind: ConditionKind.EventMatch, key: "content.membership", pattern: "invite" },
{ kind: ConditionKind.EventMatch, key: "state_key", pattern: "@kadev1:matrix.org" },
],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" },
{ set_tweak: TweakName.Highlight, value: false },
],
rule_id: ".m.rule.invite_for_me",
default: true,
enabled: true,
},
{
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.member" }],
actions: [PushRuleActionName.DontNotify],
rule_id: ".m.rule.member_event",
default: true,
enabled: true,
},
{
conditions: [{ kind: "contains_display_name" }],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" },
{ set_tweak: TweakName.Highlight },
],
rule_id: ".m.rule.contains_display_name",
default: true,
enabled: true,
},
{
conditions: [
{ kind: ConditionKind.EventMatch, key: "content.body", pattern: "@room" },
{ kind: "sender_notification_permission", key: "room" },
],
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
},
{
conditions: [
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.tombstone" },
{ kind: ConditionKind.EventMatch, key: "state_key", pattern: "" },
],
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
rule_id: ".m.rule.tombstone",
default: true,
enabled: true,
},
{
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.reaction" }],
actions: [PushRuleActionName.DontNotify],
rule_id: ".m.rule.reaction",
default: true,
enabled: true,
},
],
},
device: {},
} as IPushRules;
const flushPromises = async () => await new Promise((resolve) => window.setTimeout(resolve));
describe("<Notifications />", () => {
const getComponent = () => render(<Notifications />);
// get component, wait for async data and force a render
const getComponentAndWait = async () => {
const component = getComponent();
await waitForElementToBeRemoved(() => component.queryAllByRole("progressbar"));
return component;
};
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getPushRules: jest.fn(),
getPushers: jest.fn(),
getThreePids: jest.fn(),
setPusher: jest.fn(),
removePusher: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getRooms: jest.fn().mockReturnValue([]),
getAccountData: jest.fn().mockImplementation((eventType) => {
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: false,
},
});
}
}),
setAccountData: jest.fn(),
sendReadReceipt: jest.fn(),
supportsThreads: jest.fn().mockReturnValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
addPushRule: jest.fn().mockResolvedValue({}),
deletePushRule: jest.fn().mockResolvedValue({}),
});
mockClient.getPushRules.mockResolvedValue(pushRules);
beforeEach(async () => {
let i = 0;
mocked(randomString).mockImplementation(() => {
return "testid_" + i++;
});
mockClient.getPushRules.mockClear().mockResolvedValue(pushRules);
mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] });
mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] });
mockClient.setPusher.mockReset().mockResolvedValue({});
mockClient.removePusher.mockClear().mockResolvedValue({});
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
mockClient.pushRules = pushRules;
mockClient.getPushRules.mockClear().mockResolvedValue(pushRules);
mockClient.addPushRule.mockClear();
mockClient.deletePushRule.mockClear();
userEvent.setup();
await clearAllModals();
});
it("renders spinner while loading", async () => {
getComponent();
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("renders error message when fetching push rules fails", async () => {
mockClient.getPushRules.mockRejectedValue({});
await getComponentAndWait();
expect(screen.getByTestId("error-message")).toBeInTheDocument();
});
it("renders error message when fetching pushers fails", async () => {
mockClient.getPushers.mockRejectedValue({});
await getComponentAndWait();
expect(screen.getByTestId("error-message")).toBeInTheDocument();
});
it("renders error message when fetching threepids fails", async () => {
mockClient.getThreePids.mockRejectedValue({});
await getComponentAndWait();
expect(screen.getByTestId("error-message")).toBeInTheDocument();
});
describe("main notification switches", () => {
it("renders only enable notifications switch when notifications are disabled", async () => {
const disableNotificationsPushRules = {
global: {
...pushRules.global,
override: [{ ...masterRule, enabled: true }],
},
} as unknown as IPushRules;
mockClient.getPushRules.mockClear().mockResolvedValue(disableNotificationsPushRules);
const { container } = await getComponentAndWait();
expect(container).toMatchSnapshot();
});
it("renders switches correctly", async () => {
await getComponentAndWait();
expect(screen.getByTestId("notif-master-switch")).toBeInTheDocument();
expect(screen.getByTestId("notif-device-switch")).toBeInTheDocument();
expect(screen.getByTestId("notif-setting-notificationsEnabled")).toBeInTheDocument();
expect(screen.getByTestId("notif-setting-notificationBodyEnabled")).toBeInTheDocument();
expect(screen.getByTestId("notif-setting-audioNotificationsEnabled")).toBeInTheDocument();
});
describe("email switches", () => {
const testEmail = "tester@test.com";
beforeEach(() => {
mockClient.getThreePids.mockResolvedValue({
threepids: [
// should render switch bc pushKey and address match
{
medium: ThreepidMedium.Email,
address: testEmail,
} as unknown as IThreepid,
],
});
});
it("renders email switches correctly when email 3pids exist", async () => {
await getComponentAndWait();
expect(screen.getByTestId("notif-email-switch")).toBeInTheDocument();
});
it("renders email switches correctly when notifications are on for email", async () => {
mockClient.getPushers.mockResolvedValue({
pushers: [{ kind: "email", pushkey: testEmail } as unknown as IPusher],
});
await getComponentAndWait();
const emailSwitch = screen.getByTestId("notif-email-switch");
expect(emailSwitch.querySelector('[aria-checked="true"]')).toBeInTheDocument();
});
it("enables email notification when toggling on", async () => {
await getComponentAndWait();
const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!;
fireEvent.click(emailToggle);
expect(mockClient.setPusher).toHaveBeenCalledWith(
expect.objectContaining({
kind: "email",
app_id: "m.email",
pushkey: testEmail,
app_display_name: "Email Notifications",
device_display_name: testEmail,
append: true,
}),
);
});
it("displays error when pusher update fails", async () => {
mockClient.setPusher.mockRejectedValue({});
await getComponentAndWait();
const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!;
fireEvent.click(emailToggle);
// force render
await flushPromises();
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText("An error occurred whilst saving your notification preferences."),
).toBeInTheDocument();
// dismiss the dialog
fireEvent.click(within(dialog).getByText("OK"));
expect(screen.getByTestId("error-message")).toBeInTheDocument();
});
it("enables email notification when toggling off", async () => {
const testPusher = {
kind: "email",
pushkey: "tester@test.com",
app_id: "testtest",
} as unknown as IPusher;
mockClient.getPushers.mockResolvedValue({ pushers: [testPusher] });
await getComponentAndWait();
const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!;
fireEvent.click(emailToggle);
expect(mockClient.removePusher).toHaveBeenCalledWith(testPusher.pushkey, testPusher.app_id);
});
});
it("toggles master switch correctly", async () => {
await getComponentAndWait();
// master switch is on
expect(screen.getByLabelText("Enable notifications for this account")).toBeChecked();
fireEvent.click(screen.getByLabelText("Enable notifications for this account"));
await flushPromises();
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith("global", "override", ".m.rule.master", true);
});
it("toggles and sets settings correctly", async () => {
await getComponentAndWait();
let audioNotifsToggle!: HTMLDivElement;
const update = () => {
audioNotifsToggle = screen
.getByTestId("notif-setting-audioNotificationsEnabled")
.querySelector('div[role="switch"]')!;
};
update();
expect(audioNotifsToggle.getAttribute("aria-checked")).toEqual("true");
expect(SettingsStore.getValue("audioNotificationsEnabled")).toEqual(true);
fireEvent.click(audioNotifsToggle);
update();
expect(audioNotifsToggle.getAttribute("aria-checked")).toEqual("false");
expect(SettingsStore.getValue("audioNotificationsEnabled")).toEqual(false);
});
});
describe("individual notification level settings", () => {
it("renders categories correctly", async () => {
await getComponentAndWait();
expect(screen.getByTestId("notif-section-vector_global")).toBeInTheDocument();
expect(screen.getByTestId("notif-section-vector_mentions")).toBeInTheDocument();
expect(screen.getByTestId("notif-section-vector_other")).toBeInTheDocument();
});
it("renders radios correctly", async () => {
await getComponentAndWait();
const section = "vector_global";
const globalSection = screen.getByTestId(`notif-section-${section}`);
// 4 notification rules with class 'global'
expect(globalSection.querySelectorAll("fieldset").length).toEqual(4);
// oneToOneRule is set to 'on'
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
expect(oneToOneRuleElement.querySelector("[aria-label='On']")).toBeInTheDocument();
// encryptedOneToOneRule is set to 'loud'
const encryptedOneToOneElement = screen.getByTestId(section + encryptedOneToOneRule.rule_id);
expect(encryptedOneToOneElement.querySelector("[aria-label='Noisy']")).toBeInTheDocument();
// encryptedGroupRule is set to 'off'
const encryptedGroupElement = screen.getByTestId(section + encryptedGroupRule.rule_id);
expect(encryptedGroupElement.querySelector("[aria-label='Off']")).toBeInTheDocument();
});
it("updates notification level when changed", async () => {
await getComponentAndWait();
const section = "vector_global";
// oneToOneRule is set to 'on'
// and is kind: 'underride'
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
await act(async () => {
const offToggle = oneToOneRuleElement.querySelector('input[type="radio"]')!;
fireEvent.click(offToggle);
});
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
"underride",
oneToOneRule.rule_id,
true,
);
// actions for '.m.rule.room_one_to_one' state is ACTION_DONT_NOTIFY
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
oneToOneRule.rule_id,
StandardActions.ACTION_DONT_NOTIFY,
);
});
it("adds an error message when updating notification level fails", async () => {
await getComponentAndWait();
const section = "vector_global";
const error = new Error("oups");
mockClient.setPushRuleEnabled.mockRejectedValue(error);
// oneToOneRule is set to 'on'
// and is kind: 'underride'
const offToggle = screen.getByTestId(section + oneToOneRule.rule_id).querySelector('input[type="radio"]')!;
await act(() => {
fireEvent.click(offToggle);
});
await flushPromises();
// error message attached to oneToOne rule
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
// old value still shown as selected
expect(within(oneToOneRuleElement).getByLabelText("On")).toBeChecked();
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
it("clears error message for notification rule on retry", async () => {
await getComponentAndWait();
const section = "vector_global";
const error = new Error("oups");
mockClient.setPushRuleEnabled.mockRejectedValueOnce(error).mockResolvedValue({});
// oneToOneRule is set to 'on'
// and is kind: 'underride'
const offToggle = screen.getByTestId(section + oneToOneRule.rule_id).querySelector('input[type="radio"]')!;
await act(() => {
fireEvent.click(offToggle);
});
await flushPromises();
// error message attached to oneToOne rule
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
// retry
fireEvent.click(offToggle);
// error removed as soon as we start request
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
await flushPromises();
// no error after successful change
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
describe("synced rules", () => {
const pollStartOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
} as IPushRuleCondition<ConditionKind.RoomMemberCount>,
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
} as IPushRuleCondition<ConditionKind.EventMatch>,
],
actions: [PushRuleActionName.DontNotify],
rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollStartGroup = {
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: [PushRuleActionName.Notify],
rule_id: ".org.matrix.msc3930.rule.poll_start",
default: true,
enabled: true,
} as IPushRule;
const pollEndOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.end",
},
],
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
{ set_tweak: TweakName.Sound, value: "default" },
],
rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const setPushRuleMock = (rules: IPushRule[] = []): void => {
const combinedRules = {
...pushRules,
global: {
...pushRules.global,
underride: [...pushRules.global.underride!, ...rules],
},
};
mockClient.getPushRules.mockClear().mockResolvedValue(combinedRules);
mockClient.pushRules = combinedRules;
};
// ".m.rule.room_one_to_one" and ".m.rule.message" have synced rules
it("succeeds when no synced rules exist for user", async () => {
await getComponentAndWait();
const section = "vector_global";
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
const offToggle = oneToOneRuleElement.querySelector('input[type="radio"]')!;
fireEvent.click(offToggle);
await flushPromises();
// didnt attempt to update any non-existant rules
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
// no error
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
it("updates synced rules when they exist for user", async () => {
setPushRuleMock([pollStartOneToOne, pollStartGroup]);
await getComponentAndWait();
const section = "vector_global";
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
const offToggle = oneToOneRuleElement.querySelector('input[type="radio"]')!;
fireEvent.click(offToggle);
await flushPromises();
// updated synced rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
oneToOneRule.rule_id,
[PushRuleActionName.DontNotify],
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartOneToOne.rule_id,
[PushRuleActionName.DontNotify],
);
// only called for parent rule and one existing synced rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// no error
expect(
within(oneToOneRuleElement).queryByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).not.toBeInTheDocument();
});
it("does not update synced rules when main rule update fails", async () => {
setPushRuleMock([pollStartOneToOne]);
await getComponentAndWait();
const section = "vector_global";
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
// have main rule update fail
mockClient.setPushRuleActions.mockRejectedValue("oups");
const offToggle = oneToOneRuleElement.querySelector('input[type="radio"]')!;
await act(() => {
fireEvent.click(offToggle);
});
await flushPromises();
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
oneToOneRule.rule_id,
[PushRuleActionName.DontNotify],
);
// only called for parent rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
expect(
within(oneToOneRuleElement).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
it("sets the UI toggle to rule value when no synced rule exist for the user", async () => {
setPushRuleMock([]);
await getComponentAndWait();
const section = "vector_global";
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
// loudest state of synced rules should be the toggle value
expect(oneToOneRuleElement.querySelector('input[aria-label="On"]')).toBeChecked();
});
it("sets the UI toggle to the loudest synced rule value", async () => {
// oneToOneRule is set to 'On'
// pollEndOneToOne is set to 'Loud'
setPushRuleMock([pollStartOneToOne, pollEndOneToOne]);
await getComponentAndWait();
const section = "vector_global";
const oneToOneRuleElement = screen.getByTestId(section + oneToOneRule.rule_id);
// loudest state of synced rules should be the toggle value
expect(oneToOneRuleElement.querySelector('input[aria-label="Noisy"]')).toBeChecked();
const onToggle = oneToOneRuleElement.querySelector('input[aria-label="On"]')!;
fireEvent.click(onToggle);
await flushPromises();
// called for all 3 rules
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(3);
const expectedActions = [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }];
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
oneToOneRule.rule_id,
expectedActions,
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartOneToOne.rule_id,
expectedActions,
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
expectedActions,
);
});
});
});
describe("keywords", () => {
// keywords rule is not a real rule, but controls actions on keywords content rules
const keywordsRuleId = "_keywords";
it("updates individual keywords content rules when keywords rule is toggled", async () => {
await getComponentAndWait();
const section = "vector_mentions";
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Off"));
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith("global", "content", bananaRule.rule_id, false);
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Noisy"));
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"content",
bananaRule.rule_id,
StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
);
});
it("renders an error when updating keywords fails", async () => {
await getComponentAndWait();
const section = "vector_mentions";
mockClient.setPushRuleEnabled.mockRejectedValueOnce("oups");
await act(() => {
fireEvent.click(within(screen.getByTestId(section + keywordsRuleId)).getByLabelText("Off"));
});
await flushPromises();
const rule = screen.getByTestId(section + keywordsRuleId);
expect(
within(rule).getByText(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
),
).toBeInTheDocument();
});
it("adds a new keyword", async () => {
await getComponentAndWait();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
expect(screen.getByLabelText("Keyword")).toHaveValue("jest");
fireEvent.click(screen.getByText("Add"));
expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", {
actions: [PushRuleActionName.Notify, { set_tweak: "highlight", value: false }],
pattern: "jest",
});
});
it("adds a new keyword with same actions as existing rules when keywords rule is off", async () => {
const offContentRule = {
...bananaRule,
enabled: false,
actions: [PushRuleActionName.Notify],
};
const pushRulesWithContentOff = {
global: {
...pushRules.global,
content: [offContentRule],
},
};
mockClient.pushRules = pushRulesWithContentOff;
mockClient.getPushRules.mockClear().mockResolvedValue(pushRulesWithContentOff);
await getComponentAndWait();
const keywords = screen.getByTestId("vector_mentions_keywords");
expect(within(keywords).getByLabelText("Off")).toBeChecked();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
expect(screen.getByLabelText("Keyword")).toHaveValue("jest");
fireEvent.click(screen.getByText("Add"));
expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }],
pattern: "jest",
});
});
it("removes keyword", async () => {
await getComponentAndWait();
await userEvent.type(screen.getByLabelText("Keyword"), "jest");
const keyword = screen.getByText("banana");
fireEvent.click(within(keyword.parentElement!).getByLabelText("Remove"));
expect(mockClient.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "banana");
await flushPromises();
});
});
describe("clear all notifications", () => {
it("clears all notifications", async () => {
const room = new Room("room123", mockClient, "@alice:example.org");
mockClient.getRooms.mockReset().mockReturnValue([room]);
const message = mkMessage({
event: true,
room: "room123",
user: "@alice:example.org",
ts: 1,
});
await room.addLiveEvents([message]);
const { container } = await getComponentAndWait();
const clearNotificationEl = getByTestId(container, "clear-notifications");
fireEvent.click(clearNotificationEl);
expect(clearNotificationEl.className).toContain("mx_AccessibleButton_disabled");
await waitFor(() => expect(clearNotificationEl.className).not.toContain("mx_AccessibleButton_disabled"));
expect(mockClient.sendReadReceipt).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,110 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 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 { render, screen } from "jest-matrix-react";
import React, { ComponentProps } from "react";
import userEvent from "@testing-library/user-event";
import { PowerLevelSelector } from "../../../../../src/components/views/settings/PowerLevelSelector";
import { stubClient } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
describe("PowerLevelSelector", () => {
const matrixClient = stubClient();
const currentUser = matrixClient.getUserId()!;
const userLevels = {
[currentUser]: 100,
"@alice:server.org": 50,
"@bob:server.org": 0,
};
const renderPLS = (props: Partial<ComponentProps<typeof PowerLevelSelector>>) =>
render(
<MatrixClientContext.Provider value={matrixClient}>
<PowerLevelSelector
userLevels={userLevels}
canChangeLevels={true}
currentUserLevel={userLevels[currentUser]}
title="title"
// filter nothing by default
filter={() => true}
onClick={jest.fn()}
{...props}
>
empty label
</PowerLevelSelector>
</MatrixClientContext.Provider>,
);
it("should render", () => {
renderPLS({});
expect(screen.getByRole("group")).toMatchSnapshot();
});
it("should display only the current user", async () => {
// Display only the current user
renderPLS({ filter: (user) => user === currentUser });
// Only alice should be displayed
const userSelects = screen.getAllByRole("combobox");
expect(userSelects).toHaveLength(1);
expect(userSelects[0]).toHaveAccessibleName(currentUser);
expect(screen.getByRole("group")).toMatchSnapshot();
});
it("should be able to change the power level of the current user", async () => {
const onClick = jest.fn();
renderPLS({ onClick });
// Until the power level is changed, the apply button should be disabled
// compound button is using aria-disabled instead of the disabled attribute, we can't toBeDisabled on it
expect(screen.getByRole("button", { name: "Apply" })).toHaveAttribute("aria-disabled", "true");
const select = screen.getByRole("combobox", { name: currentUser });
// Sanity check
expect(select).toHaveValue("100");
// Change current user power level to 50
await userEvent.selectOptions(select, "50");
expect(select).toHaveValue("50");
// After the user level changes, the apply button should be enabled
expect(screen.getByRole("button", { name: "Apply" })).toHaveAttribute("aria-disabled", "false");
// Click on Apply should call onClick with the new power level
await userEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onClick).toHaveBeenCalledWith(50, currentUser);
});
it("should not be able to change the power level if `canChangeLevels` is false", async () => {
renderPLS({ canChangeLevels: false });
// The selects should be disabled
const userSelects = screen.getAllByRole("combobox");
userSelects.forEach((select) => expect(select).toBeDisabled());
});
it("should be able to change only the level of someone with a lower level", async () => {
const userLevels = {
[currentUser]: 50,
"@alice:server.org": 100,
};
renderPLS({ userLevels });
expect(screen.getByRole("combobox", { name: currentUser })).toBeEnabled();
expect(screen.getByRole("combobox", { name: "@alice:server.org" })).toBeDisabled();
});
it("should display the children if there is no user to display", async () => {
// No user to display
renderPLS({ filter: () => false });
expect(screen.getByText("empty label")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,175 @@
/*
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 React from "react";
import { fireEvent, render, screen, within } from "jest-matrix-react";
import { mocked } from "jest-mock";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../../../test-utils";
import SecureBackupPanel from "../../../../../src/components/views/settings/SecureBackupPanel";
import { accessSecretStorage } from "../../../../../src/SecurityManager";
jest.mock("../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
describe("<SecureBackupPanel />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsCrypto(),
getKeyBackupVersion: jest.fn().mockReturnValue("1"),
getClientWellKnown: jest.fn(),
});
const getComponent = () => render(<SecureBackupPanel />);
beforeEach(() => {
client.getKeyBackupVersion.mockResolvedValue({
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
});
Object.assign(client.getCrypto()!, {
isKeyBackupTrusted: jest.fn().mockResolvedValue({
trusted: false,
matchesDecryptionKey: false,
}),
getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null),
deleteKeyBackupVersion: jest.fn().mockResolvedValue(undefined),
});
mocked(client.secretStorage.hasKey).mockClear().mockResolvedValue(false);
client.getKeyBackupVersion.mockClear();
mocked(accessSecretStorage).mockClear().mockResolvedValue();
});
it("displays a loader while checking keybackup", async () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
await flushPromises();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
it("handles error fetching backup", async () => {
// getKeyBackupVersion can fail for various reasons
client.getKeyBackupVersion.mockImplementation(async () => {
throw new Error("beep beep");
});
const renderResult = getComponent();
await renderResult.findByText("Unable to load key backup status");
expect(renderResult.container).toMatchSnapshot();
});
it("handles absence of backup", async () => {
client.getKeyBackupVersion.mockResolvedValue(null);
getComponent();
// flush getKeyBackupVersion promise
await flushPromises();
expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument();
});
it("suggests connecting session to key backup when backup exists", async () => {
const { container } = getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(container).toMatchSnapshot();
});
it("displays when session is connected to key backup", async () => {
mocked(client.getCrypto()!).getActiveSessionBackupVersion.mockResolvedValue("1");
getComponent();
// flush checkKeyBackup promise
await flushPromises();
expect(screen.getByText("✅ This session is backing up your keys.")).toBeInTheDocument();
});
it("asks for confirmation before deleting a backup", async () => {
getComponent();
// flush checkKeyBackup promise
await flushPromises();
fireEvent.click(screen.getByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("Cancel"));
expect(client.getCrypto()!.deleteKeyBackupVersion).not.toHaveBeenCalled();
});
it("deletes backup after confirmation", async () => {
client.getKeyBackupVersion
.mockResolvedValueOnce({
version: "1",
algorithm: "test",
auth_data: {
public_key: "1234",
},
})
.mockResolvedValue(null);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
fireEvent.click(screen.getByText("Delete Backup"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId("dialog-primary-button"));
expect(client.getCrypto()!.deleteKeyBackupVersion).toHaveBeenCalledWith("1");
// delete request
await flushPromises();
// refresh backup info
await flushPromises();
});
it("resets secret storage", async () => {
mocked(client.secretStorage.hasKey).mockClear().mockResolvedValue(true);
getComponent();
// flush checkKeyBackup promise
await flushPromises();
client.getKeyBackupVersion.mockClear();
mocked(client.getCrypto()!).isKeyBackupTrusted.mockClear();
fireEvent.click(screen.getByText("Reset"));
// enter loading state
expect(accessSecretStorage).toHaveBeenCalled();
await flushPromises();
// backup status refreshed
expect(client.getKeyBackupVersion).toHaveBeenCalled();
expect(client.getCrypto()!.isKeyBackupTrusted).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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 React from "react";
import { fireEvent, render, screen, waitFor, within } from "jest-matrix-react";
import { logger } from "matrix-js-sdk/src/logger";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { SDKContext, SdkContextClass } from "../../../../../src/contexts/SDKContext";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { UIFeature } from "../../../../../src/settings/UIFeature";
import {
getMockClientWithEventEmitter,
mockClientMethodsServer,
mockClientMethodsUser,
flushPromises,
} from "../../../../test-utils";
import SetIntegrationManager from "../../../../../src/components/views/settings/SetIntegrationManager";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
describe("SetIntegrationManager", () => {
const userId = "@alice:server.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
getCapabilities: jest.fn(),
getThreePids: jest.fn(),
getIdentityServerUrl: jest.fn(),
deleteThreePid: jest.fn(),
});
let stores: SdkContextClass;
const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}>
<SDKContext.Provider value={stores}>
<SetIntegrationManager />
</SDKContext.Provider>
</MatrixClientContext.Provider>
);
it("should not render manage integrations section when widgets feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settingName !== UIFeature.Widgets);
render(getComponent());
expect(screen.queryByTestId("mx_SetIntegrationManager")).not.toBeInTheDocument();
expect(SettingsStore.getValue).toHaveBeenCalledWith(UIFeature.Widgets);
});
it("should render manage integrations sections", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settingName === UIFeature.Widgets);
render(getComponent());
expect(screen.getByTestId("mx_SetIntegrationManager")).toMatchSnapshot();
});
it("should update integrations provisioning on toggle", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settingName === UIFeature.Widgets);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
render(getComponent());
const integrationSection = screen.getByTestId("mx_SetIntegrationManager");
fireEvent.click(within(integrationSection).getByRole("switch"));
expect(SettingsStore.setValue).toHaveBeenCalledWith(
"integrationProvisioning",
null,
SettingLevel.ACCOUNT,
true,
);
expect(within(integrationSection).getByRole("switch")).toBeChecked();
});
it("handles error when updating setting fails", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settingName === UIFeature.Widgets);
jest.spyOn(logger, "error").mockImplementation(() => {});
jest.spyOn(SettingsStore, "setValue").mockRejectedValue("oups");
render(getComponent());
const integrationSection = screen.getByTestId("mx_SetIntegrationManager");
fireEvent.click(within(integrationSection).getByRole("switch"));
await flushPromises();
expect(logger.error).toHaveBeenCalledWith("Error changing integration manager provisioning");
expect(logger.error).toHaveBeenCalledWith("oups");
await waitFor(() => expect(within(integrationSection).getByRole("switch")).not.toBeChecked());
});
});

View file

@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 { render } from "jest-matrix-react";
import React from "react";
import SettingsFieldset from "../../../../../src/components/views/settings/SettingsFieldset";
describe("<SettingsFieldset />", () => {
const defaultProps = {
"legend": "Who can read history?",
"children": <div>test</div>,
"data-testid": "test",
};
const getComponent = (props = {}) => {
return render(<SettingsFieldset {...defaultProps} {...props} />);
};
it("renders fieldset without description", () => {
expect(getComponent().asFragment()).toMatchSnapshot();
});
it("renders fieldset with plain text description", () => {
const description = "Changes to who can read history.";
expect(getComponent({ description }).asFragment()).toMatchSnapshot();
});
it("renders fieldset with react description", () => {
const description = (
<>
<p>Test</p>
<a href="#test">a link</a>
</>
);
expect(getComponent({ description }).asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,183 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 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 React from "react";
import { act, render, screen, waitFor } from "jest-matrix-react";
import { mocked, MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock-jest";
import { ThemeChoicePanel } from "../../../../../src/components/views/settings/ThemeChoicePanel";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import ThemeWatcher from "../../../../../src/settings/watchers/ThemeWatcher";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
jest.mock("../../../../../src/settings/watchers/ThemeWatcher");
describe("<ThemeChoicePanel />", () => {
/**
* Enable or disable the system theme
* @param enable
*/
async function enableSystemTheme(enable: boolean) {
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, enable);
}
/**
* Set the theme
* @param theme
*/
async function setTheme(theme: string) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, theme);
}
beforeEach(async () => {
mocked(ThemeWatcher).mockImplementation(() => {
return {
isSystemThemeSupported: jest.fn().mockReturnValue(true),
} as unknown as MockedObject<ThemeWatcher>;
});
await enableSystemTheme(false);
await setTheme("light");
});
it("renders the theme choice UI", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot();
});
describe("theme selection", () => {
describe("system theme", () => {
it("should disable Match system theme", async () => {
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).not.toBeChecked();
});
it("should enable Match system theme", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
});
it("should change the system theme when clicked", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
act(() => screen.getByRole("checkbox", { name: "Match system theme" }).click());
// The system theme should be enabled
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
expect(SettingsStore.setValue).toHaveBeenCalledWith("use_system_theme", null, "device", true);
});
});
describe("theme selection", () => {
it("should disable theme selection when system theme is enabled", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).toBeDisabled();
});
});
it("should enable theme selection when system theme is disabled", async () => {
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).not.toBeDisabled();
});
});
it("should have light theme selected", async () => {
render(<ThemeChoicePanel />);
// We expect the light theme to be selected
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(lightTheme).toBeChecked();
// And the dark theme shouldn't be selected
const darkTheme = screen.getByRole("radio", { name: "Dark" });
expect(darkTheme).not.toBeChecked();
});
it("should switch to dark theme", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
const darkTheme = screen.getByRole("radio", { name: "Dark" });
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(darkTheme).not.toBeChecked();
// Switch to the dark theme
act(() => darkTheme.click());
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
// Dark theme is now selected
await waitFor(() => expect(darkTheme).toBeChecked());
// Light theme is not selected anymore
expect(lightTheme).not.toBeChecked();
// The setting should be updated
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
});
});
});
describe("custom theme", () => {
const aliceTheme = { name: "Alice theme", is_dark: true, colors: {} };
const bobTheme = { name: "Bob theme", is_dark: false, colors: {} };
beforeEach(async () => {
await SettingsStore.setValue("feature_custom_themes", null, SettingLevel.DEVICE, true);
await SettingsStore.setValue("custom_themes", null, SettingLevel.DEVICE, [aliceTheme]);
});
it("should render the custom theme section", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot();
});
it("should add a custom theme", async () => {
jest.spyOn(SettingsStore, "setValue");
// Respond to the theme request
fetchMock.get("http://bob.theme", {
body: bobTheme,
});
render(<ThemeChoicePanel />);
// Add the new custom theme
const customThemeInput = screen.getByRole("textbox", { name: "Add custom theme" });
await userEvent.type(customThemeInput, "http://bob.theme");
screen.getByRole("button", { name: "Add custom theme" }).click();
// The new custom theme is added to the user's themes
await waitFor(() =>
expect(SettingsStore.setValue).toHaveBeenCalledWith("custom_themes", null, "account", [
aliceTheme,
bobTheme,
]),
);
});
it("should display custom theme", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(screen.getByRole("radio", { name: aliceTheme.name })).toBeInTheDocument();
expect(screen.getByRole("listitem", { name: aliceTheme.name })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,246 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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 React, { ChangeEvent } from "react";
import { act, render, screen } from "jest-matrix-react";
import { MatrixClient, UploadResponse } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import UserProfileSettings from "../../../../../src/components/views/settings/UserProfileSettings";
import { mkStubRoom, stubClient } from "../../../../test-utils";
import { ToastContext, ToastRack } from "../../../../../src/contexts/ToastContext";
import { OwnProfileStore } from "../../../../../src/stores/OwnProfileStore";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import dis from "../../../../../src/dispatcher/dispatcher";
import Modal from "../../../../../src/Modal";
interface MockedAvatarSettingProps {
removeAvatar: () => void;
onChange: (file: File) => void;
}
let removeAvatarFn: () => void;
let changeAvatarFn: (file: File) => void;
jest.mock(
"../../../../../src/components/views/settings/AvatarSetting",
() =>
(({ removeAvatar, onChange }) => {
removeAvatarFn = removeAvatar;
changeAvatarFn = onChange;
return <div>Mocked AvatarSetting</div>;
}) as React.FC<MockedAvatarSettingProps>,
);
jest.mock("../../../../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
register: jest.fn(),
}));
let editInPlaceOnChange: (e: ChangeEvent<HTMLInputElement>) => void;
let editInPlaceOnSave: () => void;
let editInPlaceOnCancel: () => void;
interface MockedEditInPlaceProps {
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onSave: () => void;
onCancel: () => void;
value: string;
}
jest.mock("@vector-im/compound-web", () => {
const compound = jest.requireActual("@vector-im/compound-web");
return {
__esModule: true,
...compound,
EditInPlace: (({ onChange, onSave, onCancel, value }) => {
editInPlaceOnChange = onChange;
editInPlaceOnSave = onSave;
editInPlaceOnCancel = onCancel;
return <div>Mocked EditInPlace: {value}</div>;
}) as React.FC<MockedEditInPlaceProps>,
};
});
const renderProfileSettings = (toastRack: Partial<ToastRack>, client: MatrixClient) => {
return render(
<TooltipProvider>
<MatrixClientContext.Provider value={client}>
<ToastContext.Provider value={toastRack}>
<UserProfileSettings canSetAvatar={true} canSetDisplayName={true} />
</ToastContext.Provider>
</MatrixClientContext.Provider>
</TooltipProvider>,
);
};
describe("ProfileSettings", () => {
let client: MatrixClient;
let toastRack: Partial<ToastRack>;
beforeEach(() => {
client = stubClient();
toastRack = {
displayToast: jest.fn().mockReturnValue(jest.fn()),
};
});
it("removes avatar", async () => {
jest.spyOn(OwnProfileStore.instance, "avatarMxc", "get").mockReturnValue("mxc://example.org/my-avatar");
renderProfileSettings(toastRack, client);
expect(await screen.findByText("Mocked AvatarSetting")).toBeInTheDocument();
expect(removeAvatarFn).toBeDefined();
act(() => {
removeAvatarFn();
});
expect(client.setAvatarUrl).toHaveBeenCalledWith("");
});
it("changes avatar", async () => {
renderProfileSettings(toastRack, client);
expect(await screen.findByText("Mocked AvatarSetting")).toBeInTheDocument();
expect(changeAvatarFn).toBeDefined();
const returnedMxcUri = "mxc://example.org/my-avatar";
mocked(client).uploadContent.mockResolvedValue({ content_uri: returnedMxcUri });
const fileSentinel = {};
await act(async () => {
await changeAvatarFn(fileSentinel as File);
});
expect(client.uploadContent).toHaveBeenCalledWith(fileSentinel);
expect(client.setAvatarUrl).toHaveBeenCalledWith(returnedMxcUri);
});
it("displays toast while uploading avatar", async () => {
renderProfileSettings(toastRack, client);
const clearToastFn = jest.fn();
mocked(toastRack.displayToast!).mockReturnValue(clearToastFn);
expect(await screen.findByText("Mocked AvatarSetting")).toBeInTheDocument();
expect(changeAvatarFn).toBeDefined();
let resolveUploadPromise = (r: UploadResponse) => {};
const uploadPromise = new Promise<UploadResponse>((r) => {
resolveUploadPromise = r;
});
mocked(client).uploadContent.mockReturnValue(uploadPromise);
const fileSentinel = {};
const changeAvatarActPromise = act(async () => {
await changeAvatarFn(fileSentinel as File);
});
expect(toastRack.displayToast).toHaveBeenCalled();
act(() => {
resolveUploadPromise({ content_uri: "bloop" });
});
await changeAvatarActPromise;
expect(clearToastFn).toHaveBeenCalled();
});
it("changes display name", async () => {
jest.spyOn(OwnProfileStore.instance, "displayName", "get").mockReturnValue("Alice");
renderProfileSettings(toastRack, client);
expect(await screen.findByText("Mocked EditInPlace: Alice")).toBeInTheDocument();
expect(editInPlaceOnSave).toBeDefined();
act(() => {
editInPlaceOnChange({
target: { value: "The Value" } as HTMLInputElement,
} as ChangeEvent<HTMLInputElement>);
});
await act(async () => {
await editInPlaceOnSave();
});
expect(client.setDisplayName).toHaveBeenCalledWith("The Value");
});
it("displays error if changing display name fails", async () => {
jest.spyOn(OwnProfileStore.instance, "displayName", "get").mockReturnValue("Alice");
mocked(client).setDisplayName.mockRejectedValue(new Error("Failed to set display name"));
renderProfileSettings(toastRack, client);
expect(editInPlaceOnSave).toBeDefined();
act(() => {
editInPlaceOnChange({
target: { value: "Not Alice any more" } as HTMLInputElement,
} as ChangeEvent<HTMLInputElement>);
});
await act(async () => {
await expect(editInPlaceOnSave()).rejects.toEqual(expect.any(Error));
});
});
it("resets on cancel", async () => {
jest.spyOn(OwnProfileStore.instance, "displayName", "get").mockReturnValue("Alice");
renderProfileSettings(toastRack, client);
expect(await screen.findByText("Mocked EditInPlace: Alice")).toBeInTheDocument();
expect(editInPlaceOnChange).toBeDefined();
expect(editInPlaceOnCancel).toBeDefined();
act(() => {
editInPlaceOnChange({
target: { value: "Alicia Zattic" } as HTMLInputElement,
} as ChangeEvent<HTMLInputElement>);
});
expect(await screen.findByText("Mocked EditInPlace: Alicia Zattic")).toBeInTheDocument();
act(() => {
editInPlaceOnCancel();
});
expect(await screen.findByText("Mocked EditInPlace: Alice")).toBeInTheDocument();
});
it("signs out directly if no rooms are encrypted", async () => {
renderProfileSettings(toastRack, client);
const signOutButton = await screen.findByText("Sign out");
await userEvent.click(signOutButton);
expect(dis.dispatch).toHaveBeenCalledWith({ action: "logout" });
});
it("displays confirmation dialog if rooms are encrypted", async () => {
jest.spyOn(Modal, "createDialog");
const mockRoom = mkStubRoom("!test:room", "Test Room", client);
client.getRooms = jest.fn().mockReturnValue([mockRoom]);
client.getCrypto = jest.fn().mockReturnValue({
isEncryptionEnabledInRoom: jest.fn().mockReturnValue(true),
});
renderProfileSettings(toastRack, client);
const signOutButton = await screen.findByText("Sign out");
await userEvent.click(signOutButton);
expect(Modal.createDialog).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddRemoveThreepids should handle no email addresses 1`] = `
<div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="email"
id="mx_Field_3"
label="Email Address"
placeholder="Email Address"
type="text"
value=""
/>
<label
for="mx_Field_3"
>
Email Address
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;
exports[`AddRemoveThreepids should render email addresses 1`] = `
<div>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
alice@nowhere.dummy
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="email"
id="mx_Field_1"
label="Email Address"
placeholder="Email Address"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Email Address
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;
exports[`AddRemoveThreepids should render phone numbers 1`] = `
<div>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
447700900000
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
>
<span
class="mx_Field_prefix"
>
<div
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown"
>
<div
aria-describedby="mx_CountryDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Country Dropdown"
aria-owns="mx_CountryDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_CountryDropdown_value"
>
<span
class="mx_CountryDropdown_shortOption"
>
<div
class="mx_Dropdown_option_emoji"
>
🇺🇸
</div>
+1
</span>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</span>
<input
autocomplete="tel-national"
id="mx_Field_2"
label="Phone Number"
placeholder="Phone Number"
type="text"
value=""
/>
<label
for="mx_Field_2"
>
Phone Number
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
`;

View file

@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ChangePassword /> renders expected fields 1`] = `
<DocumentFragment>
<form>
<div>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_Field_1"
label="Current password"
placeholder="Current password"
type="password"
value=""
/>
<label
for="mx_Field_1"
>
Current password
</label>
</div>
</div>
<div>
<div
class="mx_Field mx_Field_input mx_PassphraseField"
>
<input
autocomplete="new-password"
id="mx_Field_2"
label="New Password"
placeholder="New Password"
type="password"
value=""
/>
<label
for="mx_Field_2"
>
New Password
</label>
</div>
</div>
<div>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="new-password"
id="mx_Field_3"
label="Confirm password"
placeholder="Confirm password"
type="password"
value=""
/>
<label
for="mx_Field_3"
>
Confirm password
</label>
</div>
</div>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Change Password
</div>
</form>
</DocumentFragment>
`;

View file

@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CrossSigningPanel /> when cross signing is not ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are not backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
not found in storage
</td>
</tr>
`;

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EventIndexPanel /> when event index is initialised renders event index information 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Securely cache encrypted messages locally for them to appear in search results, using 0 Bytes to store messages from 0 rooms.
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Manage
</div>
</div>
`;
exports[`<EventIndexPanel /> when event indexing is not supported renders link to download a desktop client 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
<span>
Element can't securely cache encrypted messages locally while running in a web browser. Use
<a
class="mx_ExternalLink"
href="https://element.io/get-started"
rel="noreferrer noopener"
target="_blank"
>
Element Desktop
<i
class="mx_ExternalLink_icon"
/>
</a>
for encrypted messages to appear in search results.
</span>
</div>
</div>
`;
exports[`<EventIndexPanel /> when event indexing is supported but not installed renders link to install seshat 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
<span>
Element is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom Element Desktop with
<a
class="mx_ExternalLink"
href="https://github.com/vector-im/element-desktop/blob/develop/docs/native-node-modules.md#adding-seshat-for-search-in-e2e-encrypted-rooms"
rel="noreferrer noopener"
target="_blank"
>
search components added
<i
class="mx_ExternalLink_icon"
/>
</a>
.
</span>
</div>
</div>
`;

View file

@ -0,0 +1,150 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FontScalingPanel renders the font scaling UI 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection"
data-testid="mx_FontScalingPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Font size
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_Field mx_Field_select mx_FontScalingPanel_Dropdown"
>
<select
id="mx_Field_1"
label="Font size"
placeholder="Font size"
type="text"
>
<option
value="-7"
>
9
</option>
<option
value="-6"
>
10
</option>
<option
value="-5"
>
11
</option>
<option
value="-4"
>
12
</option>
<option
value="-3"
>
13
</option>
<option
value="-2"
>
14
</option>
<option
value="-1"
>
15
</option>
<option
value="0"
>
16 (default)
</option>
<option
value="1"
>
17
</option>
<option
value="2"
>
18
</option>
<option
value="4"
>
20
</option>
<option
value="6"
>
22
</option>
<option
value="8"
>
24
</option>
<option
value="10"
>
26
</option>
<option
value="12"
>
28
</option>
<option
value="14"
>
30
</option>
<option
value="16"
>
32
</option>
<option
value="18"
>
34
</option>
<option
value="20"
>
36
</option>
</select>
<label
for="mx_Field_1"
>
Font size
</label>
</div>
<div
class="mx_FontScalingPanel_preview mx_EventTilePreview_loader"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`KeyboardShortcut doesn't render + if last 1`] = `
<div>
<kbd>
a
</kbd>
</div>
`;
exports[`KeyboardShortcut doesn't render same modifier twice 1`] = `
<div>
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
a
</kbd>
</div>
</div>
`;
exports[`KeyboardShortcut doesn't render same modifier twice 2`] = `
<div>
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
a
</kbd>
</div>
</div>
`;
exports[`KeyboardShortcut renders alternative key name 1`] = `
<div>
<kbd>
Page Down
</kbd>
+
</div>
`;
exports[`KeyboardShortcut renders key icon 1`] = `
<div>
<kbd>
</kbd>
+
</div>
`;

View file

@ -0,0 +1,426 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LayoutSwitcher /> should render 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="layoutPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Message layout
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24 mx_LayoutSwitcher_LayoutSelector"
>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Modern"
class="_label_dgy0u_67"
for="radix-0"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-0"
name="layout"
title=""
type="radio"
value="group"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Modern
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="group"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Message bubbles"
class="_label_dgy0u_67"
for="radix-1"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-1"
name="layout"
title=""
type="radio"
value="bubble"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Message bubbles
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="bubble"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="IRC (experimental)"
class="_label_dgy0u_67"
for="radix-2"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-2"
name="layout"
title=""
type="radio"
value="irc"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
IRC (experimental)
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview mx_IRCLayout"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="irc"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
</form>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
aria-describedby="radix-3"
class="_input_qnvru_32"
id="radix-4"
name="compactLayout"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-4"
>
Show compact text and messages
</label>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-3"
>
Modern layout must be selected to use this feature.
</span>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Notifications /> main notification switches renders only enable notifications switch when notifications are disabled 1`] = `
<div>
<div
class="mx_SettingsFlag"
data-testid="notif-master-switch"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch_testid_0"
>
Enable notifications for this account
</div>
<span
class="mx_Caption"
id="mx_LabelledToggleSwitch_testid_0_caption"
>
Turn off to disable notifications on all your devices and sessions
</span>
</span>
<div
aria-checked="false"
aria-describedby="mx_LabelledToggleSwitch_testid_0_caption"
aria-disabled="false"
aria-labelledby="mx_LabelledToggleSwitch_testid_0"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_testid_1"
>
<span
class="mx_SettingsFlag_labelText"
>
Show all activity in the room list (dots or number of unread messages)
</span>
</label>
<div
aria-checked="true"
aria-disabled="false"
aria-label="Show all activity in the room list (dots or number of unread messages)"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
id="mx_SettingsFlag_testid_1"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_testid_2"
>
<span
class="mx_SettingsFlag_labelText"
>
Only show notifications in the thread activity centre
</span>
</label>
<div
aria-checked="true"
aria-disabled="false"
aria-label="Only show notifications in the thread activity centre"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
id="mx_SettingsFlag_testid_2"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,235 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PowerLevelSelector should display only the current user 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
title
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_4"
label="@userId:matrix.org"
placeholder="@userId:matrix.org"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-50"
value="50"
>
Moderator
</option>
<option
data-testid="power-level-option-100"
value="100"
>
Admin
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_4"
>
@userId:matrix.org
</label>
</div>
</div>
<button
aria-disabled="true"
aria-label="Apply"
class="_button_i91xf_17 mx_Dialog_nonDialogButton mx_PowerLevelSelector_Button"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Apply
</button>
</div>
</fieldset>
`;
exports[`PowerLevelSelector should render 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
title
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_1"
label="@bob:server.org"
placeholder="@bob:server.org"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-50"
value="50"
>
Moderator
</option>
<option
data-testid="power-level-option-100"
value="100"
>
Admin
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_1"
>
@bob:server.org
</label>
</div>
</div>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_2"
label="@alice:server.org"
placeholder="@alice:server.org"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-50"
value="50"
>
Moderator
</option>
<option
data-testid="power-level-option-100"
value="100"
>
Admin
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_2"
>
@alice:server.org
</label>
</div>
</div>
<div
class="mx_PowerSelector"
>
<div
class="mx_Field mx_Field_select"
>
<select
data-testid="power-level-select-element"
id="mx_Field_3"
label="@userId:matrix.org"
placeholder="@userId:matrix.org"
type="text"
>
<option
data-testid="power-level-option-0"
value="0"
>
Default
</option>
<option
data-testid="power-level-option-50"
value="50"
>
Moderator
</option>
<option
data-testid="power-level-option-100"
value="100"
>
Admin
</option>
<option
data-testid="power-level-option-SELECT_VALUE_CUSTOM"
value="SELECT_VALUE_CUSTOM"
>
Custom level
</option>
</select>
<label
for="mx_Field_3"
>
@userId:matrix.org
</label>
</div>
</div>
<button
aria-disabled="true"
aria-label="Apply"
class="_button_i91xf_17 mx_Dialog_nonDialogButton mx_PowerLevelSelector_Button"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Apply
</button>
</div>
</fieldset>
`;

View file

@ -0,0 +1,193 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SecureBackupPanel /> handles error fetching backup 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.
</div>
<div
class="mx_SettingsSubsection_text"
>
Unable to load key backup status
</div>
<details>
<summary
class="mx_SecureBackupPanel_advanced"
>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
</table>
</details>
</div>
`;
exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.
</div>
<div
class="mx_SettingsSubsection_text"
>
<span>
This session is
<strong>
not backing up your keys
</strong>
, but you do have an existing backup you can restore from and add to going forward.
</span>
</div>
<div
class="mx_SettingsSubsection_text"
>
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
</div>
<details>
<summary
class="mx_SecureBackupPanel_advanced"
>
Advanced
</summary>
<table
class="mx_SecureBackupPanel_statusList"
>
<tr>
<th
scope="row"
>
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
<tr>
<th
scope="row"
>
Latest backup version on server:
</th>
<td>
1
(
Algorithm:
<code>
test
</code>
)
</td>
</tr>
<tr>
<th
scope="row"
>
Active backup version:
</th>
<td>
None
</td>
</tr>
</table>
<div />
</details>
<div
class="mx_SecureBackupPanel_buttonRow"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Connect this session to Key Backup
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline"
role="button"
tabindex="0"
>
Delete Backup
</div>
</div>
</div>
`;

View file

@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SetIntegrationManager should render manage integrations sections 1`] = `
<label
class="mx_SetIntegrationManager"
data-testid="mx_SetIntegrationManager"
for="toggle_integration"
>
<div
class="mx_SettingsFlag"
>
<div
class="mx_SetIntegrationManager_heading_manager"
>
<h3
class="mx_Heading_h3"
>
Manage integrations
</h3>
<h4
class="mx_Heading_h4"
>
(scalar.vector.im)
</h4>
</div>
<div
aria-checked="false"
aria-disabled="false"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
id="toggle_integration"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsSubsection_text"
>
<span>
Use an integration manager
<strong>
(scalar.vector.im)
</strong>
to manage bots, widgets, and sticker packs.
</span>
</div>
<div
class="mx_SettingsSubsection_text"
>
Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.
</div>
</label>
`;

View file

@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsFieldset /> renders fieldset with plain text description 1`] = `
<DocumentFragment>
<fieldset
class="mx_SettingsFieldset"
data-testid="test"
>
<legend
class="mx_SettingsFieldset_legend"
>
Who can read history?
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
Changes to who can read history.
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<div>
test
</div>
</div>
</fieldset>
</DocumentFragment>
`;
exports[`<SettingsFieldset /> renders fieldset with react description 1`] = `
<DocumentFragment>
<fieldset
class="mx_SettingsFieldset"
data-testid="test"
>
<legend
class="mx_SettingsFieldset_legend"
>
Who can read history?
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
<p>
Test
</p>
<a
href="#test"
>
a link
</a>
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<div>
test
</div>
</div>
</fieldset>
</DocumentFragment>
`;
exports[`<SettingsFieldset /> renders fieldset without description 1`] = `
<DocumentFragment>
<fieldset
class="mx_SettingsFieldset"
data-testid="test"
>
<legend
class="mx_SettingsFieldset_legend"
>
Who can read history?
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div>
test
</div>
</div>
</fieldset>
</DocumentFragment>
`;

View file

@ -0,0 +1,724 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ThemeChoicePanel /> custom theme should display custom theme 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-48"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-48"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-49"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-49"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-50"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-50"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-51"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-51"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-52"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-52"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
>
<div
class="_field_dgy0u_34"
>
<label
class="_label_dgy0u_67"
for="radix-54"
>
Add custom theme
</label>
<div
class="_controls_1h4nb_17"
>
<input
aria-describedby="radix-53"
class="_control_9gon8_18"
id="radix-54"
name="input"
title=""
value=""
/>
</div>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-53"
>
Enter the URL of a custom theme you want to apply.
</span>
</div>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
aria-labelledby="floating-ui-24"
class="_icon-button_bh2qc_17 _destructive_bh2qc_83"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 5 19V6a.968.968 0 0 1-.713-.287A.968.968 0 0 1 4 5c0-.283.096-.52.287-.713A.968.968 0 0 1 5 4h4a.97.97 0 0 1 .287-.712A.968.968 0 0 1 10 3h4a.97.97 0 0 1 .713.288A.968.968 0 0 1 15 4h4a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713A.968.968 0 0 1 19 6v13c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 17 21H7ZM7 6v13h10V6H7Zm2 10c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 11 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 10 8a.968.968 0 0 0-.713.287A.968.968 0 0 0 9 9v7Zm4 0c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 15 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 14 8a.968.968 0 0 0-.713.287A.967.967 0 0 0 13 9v7Z"
/>
</svg>
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> custom theme should render the custom theme section 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-32"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-32"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-33"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-33"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-34"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-34"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-35"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-35"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-36"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-36"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
>
<div
class="_field_dgy0u_34"
>
<label
class="_label_dgy0u_67"
for="radix-38"
>
Add custom theme
</label>
<div
class="_controls_1h4nb_17"
>
<input
aria-describedby="radix-37"
class="_control_9gon8_18"
id="radix-38"
name="input"
title=""
value=""
/>
</div>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-37"
>
Enter the URL of a custom theme you want to apply.
</span>
</div>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
aria-labelledby="floating-ui-1"
class="_icon-button_bh2qc_17 _destructive_bh2qc_83"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 5 19V6a.968.968 0 0 1-.713-.287A.968.968 0 0 1 4 5c0-.283.096-.52.287-.713A.968.968 0 0 1 5 4h4a.97.97 0 0 1 .287-.712A.968.968 0 0 1 10 3h4a.97.97 0 0 1 .713.288A.968.968 0 0 1 15 4h4a.97.97 0 0 1 .712.287c.192.192.288.43.288.713s-.096.52-.288.713A.968.968 0 0 1 19 6v13c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 17 21H7ZM7 6v13h10V6H7Zm2 10c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 11 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 10 8a.968.968 0 0 0-.713.287A.968.968 0 0 0 9 9v7Zm4 0c0 .283.096.52.287.712.192.192.43.288.713.288s.52-.096.713-.288A.968.968 0 0 0 15 16V9a.967.967 0 0 0-.287-.713A.968.968 0 0 0 14 8a.968.968 0 0 0-.713.287A.967.967 0 0 0 13 9v7Z"
/>
</svg>
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> renders the theme choice UI 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-0"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-0"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-1"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-3"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-3"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,95 @@
/*
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.
*/
import React from "react";
import { act, fireEvent, render } from "jest-matrix-react";
import CurrentDeviceSection from "../../../../../../src/components/views/settings/devices/CurrentDeviceSection";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<CurrentDeviceSection />", () => {
const deviceId = "alices_device";
const alicesVerifiedDevice = {
device_id: deviceId,
isVerified: false,
deviceType: DeviceType.Unknown,
};
const alicesUnverifiedDevice = {
device_id: deviceId,
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
device: alicesVerifiedDevice,
onVerifyCurrentDevice: jest.fn(),
onSignOutCurrentDevice: jest.fn(),
saveDeviceName: jest.fn(),
isLoading: false,
isSigningOut: false,
otherSessionsCount: 1,
setPushNotifications: jest.fn(),
};
const getComponent = (props = {}): React.ReactElement => <CurrentDeviceSection {...defaultProps} {...props} />;
it("renders spinner while device is loading", () => {
const { container } = render(getComponent({ device: undefined, isLoading: true }));
expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy();
});
it("handles when device is falsy", async () => {
const { container } = render(getComponent({ device: undefined }));
expect(container).toMatchSnapshot();
});
it("renders device and correct security card when device is verified", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders device and correct security card when device is unverified", () => {
const { container } = render(getComponent({ device: alicesUnverifiedDevice }));
expect(container).toMatchSnapshot();
});
it("displays device details on main tile click", () => {
const { getByTestId, container } = render(getComponent({ device: alicesUnverifiedDevice }));
act(() => {
fireEvent.click(getByTestId(`device-tile-${alicesUnverifiedDevice.device_id}`));
});
expect(container.getElementsByClassName("mx_DeviceDetails").length).toBeTruthy();
act(() => {
fireEvent.click(getByTestId(`device-tile-${alicesUnverifiedDevice.device_id}`));
});
// device details are hidden
expect(container.getElementsByClassName("mx_DeviceDetails").length).toBeFalsy();
});
it("displays device details on toggle click", () => {
const { container, getByTestId } = render(getComponent({ device: alicesUnverifiedDevice }));
act(() => {
fireEvent.click(getByTestId("current-session-toggle-details"));
});
expect(container.getElementsByClassName("mx_DeviceDetails")).toMatchSnapshot();
act(() => {
fireEvent.click(getByTestId("current-session-toggle-details"));
});
// device details are hidden
expect(container.getElementsByClassName("mx_DeviceDetails").length).toBeFalsy();
});
});

View file

@ -0,0 +1,141 @@
/*
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.
*/
import React from "react";
import { fireEvent, render, RenderResult } from "jest-matrix-react";
import { DeviceDetailHeading } from "../../../../../../src/components/views/settings/devices/DeviceDetailHeading";
import { flushPromisesWithFakeTimers } from "../../../../../test-utils";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
jest.useFakeTimers();
describe("<DeviceDetailHeading />", () => {
const device = {
device_id: "123",
display_name: "My device",
isVerified: true,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
device,
saveDeviceName: jest.fn(),
};
const getComponent = (props = {}) => <DeviceDetailHeading {...defaultProps} {...props} />;
const setInputValue = (getByTestId: RenderResult["getByTestId"], value: string) => {
const input = getByTestId("device-rename-input");
fireEvent.change(input, { target: { value } });
};
it("renders device name", () => {
const { container } = render(getComponent());
expect({ container }).toMatchSnapshot();
});
it("renders device id as fallback when device has no display name", () => {
const { getByText } = render(
getComponent({
device: { ...device, display_name: undefined },
}),
);
expect(getByText(device.device_id)).toBeTruthy();
});
it("displays name edit form on rename button click", () => {
const { getByTestId, container } = render(getComponent());
fireEvent.click(getByTestId("device-heading-rename-cta"));
expect({ container }).toMatchSnapshot();
});
it("cancelling edit switches back to original display", () => {
const { getByTestId, container } = render(getComponent());
// start editing
fireEvent.click(getByTestId("device-heading-rename-cta"));
// stop editing
fireEvent.click(getByTestId("device-rename-cancel-cta"));
expect(container.getElementsByClassName("mx_DeviceDetailHeading").length).toBe(1);
});
it("clicking submit updates device name with edited value", () => {
const saveDeviceName = jest.fn();
const { getByTestId } = render(getComponent({ saveDeviceName }));
// start editing
fireEvent.click(getByTestId("device-heading-rename-cta"));
setInputValue(getByTestId, "new device name");
fireEvent.click(getByTestId("device-rename-submit-cta"));
expect(saveDeviceName).toHaveBeenCalledWith("new device name");
});
it("disables form while device name is saving", () => {
const { getByTestId, container } = render(getComponent());
// start editing
fireEvent.click(getByTestId("device-heading-rename-cta"));
setInputValue(getByTestId, "new device name");
fireEvent.click(getByTestId("device-rename-submit-cta"));
// buttons disabled
expect(getByTestId("device-rename-cancel-cta").getAttribute("aria-disabled")).toEqual("true");
expect(getByTestId("device-rename-submit-cta").getAttribute("aria-disabled")).toEqual("true");
expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy();
});
it("toggles out of editing mode when device name is saved successfully", async () => {
const { getByTestId, findByTestId } = render(getComponent());
// start editing
fireEvent.click(getByTestId("device-heading-rename-cta"));
setInputValue(getByTestId, "new device name");
fireEvent.click(getByTestId("device-rename-submit-cta"));
await flushPromisesWithFakeTimers();
// read mode displayed
await expect(findByTestId("device-detail-heading")).resolves.toBeTruthy();
});
it("displays error when device name fails to save", async () => {
const saveDeviceName = jest.fn().mockRejectedValueOnce("oups").mockResolvedValue({});
const { getByTestId, queryByText, findByText, container } = render(getComponent({ saveDeviceName }));
// start editing
fireEvent.click(getByTestId("device-heading-rename-cta"));
setInputValue(getByTestId, "new device name");
fireEvent.click(getByTestId("device-rename-submit-cta"));
// flush promise
await flushPromisesWithFakeTimers();
// then tick for render
await flushPromisesWithFakeTimers();
// error message displayed
await expect(findByText("Failed to set session name")).resolves.toBeTruthy();
// spinner removed
expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy();
// try again
fireEvent.click(getByTestId("device-rename-submit-cta"));
// error message cleared
expect(queryByText("Failed to set display name")).toBeFalsy();
});
});

View file

@ -0,0 +1,187 @@
/*
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.
*/
import React, { ComponentProps } from "react";
import { fireEvent, render } from "jest-matrix-react";
import { PUSHER_ENABLED } from "matrix-js-sdk/src/matrix";
import DeviceDetails from "../../../../../../src/components/views/settings/devices/DeviceDetails";
import { mkPusher } from "../../../../../test-utils/test-utils";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<DeviceDetails />", () => {
const baseDevice = {
device_id: "my-device",
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps: ComponentProps<typeof DeviceDetails> = {
device: baseDevice,
isSigningOut: false,
onSignOutDevice: jest.fn(),
saveDeviceName: jest.fn(),
setPushNotifications: jest.fn(),
supportsMSC3881: true,
};
const getComponent = (props = {}) => <DeviceDetails {...defaultProps} {...props} />;
// 14.03.2022 16:15
const now = 1647270879403;
jest.useFakeTimers();
beforeEach(() => {
jest.setSystemTime(now);
});
it("renders device without metadata", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders device with metadata", () => {
const device = {
...baseDevice,
display_name: "My Device",
last_seen_ip: "123.456.789",
last_seen_ts: now - 60000000,
appName: "Element Web",
client: "Firefox 100",
deviceModel: "Iphone X",
deviceOperatingSystem: "Windows 95",
};
const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot();
});
it("renders a verified device", () => {
const device = {
...baseDevice,
isVerified: true,
};
const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot();
});
it("disables sign out button while sign out is pending", () => {
const device = {
...baseDevice,
};
const { getByTestId } = render(getComponent({ device, isSigningOut: true }));
expect(getByTestId("device-detail-sign-out-cta").getAttribute("aria-disabled")).toEqual("true");
});
it("renders the push notification section when a pusher exists", () => {
const device = {
...baseDevice,
};
const pusher = mkPusher({
device_id: device.device_id,
});
const { getByTestId } = render(
getComponent({
device,
pusher,
isSigningOut: true,
}),
);
expect(getByTestId("device-detail-push-notification")).toBeTruthy();
});
it("hides the push notification section when no pusher", () => {
const device = {
...baseDevice,
};
const { getByTestId } = render(
getComponent({
device,
pusher: null,
isSigningOut: true,
}),
);
expect(() => getByTestId("device-detail-push-notification")).toThrow();
});
it("disables the checkbox when there is no server support", () => {
const device = {
...baseDevice,
};
const pusher = mkPusher({
device_id: device.device_id,
[PUSHER_ENABLED.name]: false,
});
const { getByTestId } = render(
getComponent({
device,
pusher,
isSigningOut: true,
supportsMSC3881: false,
}),
);
const checkbox = getByTestId("device-detail-push-notification-checkbox");
expect(checkbox.getAttribute("aria-disabled")).toEqual("true");
expect(checkbox.getAttribute("aria-checked")).toEqual("false");
});
it("changes the pusher status when clicked", () => {
const device = {
...baseDevice,
};
const enabled = false;
const pusher = mkPusher({
device_id: device.device_id,
[PUSHER_ENABLED.name]: enabled,
});
const { getByTestId } = render(
getComponent({
device,
pusher,
isSigningOut: true,
}),
);
const checkbox = getByTestId("device-detail-push-notification-checkbox");
fireEvent.click(checkbox);
expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled);
});
it("changes the local notifications settings status when clicked", () => {
const device = {
...baseDevice,
};
const enabled = false;
const { getByTestId } = render(
getComponent({
device,
localNotificationSettings: {
is_silenced: !enabled,
},
isSigningOut: true,
}),
);
const checkbox = getByTestId("device-detail-push-notification-checkbox");
fireEvent.click(checkbox);
expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled);
});
});

View file

@ -0,0 +1,38 @@
/*
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.
*/
import React from "react";
import { fireEvent, render } from "jest-matrix-react";
import { DeviceExpandDetailsButton } from "../../../../../../src/components/views/settings/devices/DeviceExpandDetailsButton";
describe("<DeviceExpandDetailsButton />", () => {
const defaultProps = {
isExpanded: false,
onClick: jest.fn(),
};
const getComponent = (props = {}) => <DeviceExpandDetailsButton {...defaultProps} {...props} />;
it("renders when not expanded", () => {
const { container } = render(getComponent());
expect({ container }).toMatchSnapshot();
});
it("renders when expanded", () => {
const { container } = render(getComponent({ isExpanded: true }));
expect({ container }).toMatchSnapshot();
});
it("calls onClick", () => {
const onClick = jest.fn();
const { getByTestId } = render(getComponent({ "data-testid": "test", onClick }));
fireEvent.click(getByTestId("test"));
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,37 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import React from "react";
import DeviceSecurityCard from "../../../../../../src/components/views/settings/devices/DeviceSecurityCard";
import { DeviceSecurityVariation } from "../../../../../../src/components/views/settings/devices/types";
describe("<DeviceSecurityCard />", () => {
const defaultProps = {
variation: DeviceSecurityVariation.Verified,
heading: "Verified session",
description: "nice",
};
const getComponent = (props = {}): React.ReactElement => <DeviceSecurityCard {...defaultProps} {...props} />;
it("renders basic card", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders with children", () => {
const { container } = render(
getComponent({
children: <div>hey</div>,
variation: DeviceSecurityVariation.Unverified,
}),
);
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,124 @@
/*
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.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { IMyDevice } from "matrix-js-sdk/src/matrix";
import DeviceTile from "../../../../../../src/components/views/settings/devices/DeviceTile";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<DeviceTile />", () => {
const defaultProps = {
device: {
device_id: "123",
isVerified: false,
deviceType: DeviceType.Unknown,
},
};
const getComponent = (props = {}) => <DeviceTile {...defaultProps} {...props} />;
// 14.03.2022 16:15
const now = 1647270879403;
jest.useFakeTimers();
beforeEach(() => {
jest.setSystemTime(now);
});
it("renders a device with no metadata", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("applies interactive class when tile has click handler", () => {
const onClick = jest.fn();
const { getByTestId } = render(getComponent({ onClick }));
expect(getByTestId("device-tile-123").className.includes("mx_DeviceTile_interactive")).toBeTruthy();
});
it("renders a verified device with no metadata", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders display name with a tooltip", () => {
const device: IMyDevice = {
device_id: "123",
display_name: "My device",
};
const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot();
});
it("renders last seen ip metadata", () => {
const device: IMyDevice = {
device_id: "123",
display_name: "My device",
last_seen_ip: "1.2.3.4",
};
const { getByTestId } = render(getComponent({ device }));
expect(getByTestId("device-metadata-lastSeenIp").textContent).toEqual(device.last_seen_ip);
});
it("separates metadata with a dot", () => {
const device: IMyDevice = {
device_id: "123",
last_seen_ip: "1.2.3.4",
last_seen_ts: now - 60000,
};
const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot();
});
describe("Last activity", () => {
const MS_DAY = 24 * 60 * 60 * 1000;
it("renders with day of week and time when last activity is less than 6 days ago", () => {
const device: IMyDevice = {
device_id: "123",
last_seen_ip: "1.2.3.4",
last_seen_ts: now - MS_DAY * 3,
};
const { getByTestId } = render(getComponent({ device }));
expect(getByTestId("device-metadata-lastActivity").textContent).toEqual("Last activity Fri 15:14");
});
it("renders with month and date when last activity is more than 6 days ago", () => {
const device: IMyDevice = {
device_id: "123",
last_seen_ip: "1.2.3.4",
last_seen_ts: now - MS_DAY * 8,
};
const { getByTestId } = render(getComponent({ device }));
expect(getByTestId("device-metadata-lastActivity").textContent).toEqual("Last activity Mar 6");
});
it("renders with month, date, year when activity is in a different calendar year", () => {
const device: IMyDevice = {
device_id: "123",
last_seen_ip: "1.2.3.4",
last_seen_ts: new Date("2021-12-29").getTime(),
};
const { getByTestId } = render(getComponent({ device }));
expect(getByTestId("device-metadata-lastActivity").textContent).toEqual("Last activity Dec 29, 2021");
});
it("renders with inactive notice when last activity was more than 90 days ago", () => {
const device: IMyDevice = {
device_id: "123",
last_seen_ip: "1.2.3.4",
last_seen_ts: now - MS_DAY * 100,
};
const { getByTestId, queryByTestId } = render(getComponent({ device }));
expect(getByTestId("device-metadata-inactive").textContent).toEqual("Inactive for 90+ days (Dec 4, 2021)");
// last activity and verification not shown when inactive
expect(queryByTestId("device-metadata-lastActivity")).toBeFalsy();
expect(queryByTestId("device-metadata-verificationStatus")).toBeFalsy();
});
});
});

View file

@ -0,0 +1,65 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import React from "react";
import { DeviceTypeIcon } from "../../../../../../src/components/views/settings/devices/DeviceTypeIcon";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<DeviceTypeIcon />", () => {
const defaultProps = {
isVerified: false,
isSelected: false,
};
const getComponent = (props = {}) => <DeviceTypeIcon {...defaultProps} {...props} />;
it("renders an unverified device", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders a verified device", () => {
const { container } = render(getComponent({ isVerified: true }));
expect(container).toMatchSnapshot();
});
it("renders correctly when selected", () => {
const { container } = render(getComponent({ isSelected: true }));
expect(container).toMatchSnapshot();
});
it("renders an unknown device icon when no device type given", () => {
const { getByLabelText } = render(getComponent());
expect(getByLabelText("Unknown session type")).toBeTruthy();
});
it("renders a desktop device type", () => {
const deviceType = DeviceType.Desktop;
const { getByLabelText } = render(getComponent({ deviceType }));
expect(getByLabelText("Desktop session")).toBeTruthy();
});
it("renders a web device type", () => {
const deviceType = DeviceType.Web;
const { getByLabelText } = render(getComponent({ deviceType }));
expect(getByLabelText("Web session")).toBeTruthy();
});
it("renders a mobile device type", () => {
const deviceType = DeviceType.Mobile;
const { getByLabelText } = render(getComponent({ deviceType }));
expect(getByLabelText("Mobile session")).toBeTruthy();
});
it("renders an unknown device type", () => {
const deviceType = DeviceType.Unknown;
const { getByLabelText } = render(getComponent({ deviceType }));
expect(getByLabelText("Unknown session type")).toBeTruthy();
});
});

View file

@ -0,0 +1,88 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import React from "react";
import {
DeviceVerificationStatusCard,
DeviceVerificationStatusCardProps,
} from "../../../../../../src/components/views/settings/devices/DeviceVerificationStatusCard";
import { ExtendedDevice } from "../../../../../../src/components/views/settings/devices/types";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<DeviceVerificationStatusCard />", () => {
const deviceId = "test-device";
const unverifiedDevice: ExtendedDevice = {
device_id: deviceId,
isVerified: false,
deviceType: DeviceType.Unknown,
};
const verifiedDevice: ExtendedDevice = {
...unverifiedDevice,
isVerified: true,
};
const unverifiableDevice: ExtendedDevice = {
...unverifiedDevice,
isVerified: null,
};
const defaultProps = {
device: unverifiedDevice,
onVerifyDevice: jest.fn(),
};
const getComponent = (props: Partial<DeviceVerificationStatusCardProps> = {}) => (
<DeviceVerificationStatusCard {...defaultProps} {...props} />
);
const verifyButtonTestId = `verification-status-button-${deviceId}`;
describe("for the current device", () => {
// current device uses different copy
it("renders an unverified device", () => {
const { getByText } = render(getComponent({ isCurrentDevice: true }));
expect(getByText("Verify your current session for enhanced secure messaging.")).toBeTruthy();
});
it("renders an unverifiable device", () => {
const { getByText } = render(
getComponent({
device: unverifiableDevice,
isCurrentDevice: true,
}),
);
expect(getByText("This session doesn't support encryption and thus can't be verified.")).toBeTruthy();
});
it("renders a verified device", () => {
const { getByText } = render(
getComponent({
device: verifiedDevice,
isCurrentDevice: true,
}),
);
expect(getByText("Your current session is ready for secure messaging.")).toBeTruthy();
});
});
it("renders an unverified device", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders an unverifiable device", () => {
const { container, queryByTestId } = render(getComponent({ device: unverifiableDevice }));
expect(container).toMatchSnapshot();
expect(queryByTestId(verifyButtonTestId)).toBeFalsy();
});
it("renders a verified device", () => {
const { container, queryByTestId } = render(getComponent({ device: verifiedDevice }));
expect(container).toMatchSnapshot();
expect(queryByTestId(verifyButtonTestId)).toBeFalsy();
});
});

View file

@ -0,0 +1,231 @@
/*
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.
*/
import React, { ComponentProps } from "react";
import { act, fireEvent, render } from "jest-matrix-react";
import { FilteredDeviceList } from "../../../../../../src/components/views/settings/devices/FilteredDeviceList";
import { DeviceSecurityVariation } from "../../../../../../src/components/views/settings/devices/types";
import { flushPromises, mockPlatformPeg } from "../../../../../test-utils";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
mockPlatformPeg();
const MS_DAY = 86400000;
describe("<FilteredDeviceList />", () => {
// 14.03.2022 16:15
const now = 1647270879403;
jest.spyOn(global.Date, "now").mockReturnValue(now);
const newDevice = {
device_id: "new",
last_seen_ts: Date.now() - 500,
last_seen_ip: "123.456.789",
display_name: "My Device",
isVerified: true,
deviceType: DeviceType.Unknown,
};
const unverifiedNoMetadata = {
device_id: "unverified-no-metadata",
isVerified: false,
deviceType: DeviceType.Unknown,
};
const verifiedNoMetadata = {
device_id: "verified-no-metadata",
isVerified: true,
deviceType: DeviceType.Unknown,
};
const hundredDaysOld = {
device_id: "100-days-old",
isVerified: true,
last_seen_ts: Date.now() - MS_DAY * 100,
deviceType: DeviceType.Unknown,
};
const hundredDaysOldUnverified = {
device_id: "unverified-100-days-old",
isVerified: false,
last_seen_ts: Date.now() - MS_DAY * 100,
deviceType: DeviceType.Unknown,
};
const defaultProps: ComponentProps<typeof FilteredDeviceList> = {
onFilterChange: jest.fn(),
onDeviceExpandToggle: jest.fn(),
onSignOutDevices: jest.fn(),
saveDeviceName: jest.fn(),
setPushNotifications: jest.fn(),
setSelectedDeviceIds: jest.fn(),
localNotificationSettings: new Map(),
expandedDeviceIds: [],
signingOutDeviceIds: [],
selectedDeviceIds: [],
devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[newDevice.device_id]: newDevice,
[hundredDaysOld.device_id]: hundredDaysOld,
[hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified,
},
pushers: [],
supportsMSC3881: true,
};
const getComponent = (props = {}) => <FilteredDeviceList {...defaultProps} {...props} />;
afterAll(() => {
jest.spyOn(global.Date, "now").mockRestore();
});
it("renders devices in correct order", () => {
const { container } = render(getComponent());
const tiles = container.querySelectorAll(".mx_DeviceTile");
expect(tiles[0].getAttribute("data-testid")).toEqual(`device-tile-${newDevice.device_id}`);
expect(tiles[1].getAttribute("data-testid")).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[2].getAttribute("data-testid")).toEqual(`device-tile-${hundredDaysOldUnverified.device_id}`);
expect(tiles[3].getAttribute("data-testid")).toEqual(`device-tile-${unverifiedNoMetadata.device_id}`);
expect(tiles[4].getAttribute("data-testid")).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
});
it("updates list order when devices change", () => {
const updatedOldDevice = { ...hundredDaysOld, last_seen_ts: new Date().getTime() };
const updatedDevices = {
[hundredDaysOld.device_id]: updatedOldDevice,
[newDevice.device_id]: newDevice,
};
const { container, rerender } = render(getComponent());
rerender(getComponent({ devices: updatedDevices }));
const tiles = container.querySelectorAll(".mx_DeviceTile");
expect(tiles.length).toBe(2);
expect(tiles[0].getAttribute("data-testid")).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[1].getAttribute("data-testid")).toEqual(`device-tile-${newDevice.device_id}`);
});
it("displays no results message when there are no devices", () => {
const { container } = render(getComponent({ devices: {} }));
expect(container.getElementsByClassName("mx_FilteredDeviceList_noResults")).toMatchSnapshot();
});
describe("filtering", () => {
const setFilter = async (container: HTMLElement, option: DeviceSecurityVariation | string) => {
const dropdown = container.querySelector('[aria-label="Filter devices"]');
fireEvent.click(dropdown as Element);
// tick to let dropdown render
await flushPromises();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
};
it("does not display filter description when filter is falsy", () => {
const { container } = render(getComponent({ filter: undefined }));
const tiles = container.querySelectorAll(".mx_DeviceTile");
expect(container.getElementsByClassName("mx_FilteredDeviceList_securityCard").length).toBeFalsy();
expect(tiles.length).toEqual(5);
});
it("updates filter when prop changes", () => {
const { container, rerender } = render(getComponent({ filter: DeviceSecurityVariation.Verified }));
const tiles = container.querySelectorAll(".mx_DeviceTile");
expect(tiles.length).toEqual(3);
expect(tiles[0].getAttribute("data-testid")).toEqual(`device-tile-${newDevice.device_id}`);
expect(tiles[1].getAttribute("data-testid")).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[2].getAttribute("data-testid")).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
rerender(getComponent({ filter: DeviceSecurityVariation.Inactive }));
const rerenderedTiles = container.querySelectorAll(".mx_DeviceTile");
expect(rerenderedTiles.length).toEqual(2);
expect(rerenderedTiles[0].getAttribute("data-testid")).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(rerenderedTiles[1].getAttribute("data-testid")).toEqual(
`device-tile-${hundredDaysOldUnverified.device_id}`,
);
});
it("calls onFilterChange handler", async () => {
const onFilterChange = jest.fn();
const { container } = render(getComponent({ onFilterChange }));
await setFilter(container, DeviceSecurityVariation.Verified);
expect(onFilterChange).toHaveBeenCalledWith(DeviceSecurityVariation.Verified);
});
it("calls onFilterChange handler correctly when setting filter to All", async () => {
const onFilterChange = jest.fn();
const { container } = render(getComponent({ onFilterChange, filter: DeviceSecurityVariation.Verified }));
await setFilter(container, "ALL");
// filter is cleared
expect(onFilterChange).toHaveBeenCalledWith(undefined);
});
it.each([
[DeviceSecurityVariation.Verified, [newDevice, hundredDaysOld, verifiedNoMetadata]],
[DeviceSecurityVariation.Unverified, [hundredDaysOldUnverified, unverifiedNoMetadata]],
[DeviceSecurityVariation.Inactive, [hundredDaysOld, hundredDaysOldUnverified]],
])("filters correctly for %s", (filter, expectedDevices) => {
const { container } = render(getComponent({ filter }));
expect(container.getElementsByClassName("mx_FilteredDeviceList_securityCard")).toMatchSnapshot();
const tileDeviceIds = [...container.querySelectorAll(".mx_DeviceTile")].map((tile) =>
tile.getAttribute("data-testid"),
);
expect(tileDeviceIds).toEqual(expectedDevices.map((device) => `device-tile-${device.device_id}`));
});
it.each([
[DeviceSecurityVariation.Verified],
[DeviceSecurityVariation.Unverified],
[DeviceSecurityVariation.Inactive],
])("renders no results correctly for %s", (filter) => {
const { container } = render(getComponent({ filter, devices: {} }));
expect(container.getElementsByClassName("mx_FilteredDeviceList_securityCard").length).toBeFalsy();
expect(container.getElementsByClassName("mx_FilteredDeviceList_noResults")).toMatchSnapshot();
});
it("clears filter from no results message", () => {
const onFilterChange = jest.fn();
const { getByTestId } = render(
getComponent({
onFilterChange,
filter: DeviceSecurityVariation.Verified,
devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
},
}),
);
act(() => {
fireEvent.click(getByTestId("devices-clear-filter-btn"));
});
expect(onFilterChange).toHaveBeenCalledWith(undefined);
});
});
describe("device details", () => {
it("renders expanded devices with device details", () => {
const expandedDeviceIds = [newDevice.device_id, hundredDaysOld.device_id];
const { container, getByTestId } = render(getComponent({ expandedDeviceIds }));
expect(container.getElementsByClassName("mx_DeviceDetails").length).toBeTruthy();
expect(getByTestId(`device-detail-${newDevice.device_id}`)).toBeTruthy();
expect(getByTestId(`device-detail-${hundredDaysOld.device_id}`)).toBeTruthy();
});
it("clicking toggle calls onDeviceExpandToggle", () => {
const onDeviceExpandToggle = jest.fn();
const { getByTestId } = render(getComponent({ onDeviceExpandToggle }));
act(() => {
const tile = getByTestId(`device-tile-${hundredDaysOld.device_id}`);
const toggle = tile.querySelector('[aria-label="Show details"]');
fireEvent.click(toggle as Element);
});
expect(onDeviceExpandToggle).toHaveBeenCalledWith(hundredDaysOld.device_id);
});
});
});

View file

@ -0,0 +1,46 @@
/*
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.
*/
import { fireEvent, render } from "jest-matrix-react";
import React from "react";
import FilteredDeviceListHeader from "../../../../../../src/components/views/settings/devices/FilteredDeviceListHeader";
describe("<FilteredDeviceListHeader />", () => {
const defaultProps = {
selectedDeviceCount: 0,
isAllSelected: false,
toggleSelectAll: jest.fn(),
children: <div>test</div>,
["data-testid"]: "test123",
};
const getComponent = (props = {}) => <FilteredDeviceListHeader {...defaultProps} {...props} />;
it("renders correctly when no devices are selected", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders correctly when all devices are selected", () => {
const { container } = render(getComponent({ isAllSelected: true }));
expect(container).toMatchSnapshot();
});
it("renders correctly when some devices are selected", () => {
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
expect(getByText("2 sessions selected")).toBeTruthy();
});
it("clicking checkbox toggles selection", () => {
const toggleSelectAll = jest.fn();
const { getByTestId } = render(getComponent({ toggleSelectAll }));
fireEvent.click(getByTestId("device-select-all-checkbox"));
expect(toggleSelectAll).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,457 @@
/*
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.
*/
import { cleanup, render, waitFor } from "jest-matrix-react";
import { MockedObject, mocked } from "jest-mock";
import React from "react";
import {
MSC3906Rendezvous,
LegacyRendezvousFailureReason,
ClientRendezvousFailureReason,
MSC4108SignInWithQR,
MSC4108FailureReason,
} from "matrix-js-sdk/src/rendezvous";
import { HTTPError, LoginTokenPostResponse } from "matrix-js-sdk/src/matrix";
import LoginWithQR from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
jest.mock("matrix-js-sdk/src/rendezvous");
jest.mock("matrix-js-sdk/src/rendezvous/transports");
jest.mock("matrix-js-sdk/src/rendezvous/channels");
const mockedFlow = jest.fn();
jest.mock("../../../../../../src/components/views/auth/LoginWithQRFlow", () => (props: Record<string, any>) => {
mockedFlow(props);
return <div />;
});
function makeClient() {
return mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
on: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(true),
removeListener: jest.fn(),
requestLoginToken: jest.fn(),
currentState: {
on: jest.fn(),
},
getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}),
crypto: {},
} as unknown as MatrixClient);
}
function unresolvedPromise<T>(): Promise<T> {
return new Promise(() => {});
}
describe("<LoginWithQR />", () => {
let client!: MockedObject<MatrixClient>;
const defaultProps = {
legacy: true,
mode: Mode.Show,
onFinished: jest.fn(),
};
const mockConfirmationDigits = "mock-confirmation-digits";
const mockRendezvousCode = "mock-rendezvous-code";
const newDeviceId = "new-device-id";
beforeEach(() => {
mockedFlow.mockReset();
jest.resetAllMocks();
client = makeClient();
});
afterEach(() => {
client = makeClient();
jest.clearAllMocks();
jest.useRealTimers();
cleanup();
});
describe("MSC3906", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<React.StrictMode>
<LoginWithQR {...defaultProps} {...props} />
</React.StrictMode>
);
beforeEach(() => {
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockResolvedValue();
// @ts-ignore
// workaround for https://github.com/facebook/jest/issues/9675
MSC3906Rendezvous.prototype.code = mockRendezvousCode;
jest.spyOn(MSC3906Rendezvous.prototype, "cancel").mockResolvedValue();
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockResolvedValue(mockConfirmationDigits);
jest.spyOn(MSC3906Rendezvous.prototype, "declineLoginOnExistingDevice").mockResolvedValue();
jest.spyOn(MSC3906Rendezvous.prototype, "approveLoginOnExistingDevice").mockResolvedValue(newDeviceId);
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockResolvedValue(undefined);
client.requestLoginToken.mockResolvedValue({
login_token: "token",
expires_in_ms: 1000 * 1000,
} as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet
});
test("no homeserver support", async () => {
// simulate no support
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
});
test("failed to connect", async () => {
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.Unknown,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
});
test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.ShowingQR,
}),
),
);
// display QR code
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
code: mockRendezvousCode,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// back
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Back);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
});
test("render QR then decline", async () => {
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
// decline
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Decline);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
});
test("approve - no crypto", async () => {
(client as any).crypto = undefined;
(client as any).getCrypto = () => undefined;
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// approve
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.WaitingForDevice,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verifying", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() =>
unresolvedPromise(),
);
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// approve
const onClick = mockedFlow.mock.calls[0][0].onClick;
onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Verifying,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
// expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verify", async () => {
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// approve
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
expect(rendezvous.close).toHaveBeenCalled();
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve - rate limited", async () => {
mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429));
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// approve
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
// the 429 error should be handled and mapped
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Error,
failureReason: "rate_limited",
}),
),
);
});
});
describe("MSC4108", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<React.StrictMode>
<LoginWithQR {...defaultProps} {...props} legacy={false} />
</React.StrictMode>
);
test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// back
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Back);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
});
test("failed to connect", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockRejectedValue(
new HTTPError("Internal Server Error", 500),
);
const fn = jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
await waitFor(() => expect(fn).toHaveBeenLastCalledWith(ClientRendezvousFailureReason.Unknown));
});
test("reciprocates login", async () => {
jest.spyOn(global.window, "open");
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.WaitingForDevice,
onClick: expect.any(Function),
}),
);
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
});
test("handles errors during reciprocation", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
new HTTPError("Internal Server Error", 500),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.Unknown,
}),
),
);
});
test("handles user cancelling during reciprocation", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
});
});

View file

@ -0,0 +1,112 @@
/*
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.
*/
import { cleanup, fireEvent, render, screen, waitFor } from "jest-matrix-react";
import React from "react";
import {
ClientRendezvousFailureReason,
LegacyRendezvousFailureReason,
MSC4108FailureReason,
} from "matrix-js-sdk/src/rendezvous";
import LoginWithQRFlow from "../../../../../../src/components/views/auth/LoginWithQRFlow";
import { LoginWithQRFailureReason, FailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
describe("<LoginWithQRFlow />", () => {
const onClick = jest.fn();
const defaultProps = {
onClick,
};
const getComponent = (props: {
phase: Phase;
onClick?: () => Promise<void>;
failureReason?: FailureReason;
code?: string;
confirmationDigits?: string;
}) => <LoginWithQRFlow {...defaultProps} {...props} />;
beforeEach(() => {});
afterEach(() => {
onClick.mockReset();
cleanup();
});
it("renders spinner while loading", async () => {
const { container } = render(getComponent({ phase: Phase.Loading }));
expect(container).toMatchSnapshot();
});
it("renders spinner whilst QR generating", async () => {
const { container } = render(getComponent({ phase: Phase.ShowingQR }));
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders QR code", async () => {
const { container } = render(getComponent({ phase: Phase.ShowingQR, code: "mock-code" }));
// QR code is rendered async so we wait for it:
await waitFor(() => screen.getAllByAltText("QR Code").length === 1);
expect(container).toMatchSnapshot();
});
it("renders code when connected", async () => {
const { container } = render(getComponent({ phase: Phase.LegacyConnected, confirmationDigits: "mock-digits" }));
expect(screen.getAllByText("mock-digits")).toHaveLength(1);
expect(screen.getAllByTestId("decline-login-button")).toHaveLength(1);
expect(screen.getAllByTestId("approve-login-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("decline-login-button"));
expect(onClick).toHaveBeenCalledWith(Click.Decline, undefined);
fireEvent.click(screen.getByTestId("approve-login-button"));
expect(onClick).toHaveBeenCalledWith(Click.Approve, undefined);
});
it("renders spinner while signing in", async () => {
const { container } = render(getComponent({ phase: Phase.WaitingForDevice }));
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders spinner while verifying", async () => {
const { container } = render(getComponent({ phase: Phase.Verifying }));
expect(container).toMatchSnapshot();
});
it("renders check code confirmation", async () => {
const { container } = render(getComponent({ phase: Phase.OutOfBandConfirmation }));
expect(container).toMatchSnapshot();
});
describe("errors", () => {
for (const failureReason of [
...Object.values(LegacyRendezvousFailureReason),
...Object.values(MSC4108FailureReason),
...Object.values(LoginWithQRFailureReason),
...Object.values(ClientRendezvousFailureReason),
]) {
it(`renders ${failureReason}`, async () => {
const { container } = render(
getComponent({
phase: Phase.Error,
failureReason,
}),
);
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
expect(container).toMatchSnapshot();
});
}
});
});

View file

@ -0,0 +1,156 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { IClientWellKnown, IServerVersions, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "matrix-js-sdk/src/matrix";
import React from "react";
import fetchMock from "fetch-mock-jest";
import LoginWithQRSection from "../../../../../../src/components/views/settings/devices/LoginWithQRSection";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
function makeClient(wellKnown: IClientWellKnown) {
const crypto = mocked({
supportsSecretsForQrLogin: jest.fn().mockReturnValue(true),
isCrossSigningReady: jest.fn().mockReturnValue(true),
});
return mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
on: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getClientWellKnown: jest.fn().mockReturnValue(wellKnown),
getCrypto: jest.fn().mockReturnValue(crypto),
} as unknown as MatrixClient);
}
function makeVersions(unstableFeatures: Record<string, boolean>): IServerVersions {
return {
versions: [],
unstable_features: unstableFeatures,
};
}
describe("<LoginWithQRSection />", () => {
beforeAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient({}));
});
describe("MSC3906", () => {
const defaultProps = {
onShowQr: () => {},
versions: makeVersions({}),
wellKnown: {},
};
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
describe("should not render", () => {
it("no support at all", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("only get_login_token enabled", async () => {
const { container } = render(
getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }),
);
expect(container).toMatchSnapshot();
});
it("MSC3886 + get_login_token disabled", async () => {
const { container } = render(
getComponent({
versions: makeVersions({ "org.matrix.msc3886": true }),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } },
}),
);
expect(container).toMatchSnapshot();
});
});
describe("should render panel", () => {
it("get_login_token + MSC3886", async () => {
const { container } = render(
getComponent({
versions: makeVersions({
"org.matrix.msc3886": true,
}),
capabilities: {
[GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true },
},
}),
);
expect(container).toMatchSnapshot();
});
it("get_login_token + .well-known", async () => {
const wellKnown = {
"io.element.rendezvous": {
server: "https://rz.local",
},
};
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown));
const { container } = render(
getComponent({
versions: makeVersions({}),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } },
wellKnown,
}),
);
expect(container).toMatchSnapshot();
});
});
});
describe("MSC4108", () => {
describe("MSC4108", () => {
const defaultProps = {
onShowQr: () => {},
versions: makeVersions({ "org.matrix.msc4108": true }),
wellKnown: {},
};
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
let client: MatrixClient;
beforeEach(() => {
client = makeClient({});
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
});
test("no homeserver support", async () => {
const { container } = render(getComponent({ versions: makeVersions({ "org.matrix.msc4108": false }) }));
expect(container.textContent).toContain("Not supported by your account provider");
});
test("no support in crypto", async () => {
client.getCrypto()!.exportSecretsBundle = undefined;
const { container } = render(getComponent({ client }));
expect(container.textContent).toContain("Not supported by your account provider");
});
test("failed to connect", async () => {
fetchMock.catch(500);
const { container } = render(getComponent({ client }));
expect(container.textContent).toContain("Not supported by your account provider");
});
});
});
});

View file

@ -0,0 +1,108 @@
/*
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.
*/
import React from "react";
import { act, fireEvent, render } from "jest-matrix-react";
import SecurityRecommendations from "../../../../../../src/components/views/settings/devices/SecurityRecommendations";
import { DeviceSecurityVariation } from "../../../../../../src/components/views/settings/devices/types";
const MS_DAY = 24 * 60 * 60 * 1000;
describe("<SecurityRecommendations />", () => {
const unverifiedNoMetadata = { device_id: "unverified-no-metadata", isVerified: false };
const verifiedNoMetadata = { device_id: "verified-no-metadata", isVerified: true };
const hundredDaysOld = { device_id: "100-days-old", isVerified: true, last_seen_ts: Date.now() - MS_DAY * 100 };
const hundredDaysOldUnverified = {
device_id: "unverified-100-days-old",
isVerified: false,
last_seen_ts: Date.now() - MS_DAY * 100,
};
const defaultProps = {
devices: {},
goToFilteredList: jest.fn(),
currentDeviceId: "abc123",
};
const getComponent = (props = {}) => <SecurityRecommendations {...defaultProps} {...props} />;
it("renders null when no devices", () => {
const { container } = render(getComponent());
expect(container.firstChild).toBeNull();
});
it("renders unverified devices section when user has unverified devices", () => {
const devices = {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified,
};
const { container } = render(getComponent({ devices }));
expect(container).toMatchSnapshot();
});
it("does not render unverified devices section when only the current device is unverified", () => {
const devices = {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
};
const { container } = render(getComponent({ devices, currentDeviceId: unverifiedNoMetadata.device_id }));
// nothing to render
expect(container.firstChild).toBeFalsy();
});
it("renders inactive devices section when user has inactive devices", () => {
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified,
};
const { container } = render(getComponent({ devices }));
expect(container).toMatchSnapshot();
});
it("renders both cards when user has both unverified and inactive devices", () => {
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOld.device_id]: hundredDaysOld,
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
};
const { container } = render(getComponent({ devices }));
expect(container).toMatchSnapshot();
});
it("clicking view all unverified devices button works", () => {
const goToFilteredList = jest.fn();
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOld.device_id]: hundredDaysOld,
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
};
const { getByTestId } = render(getComponent({ devices, goToFilteredList }));
act(() => {
fireEvent.click(getByTestId("unverified-devices-cta"));
});
expect(goToFilteredList).toHaveBeenCalledWith(DeviceSecurityVariation.Unverified);
});
it("clicking view all inactive devices button works", () => {
const goToFilteredList = jest.fn();
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOld.device_id]: hundredDaysOld,
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
};
const { getByTestId } = render(getComponent({ devices, goToFilteredList }));
act(() => {
fireEvent.click(getByTestId("inactive-devices-cta"));
});
expect(goToFilteredList).toHaveBeenCalledWith(DeviceSecurityVariation.Inactive);
});
});

View file

@ -0,0 +1,83 @@
/*
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.
*/
import { act, fireEvent, render } from "jest-matrix-react";
import React from "react";
import SelectableDeviceTile from "../../../../../../src/components/views/settings/devices/SelectableDeviceTile";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
describe("<SelectableDeviceTile />", () => {
const device = {
display_name: "My Device",
device_id: "my-device",
last_seen_ip: "123.456.789",
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
onSelect: jest.fn(),
onClick: jest.fn(),
device,
children: <div>test</div>,
isSelected: false,
};
const getComponent = (props = {}) => <SelectableDeviceTile {...defaultProps} {...props} />;
it("renders unselected device tile with checkbox", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders selected tile", () => {
const { container } = render(getComponent({ isSelected: true }));
expect(container.querySelector(`#device-tile-checkbox-${device.device_id}`)).toMatchSnapshot();
});
it("calls onSelect on checkbox click", () => {
const onSelect = jest.fn();
const { container } = render(getComponent({ onSelect }));
act(() => {
fireEvent.click(container.querySelector(`#device-tile-checkbox-${device.device_id}`)!);
});
expect(onSelect).toHaveBeenCalled();
});
it("calls onClick on device tile info click", () => {
const onClick = jest.fn();
const { getByText } = render(getComponent({ onClick }));
act(() => {
fireEvent.click(getByText(device.display_name));
});
expect(onClick).toHaveBeenCalled();
});
it("does not call onClick when clicking device tiles actions", () => {
const onClick = jest.fn();
const onDeviceActionClick = jest.fn();
const children = (
<button onClick={onDeviceActionClick} data-testid="device-action-button">
test
</button>
);
const { getByTestId } = render(getComponent({ onClick, children }));
act(() => {
fireEvent.click(getByTestId("device-action-button"));
});
// action click handler called
expect(onDeviceActionClick).toHaveBeenCalled();
// main click handler not called
expect(onClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,447 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CurrentDeviceSection /> displays device details on toggle click 1`] = `
HTMLCollection [
<div
class="mx_DeviceDetails mx_CurrentDeviceSection_deviceDetails"
data-testid="device-detail-alices_device"
>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_DeviceDetailHeading"
data-testid="device-detail-heading"
>
<h4
class="mx_Heading_h4"
>
alices_device
</h4>
<div
class="mx_AccessibleButton mx_DeviceDetailHeading_renameCta mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="device-heading-rename-cta"
role="button"
tabindex="0"
>
Rename
</div>
</div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your current session for enhanced secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="verification-status-button-alices_device"
role="button"
tabindex="0"
>
Verify session
</div>
</div>
</div>
</div>
</section>
<section
class="mx_DeviceDetails_section"
>
<p
class="mx_DeviceDetails_sectionHeading"
>
Session details
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Session ID
</td>
<td
class="mxDeviceDetails_metadataValue"
>
alices_device
</td>
</tr>
</tbody>
</table>
</section>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
data-testid="device-detail-sign-out-cta"
role="button"
tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
>
Sign out of this session
</span>
</div>
</section>
</div>,
]
`;
exports[`<CurrentDeviceSection /> handles when device is falsy 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="current-session-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-disabled="true"
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_AccessibleButton_disabled"
data-testid="current-session-menu"
disabled=""
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div
class="mx_SettingsSubsection_content"
/>
</div>
</div>
`;
exports[`<CurrentDeviceSection /> renders device and correct security card when device is unverified 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="current-session-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton"
data-testid="current-session-menu"
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_DeviceTile mx_DeviceTile_interactive"
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
alices_device
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
alices_device
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
>
<div
aria-label="Show details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details"
role="button"
tabindex="0"
>
<div
class="mx_DeviceExpandDetailsButton_icon"
/>
</div>
</div>
</div>
<br />
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your current session for enhanced secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="verification-status-button-alices_device"
role="button"
tabindex="0"
>
Verify session
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<CurrentDeviceSection /> renders device and correct security card when device is verified 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="current-session-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton"
data-testid="current-session-menu"
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_DeviceTile mx_DeviceTile_interactive"
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
alices_device
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
alices_device
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
>
<div
aria-label="Show details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details"
role="button"
tabindex="0"
>
<div
class="mx_DeviceExpandDetailsButton_icon"
/>
</div>
</div>
</div>
<br />
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your current session for enhanced secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="verification-status-button-alices_device"
role="button"
tabindex="0"
>
Verify session
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,97 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceDetailHeading /> displays name edit form on rename button click 1`] = `
{
"container": <div>
<form
aria-disabled="false"
class="mx_DeviceDetailHeading_renameForm"
method="post"
>
<p
class="mx_DeviceDetailHeading_renameFormHeading"
id="device-rename-123"
>
Rename session
</p>
<div>
<div
class="mx_Field mx_Field_input mx_DeviceDetailHeading_renameFormInput"
>
<input
aria-describedby="device-rename-description-123"
aria-labelledby="device-rename-123"
autocomplete="off"
data-testid="device-rename-input"
id="mx_Field_1"
maxlength="100"
type="text"
value="My device"
/>
<label
for="mx_Field_1"
/>
</div>
<span
class="mx_Caption"
id="device-rename-description-123"
>
Please be aware that session names are also visible to people you communicate with.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</span>
</div>
<div
class="mx_DeviceDetailHeading_renameFormButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="device-rename-submit-cta"
role="button"
tabindex="0"
>
Save
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
data-testid="device-rename-cancel-cta"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</form>
</div>,
}
`;
exports[`<DeviceDetailHeading /> renders device name 1`] = `
{
"container": <div>
<div
class="mx_DeviceDetailHeading"
data-testid="device-detail-heading"
>
<h4
class="mx_Heading_h4"
>
My device
</h4>
<div
class="mx_AccessibleButton mx_DeviceDetailHeading_renameCta mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="device-heading-rename-cta"
role="button"
tabindex="0"
>
Rename
</div>
</div>
</div>,
}
`;

View file

@ -0,0 +1,428 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceDetails /> renders a verified device 1`] = `
<div>
<div
class="mx_DeviceDetails"
data-testid="device-detail-my-device"
>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_DeviceDetailHeading"
data-testid="device-detail-heading"
>
<h4
class="mx_Heading_h4"
>
my-device
</h4>
<div
class="mx_AccessibleButton mx_DeviceDetailHeading_renameCta mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="device-heading-rename-cta"
role="button"
tabindex="0"
>
Rename
</div>
</div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Verified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Your current session is ready for secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>
</section>
<section
class="mx_DeviceDetails_section"
>
<p
class="mx_DeviceDetails_sectionHeading"
>
Session details
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Session ID
</td>
<td
class="mxDeviceDetails_metadataValue"
>
my-device
</td>
</tr>
</tbody>
</table>
</section>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
data-testid="device-detail-sign-out-cta"
role="button"
tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
>
Sign out of this session
</span>
</div>
</section>
</div>
</div>
`;
exports[`<DeviceDetails /> renders device with metadata 1`] = `
<div>
<div
class="mx_DeviceDetails"
data-testid="device-detail-my-device"
>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_DeviceDetailHeading"
data-testid="device-detail-heading"
>
<h4
class="mx_Heading_h4"
>
My Device
</h4>
<div
class="mx_AccessibleButton mx_DeviceDetailHeading_renameCta mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="device-heading-rename-cta"
role="button"
tabindex="0"
>
Rename
</div>
</div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your current session for enhanced secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>
</section>
<section
class="mx_DeviceDetails_section"
>
<p
class="mx_DeviceDetails_sectionHeading"
>
Session details
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Session ID
</td>
<td
class="mxDeviceDetails_metadataValue"
>
my-device
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Last activity
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Sun 22:34
</td>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-application"
>
<thead>
<tr>
<th>
Application
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Name
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Element Web
</td>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-device"
>
<thead>
<tr>
<th>
Device
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Model
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Iphone X
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Operating system
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Windows 95
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Browser
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Firefox 100
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
IP address
</td>
<td
class="mxDeviceDetails_metadataValue"
>
123.456.789
</td>
</tr>
</tbody>
</table>
</section>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
data-testid="device-detail-sign-out-cta"
role="button"
tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
>
Sign out of this session
</span>
</div>
</section>
</div>
</div>
`;
exports[`<DeviceDetails /> renders device without metadata 1`] = `
<div>
<div
class="mx_DeviceDetails"
data-testid="device-detail-my-device"
>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_DeviceDetailHeading"
data-testid="device-detail-heading"
>
<h4
class="mx_Heading_h4"
>
my-device
</h4>
<div
class="mx_AccessibleButton mx_DeviceDetailHeading_renameCta mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="device-heading-rename-cta"
role="button"
tabindex="0"
>
Rename
</div>
</div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your current session for enhanced secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>
</section>
<section
class="mx_DeviceDetails_section"
>
<p
class="mx_DeviceDetails_sectionHeading"
>
Session details
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Session ID
</td>
<td
class="mxDeviceDetails_metadataValue"
>
my-device
</td>
</tr>
</tbody>
</table>
</section>
<section
class="mx_DeviceDetails_section"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
data-testid="device-detail-sign-out-cta"
role="button"
tabindex="0"
>
<span
class="mx_DeviceDetails_signOutButtonContent"
>
Sign out of this session
</span>
</div>
</section>
</div>
</div>
`;

View file

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceExpandDetailsButton /> renders when expanded 1`] = `
{
"container": <div>
<div
aria-label="Hide details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_DeviceExpandDetailsButton_expanded mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
role="button"
tabindex="0"
>
<div
class="mx_DeviceExpandDetailsButton_icon"
/>
</div>
</div>,
}
`;
exports[`<DeviceExpandDetailsButton /> renders when not expanded 1`] = `
{
"container": <div>
<div
aria-label="Show details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
role="button"
tabindex="0"
>
<div
class="mx_DeviceExpandDetailsButton_icon"
/>
</div>
</div>,
}
`;

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceSecurityCard /> renders basic card 1`] = `
<div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Verified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
nice
</p>
</div>
</div>
</div>
`;
exports[`<DeviceSecurityCard /> renders with children 1`] = `
<div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
nice
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div>
hey
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,233 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceTile /> renders a device with no metadata 1`] = `
<div>
<div
class="mx_DeviceTile"
data-testid="device-tile-123"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
123
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
123
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
/>
</div>
</div>
`;
exports[`<DeviceTile /> renders a verified device with no metadata 1`] = `
<div>
<div
class="mx_DeviceTile"
data-testid="device-tile-123"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
123
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
123
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
/>
</div>
</div>
`;
exports[`<DeviceTile /> renders display name with a tooltip 1`] = `
<div>
<div
class="mx_DeviceTile"
data-testid="device-tile-123"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
My device
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
123
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
/>
</div>
</div>
`;
exports[`<DeviceTile /> separates metadata with a dot 1`] = `
<div>
<div
class="mx_DeviceTile"
data-testid="device-tile-123"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
123
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-lastActivity"
>
Last activity 15:13
</span>
·
<span
data-testid="device-metadata-lastSeenIp"
>
1.2.3.4
</span>
·
<span
data-testid="device-metadata-deviceId"
>
123
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
/>
</div>
</div>
`;

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceTypeIcon /> renders a verified device 1`] = `
<div>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Verified"
class="mx_DeviceTypeIcon_verificationIcon verified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceTypeIcon /> renders an unverified device 1`] = `
<div>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceTypeIcon /> renders correctly when selected 1`] = `
<div>
<div
class="mx_DeviceTypeIcon mx_DeviceTypeIcon_selected"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;

View file

@ -0,0 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceVerificationStatusCard /> renders a verified device 1`] = `
<div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Verified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
This session is ready for secure messaging.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>
</div>
`;
exports[`<DeviceVerificationStatusCard /> renders an unverifiable device 1`] = `
<div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
This session doesn't support encryption and thus can't be verified.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>
</div>
`;
exports[`<DeviceVerificationStatusCard /> renders an unverified device 1`] = `
<div>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="verification-status-button-test-device"
role="button"
tabindex="0"
>
Verify session
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,200 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteredDeviceList /> displays no results message when there are no devices 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No sessions found.
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Inactive 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Inactive"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Inactive sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
<span>
Consider signing out from old sessions (90 days or older) you don't use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</span>
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Unverified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
<span>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</span>
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Verified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Verified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
<span>
For best security, sign out from any session that you don't recognize or use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</span>
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Inactive 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No inactive sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Unverified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No unverified sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Verified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No verified sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;

View file

@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteredDeviceListHeader /> renders correctly when all devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<span
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Deselect all"
aria-labelledby="floating-ui-6"
checked=""
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</span>
<span
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
<div>
test
</div>
</div>
</div>
`;
exports[`<FilteredDeviceListHeader /> renders correctly when no devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<span
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Select all"
aria-labelledby="floating-ui-1"
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</span>
<span
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
<div>
test
</div>
</div>
</div>
`;

View file

@ -0,0 +1,307 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQRSection /> MSC3906 should not render MSC3886 + get_login_token disabled 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Link new device
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
Use a QR code to sign in to another device and set up secure messaging.
</p>
<div
aria-disabled="true"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 5V5h4v4H5Zm-2 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 5v-4h4v4H5Zm9-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-6Zm1 2v4h4V5h-4Z"
fill-rule="evenodd"
/>
<path
d="M15 16v-3h-2v3h2Z"
/>
<path
d="M17 16h-2v2h-2v3h2v-3h2v2h4v-2h-2v-5h-2v3Z"
/>
</svg>
Show QR code
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
Not supported by your account provider
</p>
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRSection /> MSC3906 should not render no support at all 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Link new device
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
Use a QR code to sign in to another device and set up secure messaging.
</p>
<div
aria-disabled="true"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 5V5h4v4H5Zm-2 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 5v-4h4v4H5Zm9-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-6Zm1 2v4h4V5h-4Z"
fill-rule="evenodd"
/>
<path
d="M15 16v-3h-2v3h2Z"
/>
<path
d="M17 16h-2v2h-2v3h2v-3h2v2h4v-2h-2v-5h-2v3Z"
/>
</svg>
Show QR code
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
Not supported by your account provider
</p>
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRSection /> MSC3906 should not render only get_login_token enabled 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Link new device
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
Use a QR code to sign in to another device and set up secure messaging.
</p>
<div
aria-disabled="true"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 5V5h4v4H5Zm-2 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 5v-4h4v4H5Zm9-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-6Zm1 2v4h4V5h-4Z"
fill-rule="evenodd"
/>
<path
d="M15 16v-3h-2v3h2Z"
/>
<path
d="M17 16h-2v2h-2v3h2v-3h2v2h4v-2h-2v-5h-2v3Z"
/>
</svg>
Show QR code
</div>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
Not supported by your account provider
</p>
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + .well-known 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Link new device
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
Use a QR code to sign in to another device and set up secure messaging.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 5V5h4v4H5Zm-2 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 5v-4h4v4H5Zm9-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-6Zm1 2v4h4V5h-4Z"
fill-rule="evenodd"
/>
<path
d="M15 16v-3h-2v3h2Z"
/>
<path
d="M17 16h-2v2h-2v3h2v-3h2v2h4v-2h-2v-5h-2v3Z"
/>
</svg>
Show QR code
</div>
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + MSC3886 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Link new device
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
Use a QR code to sign in to another device and set up secure messaging.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 5V5h4v4H5Zm-2 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 5v-4h4v4H5Zm9-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-6Zm1 2v4h4V5h-4Z"
fill-rule="evenodd"
/>
<path
d="M15 16v-3h-2v3h2Z"
/>
<path
d="M17 16h-2v2h-2v3h2v-3h2v2h4v-2h-2v-5h-2v3Z"
/>
</svg>
Show QR code
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,376 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SecurityRecommendations /> renders both cards when user has both unverified and inactive devices 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="security-recommendations-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Improve your account security by following these recommendations.
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button"
tabindex="0"
>
View all (1)
</div>
</div>
</div>
</div>
<div
class="mx_SecurityRecommendations_spacing"
/>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Inactive"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Inactive sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button"
tabindex="0"
>
View all (1)
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<SecurityRecommendations /> renders inactive devices section when user has inactive devices 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="security-recommendations-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Improve your account security by following these recommendations.
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button"
tabindex="0"
>
View all (1)
</div>
</div>
</div>
</div>
<div
class="mx_SecurityRecommendations_spacing"
/>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Inactive"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Inactive sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button"
tabindex="0"
>
View all (1)
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<SecurityRecommendations /> renders unverified devices section when user has unverified devices 1`] = `
<div>
<div
class="mx_SettingsSubsection"
data-testid="security-recommendations-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Improve your account security by following these recommendations.
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button"
tabindex="0"
>
View all (2)
</div>
</div>
</div>
</div>
<div
class="mx_SecurityRecommendations_spacing"
/>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Inactive"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Inactive sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
<div
class="mx_DeviceSecurityCard_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button"
tabindex="0"
>
View all (1)
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SelectableDeviceTile /> renders selected tile 1`] = `
<input
checked=""
data-testid="device-tile-checkbox-my-device"
id="device-tile-checkbox-my-device"
type="checkbox"
/>
`;
exports[`<SelectableDeviceTile /> renders unselected device tile with checkbox 1`] = `
<div>
<div
class="mx_SelectableDeviceTile"
>
<span
class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
data-testid="device-tile-checkbox-my-device"
id="device-tile-checkbox-my-device"
type="checkbox"
/>
<label
for="device-tile-checkbox-my-device"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
<div>
<div
class="mx_DeviceTile mx_DeviceTile_interactive"
data-testid="device-tile-my-device"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
My Device
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Unverified
</span>
·
<span
data-testid="device-metadata-lastSeenIp"
>
123.456.789
</span>
·
<span
data-testid="device-metadata-deviceId"
>
my-device
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
>
<div>
test
</div>
</div>
</div>
</div>
</label>
</span>
</div>
</div>
`;

View file

@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`deleteDevices() opens interactive auth dialog when delete fails with 401 1`] = `
{
"m.login.sso": {
"1": {
"body": "Confirm logging out these devices by using Single Sign On to prove your identity.",
"continueKind": "primary",
"continueText": "Single Sign On",
"title": "Use Single Sign On to continue",
},
"2": {
"body": "Click the button below to confirm signing out these devices.",
"continueKind": "danger",
"continueText": "Sign out devices",
"title": "Confirm signing out these devices",
},
},
"org.matrix.login.sso": {
"1": {
"body": "Confirm logging out these devices by using Single Sign On to prove your identity.",
"continueKind": "primary",
"continueText": "Single Sign On",
"title": "Use Single Sign On to continue",
},
"2": {
"body": "Click the button below to confirm signing out these devices.",
"continueKind": "danger",
"continueText": "Sign out devices",
"title": "Confirm signing out these devices",
},
},
}
`;

View file

@ -0,0 +1,94 @@
/*
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.
*/
import { MatrixError, UIAFlow } from "matrix-js-sdk/src/matrix";
import { deleteDevicesWithInteractiveAuth } from "../../../../../../src/components/views/settings/devices/deleteDevices";
import Modal from "../../../../../../src/Modal";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
describe("deleteDevices()", () => {
const userId = "@alice:server.org";
const deviceIds = ["device_1", "device_2"];
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
deleteMultipleDevices: jest.fn(),
});
const modalSpy = jest.spyOn(Modal, "createDialog") as jest.SpyInstance;
const interactiveAuthError = new MatrixError({ flows: [] as UIAFlow[] }, 401);
beforeEach(() => {
jest.clearAllMocks();
});
it("deletes devices and calls onFinished when interactive auth is not required", async () => {
mockClient.deleteMultipleDevices.mockResolvedValue({});
const onFinished = jest.fn();
await deleteDevicesWithInteractiveAuth(mockClient, deviceIds, onFinished);
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(deviceIds, undefined);
expect(onFinished).toHaveBeenCalledWith(true, undefined);
// didnt open modal
expect(modalSpy).not.toHaveBeenCalled();
});
it("throws without opening auth dialog when delete fails with a non-401 status code", async () => {
const error = new Error("");
// @ts-ignore
error.httpStatus = 404;
mockClient.deleteMultipleDevices.mockRejectedValue(error);
const onFinished = jest.fn();
await expect(deleteDevicesWithInteractiveAuth(mockClient, deviceIds, onFinished)).rejects.toThrow(error);
expect(onFinished).not.toHaveBeenCalled();
// didnt open modal
expect(modalSpy).not.toHaveBeenCalled();
});
it("throws without opening auth dialog when delete fails without data.flows", async () => {
const error = new Error("");
// @ts-ignore
error.httpStatus = 401;
// @ts-ignore
error.data = {};
mockClient.deleteMultipleDevices.mockRejectedValue(error);
const onFinished = jest.fn();
await expect(deleteDevicesWithInteractiveAuth(mockClient, deviceIds, onFinished)).rejects.toThrow(error);
expect(onFinished).not.toHaveBeenCalled();
// didnt open modal
expect(modalSpy).not.toHaveBeenCalled();
});
it("opens interactive auth dialog when delete fails with 401", async () => {
mockClient.deleteMultipleDevices.mockRejectedValue(interactiveAuthError);
const onFinished = jest.fn();
await deleteDevicesWithInteractiveAuth(mockClient, deviceIds, onFinished);
expect(onFinished).not.toHaveBeenCalled();
// opened modal
expect(modalSpy).toHaveBeenCalled();
const { title, authData, aestheticsForStagePhases } = modalSpy.mock.calls[0][1]!;
// modal opened as expected
expect(title).toEqual("Authentication");
expect(authData).toEqual(interactiveAuthError.data);
expect(aestheticsForStagePhases).toMatchSnapshot();
});
});

View file

@ -0,0 +1,81 @@
/*
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.
*/
import { filterDevicesBySecurityRecommendation } from "../../../../../../src/components/views/settings/devices/filter";
import { DeviceSecurityVariation } from "../../../../../../src/components/views/settings/devices/types";
import { DeviceType } from "../../../../../../src/utils/device/parseUserAgent";
const MS_DAY = 86400000;
describe("filterDevicesBySecurityRecommendation()", () => {
const unverifiedNoMetadata = {
device_id: "unverified-no-metadata",
isVerified: false,
deviceType: DeviceType.Unknown,
};
const verifiedNoMetadata = {
device_id: "verified-no-metadata",
isVerified: true,
deviceType: DeviceType.Unknown,
};
const hundredDaysOld = {
device_id: "100-days-old",
isVerified: true,
last_seen_ts: Date.now() - MS_DAY * 100,
deviceType: DeviceType.Unknown,
};
const hundredDaysOldUnverified = {
device_id: "unverified-100-days-old",
isVerified: false,
last_seen_ts: Date.now() - MS_DAY * 100,
deviceType: DeviceType.Unknown,
};
const fiftyDaysOld = {
device_id: "50-days-old",
isVerified: true,
last_seen_ts: Date.now() - MS_DAY * 50,
deviceType: DeviceType.Unknown,
};
const devices = [unverifiedNoMetadata, verifiedNoMetadata, hundredDaysOld, hundredDaysOldUnverified, fiftyDaysOld];
it("returns all devices when no securityRecommendations are passed", () => {
expect(filterDevicesBySecurityRecommendation(devices, [])).toBe(devices);
});
it("returns devices older than 90 days as inactive", () => {
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Inactive])).toEqual([
// devices without ts metadata are not filtered as inactive
hundredDaysOld,
hundredDaysOldUnverified,
]);
});
it("returns correct devices for verified filter", () => {
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Verified])).toEqual([
verifiedNoMetadata,
hundredDaysOld,
fiftyDaysOld,
]);
});
it("returns correct devices for unverified filter", () => {
expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Unverified])).toEqual([
unverifiedNoMetadata,
hundredDaysOldUnverified,
]);
});
it("returns correct devices for combined verified and inactive filters", () => {
expect(
filterDevicesBySecurityRecommendation(devices, [
DeviceSecurityVariation.Unverified,
DeviceSecurityVariation.Inactive,
]),
).toEqual([hundredDaysOldUnverified]);
});
});

View file

@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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 React from "react";
import { act, render, screen } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import DiscoverySettings from "../../../../../../src/components/views/settings/discovery/DiscoverySettings";
import { stubClient } from "../../../../../test-utils";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { UIFeature } from "../../../../../../src/settings/UIFeature";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
const mockGetAccessToken = jest.fn().mockResolvedValue("$$getAccessToken");
jest.mock("../../../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
describe("DiscoverySettings", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
afterEach(() => {
jest.restoreAllMocks();
});
const DiscoveryWrapper = (props = {}) => <MatrixClientContext.Provider value={client} {...props} />;
it("is empty if 3pid features are disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key) => {
if (key === UIFeature.ThirdPartyID) return false;
});
const { container } = render(<DiscoverySettings />, { wrapper: DiscoveryWrapper });
expect(container).toBeEmptyDOMElement();
});
it("displays alert if an identity server needs terms accepting", async () => {
mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com");
mocked(client).getTerms.mockResolvedValue({
["policies"]: { en: "No ball games" },
});
render(<DiscoverySettings />, { wrapper: DiscoveryWrapper });
await expect(await screen.findByText("Let people find you")).toBeInTheDocument();
});
it("button to accept terms is disabled if checkbox not checked", async () => {
mocked(client).getIdentityServerUrl.mockReturnValue("https://example.com");
mocked(client).getTerms.mockResolvedValue({
["policies"]: { en: "No ball games" },
});
render(<DiscoverySettings />, { wrapper: DiscoveryWrapper });
const acceptCheckbox = await screen.findByRole("checkbox", { name: "Accept" });
const continueButton = screen.getByRole("button", { name: "Continue" });
expect(acceptCheckbox).toBeInTheDocument();
expect(continueButton).toHaveAttribute("aria-disabled", "true");
await userEvent.click(acceptCheckbox);
expect(continueButton).not.toHaveAttribute("aria-disabled", "true");
});
it("updates if ID server is changed", async () => {
render(<DiscoverySettings />, { wrapper: DiscoveryWrapper });
mocked(client).getThreePids.mockClear();
act(() => {
defaultDispatcher.dispatch(
{
action: "id_server_changed",
},
true,
);
});
expect(client.getThreePids).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,744 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 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 { act, findByRole, getByRole, queryByRole, render, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
ThreepidMedium,
IPushRules,
MatrixClient,
NotificationCountType,
PushRuleKind,
Room,
RuleId,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import NotificationSettings2 from "../../../../../../src/components/views/settings/notifications/NotificationSettings2";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { StandardActions } from "../../../../../../src/notifications/StandardActions";
import { mkMessage, stubClient } from "../../../../../test-utils";
import Mock = jest.Mock;
const waitForUpdate = (): Promise<void> => new Promise((resolve) => setTimeout(resolve));
const labelGlobalMute = "Enable notifications for this account";
const labelLevelAllMessage = "All messages";
const labelLevelMentionsOnly = "Mentions and Keywords only";
const labelSoundPeople = "People";
const labelSoundMentions = "Mentions and Keywords";
const labelSoundCalls = "Audio and Video calls";
const labelActivityInvites = "Invited to a room";
const labelActivityStatus = "New room activity, upgrades and status messages occur";
const labelActivityBots = "Messages sent by bots";
const labelMentionUser = "Notify when someone mentions using @displayname or @mxid";
const labelMentionRoom = "Notify when someone mentions using @room";
const labelMentionKeyword =
"Notify when someone uses a keyword" + "Enter keywords here, or use for spelling variations or nicknames";
const labelResetDefault = "Reset to default settings";
const keywords = ["justjann3", "justj4nn3", "justj4nne", "Janne", "J4nne", "Jann3", "jann3", "j4nne", "janne"];
describe("<Notifications />", () => {
let cli: MatrixClient;
let pushRules: IPushRules;
beforeAll(async () => {
pushRules = (await import("../../../../models/notificationsettings/pushrules_sample.json")) as IPushRules;
});
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
cli.getPushRules = jest.fn(cli.getPushRules).mockResolvedValue(pushRules);
cli.supportsIntentionalMentions = jest.fn(cli.supportsIntentionalMentions).mockReturnValue(false);
cli.setPushRuleEnabled = jest.fn(cli.setPushRuleEnabled);
cli.setPushRuleActions = jest.fn(cli.setPushRuleActions);
cli.addPushRule = jest.fn(cli.addPushRule).mockResolvedValue({});
cli.deletePushRule = jest.fn(cli.deletePushRule).mockResolvedValue({});
cli.removePusher = jest.fn(cli.removePusher).mockResolvedValue({});
cli.setPusher = jest.fn(cli.setPusher).mockResolvedValue({});
});
it("matches the snapshot", async () => {
cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({
pushers: [
{
app_display_name: "Element",
app_id: "im.vector.app",
data: {},
device_display_name: "My EyeFon",
kind: "http",
lang: "en",
pushkey: "",
enabled: true,
},
],
});
cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({
threepids: [
{
medium: ThreepidMedium.Email,
address: "test@example.tld",
validated_at: 1656633600,
added_at: 1656633600,
},
],
});
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.container).toMatchSnapshot();
});
it("correctly handles the loading/disabled state", async () => {
(cli.getPushRules as Mock).mockReturnValue(new Promise<IPushRules>(() => {}));
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(async () => {
await waitForUpdate();
expect(screen.container).toMatchSnapshot();
const globalMute = screen.getByLabelText(labelGlobalMute);
expect(globalMute).toHaveAttribute("aria-disabled", "true");
const levelAllMessages = screen.getByLabelText(labelLevelAllMessage);
expect(levelAllMessages).toBeDisabled();
const soundPeople = screen.getByLabelText(labelSoundPeople);
expect(soundPeople).toBeDisabled();
const soundMentions = screen.getByLabelText(labelSoundMentions);
expect(soundMentions).toBeDisabled();
const soundCalls = screen.getByLabelText(labelSoundCalls);
expect(soundCalls).toBeDisabled();
const activityInvites = screen.getByLabelText(labelActivityInvites);
expect(activityInvites).toBeDisabled();
const activityStatus = screen.getByLabelText(labelActivityStatus);
expect(activityStatus).toBeDisabled();
const activityBots = screen.getByLabelText(labelActivityBots);
expect(activityBots).toBeDisabled();
const mentionUser = screen.getByLabelText(labelMentionUser.replace("@mxid", cli.getUserId()!));
expect(mentionUser).toBeDisabled();
const mentionRoom = screen.getByLabelText(labelMentionRoom);
expect(mentionRoom).toBeDisabled();
const mentionKeyword = screen.getByLabelText(labelMentionKeyword);
expect(mentionKeyword).toBeDisabled();
await Promise.all([
user.click(globalMute),
user.click(levelAllMessages),
user.click(soundPeople),
user.click(soundMentions),
user.click(soundCalls),
user.click(activityInvites),
user.click(activityStatus),
user.click(activityBots),
user.click(mentionUser),
user.click(mentionRoom),
user.click(mentionKeyword),
]);
});
expect(cli.setPushRuleActions).not.toHaveBeenCalled();
expect(cli.setPushRuleEnabled).not.toHaveBeenCalled();
expect(cli.addPushRule).not.toHaveBeenCalled();
expect(cli.deletePushRule).not.toHaveBeenCalled();
});
describe("form elements actually toggle the model value", () => {
it("global mute", async () => {
const label = labelGlobalMute;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Override, RuleId.Master, true);
});
it("notification level", async () => {
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(labelLevelAllMessage)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(labelLevelAllMessage));
await waitForUpdate();
});
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedMessage,
true,
);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.Message, true);
(cli.setPushRuleEnabled as Mock).mockClear();
expect(screen.getByLabelText(labelLevelMentionsOnly)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(labelLevelMentionsOnly));
await waitForUpdate();
});
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedDM,
true,
);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.DM, true);
});
describe("play a sound for", () => {
it("people", async () => {
const label = labelSoundPeople;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedDM,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.DM,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.InviteToSelf,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
});
it("mentions", async () => {
const label = labelSoundMentions;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.ContainsDisplayName,
StandardActions.ACTION_HIGHLIGHT,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.ContentSpecific,
RuleId.ContainsUserName,
StandardActions.ACTION_HIGHLIGHT,
);
});
it("calls", async () => {
const label = labelSoundCalls;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.IncomingCall,
StandardActions.ACTION_NOTIFY,
);
});
});
describe("activity", () => {
it("invite", async () => {
const label = labelActivityInvites;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.InviteToSelf,
StandardActions.ACTION_NOTIFY,
);
});
it("status messages", async () => {
const label = labelActivityStatus;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.MemberEvent,
StandardActions.ACTION_NOTIFY,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.Tombstone,
StandardActions.ACTION_HIGHLIGHT,
);
});
it("notices", async () => {
const label = labelActivityBots;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.SuppressNotices,
StandardActions.ACTION_DONT_NOTIFY,
);
});
});
describe("mentions", () => {
it("room mentions", async () => {
const label = labelMentionRoom;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.AtRoomNotification,
StandardActions.ACTION_DONT_NOTIFY,
);
});
it("user mentions", async () => {
const label = labelMentionUser.replace("@mxid", cli.getUserId()!);
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.ContainsDisplayName,
StandardActions.ACTION_DONT_NOTIFY,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.ContentSpecific,
RuleId.ContainsUserName,
StandardActions.ACTION_DONT_NOTIFY,
);
});
it("keywords", async () => {
const label = labelMentionKeyword;
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
for (const pattern of keywords) {
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.ContentSpecific,
pattern,
false,
);
}
});
});
describe("keywords", () => {
it("allows adding keywords", async () => {
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
const inputField = screen.getByRole("textbox", { name: "Keyword" });
const addButton = screen.getByRole("button", { name: "Add" });
expect(inputField).not.toBeDisabled();
expect(addButton).not.toBeDisabled();
await act(async () => {
await user.type(inputField, "testkeyword");
await user.click(addButton);
await waitForUpdate();
});
expect(cli.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "testkeyword", {
kind: PushRuleKind.ContentSpecific,
rule_id: "testkeyword",
enabled: true,
default: false,
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
pattern: "testkeyword",
});
});
it("allows deleting keywords", async () => {
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
const tag = screen.getByText("justj4nn3");
const deleteButton = getByRole(tag, "button", { name: "Remove" });
expect(deleteButton).not.toBeDisabled();
await act(async () => {
await user.click(deleteButton);
await waitForUpdate();
});
expect(cli.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "justj4nn3");
});
});
it("resets the model correctly", async () => {
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
const button = screen.getByText(labelResetDefault);
expect(button).not.toBeDisabled();
await act(async () => {
await user.click(button);
await waitForUpdate();
});
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedMessage,
true,
);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.Message, true);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedDM,
true,
);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.DM, true);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.SuppressNotices,
false,
);
expect(cli.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.InviteToSelf,
true,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedMessage,
StandardActions.ACTION_NOTIFY,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.Message,
StandardActions.ACTION_NOTIFY,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.EncryptedDM,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
RuleId.DM,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.SuppressNotices,
StandardActions.ACTION_DONT_NOTIFY,
);
expect(cli.setPushRuleActions).toHaveBeenCalledWith(
"global",
PushRuleKind.Override,
RuleId.InviteToSelf,
StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
);
for (const pattern of keywords) {
expect(cli.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, pattern);
}
});
});
describe("pusher settings", () => {
it("can create email pushers", async () => {
cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({
pushers: [
{
app_display_name: "Element",
app_id: "im.vector.app",
data: {},
device_display_name: "My EyeFon",
kind: "http",
lang: "en",
pushkey: "",
enabled: true,
},
],
});
cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({
threepids: [
{
medium: ThreepidMedium.Email,
address: "test@example.tld",
validated_at: 1656633600,
added_at: 1656633600,
},
],
});
const label = "test@example.tld";
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.setPusher).toHaveBeenCalledWith({
app_display_name: "Email Notifications",
app_id: "m.email",
append: true,
data: { brand: "Element" },
device_display_name: "test@example.tld",
kind: "email",
lang: "en-US",
pushkey: "test@example.tld",
});
});
it("can remove email pushers", async () => {
cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({
pushers: [
{
app_display_name: "Element",
app_id: "im.vector.app",
data: {},
device_display_name: "My EyeFon",
kind: "http",
lang: "en",
pushkey: "abctest",
},
{
app_display_name: "Email Notifications",
app_id: "m.email",
data: { brand: "Element" },
device_display_name: "test@example.tld",
kind: "email",
lang: "en-US",
pushkey: "test@example.tld",
},
],
});
cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({
threepids: [
{
medium: ThreepidMedium.Email,
address: "test@example.tld",
validated_at: 1656633600,
added_at: 1656633600,
},
],
});
const label = "test@example.tld";
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.getByLabelText(label)).not.toBeDisabled();
await act(async () => {
await user.click(screen.getByLabelText(label));
await waitForUpdate();
});
expect(cli.removePusher).toHaveBeenCalledWith("test@example.tld", "m.email");
});
});
describe("clear all notifications", () => {
it("is hidden when no notifications exist", async () => {
const room = new Room("room123", cli, "@alice:example.org");
cli.getRooms = jest.fn(cli.getRooms).mockReturnValue([room]);
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await waitForUpdate();
expect(
queryByRole(container, "button", {
name: "Mark all messages as read",
}),
).not.toBeInTheDocument();
});
it("clears all notifications", async () => {
const room = new Room("room123", cli, "@alice:example.org");
cli.getRooms = jest.fn(cli.getRooms).mockReturnValue([room]);
const message = mkMessage({
event: true,
room: "room123",
user: "@alice:example.org",
ts: 1,
});
room.addLiveEvents([message]);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
const user = userEvent.setup();
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await waitForUpdate();
const clearNotificationEl = await findByRole(container, "button", {
name: "Mark all messages as read",
});
await act(async () => {
await user.click(clearNotificationEl);
await waitForUpdate();
});
expect(cli.sendReadReceipt).toHaveBeenCalled();
await waitFor(() => {
expect(clearNotificationEl).not.toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,51 @@
/*
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.
*/
import React from "react";
import { render } from "jest-matrix-react";
import SettingsSubsection from "../../../../../../src/components/views/settings/shared/SettingsSubsection";
describe("<SettingsSubsection />", () => {
const defaultProps = {
heading: "Test",
children: <div>test settings content</div>,
};
const getComponent = (props = {}): React.ReactElement => <SettingsSubsection {...defaultProps} {...props} />;
it("renders with plain text heading", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders with react element heading", () => {
const heading = <h3>This is the heading</h3>;
const { container } = render(getComponent({ heading }));
expect(container).toMatchSnapshot();
});
it("renders without description", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders with plain text description", () => {
const { container } = render(getComponent({ description: "This describes the subsection" }));
expect(container).toMatchSnapshot();
});
it("renders with react element description", () => {
const description = (
<p>
This describes the section <a href="/#">link</a>
</p>
);
const { container } = render(getComponent({ description }));
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,30 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import React from "react";
import { SettingsSubsectionHeading } from "../../../../../../src/components/views/settings/shared/SettingsSubsectionHeading";
describe("<SettingsSubsectionHeading />", () => {
const defaultProps = {
heading: "test",
};
const getComponent = (props = {}) => render(<SettingsSubsectionHeading {...defaultProps} {...props} />);
it("renders without children", () => {
const { container } = getComponent();
expect({ container }).toMatchSnapshot();
});
it("renders with children", () => {
const children = <a href="/#">test</a>;
const { container } = getComponent({ children });
expect({ container }).toMatchSnapshot();
});
});

View file

@ -0,0 +1,145 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsSubsection /> renders with plain text description 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Test
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
This describes the subsection
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
test settings content
</div>
</div>
</div>
</div>
`;
exports[`<SettingsSubsection /> renders with plain text heading 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Test
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
test settings content
</div>
</div>
</div>
</div>
`;
exports[`<SettingsSubsection /> renders with react element description 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Test
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
<p>
This describes the section
<a
href="/#"
>
link
</a>
</p>
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
test settings content
</div>
</div>
</div>
</div>
`;
exports[`<SettingsSubsection /> renders with react element heading 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<h3>
This is the heading
</h3>
<div
class="mx_SettingsSubsection_content"
>
<div>
test settings content
</div>
</div>
</div>
</div>
`;
exports[`<SettingsSubsection /> renders without description 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Test
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
test settings content
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsSubsectionHeading /> renders with children 1`] = `
{
"container": <div>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
test
</h3>
<a
href="/#"
>
test
</a>
</div>
</div>,
}
`;
exports[`<SettingsSubsectionHeading /> renders without children 1`] = `
{
"container": <div>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
test
</h3>
</div>
</div>,
}
`;

View file

@ -0,0 +1,21 @@
/*
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.
*/
import React, { ReactElement } from "react";
import { render } from "jest-matrix-react";
import SettingsTab, { SettingsTabProps } from "../../../../../../src/components/views/settings/tabs/SettingsTab";
describe("<SettingsTab />", () => {
const getComponent = (props: SettingsTabProps): ReactElement => <SettingsTab {...props} />;
it("renders tab", () => {
const { container } = render(getComponent({ children: <div>test</div> }));
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SettingsTab /> renders tab 1`] = `
<div>
<div
class="mx_SettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div>
test
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,164 @@
/*
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 React from "react";
import { fireEvent, render, RenderResult, screen } from "jest-matrix-react";
import { MatrixClient, Room, EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import AdvancedRoomSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/AdvancedRoomSettingsTab";
import { mkEvent, mkStubRoom, stubClient } from "../../../../../../test-utils";
import dis from "../../../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../../../src/dispatcher/actions";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
jest.mock("../../../../../../../src/dispatcher/dispatcher");
describe("AdvancedRoomSettingsTab", () => {
const roomId = "!room:example.com";
let cli: MatrixClient;
let room: Room;
const renderTab = (): RenderResult => {
return render(<AdvancedRoomSettingsTab room={room} closeSettingsFn={jest.fn()} />);
};
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
room = mkStubRoom(roomId, "test room", cli);
mocked(cli.getRoom).mockReturnValue(room);
mocked(dis.dispatch).mockReset();
mocked(room.findPredecessor).mockImplementation((msc3946: boolean) =>
msc3946
? { roomId: "old_room_id_via_predecessor", viaServers: ["one.example.com", "two.example.com"] }
: { roomId: "old_room_id", eventId: "tombstone_event_id" },
);
});
it("should render as expected", () => {
const tab = renderTab();
expect(tab.asFragment()).toMatchSnapshot();
});
it("should display room ID", () => {
const tab = renderTab();
tab.getByText(roomId);
});
it("should display room version", () => {
mocked(room.getVersion).mockReturnValue("custom_room_version_1");
const tab = renderTab();
tab.getByText("custom_room_version_1");
});
it("displays message when room cannot federate", () => {
const createEvent = new MatrixEvent({
sender: "@a:b.com",
type: EventType.RoomCreate,
content: { "m.federate": false },
room_id: room.roomId,
state_key: "",
});
jest.spyOn(room.currentState, "getStateEvents").mockImplementation((type) =>
type === EventType.RoomCreate ? createEvent : null,
);
renderTab();
expect(screen.getByText("This room is not accessible by remote Matrix servers")).toBeInTheDocument();
});
function mockStateEvents(room: Room) {
const createEvent = mkEvent({
event: true,
user: "@a:b.com",
type: EventType.RoomCreate,
content: { predecessor: { room_id: "old_room_id", event_id: "tombstone_event_id" } },
room: room.roomId,
});
// Because we're mocking Room.findPredecessor, it may not be necessary
// to provide the actual event here, but we do need the create event,
// and in future this may be needed, so included for symmetry.
const predecessorEvent = mkEvent({
event: true,
user: "@a:b.com",
type: EventType.RoomPredecessor,
content: { predecessor_room_id: "old_room_id_via_predecessor" },
room: room.roomId,
});
type GetStateEvents2Args = (eventType: EventType | string, stateKey: string) => MatrixEvent | null;
const getStateEvents = jest.spyOn(
room.currentState,
"getStateEvents",
) as unknown as jest.MockedFunction<GetStateEvents2Args>;
getStateEvents.mockImplementation((eventType: string | null, _key: string) => {
switch (eventType) {
case EventType.RoomCreate:
return createEvent;
case EventType.RoomPredecessor:
return predecessorEvent;
default:
return null;
}
});
}
it("should link to predecessor room", async () => {
mockStateEvents(room);
const tab = renderTab();
const link = await tab.findByText("View older messages in test room.");
fireEvent.click(link);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: "tombstone_event_id",
room_id: "old_room_id",
metricsTrigger: "WebPredecessorSettings",
metricsViaKeyboard: false,
});
});
describe("When MSC3946 support is enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue")
.mockReset()
.mockImplementation((settingName) => settingName === "feature_dynamic_room_predecessors");
});
it("should link to predecessor room via MSC3946 if enabled", async () => {
mockStateEvents(room);
const tab = renderTab();
const link = await tab.findByText("View older messages in test room.");
fireEvent.click(link);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: undefined,
room_id: "old_room_id_via_predecessor",
via_servers: ["one.example.com", "two.example.com"],
metricsTrigger: "WebPredecessorSettings",
metricsViaKeyboard: false,
});
});
it("handles when room is a space", async () => {
mockStateEvents(room);
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
mockStateEvents(room);
const tab = renderTab();
const link = await tab.findByText("View older version of test room.");
expect(link).toBeInTheDocument();
expect(screen.getByText("Space information")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,53 @@
/*
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 React from "react";
import { render } from "jest-matrix-react";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import BridgeSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/BridgeSettingsTab";
import { getMockClientWithEventEmitter, withClientContextRenderOptions } from "../../../../../../test-utils";
describe("<BridgeSettingsTab />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
getRoom: jest.fn(),
});
const roomId = "!room:server.org";
const getComponent = (room: Room) =>
render(<BridgeSettingsTab room={room} />, withClientContextRenderOptions(client));
it("renders when room is not bridging messages to any platform", () => {
const room = new Room(roomId, client, userId);
const { container } = getComponent(room);
expect(container).toMatchSnapshot();
});
it("renders when room is bridging messages", () => {
const bridgeEvent = new MatrixEvent({
type: "uk.half-shot.bridge",
content: {
channel: { id: "channel-test" },
protocol: { id: "protocol-test" },
bridgebot: "test",
},
room_id: roomId,
state_key: "1",
});
const room = new Room(roomId, client, userId);
room.currentState.setStateEvents([bridgeEvent]);
client.getRoom.mockReturnValue(room);
const { container } = getComponent(room);
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,72 @@
/*
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.
*/
import React from "react";
import { render, RenderResult, screen } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import NotificationSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/NotificationSettingsTab";
import { mkStubRoom, stubClient } from "../../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { EchoChamber } from "../../../../../../../src/stores/local-echo/EchoChamber";
import { RoomEchoChamber } from "../../../../../../../src/stores/local-echo/RoomEchoChamber";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
describe("NotificatinSettingsTab", () => {
const roomId = "!room:example.com";
let cli: MatrixClient;
let roomProps: RoomEchoChamber;
const renderTab = (): RenderResult => {
return render(<NotificationSettingsTab roomId={roomId} closeSettingsFn={() => {}} />);
};
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
const room = mkStubRoom(roomId, "test room", cli);
roomProps = EchoChamber.forRoom(room);
NotificationSettingsTab.contextType = React.createContext<MatrixClient>(cli);
});
it("should prevent »Settings« link click from bubbling up to radio buttons", async () => {
const tab = renderTab();
// settings link of mentions_only volume
const settingsLink = tab.container.querySelector(
"label.mx_NotificationSettingsTab_mentionsKeywordsEntry div.mx_AccessibleButton",
);
if (!settingsLink) throw new Error("settings link does not exist.");
await userEvent.click(settingsLink);
expect(roomProps.notificationVolume).not.toBe("mentions_only");
});
it("should show the currently chosen custom notification sound", async () => {
SettingsStore.setValue("notificationSound", roomId, SettingLevel.ACCOUNT, {
url: "mxc://server/custom-sound-123",
name: "custom-sound-123",
});
renderTab();
await screen.findByText("custom-sound-123");
});
it("should show the currently chosen custom notification sound url if no name", async () => {
SettingsStore.setValue("notificationSound", roomId, SettingLevel.ACCOUNT, {
url: "mxc://server/custom-sound-123",
});
renderTab();
await screen.findByText("http://this.is.a.url/server/custom-sound-123");
});
});

View file

@ -0,0 +1,210 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Nordeck IT + Consulting GmbH
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { act, fireEvent, render, screen, within } from "jest-matrix-react";
import {
EventTimeline,
EventType,
MatrixError,
MatrixEvent,
Room,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import React from "react";
import ErrorDialog from "../../../../../../../src/components/views/dialogs/ErrorDialog";
import { PeopleRoomSettingsTab } from "../../../../../../../src/components/views/settings/tabs/room/PeopleRoomSettingsTab";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../../../src/Modal";
import { flushPromises, getMockClientWithEventEmitter } from "../../../../../../test-utils";
describe("PeopleRoomSettingsTab", () => {
const client = getMockClientWithEventEmitter({
getUserId: jest.fn(),
invite: jest.fn(),
kick: jest.fn(),
mxcUrlToHttp: (mxcUrl: string) => mxcUrl,
});
const roomId = "#ask-to-join:example.org";
const userId = "@alice:example.org";
const member = new RoomMember(roomId, userId);
const room = new Room(roomId, client, userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const getButton = (name: "Approve" | "Deny" | "See less" | "See more") => screen.getByRole("button", { name });
const getComponent = (room: Room) =>
render(
<MatrixClientContext.Provider value={client}>
<PeopleRoomSettingsTab room={room} />
</MatrixClientContext.Provider>,
);
const getGroup = () => screen.getByRole("group", { name: "Asking to join" });
const getParagraph = () => screen.getByRole("paragraph");
it("renders a heading", () => {
getComponent(room);
expect(screen.getByRole("heading")).toHaveTextContent("People");
});
it('renders a group "asking to join"', () => {
getComponent(room);
expect(getGroup()).toBeInTheDocument();
});
describe("without requests to join", () => {
it('renders a paragraph "no requests"', () => {
getComponent(room);
expect(getParagraph()).toHaveTextContent("No requests");
});
});
describe("with requests to join", () => {
const error = new MatrixError();
const knockUserId = "@albert.einstein:example.org";
const knockMember = new RoomMember(roomId, knockUserId);
const reason =
"There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.";
beforeEach(() => {
jest.spyOn(Modal, "createDialog");
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([knockMember]);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
avatar_url: "mxc://example.org/albert-einstein.png",
displayname: "Albert Einstein",
membership: KnownMembership.Knock,
reason,
},
origin_server_ts: -464140800000,
type: EventType.RoomMember,
}),
);
});
it("renders requests fully", () => {
getComponent(room);
expect(getGroup()).toMatchSnapshot();
});
it("renders requests reduced", () => {
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
displayname: "albert.einstein",
membership: KnownMembership.Knock,
},
type: EventType.RoomMember,
}),
);
getComponent(room);
expect(getGroup()).toMatchSnapshot();
});
it("allows to expand a reason", () => {
getComponent(room);
fireEvent.click(getButton("See more"));
expect(within(getGroup()).getByRole("paragraph")).toHaveTextContent(reason);
});
it("allows to collapse a reason", () => {
getComponent(room);
fireEvent.click(getButton("See more"));
fireEvent.click(getButton("See less"));
expect(getParagraph()).toHaveTextContent(`${reason.substring(0, 120)}`);
});
it("does not truncate a reason unnecessarily", () => {
const reason = "I have no special talents. I am only passionately curious.";
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
displayname: "albert.einstein",
membership: KnownMembership.Knock,
reason,
},
type: EventType.RoomMember,
}),
);
getComponent(room);
expect(getParagraph()).toHaveTextContent(reason);
});
it("disables the deny button if the power level is insufficient", () => {
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
getComponent(room);
expect(getButton("Deny")).toHaveAttribute("disabled");
});
it("calls kick on deny", () => {
jest.spyOn(client, "kick").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Deny"));
expect(client.kick).toHaveBeenCalledWith(roomId, knockUserId);
});
it("fails to deny a request", async () => {
jest.spyOn(client, "kick").mockRejectedValue(error);
getComponent(room);
fireEvent.click(getButton("Deny"));
await act(() => flushPromises());
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
title: error.name,
description: error.message,
});
});
it("succeeds to deny a request", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
getComponent(room);
act(() => {
room.emit(RoomStateEvent.Update, state);
});
expect(getParagraph()).toHaveTextContent("No requests");
});
it("disables the approve button if the power level is insufficient", () => {
jest.spyOn(room, "canInvite").mockReturnValue(false);
getComponent(room);
expect(getButton("Approve")).toHaveAttribute("disabled");
});
it("calls invite on approve", () => {
jest.spyOn(client, "invite").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Approve"));
expect(client.invite).toHaveBeenCalledWith(roomId, knockUserId);
});
it("fails to approve a request", async () => {
jest.spyOn(client, "invite").mockRejectedValue(error);
getComponent(room);
fireEvent.click(getButton("Approve"));
await act(() => flushPromises());
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
title: error.name,
description: error.message,
});
});
it("succeeds to approve a request", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
getComponent(room);
act(() => {
room.emit(RoomStateEvent.Update, state);
});
expect(getParagraph()).toHaveTextContent("No requests");
});
});
});

View file

@ -0,0 +1,267 @@
/*
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.
*/
import React from "react";
import { fireEvent, getByRole, render, RenderResult, screen, waitFor } from "jest-matrix-react";
import { MatrixClient, EventType, MatrixEvent, Room, RoomMember, ISendEventResponse } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { defer } from "matrix-js-sdk/src/utils";
import userEvent from "@testing-library/user-event";
import RolesRoomSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab";
import { mkStubRoom, withClientContextRenderOptions, stubClient } from "../../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { VoiceBroadcastInfoEventType } from "../../../../../../../src/voice-broadcast";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { ElementCall } from "../../../../../../../src/models/Call";
describe("RolesRoomSettingsTab", () => {
const userId = "@alice:server.org";
const roomId = "!room:example.com";
let cli: MatrixClient;
let room: Room;
const renderTab = (propRoom: Room = room): RenderResult => {
return render(<RolesRoomSettingsTab room={propRoom} />, withClientContextRenderOptions(cli));
};
const getVoiceBroadcastsSelect = (): HTMLElement => {
return renderTab().container.querySelector("select[label='Voice broadcasts']")!;
};
const getVoiceBroadcastsSelectedOption = (): HTMLElement => {
return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked")!;
};
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
room = mkStubRoom(roomId, "test room", cli);
});
it("should allow an Admin to demote themselves but not others", () => {
mocked(cli.getRoom).mockReturnValue(room);
// @ts-ignore - mocked doesn't support overloads properly
mocked(room.currentState.getStateEvents).mockImplementation((type, key) => {
if (key === undefined) return [] as MatrixEvent[];
if (type === "m.room.power_levels") {
return new MatrixEvent({
sender: "@sender:server",
room_id: roomId,
type: "m.room.power_levels",
state_key: "",
content: {
users: {
[cli.getUserId()!]: 100,
"@admin:server": 100,
},
},
});
}
return null;
});
mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true);
const { container } = renderTab();
expect(container.querySelector(`[placeholder="${cli.getUserId()}"]`)).not.toBeDisabled();
expect(container.querySelector(`[placeholder="@admin:server"]`)).toBeDisabled();
});
it("should initially show »Moderator« permission for »Voice broadcasts«", () => {
expect(getVoiceBroadcastsSelectedOption().textContent).toBe("Moderator");
});
describe("when setting »Default« permission for »Voice broadcasts«", () => {
beforeEach(() => {
fireEvent.change(getVoiceBroadcastsSelect(), {
target: { value: 0 },
});
});
it("should update the power levels", () => {
expect(cli.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPowerLevels, {
events: {
[VoiceBroadcastInfoEventType]: 0,
},
});
});
});
describe("Element Call", () => {
const setGroupCallsEnabled = (val: boolean): void => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "feature_group_calls") return val;
});
};
const getStartCallSelect = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Start Element Call calls']")!;
};
const getStartCallSelectedOption = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Start Element Call calls'] option:checked")!;
};
const getJoinCallSelect = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Join Element Call calls']")!;
};
const getJoinCallSelectedOption = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("select[label='Join Element Call calls'] option:checked")!;
};
describe("Element Call enabled", () => {
beforeEach(() => {
setGroupCallsEnabled(true);
});
describe("Join Element calls", () => {
it("defaults to moderator for joining calls", () => {
expect(getJoinCallSelectedOption(renderTab())?.textContent).toBe("Moderator");
});
it("can change joining calls power level", () => {
const tab = renderTab();
fireEvent.change(getJoinCallSelect(tab), {
target: { value: 0 },
});
expect(getJoinCallSelectedOption(tab)?.textContent).toBe("Default");
expect(cli.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPowerLevels, {
events: {
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
},
});
});
});
describe("Start Element calls", () => {
it("defaults to moderator for starting calls", () => {
expect(getStartCallSelectedOption(renderTab())?.textContent).toBe("Moderator");
});
it("can change starting calls power level", () => {
const tab = renderTab();
fireEvent.change(getStartCallSelect(tab), {
target: { value: 0 },
});
expect(getStartCallSelectedOption(tab)?.textContent).toBe("Default");
expect(cli.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPowerLevels, {
events: {
[ElementCall.CALL_EVENT_TYPE.name]: 0,
},
});
});
});
});
it("hides when group calls disabled", () => {
setGroupCallsEnabled(false);
const tab = renderTab();
expect(getStartCallSelect(tab)).toBeFalsy();
expect(getStartCallSelectedOption(tab)).toBeFalsy();
expect(getJoinCallSelect(tab)).toBeFalsy();
expect(getJoinCallSelectedOption(tab)).toBeFalsy();
});
});
describe("Banned users", () => {
it("should not render banned section when no banned users", () => {
const room = new Room(roomId, cli, userId);
renderTab(room);
expect(screen.queryByText("Banned users")).not.toBeInTheDocument();
});
it("renders banned users", () => {
const bannedMember = new RoomMember(roomId, "@bob:server.org");
bannedMember.setMembershipEvent(
new MatrixEvent({
type: EventType.RoomMember,
content: {
membership: KnownMembership.Ban,
reason: "just testing",
},
sender: userId,
}),
);
const room = new Room(roomId, cli, userId);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bannedMember]);
renderTab(room);
expect(screen.getByText("Banned users").parentElement).toMatchSnapshot();
});
it("uses banners display name when available", () => {
const bannedMember = new RoomMember(roomId, "@bob:server.org");
const senderMember = new RoomMember(roomId, "@alice:server.org");
senderMember.name = "Alice";
bannedMember.setMembershipEvent(
new MatrixEvent({
type: EventType.RoomMember,
content: {
membership: KnownMembership.Ban,
reason: "just testing",
},
sender: userId,
}),
);
const room = new Room(roomId, cli, userId);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bannedMember]);
jest.spyOn(room, "getMember").mockReturnValue(senderMember);
renderTab(room);
expect(screen.getByTitle("Banned by Alice")).toBeInTheDocument();
});
});
it("should roll back power level change on error", async () => {
const deferred = defer<ISendEventResponse>();
mocked(cli.sendStateEvent).mockReturnValue(deferred.promise);
mocked(cli.getRoom).mockReturnValue(room);
// @ts-ignore - mocked doesn't support overloads properly
mocked(room.currentState.getStateEvents).mockImplementation((type, key) => {
if (key === undefined) return [] as MatrixEvent[];
if (type === "m.room.power_levels") {
return new MatrixEvent({
sender: "@sender:server",
room_id: roomId,
type: "m.room.power_levels",
state_key: "",
content: {
users: {
[cli.getUserId()!]: 100,
},
},
});
}
return null;
});
mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true);
const { container } = renderTab();
const selector = container.querySelector(`[placeholder="${cli.getUserId()}"]`)!;
fireEvent.change(selector, { target: { value: "50" } });
expect(selector).toHaveValue("50");
// Get the apply button of the privileged user section and click on it
const privilegedUsersSection = screen.getByRole("group", { name: "Privileged Users" });
const applyButton = getByRole(privilegedUsersSection, "button", { name: "Apply" });
await userEvent.click(applyButton);
deferred.reject("Error");
await waitFor(() => expect(selector).toHaveValue("100"));
});
});

View file

@ -0,0 +1,440 @@
/*
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 React from "react";
import { fireEvent, render, screen, waitFor, within } from "jest-matrix-react";
import { EventType, GuestAccess, HistoryVisibility, JoinRule, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import SecurityRoomSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/SecurityRoomSettingsTab";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import {
clearAllModals,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsUser,
} from "../../../../../../test-utils";
import { filterBoolean } from "../../../../../../../src/utils/arrays";
describe("<SecurityRoomSettingsTab />", () => {
const userId = "@alice:server.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
isRoomEncrypted: jest.fn(),
getLocalAliases: jest.fn().mockReturnValue([]),
sendStateEvent: jest.fn(),
getClientWellKnown: jest.fn(),
});
const roomId = "!room:server.org";
const getComponent = (room: Room, closeSettingsFn = jest.fn()) =>
render(<SecurityRoomSettingsTab room={room} closeSettingsFn={closeSettingsFn} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
});
const setRoomStateEvents = (
room: Room,
joinRule?: JoinRule,
guestAccess?: GuestAccess,
history?: HistoryVisibility,
): void => {
const events = filterBoolean<MatrixEvent>([
new MatrixEvent({
type: EventType.RoomCreate,
content: { version: "test" },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
guestAccess &&
new MatrixEvent({
type: EventType.RoomGuestAccess,
content: { guest_access: guestAccess },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
history &&
new MatrixEvent({
type: EventType.RoomHistoryVisibility,
content: { history_visibility: history },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
joinRule &&
new MatrixEvent({
type: EventType.RoomJoinRules,
content: { join_rule: joinRule },
sender: userId,
state_key: "",
room_id: room.roomId,
}),
]);
room.currentState.setStateEvents(events);
};
beforeEach(async () => {
client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" });
client.isRoomEncrypted.mockReturnValue(false);
client.getClientWellKnown.mockReturnValue(undefined);
jest.spyOn(SettingsStore, "getValue").mockRestore();
await clearAllModals();
});
describe("join rule", () => {
it("warns when trying to make an encrypted room public", async () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room, JoinRule.Invite);
getComponent(room);
fireEvent.click(screen.getByLabelText("Public"));
const modal = await screen.findByRole("dialog");
expect(modal).toMatchSnapshot();
fireEvent.click(screen.getByText("Cancel"));
// join rule not updated
expect(screen.getByLabelText("Private (invite only)").hasAttribute("checked")).toBeTruthy();
});
it("updates join rule", async () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Invite);
getComponent(room);
fireEvent.click(screen.getByLabelText("Public"));
await flushPromises();
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomJoinRules,
{
join_rule: JoinRule.Public,
},
"",
);
});
it("handles error when updating join rule fails", async () => {
const room = new Room(roomId, client, userId);
client.sendStateEvent.mockRejectedValue("oups");
setRoomStateEvents(room, JoinRule.Invite);
getComponent(room);
fireEvent.click(screen.getByLabelText("Public"));
await flushPromises();
const dialog = await screen.findByRole("dialog");
expect(dialog).toMatchSnapshot();
fireEvent.click(within(dialog).getByText("OK"));
});
it("displays advanced section toggle when join rule is public", () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Public);
getComponent(room);
expect(screen.getByText("Show advanced")).toBeInTheDocument();
});
it("does not display advanced section toggle when join rule is not public", () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Invite);
getComponent(room);
expect(screen.queryByText("Show advanced")).not.toBeInTheDocument();
});
});
describe("guest access", () => {
it("uses forbidden by default when room has no guest access event", () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Public);
getComponent(room);
fireEvent.click(screen.getByText("Show advanced"));
expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("false");
});
it("updates guest access on toggle", () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Public);
getComponent(room);
fireEvent.click(screen.getByText("Show advanced"));
fireEvent.click(screen.getByLabelText("Enable guest access"));
// toggle set immediately
expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("true");
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomGuestAccess,
{ guest_access: GuestAccess.CanJoin },
"",
);
});
it("logs error and resets state when updating guest access fails", async () => {
client.sendStateEvent.mockRejectedValue("oups");
jest.spyOn(logger, "error").mockImplementation(() => {});
const room = new Room(roomId, client, userId);
setRoomStateEvents(room, JoinRule.Public, GuestAccess.CanJoin);
getComponent(room);
fireEvent.click(screen.getByText("Show advanced"));
fireEvent.click(screen.getByLabelText("Enable guest access"));
// toggle set immediately
expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("false");
await flushPromises();
expect(client.sendStateEvent).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith("oups");
// toggle reset to old value
expect(screen.getByLabelText("Enable guest access").getAttribute("aria-checked")).toBe("true");
});
});
describe("history visibility", () => {
it("does not render section when RoomHistorySettings feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const room = new Room(roomId, client, userId);
setRoomStateEvents(room);
getComponent(room);
expect(screen.queryByText("Who can read history")).not.toBeInTheDocument();
});
it("uses shared as default history visibility when no state event found", () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByText("Who can read history?").parentElement).toMatchSnapshot();
expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked();
});
it("does not render world readable option when room is encrypted", () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room);
getComponent(room);
expect(screen.queryByDisplayValue(HistoryVisibility.WorldReadable)).not.toBeInTheDocument();
});
it("renders world readable option when room is encrypted and history is already set to world readable", () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room, undefined, undefined, HistoryVisibility.WorldReadable);
getComponent(room);
expect(screen.getByDisplayValue(HistoryVisibility.WorldReadable)).toBeInTheDocument();
});
it("updates history visibility", () => {
const room = new Room(roomId, client, userId);
getComponent(room);
fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited));
// toggle updated immediately
expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked();
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomHistoryVisibility,
{
history_visibility: HistoryVisibility.Invited,
},
"",
);
});
it("handles error when updating history visibility", async () => {
const room = new Room(roomId, client, userId);
client.sendStateEvent.mockRejectedValue("oups");
jest.spyOn(logger, "error").mockImplementation(() => {});
getComponent(room);
fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited));
// toggle updated immediately
expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked();
await flushPromises();
// reset to before updated value
expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked();
expect(logger.error).toHaveBeenCalledWith("oups");
});
});
describe("encryption", () => {
it("displays encryption as enabled", () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByLabelText("Encrypted")).toBeChecked();
// can't disable encryption once enabled
expect(screen.getByLabelText("Encrypted").getAttribute("aria-disabled")).toEqual("true");
});
it("asks users to confirm when setting room to encrypted", async () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByLabelText("Encrypted")).not.toBeChecked();
fireEvent.click(screen.getByLabelText("Encrypted"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Cancel"));
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(screen.getByLabelText("Encrypted")).not.toBeChecked();
});
it("enables encryption after confirmation", async () => {
const room = new Room(roomId, client, userId);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByLabelText("Encrypted")).not.toBeChecked();
fireEvent.click(screen.getByLabelText("Encrypted"));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Enable encryption?")).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("OK"));
await waitFor(() =>
expect(client.sendStateEvent).toHaveBeenCalledWith(room.roomId, EventType.RoomEncryption, {
algorithm: "m.megolm.v1.aes-sha2",
}),
);
});
it("renders world readable option when room is encrypted and history is already set to world readable", () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room, undefined, undefined, HistoryVisibility.WorldReadable);
getComponent(room);
expect(screen.getByDisplayValue(HistoryVisibility.WorldReadable)).toBeInTheDocument();
});
it("updates history visibility", () => {
const room = new Room(roomId, client, userId);
getComponent(room);
fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited));
// toggle updated immediately
expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked();
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomHistoryVisibility,
{
history_visibility: HistoryVisibility.Invited,
},
"",
);
});
it("handles error when updating history visibility", async () => {
const room = new Room(roomId, client, userId);
client.sendStateEvent.mockRejectedValue("oups");
jest.spyOn(logger, "error").mockImplementation(() => {});
getComponent(room);
fireEvent.click(screen.getByDisplayValue(HistoryVisibility.Invited));
// toggle updated immediately
expect(screen.getByDisplayValue(HistoryVisibility.Invited)).toBeChecked();
await flushPromises();
// reset to before updated value
expect(screen.getByDisplayValue(HistoryVisibility.Shared)).toBeChecked();
expect(logger.error).toHaveBeenCalledWith("oups");
});
describe("when encryption is force disabled by e2ee well-known config", () => {
beforeEach(() => {
client.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: true,
},
});
});
it("displays encrypted rooms as encrypted", () => {
// rooms that are already encrypted still show encrypted
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(true);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByLabelText("Encrypted")).toBeChecked();
expect(screen.getByLabelText("Encrypted").getAttribute("aria-disabled")).toEqual("true");
expect(screen.getByText("Once enabled, encryption cannot be disabled.")).toBeInTheDocument();
});
it("displays unencrypted rooms with toggle disabled", () => {
const room = new Room(roomId, client, userId);
client.isRoomEncrypted.mockReturnValue(false);
setRoomStateEvents(room);
getComponent(room);
expect(screen.getByLabelText("Encrypted")).not.toBeChecked();
expect(screen.getByLabelText("Encrypted").getAttribute("aria-disabled")).toEqual("true");
expect(screen.queryByText("Once enabled, encryption cannot be disabled.")).not.toBeInTheDocument();
expect(screen.getByText("Your server requires encryption to be disabled.")).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,135 @@
/*
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.
*/
import React from "react";
import { fireEvent, render, RenderResult, waitFor } from "jest-matrix-react";
import { MatrixClient, Room, MatrixEvent, EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { mkStubRoom, stubClient } from "../../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { VoipRoomSettingsTab } from "../../../../../../../src/components/views/settings/tabs/room/VoipRoomSettingsTab";
import { ElementCall } from "../../../../../../../src/models/Call";
describe("VoipRoomSettingsTab", () => {
const roomId = "!room:example.com";
let cli: MatrixClient;
let room: Room;
const renderTab = (): RenderResult => {
return render(<VoipRoomSettingsTab room={room} />);
};
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
room = mkStubRoom(roomId, "test room", cli);
jest.spyOn(cli, "sendStateEvent");
jest.spyOn(cli, "getRoom").mockReturnValue(room);
});
describe("Element Call", () => {
const mockPowerLevels = (events: Record<string, number>): void => {
jest.spyOn(room.currentState, "getStateEvents").mockReturnValue({
getContent: () => ({
events,
}),
} as unknown as MatrixEvent);
};
const getElementCallSwitch = (tab: RenderResult): HTMLElement => {
return tab.container.querySelector("[data-testid='element-call-switch']")!;
};
describe("correct state", () => {
it("shows enabled when call member power level is 0", () => {
mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 0 });
const tab = renderTab();
expect(getElementCallSwitch(tab).querySelector("[aria-checked='true']")).toBeTruthy();
});
it.each([1, 50, 100])("shows disabled when call member power level is 0", (level: number) => {
mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: level });
const tab = renderTab();
expect(getElementCallSwitch(tab).querySelector("[aria-checked='false']")).toBeTruthy();
});
});
describe("enabling/disabling", () => {
describe("enabling Element calls", () => {
beforeEach(() => {
mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 100 });
});
it("enables Element calls in public room", async () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
const tab = renderTab();
fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")!);
await waitFor(() =>
expect(cli.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomPowerLevels,
expect.objectContaining({
events: {
[ElementCall.CALL_EVENT_TYPE.name]: 50,
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
},
}),
),
);
});
it("enables Element calls in private room", async () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
const tab = renderTab();
fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")!);
await waitFor(() =>
expect(cli.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomPowerLevels,
expect.objectContaining({
events: {
[ElementCall.CALL_EVENT_TYPE.name]: 0,
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
},
}),
),
);
});
});
it("disables Element calls", async () => {
mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 0 });
const tab = renderTab();
fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")!);
await waitFor(() =>
expect(cli.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomPowerLevels,
expect.objectContaining({
events: {
[ElementCall.CALL_EVENT_TYPE.name]: 100,
[ElementCall.MEMBER_EVENT_TYPE.name]: 100,
},
}),
),
);
});
});
});
});

View file

@ -0,0 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdvancedRoomSettingsTab should render as expected 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection"
>
<h2
class="mx_Heading_h3"
>
Advanced
</h2>
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Room information
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
<span>
Internal room ID
</span>
<div
class="mx_CopyableText mx_CopyableText_border"
>
!room:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</div>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Room version
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div>
<span>
Room version:
</span>
 1
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BridgeSettingsTab /> renders when room is bridging messages 1`] = `
<div>
<div
class="mx_SettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection"
>
<h2
class="mx_Heading_h3"
>
Bridges
</h2>
<div
class="mx_SettingsSection_subSections"
>
<div>
<p>
<span>
This room is bridging messages to the following platforms.
<a
href="https://matrix.org/bridges/"
rel="noreferrer noopener"
target="_blank"
>
Learn more.
</a>
</span>
</p>
<ul
class="mx_RoomSettingsDialog_BridgeList"
>
<li
class="mx_RoomSettingsDialog_BridgeList_listItem"
>
<div
class="mx_RoomSettingsDialog_column_icon"
>
<div
class="mx_RoomSettingsDialog_noProtocolIcon"
/>
</div>
<div
class="mx_RoomSettingsDialog_column_data"
>
<h3
class="mx_RoomSettingsDialog_column_data_protocolName"
>
protocol-test
</h3>
<p
class="mx_RoomSettingsDialog_column_data_details mx_RoomSettingsDialog_workspace_channel_details"
>
<span
class="mx_RoomSettingsDialog_channel"
>
<span>
Channel:
<span>
channel-test
</span>
</span>
</span>
</p>
<ul
class="mx_RoomSettingsDialog_column_data_metadata mx_RoomSettingsDialog_metadata"
>
<li>
<span>
This bridge is managed by
.
</span>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<BridgeSettingsTab /> renders when room is not bridging messages to any platform 1`] = `
<div>
<div
class="mx_SettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection"
>
<h2
class="mx_Heading_h3"
>
Bridges
</h2>
<div
class="mx_SettingsSection_subSections"
>
<p>
<span>
This room isn't bridging messages to any platforms.
<a
href="https://matrix.org/bridges/"
rel="noreferrer noopener"
target="_blank"
>
Learn more.
</a>
</span>
</p>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,175 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PeopleRoomSettingsTab with requests to join renders requests fully 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Asking to join
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PeopleRoomSettingsTab_knock"
>
<span
aria-label="Profile picture"
class="_avatar_mcap2_17 mx_BaseAvatar mx_PeopleRoomSettingsTab_avatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 42px;"
title="@albert.einstein:example.org"
>
<img
alt=""
class="_image_mcap2_50"
data-type="round"
height="42px"
loading="lazy"
referrerpolicy="no-referrer"
src="mxc://example.org/albert-einstein.png"
width="42px"
/>
</span>
<div
class="mx_PeopleRoomSettingsTab_content"
>
<span
class="mx_PeopleRoomSettingsTab_name"
>
Albert Einstein
</span>
<time
class="mx_PeopleRoomSettingsTab_timestamp"
>
Apr 18, 1955
</time>
<span
class="mx_PeopleRoomSettingsTab_userId"
>
@albert.einstein:example.org
</span>
<p
class="mx_PeopleRoomSettingsTab_seeMoreOrLess"
>
There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a…
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
See more
</div>
</div>
<div
aria-label="Deny"
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary_outline"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="18"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
<div
aria-label="Approve"
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary"
role="button"
tabindex="0"
>
<div
height="18"
width="18"
/>
</div>
</div>
</div>
</fieldset>
`;
exports[`PeopleRoomSettingsTab with requests to join renders requests reduced 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Asking to join
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PeopleRoomSettingsTab_knock"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PeopleRoomSettingsTab_avatar _avatar-imageless_mcap2_61"
data-color="4"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 42px;"
title="@albert.einstein:example.org"
>
a
</span>
<div
class="mx_PeopleRoomSettingsTab_content"
>
<span
class="mx_PeopleRoomSettingsTab_name"
>
albert.einstein
</span>
<span
class="mx_PeopleRoomSettingsTab_userId"
>
@albert.einstein:example.org
</span>
</div>
<div
aria-label="Deny"
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary_outline"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="18"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
<div
aria-label="Approve"
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary"
role="button"
tabindex="0"
>
<div
height="18"
width="18"
/>
</div>
</div>
</div>
</fieldset>
`;

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RolesRoomSettingsTab Banned users renders banned users 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Banned users
</legend>
<div
class="mx_SettingsFieldset_content"
>
<ul
class="mx_RolesRoomSettingsTab_bannedList"
>
<li>
<span
title="Banned by @alice:server.org"
>
<strong>
@bob:server.org
</strong>
Reason: just testing
</span>
</li>
</ul>
</div>
</fieldset>
`;

View file

@ -0,0 +1,235 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SecurityRoomSettingsTab /> history visibility uses shared as default history visibility when no state event found 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Who can read history?
</legend>
<div
class="mx_SettingsFieldset_description"
>
<div
class="mx_SettingsSubsection_text"
>
Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.
</div>
</div>
<div
class="mx_SettingsFieldset_content"
>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
id="historyVis-world_readable"
name="historyVis"
type="radio"
value="world_readable"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Anyone
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled mx_StyledRadioButton_checked"
>
<input
checked=""
id="historyVis-shared"
name="historyVis"
type="radio"
value="shared"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Members only (since the point in time of selecting this option)
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
id="historyVis-invited"
name="historyVis"
type="radio"
value="invited"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Members only (since they were invited)
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
id="historyVis-joined"
name="historyVis"
type="radio"
value="joined"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Members only (since they joined)
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</div>
</fieldset>
`;
exports[`<SecurityRoomSettingsTab /> join rule handles error when updating join rule fails 1`] = `
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_ErrorDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Failed to update the join rules
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
Unknown failure
</div>
<div
class="mx_Dialog_buttons"
>
<button
class="mx_Dialog_primary"
>
OK
</button>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;
exports[`<SecurityRoomSettingsTab /> join rule warns when trying to make an encrypted room public 1`] = `
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Are you sure you want to make this encrypted room public?
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<div>
<p>
<span>
<strong>
It's not recommended to make encrypted rooms public.
</strong>
It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.
</span>
</p>
<p>
<span>
To avoid these issues, create a
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
new public room
</div>
for the conversation you plan to have.
</span>
</p>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
OK
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;

View file

@ -0,0 +1,417 @@
/*
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 { fireEvent, render, screen, within } from "jest-matrix-react";
import React from "react";
import { MatrixClient, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import userEvent from "@testing-library/user-event";
import { MockedObject } from "jest-mock";
import AccountUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/AccountUserSettingsTab";
import { SdkContextClass, SDKContext } from "../../../../../../../src/contexts/SDKContext";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import {
getMockClientWithEventEmitter,
mockClientMethodsServer,
mockClientMethodsUser,
mockPlatformPeg,
flushPromises,
} from "../../../../../../test-utils";
import { UIFeature } from "../../../../../../../src/settings/UIFeature";
import { OidcClientStore } from "../../../../../../../src/stores/oidc/OidcClientStore";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../../../src/Modal";
let changePasswordOnError: (e: Error) => void;
let changePasswordOnFinished: () => void;
jest.mock(
"../../../../../../../src/components/views/settings/ChangePassword",
() =>
({ onError, onFinished }: { onError: (e: Error) => void; onFinished: () => void }) => {
changePasswordOnError = onError;
changePasswordOnFinished = onFinished;
return <button>Mock change password</button>;
},
);
describe("<AccountUserSettingsTab />", () => {
const defaultProps = {
closeSettingsFn: jest.fn(),
};
const userId = "@alice:server.org";
let mockClient: MockedObject<MatrixClient>;
let stores: SdkContextClass;
const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}>
<SDKContext.Provider value={stores}>
<AccountUserSettingsTab {...defaultProps} />
</SDKContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mockPlatformPeg();
jest.clearAllMocks();
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.spyOn(logger, "error").mockRestore();
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
getCapabilities: jest.fn(),
getThreePids: jest.fn(),
getIdentityServerUrl: jest.fn(),
deleteThreePid: jest.fn(),
});
mockClient.getCapabilities.mockResolvedValue({});
mockClient.getThreePids.mockResolvedValue({
threepids: [],
});
mockClient.deleteThreePid.mockResolvedValue({
id_server_unbind_result: "success",
});
stores = new SdkContextClass();
stores.client = mockClient;
// stub out this store completely to avoid mocking initialisation
const mockOidcClientStore = {} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("does not show account management link when not available", () => {
const { queryByTestId } = render(getComponent());
expect(queryByTestId("external-account-management-outer")).toBeFalsy();
expect(queryByTestId("external-account-management-link")).toBeFalsy();
});
it("show account management link in expected format", async () => {
const accountManagementLink = "https://id.server.org/my-account";
const mockOidcClientStore = {
accountManagementEndpoint: accountManagementLink,
} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
render(getComponent());
const manageAccountLink = await screen.findByRole("button", { name: "Manage account" });
expect(manageAccountLink.getAttribute("href")).toMatch(accountManagementLink);
});
describe("deactive account", () => {
it("should not render section when account deactivation feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName !== UIFeature.Deactivate,
);
render(getComponent());
expect(screen.queryByText("Deactivate Account")).not.toBeInTheDocument();
expect(SettingsStore.getValue).toHaveBeenCalledWith(UIFeature.Deactivate);
});
it("should not render section when account is managed externally", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.Deactivate,
);
// account is managed externally when we have delegated auth configured
const accountManagementLink = "https://id.server.org/my-account";
const mockOidcClientStore = {
accountManagementEndpoint: accountManagementLink,
} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
render(getComponent());
await flushPromises();
expect(screen.queryByText("Deactivate Account")).not.toBeInTheDocument();
});
it("should render section when account deactivation feature is enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.Deactivate,
);
render(getComponent());
expect(screen.getByText("Deactivate Account", { selector: "h2" }).parentElement!).toMatchSnapshot();
});
it("should display the deactivate account dialog when clicked", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.Deactivate,
);
const createDialogFn = jest.fn();
jest.spyOn(Modal, "createDialog").mockImplementation(createDialogFn);
render(getComponent());
await userEvent.click(screen.getByRole("button", { name: "Deactivate Account" }));
expect(createDialogFn).toHaveBeenCalled();
});
it("should close settings if account deactivated", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.Deactivate,
);
const createDialogFn = jest.fn();
jest.spyOn(Modal, "createDialog").mockImplementation(createDialogFn);
render(getComponent());
await userEvent.click(screen.getByRole("button", { name: "Deactivate Account" }));
createDialogFn.mock.calls[0][1].onFinished(true);
expect(defaultProps.closeSettingsFn).toHaveBeenCalled();
});
it("should not close settings if account not deactivated", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.Deactivate,
);
const createDialogFn = jest.fn();
jest.spyOn(Modal, "createDialog").mockImplementation(createDialogFn);
render(getComponent());
await userEvent.click(screen.getByRole("button", { name: "Deactivate Account" }));
createDialogFn.mock.calls[0][1].onFinished(false);
expect(defaultProps.closeSettingsFn).not.toHaveBeenCalled();
});
});
describe("3pids", () => {
beforeEach(() => {
mockClient.getCapabilities.mockResolvedValue({
"m.3pid_changes": {
enabled: true,
},
});
mockClient.getThreePids.mockResolvedValue({
threepids: [
{
medium: ThreepidMedium.Email,
address: "test@test.io",
validated_at: 1685067124552,
added_at: 1685067124552,
},
{
medium: ThreepidMedium.Phone,
address: "123456789",
validated_at: 1685067124552,
added_at: 1685067124552,
},
],
});
mockClient.getIdentityServerUrl.mockReturnValue(undefined);
});
it("should show loaders while 3pids load", () => {
render(getComponent());
expect(
within(screen.getByTestId("mx_AccountEmailAddresses")).getByLabelText("Loading…"),
).toBeInTheDocument();
expect(within(screen.getByTestId("mx_AccountPhoneNumbers")).getByLabelText("Loading…")).toBeInTheDocument();
});
it("should display 3pid email addresses and phone numbers", async () => {
render(getComponent());
await flushPromises();
expect(screen.getByTestId("mx_AccountEmailAddresses")).toMatchSnapshot();
expect(screen.getByTestId("mx_AccountPhoneNumbers")).toMatchSnapshot();
});
it("should allow removing an existing email addresses", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountEmailAddresses");
fireEvent.click(within(section).getByText("Remove"));
// confirm removal
expect(screen.getByText("Remove test@test.io?")).toBeInTheDocument();
fireEvent.click(within(section).getByText("Remove"));
expect(mockClient.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, "test@test.io");
});
it("should allow adding a new email address", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountEmailAddresses");
// just check the fields are enabled
expect(within(section).getByLabelText("Email Address")).not.toBeDisabled();
expect(within(section).getByText("Add")).not.toHaveAttribute("aria-disabled");
});
it("should allow removing an existing phone number", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountPhoneNumbers");
fireEvent.click(within(section).getByText("Remove"));
// confirm removal
expect(screen.getByText("Remove 123456789?")).toBeInTheDocument();
fireEvent.click(within(section).getByText("Remove"));
expect(mockClient.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, "123456789");
});
it("should allow adding a new phone number", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountPhoneNumbers");
// just check the fields are enabled
expect(within(section).getByLabelText("Phone Number")).not.toBeDisabled();
});
it("should allow 3pid changes when capabilities does not have 3pid_changes", async () => {
// We support as far back as v1.1 which doesn't have m.3pid_changes
// so the behaviour for when it is missing has to be assume true
mockClient.getCapabilities.mockResolvedValue({});
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountEmailAddresses");
// just check the fields are enabled
expect(within(section).getByLabelText("Email Address")).not.toBeDisabled();
expect(within(section).getByText("Add")).not.toHaveAttribute("aria-disabled");
});
describe("when 3pid changes capability is disabled", () => {
beforeEach(() => {
mockClient.getCapabilities.mockResolvedValue({
"m.3pid_changes": {
enabled: false,
},
});
});
it("should not allow removing email addresses", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountEmailAddresses");
expect(within(section).getByText("Remove")).toHaveAttribute("aria-disabled");
});
it("should not allow adding a new email addresses", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountEmailAddresses");
// fields are not enabled
expect(within(section).getByLabelText("Email Address")).toBeDisabled();
expect(within(section).getByText("Add")).toHaveAttribute("aria-disabled");
});
it("should not allow removing phone numbers", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountPhoneNumbers");
expect(within(section).getByText("Remove")).toHaveAttribute("aria-disabled");
});
it("should not allow adding a new phone number", async () => {
render(getComponent());
await flushPromises();
const section = screen.getByTestId("mx_AccountPhoneNumbers");
expect(within(section).getByLabelText("Phone Number")).toBeDisabled();
});
});
});
describe("Password change", () => {
beforeEach(() => {
mockClient.getCapabilities.mockResolvedValue({
"m.change_password": {
enabled: true,
},
});
});
it("should display a dialog if password change succeeded", async () => {
const createDialogFn = jest.fn();
jest.spyOn(Modal, "createDialog").mockImplementation(createDialogFn);
render(getComponent());
const changeButton = await screen.findByRole("button", { name: "Mock change password" });
userEvent.click(changeButton);
expect(changePasswordOnFinished).toBeDefined();
changePasswordOnFinished();
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
title: "Success",
description: "Your password was successfully changed.",
});
});
it("should display an error if password change failed", async () => {
const ERROR_STRING =
"Your password must contain exactly 5 lowercase letters, a box drawing character and the badger emoji.";
const createDialogFn = jest.fn();
jest.spyOn(Modal, "createDialog").mockImplementation(createDialogFn);
render(getComponent());
const changeButton = await screen.findByRole("button", { name: "Mock change password" });
userEvent.click(changeButton);
expect(changePasswordOnError).toBeDefined();
changePasswordOnError(new Error(ERROR_STRING));
expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), {
title: "Error changing password",
description: ERROR_STRING,
});
});
});
});

View file

@ -0,0 +1,27 @@
/*
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 { render } from "jest-matrix-react";
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import AppearanceUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/AppearanceUserSettingsTab";
import { withClientContextRenderOptions, stubClient } from "../../../../../../test-utils";
describe("AppearanceUserSettingsTab", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("should render", () => {
const { asFragment } = render(<AppearanceUserSettingsTab />, withClientContextRenderOptions(client));
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import React from "react";
import KeyboardUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/KeyboardUserSettingsTab";
import { Key } from "../../../../../../../src/Keyboard";
import { mockPlatformPeg } from "../../../../../../test-utils/platform";
const PATH_TO_KEYBOARD_SHORTCUTS = "../../../../../../../src/accessibility/KeyboardShortcuts";
const PATH_TO_KEYBOARD_SHORTCUT_UTILS = "../../../../../../../src/accessibility/KeyboardShortcutUtils";
const mockKeyboardShortcuts = (override: Record<string, any>) => {
jest.doMock(PATH_TO_KEYBOARD_SHORTCUTS, () => {
const original = jest.requireActual(PATH_TO_KEYBOARD_SHORTCUTS);
return {
...original,
...override,
};
});
};
const mockKeyboardShortcutUtils = (override: Record<string, any>) => {
jest.doMock(PATH_TO_KEYBOARD_SHORTCUT_UTILS, () => {
const original = jest.requireActual(PATH_TO_KEYBOARD_SHORTCUT_UTILS);
return {
...original,
...override,
};
});
};
const renderKeyboardUserSettingsTab = () => {
return render(<KeyboardUserSettingsTab />).container;
};
describe("KeyboardUserSettingsTab", () => {
beforeEach(() => {
jest.resetModules();
mockPlatformPeg();
});
it("renders list of keyboard shortcuts", () => {
mockKeyboardShortcuts({
CATEGORIES: {
Composer: {
settingNames: ["keybind1", "keybind2"],
categoryLabel: "Composer",
},
Navigation: {
settingNames: ["keybind3"],
categoryLabel: "Navigation",
},
},
});
mockKeyboardShortcutUtils({
getKeyboardShortcutValue: (name: string) => {
switch (name) {
case "keybind1":
return {
key: Key.A,
ctrlKey: true,
};
case "keybind2": {
return {
key: Key.B,
ctrlKey: true,
};
}
case "keybind3": {
return {
key: Key.ENTER,
};
}
}
},
getKeyboardShortcutDisplayName: (name: string) => {
switch (name) {
case "keybind1":
return "Cancel replying to a message";
case "keybind2":
return "Toggle Bold";
case "keybind3":
return "Select room from the room list";
}
},
});
const body = renderKeyboardUserSettingsTab();
expect(body).toMatchSnapshot();
});
});

View file

@ -0,0 +1,56 @@
/*
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.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import LabsUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/LabsUserSettingsTab";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import SdkConfig from "../../../../../../../src/SdkConfig";
describe("<LabsUserSettingsTab />", () => {
const defaultProps = {
closeSettingsFn: jest.fn(),
};
const getComponent = () => <LabsUserSettingsTab {...defaultProps} />;
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
beforeEach(() => {
jest.clearAllMocks();
settingsValueSpy.mockReturnValue(false);
SdkConfig.reset();
SdkConfig.add({ brand: "BrandedClient" });
localStorage.clear();
});
it("renders settings marked as beta as beta cards", () => {
render(getComponent());
expect(screen.getByText("Upcoming features").parentElement!).toMatchSnapshot();
});
it("does not render non-beta labs settings when disabled in config", () => {
const sdkConfigSpy = jest.spyOn(SdkConfig, "get");
render(getComponent());
expect(sdkConfigSpy).toHaveBeenCalledWith("show_labs_settings");
// only section is beta section
expect(screen.queryByText("Early previews")).not.toBeInTheDocument();
});
it("renders non-beta labs settings when enabled in config", () => {
// enable labs
SdkConfig.add({ show_labs_settings: true });
const { container } = render(getComponent());
// non-beta labs section
expect(screen.getByText("Early previews")).toBeInTheDocument();
const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
expect(labsSections).toHaveLength(10);
});
});

View file

@ -0,0 +1,37 @@
/*
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 React from "react";
import { render } from "jest-matrix-react";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../../test-utils";
import MjolnirUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/MjolnirUserSettingsTab";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
describe("<MjolnirUserSettingsTab />", () => {
const userId = "@alice:server.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
});
const getComponent = () =>
render(<MjolnirUserSettingsTab />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
it("renders correctly when user has no ignored users", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,189 @@
/*
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.
*/
import React from "react";
import { fireEvent, render, RenderResult, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import PreferencesUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/PreferencesUserSettingsTab";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { mockPlatformPeg, stubClient } from "../../../../../../test-utils";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
import MatrixClientBackedController from "../../../../../../../src/settings/controllers/MatrixClientBackedController";
import PlatformPeg from "../../../../../../../src/PlatformPeg";
describe("PreferencesUserSettingsTab", () => {
beforeEach(() => {
mockPlatformPeg();
});
const renderTab = (): RenderResult => {
return render(<PreferencesUserSettingsTab closeSettingsFn={() => {}} />);
};
it("should render", () => {
const { asFragment } = renderTab();
expect(asFragment()).toMatchSnapshot();
});
it("should reload when changing language", async () => {
const reloadStub = jest.fn();
PlatformPeg.get()!.reload = reloadStub;
renderTab();
const languageDropdown = await screen.findByRole("button", { name: "Language Dropdown" });
expect(languageDropdown).toBeInTheDocument();
await userEvent.click(languageDropdown);
const germanOption = await screen.findByText("Deutsch");
await userEvent.click(germanOption);
expect(reloadStub).toHaveBeenCalled();
});
it("should search and select a user timezone", async () => {
renderTab();
expect(await screen.findByText(/Browser default/)).toBeInTheDocument();
const timezoneDropdown = await screen.findByRole("button", { name: "Set timezone" });
await userEvent.click(timezoneDropdown);
// Without filtering `expect(screen.queryByRole("option" ...` take over 1s.
await fireEvent.change(screen.getByRole("combobox", { name: "Set timezone" }), {
target: { value: "Africa/Abidjan" },
});
expect(screen.queryByRole("option", { name: "Africa/Abidjan" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Europe/Paris" })).not.toBeInTheDocument();
await fireEvent.change(screen.getByRole("combobox", { name: "Set timezone" }), {
target: { value: "Europe/Paris" },
});
expect(screen.queryByRole("option", { name: "Africa/Abidjan" })).not.toBeInTheDocument();
const option = await screen.getByRole("option", { name: "Europe/Paris" });
await userEvent.click(option);
expect(await screen.findByText("Europe/Paris")).toBeInTheDocument();
});
it("should not show spell check setting if unsupported", async () => {
PlatformPeg.get()!.supportsSpellCheckSettings = jest.fn().mockReturnValue(false);
renderTab();
expect(screen.queryByRole("switch", { name: "Allow spell check" })).not.toBeInTheDocument();
});
it("should enable spell check", async () => {
const spellCheckEnableFn = jest.fn();
PlatformPeg.get()!.supportsSpellCheckSettings = jest.fn().mockReturnValue(true);
PlatformPeg.get()!.getSpellCheckEnabled = jest.fn().mockReturnValue(false);
PlatformPeg.get()!.setSpellCheckEnabled = spellCheckEnableFn;
renderTab();
const toggle = await screen.findByRole("switch", { name: "Allow spell check" });
expect(toggle).toHaveAttribute("aria-checked", "false");
await userEvent.click(toggle);
expect(spellCheckEnableFn).toHaveBeenCalledWith(true);
});
describe("send read receipts", () => {
beforeEach(() => {
stubClient();
jest.spyOn(SettingsStore, "setValue");
jest.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList);
});
afterEach(() => {
jest.resetAllMocks();
});
const getToggle = () => renderTab().getByRole("switch", { name: "Send read receipts" });
const mockIsVersionSupported = (val: boolean) => {
const client = MatrixClientPeg.safeGet();
jest.spyOn(client, "doesServerSupportUnstableFeature").mockResolvedValue(false);
jest.spyOn(client, "isVersionSupported").mockImplementation(async (version: string) => {
if (version === "v1.4") return val;
return false;
});
MatrixClientBackedController.matrixClient = client;
};
const mockGetValue = (val: boolean) => {
const copyOfGetValueAt = SettingsStore.getValueAt;
SettingsStore.getValueAt = <T,>(
level: SettingLevel,
name: string,
roomId?: string,
isExplicit?: boolean,
): T => {
if (name === "sendReadReceipts") return val as T;
return copyOfGetValueAt(level, name, roomId, isExplicit);
};
};
const expectSetValueToHaveBeenCalled = (
name: string,
roomId: string | null,
level: SettingLevel,
value: boolean,
) => expect(SettingsStore.setValue).toHaveBeenCalledWith(name, roomId, level, value);
describe("with server support", () => {
beforeEach(() => {
mockIsVersionSupported(true);
});
it("can be enabled", async () => {
mockGetValue(false);
const toggle = getToggle();
await waitFor(() => expect(toggle).toHaveAttribute("aria-disabled", "false"));
fireEvent.click(toggle);
expectSetValueToHaveBeenCalled("sendReadReceipts", null, SettingLevel.ACCOUNT, true);
});
it("can be disabled", async () => {
mockGetValue(true);
const toggle = getToggle();
await waitFor(() => expect(toggle).toHaveAttribute("aria-disabled", "false"));
fireEvent.click(toggle);
expectSetValueToHaveBeenCalled("sendReadReceipts", null, SettingLevel.ACCOUNT, false);
});
});
describe("without server support", () => {
beforeEach(() => {
mockIsVersionSupported(false);
});
it("is forcibly enabled", async () => {
const toggle = getToggle();
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-checked", "true");
expect(toggle).toHaveAttribute("aria-disabled", "true");
});
});
it("cannot be disabled", async () => {
mockGetValue(true);
const toggle = getToggle();
await waitFor(() => expect(toggle).toHaveAttribute("aria-disabled", "true"));
fireEvent.click(toggle);
expect(SettingsStore.setValue).not.toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,61 @@
/*
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.
*/
import { render } from "jest-matrix-react";
import React from "react";
import SecurityUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import {
getMockClientWithEventEmitter,
mockClientMethodsServer,
mockClientMethodsUser,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockPlatformPeg,
} from "../../../../../../test-utils";
import { SDKContext, SdkContextClass } from "../../../../../../../src/contexts/SDKContext";
describe("<SecurityUserSettingsTab />", () => {
const defaultProps = {
closeSettingsFn: jest.fn(),
};
const userId = "@alice:server.org";
const deviceId = "alices-device";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getKeyBackupVersion: jest.fn(),
});
const sdkContext = new SdkContextClass();
sdkContext.client = mockClient;
const getComponent = () => (
<MatrixClientContext.Provider value={mockClient}>
<SDKContext.Provider value={sdkContext}>
<SecurityUserSettingsTab {...defaultProps} />
</SDKContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(() => {
mockPlatformPeg();
jest.clearAllMocks();
});
it("renders security section", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
/*
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 React from "react";
import { fireEvent, render, screen } from "jest-matrix-react";
import SidebarUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/SidebarUserSettingsTab";
import PosthogTrackers from "../../../../../../../src/PosthogTrackers";
import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { MetaSpace } from "../../../../../../../src/stores/spaces";
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
import { flushPromises } from "../../../../../../test-utils";
import SdkConfig from "../../../../../../../src/SdkConfig";
describe("<SidebarUserSettingsTab />", () => {
beforeEach(() => {
jest.spyOn(PosthogTrackers, "trackInteraction").mockClear();
jest.spyOn(SettingsStore, "getValue").mockRestore();
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("renders sidebar settings with guest spa url", () => {
const spy = jest.spyOn(SdkConfig, "get").mockReturnValue({ guest_spa_url: "https://somewhere.org" });
const originalGetValue = SettingsStore.getValue;
const spySettingsStore = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
return setting === "feature_video_rooms" ? true : originalGetValue(setting);
});
const { container } = render(<SidebarUserSettingsTab />);
expect(container).toMatchSnapshot();
spySettingsStore.mockRestore();
spy.mockRestore();
});
it("renders sidebar settings without guest spa url", () => {
const originalGetValue = SettingsStore.getValue;
const spySettingsStore = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
return setting === "feature_video_rooms" ? true : originalGetValue(setting);
});
const { container } = render(<SidebarUserSettingsTab />);
expect(container).toMatchSnapshot();
spySettingsStore.mockRestore();
});
it("toggles all rooms in home setting", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "Spaces.enabledMetaSpaces") {
return {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: true,
[MetaSpace.Orphans]: true,
};
}
return false;
});
render(<SidebarUserSettingsTab />);
fireEvent.click(screen.getByTestId("mx_SidebarUserSettingsTab_homeAllRoomsCheckbox"));
await flushPromises();
expect(SettingsStore.setValue).toHaveBeenCalledWith("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, true);
expect(PosthogTrackers.trackInteraction).toHaveBeenCalledWith(
"WebSettingsSidebarTabSpacesCheckbox",
// synthetic event from checkbox
expect.objectContaining({ type: "change" }),
1,
);
});
it("disables all rooms in home setting when home space is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "Spaces.enabledMetaSpaces") {
return {
[MetaSpace.Home]: false,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: true,
[MetaSpace.Orphans]: true,
};
}
return false;
});
render(<SidebarUserSettingsTab />);
expect(screen.getByTestId("mx_SidebarUserSettingsTab_homeAllRoomsCheckbox")).toBeDisabled();
});
});

View file

@ -0,0 +1,135 @@
/*
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.
*/
import React from "react";
import { mocked } from "jest-mock";
import { fireEvent, render, screen } from "jest-matrix-react";
import { logger } from "matrix-js-sdk/src/logger";
import VoiceUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/VoiceUserSettingsTab";
import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../../../src/MediaDeviceHandler";
import { flushPromises } from "../../../../../../test-utils";
jest.mock("../../../../../../../src/MediaDeviceHandler");
const MediaDeviceHandlerMock = mocked(MediaDeviceHandler);
describe("<VoiceUserSettingsTab />", () => {
const getComponent = (): React.ReactElement => <VoiceUserSettingsTab />;
const audioIn1 = {
deviceId: "1",
groupId: "g1",
kind: MediaDeviceKindEnum.AudioInput,
label: "Audio input test 1",
};
const videoIn1 = {
deviceId: "2",
groupId: "g1",
kind: MediaDeviceKindEnum.VideoInput,
label: "Video input test 1",
};
const videoIn2 = {
deviceId: "3",
groupId: "g1",
kind: MediaDeviceKindEnum.VideoInput,
label: "Video input test 2",
};
const defaultMediaDevices = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [audioIn1],
[MediaDeviceKindEnum.VideoInput]: [videoIn1, videoIn2],
} as unknown as IMediaDevices;
beforeEach(() => {
jest.clearAllMocks();
MediaDeviceHandlerMock.hasAnyLabeledDevices.mockResolvedValue(true);
MediaDeviceHandlerMock.getDevices.mockResolvedValue(defaultMediaDevices);
MediaDeviceHandlerMock.getVideoInput.mockReturnValue(videoIn1.deviceId);
// @ts-ignore bad mocking
MediaDeviceHandlerMock.instance = { setDevice: jest.fn().mockResolvedValue(undefined) };
});
describe("devices", () => {
it("renders dropdowns for input devices", async () => {
render(getComponent());
await flushPromises();
expect(screen.getByLabelText("Microphone")).toHaveDisplayValue(audioIn1.label);
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn1.label);
});
it("updates device", async () => {
render(getComponent());
await flushPromises();
fireEvent.change(screen.getByLabelText("Camera"), { target: { value: videoIn2.deviceId } });
expect(MediaDeviceHandlerMock.instance.setDevice).toHaveBeenCalledWith(
videoIn2.deviceId,
MediaDeviceKindEnum.VideoInput,
);
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn2.label);
});
it("logs and resets device when update fails", async () => {
// stub to avoid littering console with expected error
jest.spyOn(logger, "error").mockImplementation(() => {});
MediaDeviceHandlerMock.instance.setDevice.mockRejectedValue("oups!");
render(getComponent());
await flushPromises();
fireEvent.change(screen.getByLabelText("Camera"), { target: { value: videoIn2.deviceId } });
expect(MediaDeviceHandlerMock.instance.setDevice).toHaveBeenCalledWith(
videoIn2.deviceId,
MediaDeviceKindEnum.VideoInput,
);
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn2.label);
await flushPromises();
expect(logger.error).toHaveBeenCalledWith("Failed to set device videoinput: 3");
// reset to original
expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn1.label);
});
it("does not render dropdown when no devices exist for type", async () => {
render(getComponent());
await flushPromises();
expect(screen.getByText("No Audio Outputs detected")).toBeInTheDocument();
expect(screen.queryByLabelText("Audio Output")).not.toBeInTheDocument();
});
});
it("renders audio processing settings", () => {
const { getByTestId } = render(getComponent());
expect(getByTestId("voice-auto-gain")).toBeTruthy();
expect(getByTestId("voice-noise-suppression")).toBeTruthy();
expect(getByTestId("voice-echo-cancellation")).toBeTruthy();
});
it("sets and displays audio processing settings", () => {
MediaDeviceHandlerMock.getAudioAutoGainControl.mockReturnValue(false);
MediaDeviceHandlerMock.getAudioEchoCancellation.mockReturnValue(true);
MediaDeviceHandlerMock.getAudioNoiseSuppression.mockReturnValue(false);
const { getByRole } = render(getComponent());
getByRole("switch", { name: "Automatically adjust the microphone volume" }).click();
getByRole("switch", { name: "Noise suppression" }).click();
getByRole("switch", { name: "Echo cancellation" }).click();
expect(MediaDeviceHandler.setAudioAutoGainControl).toHaveBeenCalledWith(true);
expect(MediaDeviceHandler.setAudioEchoCancellation).toHaveBeenCalledWith(false);
expect(MediaDeviceHandler.setAudioNoiseSuppression).toHaveBeenCalledWith(true);
});
});

View file

@ -0,0 +1,220 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses and phone numbers 1`] = `
<div
class="mx_SettingsSubsection"
data-testid="mx_AccountEmailAddresses"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Email addresses
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
test@test.io
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="email"
id="mx_Field_9"
label="Email Address"
placeholder="Email Address"
type="text"
value=""
/>
<label
for="mx_Field_9"
>
Email Address
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
</div>
`;
exports[`<AccountUserSettingsTab /> 3pids should display 3pid email addresses and phone numbers 2`] = `
<div
class="mx_SettingsSubsection"
data-testid="mx_AccountPhoneNumbers"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Phone numbers
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_AddRemoveThreepids_existing"
>
<span
class="mx_AddRemoveThreepids_existing_address"
>
123456789
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_sm"
role="button"
tabindex="0"
>
Remove
</div>
</div>
<form
autocomplete="off"
novalidate=""
>
<div
class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft"
>
<span
class="mx_Field_prefix"
>
<div
class="mx_Dropdown mx_PhoneNumbers_country mx_CountryDropdown"
>
<div
aria-describedby="mx_CountryDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Country Dropdown"
aria-owns="mx_CountryDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_CountryDropdown_value"
>
<span
class="mx_CountryDropdown_shortOption"
>
<div
class="mx_Dropdown_option_emoji"
>
🇺🇸
</div>
+1
</span>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</span>
<input
autocomplete="tel-national"
id="mx_Field_10"
label="Phone Number"
placeholder="Phone Number"
type="text"
value=""
/>
<label
for="mx_Field_10"
>
Phone Number
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Add
</div>
</form>
</div>
</div>
`;
exports[`<AccountUserSettingsTab /> deactive account should render section when account deactivation feature is enabled 1`] = `
<div
class="mx_SettingsSection"
>
<h2
class="mx_Heading_h3"
>
Deactivate Account
</h2>
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection"
data-testid="account-management-section"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Account management
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Deactivating your account is a permanent action — be careful!
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
role="button"
tabindex="0"
>
Deactivate Account
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,805 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppearanceUserSettingsTab should render 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab"
data-testid="mx_AppearanceUserSettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection"
>
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-0"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-0"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-dark"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-1"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_dgy0u_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="layoutPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Message layout
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24 mx_LayoutSwitcher_LayoutSelector"
>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Modern"
class="_label_dgy0u_67"
for="radix-3"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-3"
name="layout"
title=""
type="radio"
value="group"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Modern
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="group"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Message bubbles"
class="_label_dgy0u_67"
for="radix-4"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-4"
name="layout"
title=""
type="radio"
value="bubble"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Message bubbles
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="bubble"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="IRC (experimental)"
class="_label_dgy0u_67"
for="radix-5"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-5"
name="layout"
title=""
type="radio"
value="irc"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
IRC (experimental)
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview mx_IRCLayout"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="irc"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
Hey you. You're the best!
</div>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
</form>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
aria-describedby="radix-6"
class="_input_qnvru_32"
id="radix-7"
name="compactLayout"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-7"
>
Show compact text and messages
</label>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-6"
>
Modern layout must be selected to use this feature.
</span>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div
class="mx_SettingsSubsection"
data-testid="mx_FontScalingPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Font size
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_contentStretch"
>
<div
class="mx_Field mx_Field_select mx_FontScalingPanel_Dropdown"
>
<select
id="mx_Field_1"
label="Font size"
placeholder="Font size"
type="text"
>
<option
value="-7"
>
9
</option>
<option
value="-6"
>
10
</option>
<option
value="-5"
>
11
</option>
<option
value="-4"
>
12
</option>
<option
value="-3"
>
13
</option>
<option
value="-2"
>
14
</option>
<option
value="-1"
>
15
</option>
<option
value="0"
>
16 (default)
</option>
<option
value="1"
>
17
</option>
<option
value="2"
>
18
</option>
<option
value="4"
>
20
</option>
<option
value="6"
>
22
</option>
<option
value="8"
>
24
</option>
<option
value="10"
>
26
</option>
<option
value="12"
>
28
</option>
<option
value="14"
>
30
</option>
<option
value="16"
>
32
</option>
<option
value="18"
>
34
</option>
<option
value="20"
>
36
</option>
</select>
<label
for="mx_Field_1"
>
Font size
</label>
</div>
<div
class="mx_FontScalingPanel_preview mx_EventTilePreview_loader"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_noHeading"
>
<div
aria-expanded="false"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Show advanced
</div>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Image size in the timeline
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_ImageSizePanel_radios"
>
<label>
<div
class="mx_ImageSizePanel_size mx_ImageSizePanel_sizeDefault"
/>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled mx_StyledRadioButton_checked"
>
<input
checked=""
name="image_size"
type="radio"
value="normal"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Default
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</label>
<label>
<div
class="mx_ImageSizePanel_size mx_ImageSizePanel_sizeLarge"
/>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
name="image_size"
type="radio"
value="large"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Large
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,134 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LabsUserSettingsTab /> renders settings marked as beta as beta cards 1`] = `
<div
class="mx_SettingsSection"
>
<h2
class="mx_Heading_h3"
>
Upcoming features
</h2>
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection_text"
>
What's next for BrandedClient? Labs are the best way to get things early, test out new features and help shape them before they actually launch.
</div>
<div
class="mx_BetaCard"
>
<div
class="mx_BetaCard_columns"
>
<div
class="mx_BetaCard_columns_description"
>
<h3
class="mx_BetaCard_title"
>
<span>
Video rooms
</span>
<span
class="mx_BetaCard_betaPill"
>
Beta
</span>
</h3>
<div
class="mx_BetaCard_caption"
>
<p>
A new way to chat over voice and video in BrandedClient.
</p>
<p>
Video rooms are always-on VoIP channels embedded within a room in BrandedClient.
</p>
</div>
<div
class="mx_BetaCard_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join the beta
</div>
</div>
<div
class="mx_BetaCard_refreshWarning"
>
Joining the beta will reload BrandedClient.
</div>
<div
class="mx_BetaCard_faq"
/>
</div>
<div
class="mx_BetaCard_columns_image_wrapper"
>
<img
alt=""
class="mx_BetaCard_columns_image"
src="image-file-stub"
/>
</div>
</div>
</div>
<div
class="mx_BetaCard"
>
<div
class="mx_BetaCard_columns"
>
<div
class="mx_BetaCard_columns_description"
>
<h3
class="mx_BetaCard_title"
>
<span>
Notification Settings
</span>
<span
class="mx_BetaCard_betaPill"
>
Beta
</span>
</h3>
<div
class="mx_BetaCard_caption"
>
<p>
Introducing a simpler way to change your notification settings. Customize your BrandedClient, just the way you like.
</p>
</div>
<div
class="mx_BetaCard_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join the beta
</div>
</div>
</div>
<div
class="mx_BetaCard_columns_image_wrapper"
>
<img
alt=""
class="mx_BetaCard_columns_image"
/>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,165 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MjolnirUserSettingsTab /> renders correctly when user has no ignored users 1`] = `
<div>
<div
class="mx_SettingsTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection"
>
<div
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection_text"
>
<strong
class="warning"
>
⚠ These settings are meant for advanced users.
</strong>
<p>
<span>
Add users and servers you want to ignore here. Use asterisks to have Element match any characters. For example,
<code>
@bot:*
</code>
would ignore all users that have the name 'bot' on any server.
</span>
</p>
<p>
Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.
</p>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Personal ban list
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<i>
You have not ignored anyone.
</i>
<form
autocomplete="off"
>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_Field_1"
label="Server or user ID to ignore"
placeholder="eg: @bot:* or example.org"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Server or user ID to ignore
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
type="submit"
>
Ignore
</div>
</form>
</div>
</div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Subscribed lists
</h3>
</div>
<div
class="mx_SettingsSubsection_description"
>
<div
class="mx_SettingsSubsection_text"
>
<strong
class="warning"
>
Subscribing to a ban list will cause you to join it!
</strong>
 
<span>
If this isn't what you want, please use a different tool to ignore users.
</span>
</div>
</div>
<div
class="mx_SettingsSubsection_content"
>
<i>
You are not subscribed to any lists
</i>
<form
autocomplete="off"
>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_Field_2"
label="Room ID or address of ban list"
placeholder="Room ID or address of ban list"
type="text"
value=""
/>
<label
for="mx_Field_2"
>
Room ID or address of ban list
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
type="submit"
>
Subscribe
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

Some files were not shown because too many files have changed in this diff Show more