Prepare for repo merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2024-10-15 11:35:21 +01:00
parent 0f670b8dc0
commit b084ff2313
807 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
/*
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 { screen, render, fireEvent, waitFor, within, act } from "jest-matrix-react";
import * as TestUtils from "../../test-utils";
import AutocompleteProvider from "../../../src/autocomplete/AutocompleteProvider";
import { ICompletion } from "../../../src/autocomplete/Autocompleter";
import { AutocompleteInput } from "../../../src/components/structures/AutocompleteInput";
describe("AutocompleteInput", () => {
const mockCompletion: ICompletion[] = [
{
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 />,
},
];
const constructMockProvider = (data: ICompletion[]) =>
({
getCompletions: jest.fn().mockImplementation(async () => data),
}) as unknown as AutocompleteProvider;
beforeEach(() => {
TestUtils.stubClient();
});
const getEditorInput = () => {
const input = screen.getByTestId("autocomplete-input");
expect(input).toBeDefined();
return input;
};
it("should render suggestions when a query is set", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
expect(screen.getByTestId("autocomplete-matches").childNodes).toHaveLength(mockCompletion.length);
});
it("should render selected items passed in via props", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const editor = screen.getByTestId("autocomplete-editor");
const selection = within(editor).getAllByTestId("autocomplete-selection-item", { exact: false });
expect(selection).toHaveLength(mockCompletion.length);
});
it("should call onSelectionChange() when an item is removed from selection", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const editor = screen.getByTestId("autocomplete-editor");
const removeButtons = within(editor).getAllByTestId("autocomplete-selection-remove-button", { exact: false });
expect(removeButtons).toHaveLength(mockCompletion.length);
act(() => {
fireEvent.click(removeButtons[0]);
});
expect(onSelectionChangeMock).toHaveBeenCalledTimes(1);
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[1]]);
});
it("should render custom selection element when renderSelection() is defined", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const renderSelection = () => <span data-testid="custom-selection-element">custom selection element</span>;
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
renderSelection={renderSelection}
/>,
);
expect(screen.getAllByTestId("custom-selection-element")).toHaveLength(mockCompletion.length);
});
it("should render custom suggestion element when renderSuggestion() is defined", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const renderSuggestion = () => <span data-testid="custom-suggestion-element">custom suggestion element</span>;
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
renderSuggestion={renderSuggestion}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
expect(screen.getAllByTestId("custom-suggestion-element")).toHaveLength(mockCompletion.length);
});
it("should mark selected suggestions as selected", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
expect(suggestions).toHaveLength(mockCompletion.length);
suggestions.map((suggestion) => expect(suggestion).toHaveClass("mx_AutocompleteInput_suggestion--selected"));
});
it("should remove the last added selection when backspace is pressed in empty input", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.keyDown(input, { key: "Backspace" });
});
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]);
});
it("should toggle a selected item when a suggestion is clicked", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
act(() => {
fireEvent.mouseDown(suggestions[0]);
});
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]);
});
it("should clear text field and suggestions when a suggestion is accepted", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
act(() => {
fireEvent.mouseDown(suggestions[0]);
});
expect(input).toHaveValue("");
expect(within(container).queryAllByTestId("autocomplete-suggestion-item", { exact: false })).toHaveLength(0);
});
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
});

View File

@@ -0,0 +1,79 @@
/*
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 { toLeftOf, toLeftOrRightOf, toRightOf } from "../../../src/components/structures/ContextMenu";
import UIStore from "../../../src/stores/UIStore";
describe("ContextMenu", () => {
const rect = new DOMRect();
// @ts-ignore
rect.left = 23;
// @ts-ignore
rect.right = 46;
// @ts-ignore
rect.top = 42;
rect.width = 640;
rect.height = 480;
beforeEach(() => {
window.scrollX = 31;
window.scrollY = 41;
UIStore.instance.windowWidth = 1280;
});
describe("toLeftOf", () => {
it("should return the correct positioning", () => {
expect(toLeftOf(rect)).toEqual({
chevronOffset: 12,
right: 1285, // 1280 - 23 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toRightOf", () => {
it("should return the correct positioning", () => {
expect(toRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toLeftOrRightOf", () => {
describe("when there is more space to the right", () => {
// default case from test setup
it("should return a position to the right", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("when there is more space to the left", () => {
beforeEach(() => {
// @ts-ignore
rect.left = 500;
// @ts-ignore
rect.right = 1000;
});
it("should return a position to the left", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
right: 808, // 1280 - 500 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
});
});

View File

@@ -0,0 +1,50 @@
/*
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 { EventTimelineSet, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
import { screen, render, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import FilePanel from "../../../src/components/structures/FilePanel";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
TimelineWindow: jest.fn().mockReturnValue({
load: jest.fn().mockResolvedValue(null),
getEvents: jest.fn().mockReturnValue([]),
canPaginate: jest.fn().mockReturnValue(false),
}),
}));
describe("FilePanel", () => {
beforeEach(() => {
stubClient();
});
it("renders empty state", async () => {
const cli = MatrixClientPeg.safeGet();
const room = new Room("!room:server", cli, cli.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const timelineSet = new EventTimelineSet(room);
room.getOrCreateFilteredTimelineSet = jest.fn().mockReturnValue(timelineSet);
mocked(cli.getRoom).mockReturnValue(room);
const { asFragment } = render(
<FilePanel roomId={room.roomId} onClose={jest.fn()} resizeNotifier={new ResizeNotifier()} />,
);
await waitFor(() => {
expect(screen.getByText("No files visible in this room")).toBeInTheDocument();
});
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,24 @@
/*
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 { LargeLoader } from "../../../src/components/structures/LargeLoader";
describe("LargeLoader", () => {
const text = "test loading text";
beforeEach(() => {
render(<LargeLoader text={text} />);
});
it("should render the text", () => {
screen.getByText(text);
});
});

View File

@@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
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 React from "react";
import { render, RenderResult, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import LeftPanel from "../../../src/components/structures/LeftPanel";
import PageType from "../../../src/PageTypes";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../src/settings/UIFeature";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("LeftPanel", () => {
function renderComponent(): RenderResult {
return render(
<LeftPanel isMinimized={false} pageType={PageType.RoomView} resizeNotifier={new ResizeNotifier()} />,
);
}
it("does not show filter container when disabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.queryByRole("button", { name: /search/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
});
it("renders filter container when enabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Explore rooms" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Š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 { MatrixClient, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { CallState } from "matrix-js-sdk/src/webrtc/call";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import LegacyCallEventGrouper from "../../../src/components/structures/LegacyCallEventGrouper";
const MY_USER_ID = "@me:here";
const THEIR_USER_ID = "@they:here";
let client: MatrixClient;
describe("LegacyCallEventGrouper", () => {
beforeEach(() => {
stubClient();
client = MatrixClientPeg.safeGet();
client.getUserId = () => {
return MY_USER_ID;
};
});
it("detects a missed call", () => {
const grouper = new LegacyCallEventGrouper();
// This assumes that the other party aborted the call by sending a hangup,
// which is the usual case. Another possible test would be for the edge
// case where there is only an expired invite event.
grouper.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
grouper.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallHangup;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
expect(grouper.state).toBe(CallState.Ended);
expect(grouper.callWasMissed).toBe(true);
});
it("detects an ended call", () => {
const grouperHangup = new LegacyCallEventGrouper();
const grouperReject = new LegacyCallEventGrouper();
grouperHangup.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: MY_USER_ID,
},
} as unknown as MatrixEvent);
grouperHangup.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallHangup;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
grouperReject.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: MY_USER_ID,
},
} as unknown as MatrixEvent);
grouperReject.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallReject;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
expect(grouperHangup.state).toBe(CallState.Ended);
expect(grouperReject.state).toBe(CallState.Ended);
});
it("detects call type", () => {
const grouper = new LegacyCallEventGrouper();
grouper.add({
getContent: () => {
return {
call_id: "callId",
offer: {
sdp: "this is definitely an SDP m=video",
},
};
},
getType: () => {
return EventType.CallInvite;
},
} as unknown as MatrixEvent);
expect(grouper.isVoice).toBe(false);
});
});

View File

@@ -0,0 +1,460 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-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, RenderResult } from "jest-matrix-react";
import { ConditionKind, EventType, IPushRule, MatrixEvent, ClientEvent, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { logger } from "matrix-js-sdk/src/logger";
import userEvent from "@testing-library/user-event";
import LoggedInView from "../../../src/components/structures/LoggedInView";
import { SDKContext } from "../../../src/contexts/SDKContext";
import { StandardActions } from "../../../src/notifications/StandardActions";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Action } from "../../../src/dispatcher/actions";
import Modal from "../../../src/Modal";
import { SETTINGS } from "../../../src/settings/Settings";
describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getAccountData: jest.fn(),
getRoom: jest.fn(),
getSyncState: jest.fn().mockReturnValue(null),
getSyncStateData: jest.fn().mockReturnValue(null),
getMediaHandler: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSdkContext();
const defaultProps = {
matrixClient: mockClient,
onRegistered: jest.fn(),
resizeNotifier: new ResizeNotifier(),
collapseLhs: false,
hideToSRUsers: false,
config: {
brand: "Test",
element_call: {},
},
currentRoomId: "",
currentUserId: "@bob:server",
};
const getComponent = (props = {}): RenderResult =>
render(<LoggedInView {...defaultProps} {...props} />, {
wrapper: ({ children }) => <SDKContext.Provider value={mockSdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
jest.clearAllMocks();
mockClient.getMediaHandler.mockReturnValue(mediaHandler);
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
});
describe("synced push rules", () => {
const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules });
const oneToOneRule = {
conditions: [
{ kind: ConditionKind.RoomMemberCount, is: "2" },
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" },
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const oneToOneRuleDisabled = {
...oneToOneRule,
enabled: false,
};
const groupRule = {
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" }],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.message",
default: true,
enabled: true,
} as IPushRule;
const pollStartOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one",
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: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollStartGroup = {
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_start",
default: true,
enabled: true,
} as IPushRule;
beforeEach(() => {
mockClient.getAccountData.mockImplementation((eventType: string) =>
eventType === EventType.PushRules ? pushRulesEvent : undefined,
);
setPushRules([]);
// stub out error logger to avoid littering console
jest.spyOn(logger, "error")
.mockClear()
.mockImplementation(() => {});
mockClient.setPushRuleActions.mockClear();
mockClient.setPushRuleEnabled.mockClear();
});
const setPushRules = (rules: IPushRule[] = []): void => {
const pushRules = {
global: {
underride: [...rules],
},
};
mockClient.pushRules = pushRules;
};
describe("on mount", () => {
it("handles when user has no push rules event in account data", () => {
mockClient.getAccountData.mockReturnValue(undefined);
getComponent();
expect(mockClient.getAccountData).toHaveBeenCalledWith(EventType.PushRules);
expect(logger.error).not.toHaveBeenCalled();
});
it("handles when user doesnt have a push rule defined in vector definitions", () => {
// synced push rules uses VectorPushRulesDefinitions
// rules defined there may not exist in m.push_rules
// mock push rules with group rule, but missing oneToOne rule
setPushRules([pollStartOneToOne, groupRule, pollStartGroup]);
getComponent();
// just called once for one-to-one
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
// set to match primary rule (groupRule)
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules", () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
// only called for rules not in sync with their primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules when primary rule is disabled", async () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollStartOneToOne.rule_id,
false,
);
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollEndOneToOne.rule_id,
false,
);
});
it("catches and logs errors while updating a rule", async () => {
mockClient.setPushRuleActions.mockRejectedValueOnce("oups").mockResolvedValueOnce({});
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// both calls made
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
// second primary rule still updated after first rule failed
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(logger.error).toHaveBeenCalledWith(
"Failed to fully synchronise push rules for .m.rule.room_one_to_one",
"oups",
);
});
});
describe("on changes to account_data", () => {
it("ignores other account data events", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
const someOtherAccountData = new MatrixEvent({ type: "my-test-account-data " });
mockClient.emit(ClientEvent.AccountData, someOtherAccountData);
// didnt check rule sync
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
it("updates all mismatched rules from synced rules on a change to push rules account data", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules on a change to push rules account data when primary rule is disabled", async () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollEndOneToOne, // loud
]);
getComponent();
await flushPromises();
mockClient.setPushRuleEnabled.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
false,
);
});
it("stops listening to account data events on unmount", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
const { unmount } = getComponent();
mockClient.setPushRuleActions.mockClear();
unmount();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// not called
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
});
});
it("should fire FocusMessageSearch on Ctrl+F when enabled", async () => {
jest.spyOn(defaultDispatcher, "fire");
await SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.DEVICE, true);
getComponent();
await userEvent.keyboard("{Control>}f{/Control}");
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusMessageSearch);
});
it("should go home on home shortcut", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
it("should ignore home shortcut if dialogs are open", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
jest.spyOn(Modal, "hasDialogs").mockReturnValue(true);
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
describe("timezone updates", () => {
const userTimezone = "Europe/London";
const originalController = SETTINGS["userTimezonePublish"].controller;
beforeEach(async () => {
SETTINGS["userTimezonePublish"].controller = undefined;
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone);
});
afterEach(() => {
SETTINGS["userTimezonePublish"].controller = originalController;
});
it("does not update the timezone when userTimezonePublish is off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled();
});
it("should set the user timezone when userTimezonePublish is enabled", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
});
it("should set the user timezone when the timezone is changed", async () => {
const newTimezone = "Europe/Paris";
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone);
});
it("should clear the timezone when the publish feature is turned off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
});
});
});

View File

@@ -0,0 +1,99 @@
/*
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, fireEvent } from "jest-matrix-react";
import MainSplit from "../../../src/components/structures/MainSplit";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics.ts";
describe("<MainSplit/>", () => {
const resizeNotifier = new ResizeNotifier();
const children = (
<div>
Child<span>Foo</span>Bar
</div>
);
const panel = <div>Right panel</div>;
beforeEach(() => {
localStorage.clear();
});
it("renders", () => {
const { asFragment, container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
analyticsRoomType="other_room"
/>,
);
expect(asFragment()).toMatchSnapshot();
// Assert it matches the default width of 320
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("320px");
});
it("respects defaultSize prop", () => {
const { asFragment, container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
defaultSize={500}
analyticsRoomType="other_room"
/>,
);
expect(asFragment()).toMatchSnapshot();
// Assert it matches the default width of 350
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("500px");
});
it("prefers size stashed in LocalStorage to the defaultSize prop", () => {
localStorage.setItem("mx_rhs_size_thread", "333");
const { container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
sizeKey="thread"
defaultSize={400}
analyticsRoomType="other_room"
/>,
);
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("333px");
});
it("should report to analytics on resize stop", () => {
const { container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
sizeKey="thread"
defaultSize={400}
analyticsRoomType="other_room"
/>,
);
const spy = jest.spyOn(PosthogAnalytics.instance, "trackEvent");
const handle = container.querySelector(".mx_ResizeHandle--horizontal")!;
fireEvent.mouseDown(handle);
fireEvent.mouseMove(handle, { clientX: 0 });
fireEvent.mouseUp(handle);
expect(spy).toHaveBeenCalledWith({
eventName: "WebPanelResize",
panel: "right",
roomType: "other_room",
size: 400,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
/*
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 { act, render } from "jest-matrix-react";
import React, { useContext } from "react";
import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
import { LocalDeviceVerificationStateContext } from "../../../src/contexts/LocalDeviceVerificationStateContext";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../test-utils";
describe("MatrixClientContextProvider", () => {
it("Should expose a matrix client context", () => {
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getCrypto: () => null,
});
let receivedClient: MatrixClient | undefined;
function ContextReceiver() {
receivedClient = useContext(MatrixClientContext);
return <></>;
}
render(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
expect(receivedClient).toBe(mockClient);
});
describe("Should expose a verification status context", () => {
/** The most recent verification status received by our `ContextReceiver` */
let receivedState: boolean | undefined;
/** The mock client for use in the tests */
let mockClient: MatrixClient;
function ContextReceiver() {
receivedState = useContext(LocalDeviceVerificationStateContext);
return <></>;
}
function getComponent(mockClient: MatrixClient) {
return render(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
}
beforeEach(() => {
receivedState = undefined;
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsCrypto(),
});
});
it("returns false if device is unverified", async () => {
mockClient.getCrypto()!.getUserVerificationStatus = jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, false));
getComponent(mockClient);
expect(receivedState).toBe(false);
});
it("returns true if device is verified", async () => {
mockClient.getCrypto()!.getUserVerificationStatus = jest
.fn()
.mockResolvedValue(new UserVerificationStatus(true, false, false));
getComponent(mockClient);
await act(() => flushPromises());
expect(receivedState).toBe(true);
});
it("updates when the trust status updates", async () => {
const getVerificationStatus = jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false));
mockClient.getCrypto()!.getUserVerificationStatus = getVerificationStatus;
getComponent(mockClient);
// starts out false
await act(() => flushPromises());
expect(receivedState).toBe(false);
// Now the state is updated
const verifiedStatus = new UserVerificationStatus(true, false, false);
getVerificationStatus.mockResolvedValue(verifiedStatus);
act(() => {
mockClient.emit(CryptoEvent.UserTrustStatusChanged, mockClient.getSafeUserId(), verifiedStatus);
});
expect(receivedState).toBe(true);
});
});
});

View File

@@ -0,0 +1,835 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 , 2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
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 { EventEmitter } from "events";
import { MatrixEvent, Room, RoomMember, Thread, ReceiptType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { render } from "jest-matrix-react";
import MessagePanel, { shouldFormContinuation } from "../../../src/components/structures/MessagePanel";
import SettingsStore from "../../../src/settings/SettingsStore";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import * as TestUtilsMatrix from "../../test-utils";
import {
createTestClient,
getMockClientWithEventEmitter,
makeBeaconInfoEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { IRoomState } from "../../../src/components/structures/RoomView";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
jest.mock("../../../src/utils/beacon", () => ({
useBeacon: jest.fn(),
}));
const roomId = "!roomId:server_name";
describe("MessagePanel", function () {
const events = mkEvents();
const userId = "@me:here";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getAccountData: jest.fn(),
isUserIgnored: jest.fn().mockReturnValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getRoom: jest.fn(),
getClientWellKnown: jest.fn().mockReturnValue({}),
supportsThreads: jest.fn().mockReturnValue(true),
});
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
const room = new Room(roomId, client, userId);
const bobMember = new RoomMember(roomId, "@bob:id");
bobMember.name = "Bob";
jest.spyOn(bobMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(bobMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const alice = "@alice:example.org";
const aliceMember = new RoomMember(roomId, alice);
aliceMember.name = "Alice";
jest.spyOn(aliceMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(aliceMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const defaultProps = {
resizeNotifier: new EventEmitter() as unknown as ResizeNotifier,
callEventGroupers: new Map(),
room,
className: "cls",
events: [] as MatrixEvent[],
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
room,
roomId: room.roomId,
canReact: true,
canSendMessages: true,
showReadReceipts: true,
showRedactions: false,
showJoinLeaves: false,
showAvatarChanges: false,
showDisplaynameChanges: true,
showHiddenEvents: false,
} as unknown as IRoomState;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) => (
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
<MessagePanel {...defaultProps} {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(function () {
jest.clearAllMocks();
// HACK: We assume all settings want to be disabled
jest.spyOn(SettingsStore, "getValue").mockImplementation((arg) => {
return arg === "showDisplaynameChanges";
});
DMRoomMap.makeShared(client);
});
function mkEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// Just to avoid breaking Dateseparator tests that might run at 00hrs
function mkOneDayEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.parse("09 May 2004 00:12:00 GMT");
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// make a collection of events with some member events that should be collapsed with an EventListSummary
function mkMelsEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
let i = 0;
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
);
}
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
return events;
}
// A list of membership events only with nothing else
function mkMelsEventsOnly() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
let i = 0;
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
);
}
return events;
}
// A list of room creation, encryption, and invite events.
function mkCreationEvents() {
const mkEvent = TestUtilsMatrix.mkEvent;
const mkMembership = TestUtilsMatrix.mkMembership;
const roomId = "!someroom";
const ts0 = Date.now();
return [
mkEvent({
event: true,
type: "m.room.create",
room: roomId,
user: alice,
content: {
creator: alice,
room_version: "5",
predecessor: {
room_id: "!prevroom",
event_id: "$someevent",
},
},
ts: ts0,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
target: aliceMember,
ts: ts0 + 1,
mship: KnownMembership.Join,
name: "Alice",
}),
mkEvent({
event: true,
type: "m.room.join_rules",
room: roomId,
user: alice,
content: {
join_rule: "invite",
},
ts: ts0 + 2,
}),
mkEvent({
event: true,
type: "m.room.history_visibility",
room: roomId,
user: alice,
content: {
history_visibility: "invited",
},
ts: ts0 + 3,
}),
mkEvent({
event: true,
type: "m.room.encryption",
room: roomId,
user: alice,
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
ts: ts0 + 4,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
skey: "@bob:example.org",
target: bobMember,
ts: ts0 + 5,
mship: KnownMembership.Invite,
name: "Bob",
}),
];
}
function mkMixedHiddenAndShownEvents() {
const roomId = "!room:id";
const userId = "@alice:example.org";
const ts0 = Date.now();
return [
TestUtilsMatrix.mkMessage({
event: true,
room: roomId,
user: userId,
ts: ts0,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "org.example.a_hidden_event",
room: roomId,
user: userId,
content: {},
ts: ts0 + 1,
}),
];
}
function isReadMarkerVisible(rmContainer?: Element) {
return !!rmContainer?.children.length;
}
it("should show the events", function () {
const { container } = render(getComponent({ events }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(10);
});
it("should collapse adjacent member events", function () {
const { container } = render(getComponent({ events: mkMelsEvents() }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
const summaryTiles = container.getElementsByClassName("mx_GenericEventListSummary");
expect(summaryTiles.length).toEqual(1);
});
it("should insert the read-marker in the right place", function () {
const { container } = render(
getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
}),
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
// it should follow the <li> which wraps the event tile for event 4
const eventContainer = tiles[4];
expect(rm.previousSibling).toEqual(eventContainer);
});
it("should show the read-marker that fall in summarised events after the summary", function () {
const melsEvents = mkMelsEvents();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[4].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(summary);
// read marker should be visible given props and not at the last event
expect(isReadMarkerVisible(rm)).toBeTruthy();
});
it("should hide the read-marker at the end of summarised events", function () {
const melsEvents = mkMelsEventsOnly();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[9].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(summary);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("shows a ghost read-marker when the read-marker moves", function () {
// fake the clock so that we can test the velocity animation.
jest.useFakeTimers();
const { container, rerender } = render(
<div>
{getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
})}
</div>,
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(tiles[4]);
rerender(
<div>
{getComponent({
events,
readMarkerEventId: events[6].getId(),
readMarkerVisible: true,
})}
</div>,
);
// now there should be two RM containers
const readMarkers = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(readMarkers.length).toEqual(2);
// the first should be the ghost
expect(readMarkers[0].previousSibling).toEqual(tiles[4]);
const hr: HTMLElement = readMarkers[0].children[0] as HTMLElement;
// the second should be the real thing
expect(readMarkers[1].previousSibling).toEqual(tiles[6]);
// advance the clock, and then let the browser run an animation frame to let the animation start
jest.advanceTimersByTime(1500);
expect(hr.style.opacity).toEqual("0");
});
it("should collapse creation events", function () {
const events = mkCreationEvents();
const createEvent = events.find((event) => event.getType() === "m.room.create")!;
const encryptionEvent = events.find((event) => event.getType() === "m.room.encryption")!;
client.getRoom.mockImplementation((id) => (id === createEvent!.getRoomId() ? room : null));
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(getComponent({ events }));
// we expect that
// - the room creation event, the room encryption event, and Alice inviting Bob,
// should be outside of the room creation summary
// - all other events should be inside the room creation summary
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles[0].getAttribute("data-event-id")).toEqual(createEvent.getId());
expect(tiles[1].getAttribute("data-event-id")).toEqual(encryptionEvent.getId());
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
const summaryEventTiles = summaryTile.getElementsByClassName("mx_EventTile");
// every event except for the room creation, room encryption, and Bob's
// invite event should be in the event summary
expect(summaryEventTiles.length).toEqual(tiles.length - 3);
});
it("should not collapse beacons as part of creation events", function () {
const events = mkCreationEvents();
const creationEvent = events.find((event) => event.getType() === "m.room.create")!;
const beaconInfoEvent = makeBeaconInfoEvent(creationEvent.getSender()!, creationEvent.getRoomId()!, {
isLive: true,
});
const combinedEvents = [...events, beaconInfoEvent];
TestUtilsMatrix.upsertRoomStateEvents(room, combinedEvents);
const { container } = render(getComponent({ events: combinedEvents }));
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
// beacon body is not in the summary
expect(summaryTile.getElementsByClassName("mx_MBeaconBody").length).toBe(0);
// beacon tile is rendered
expect(container.getElementsByClassName("mx_MBeaconBody").length).toBe(1);
});
it("should hide read-marker at the end of creation event summary", function () {
const events = mkCreationEvents();
const createEvent = events.find((event) => event.getType() === "m.room.create");
client.getRoom.mockImplementation((id) => (id === createEvent!.getRoomId() ? room : null));
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(
getComponent({
events,
readMarkerEventId: events[5].getId(),
readMarkerVisible: true,
}),
);
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
const [messageList] = container.getElementsByClassName("mx_RoomView_MessageList");
const rows = messageList.children;
expect(rows.length).toEqual(7); // 6 events + the NewRoomIntro
expect(rm.previousSibling).toEqual(rows[5]);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("should render Date separators for the events", function () {
const events = mkOneDayEvents();
const { queryAllByRole } = render(getComponent({ events }));
const dates = queryAllByRole("separator");
expect(dates.length).toEqual(1);
});
it("appends events into summaries during forward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
...events,
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(11);
});
it("prepends events into summaries during backward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
...events,
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(11);
});
it("assigns different keys to summaries that get split up", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
...events.slice(0, 5),
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Hello!",
}),
...events.slice(5, 10),
];
rerender(getComponent({ events: updatedEvents }));
// summaries split becuase room messages are not summarised
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(2);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(5);
expect(els[1].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[5].getId()}`);
expect(els[1].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(5);
});
// We test this because setting lookups can be *slow*, and we don't want
// them to happen in this code path
it("doesn't lookup showHiddenEventsInTimeline while rendering", () => {
// We're only interested in the setting lookups that happen on every render,
// rather than those happening on first mount, so let's get those out of the way
const { rerender } = render(getComponent({ events: [] }));
// Set up our spy and re-render with new events
const settingsSpy = jest.spyOn(SettingsStore, "getValue").mockClear();
rerender(getComponent({ events: mkMixedHiddenAndShownEvents() }));
expect(settingsSpy).not.toHaveBeenCalledWith("showHiddenEventsInTimeline");
settingsSpy.mockRestore();
});
it("should group hidden event reactions into an event list summary", () => {
const events = [
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 1,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 2,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 3,
}),
];
const { container } = render(getComponent({ events }, { showHiddenEvents: true }));
const els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(3);
});
it("should handle large numbers of hidden events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events: MatrixEvent[] = [];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkEvent({
event: true,
type: "unknown.event.type",
content: { key: "value" },
room: "!room:id",
user: "@user:id",
ts: 1000000 + i,
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: false }));
expect(asFragment()).toMatchSnapshot();
});
it("should handle lots of room creation events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events = [TestUtilsMatrix.mkRoomCreateEvent("@user:id", "!room:id")];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkMembership({
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
room: "!room:id",
user: "@user:id",
event: true,
skey: "123",
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: false }));
expect(asFragment()).toMatchSnapshot();
});
it("should handle lots of membership events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events: MatrixEvent[] = [];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkMembership({
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
room: "!room:id",
user: "@user:id",
event: true,
skey: "123",
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: true }));
const cpt = asFragment();
// Ignore properties that change every time
cpt.querySelectorAll("li").forEach((li) => {
li.setAttribute("data-scroll-tokens", "__scroll_tokens__");
li.setAttribute("data-testid", "__testid__");
});
expect(cpt).toMatchSnapshot();
});
it("should set lastSuccessful=true on non-last event if last event is not eligible for special receipt", () => {
client.getRoom.mockImplementation((id) => (id === room.roomId ? room : null));
const events = [
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
}),
TestUtilsMatrix.mkEvent({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
type: "m.room.topic",
skey: "",
content: { topic: "TOPIC" },
}),
];
const { container } = render(getComponent({ events, showReadReceipts: true }));
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].querySelector(".mx_EventTile_receiptSent")).toBeTruthy();
expect(tiles[1].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
});
it("should set lastSuccessful=false on non-last event if last event has a receipt from someone else", () => {
client.getRoom.mockImplementation((id) => (id === room.roomId ? room : null));
const events = [
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
}),
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: "@other:user",
ts: 1001,
}),
];
room.addReceiptToStructure(
events[1].getId()!,
ReceiptType.Read,
"@other:user",
{
ts: 1001,
},
true,
);
const { container } = render(getComponent({ events, showReadReceipts: true }));
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
expect(tiles[1].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
});
});
describe("shouldFormContinuation", () => {
it("does not form continuations from thread roots which have summaries", () => {
const message1 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a message in the main timeline",
});
const message2 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline",
});
const threadRoot = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a thread",
});
jest.spyOn(threadRoot, "isThreadRoot", "get").mockReturnValue(true);
const message3 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline after the thread root",
});
const client = createTestClient();
expect(shouldFormContinuation(message1, message2, client, false)).toEqual(true);
expect(shouldFormContinuation(message2, threadRoot, client, false)).toEqual(true);
expect(shouldFormContinuation(threadRoot, message3, client, false)).toEqual(true);
const thread = {
length: 1,
replyToEvent: {},
} as unknown as Thread;
jest.spyOn(threadRoot, "getThread").mockReturnValue(thread);
expect(shouldFormContinuation(message2, threadRoot, client, false)).toEqual(false);
expect(shouldFormContinuation(threadRoot, message3, client, false)).toEqual(false);
});
});

View File

@@ -0,0 +1,113 @@
/*
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, { MouseEventHandler } from "react";
import { screen, render, RenderResult } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import PictureInPictureDragger, { CreatePipChildren } from "../../../src/components/structures/PictureInPictureDragger";
describe("PictureInPictureDragger", () => {
let renderResult: RenderResult;
const mkContent1: Array<CreatePipChildren> = [
() => {
return <div>content 1</div>;
},
];
const mkContent2: Array<CreatePipChildren> = [
() => {
return (
<div>
content 2<br />
content 2.2
</div>
);
},
];
describe("when rendering the dragger with PiP content 1", () => {
beforeEach(() => {
renderResult = render(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
});
it("should render the PiP content", () => {
expect(renderResult.container).toMatchSnapshot("pip-content-1");
});
describe("and rerendering PiP content 1", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
});
it("should not change the PiP content", () => {
expect(renderResult.container).toMatchSnapshot("pip-content-1");
});
});
describe("and rendering PiP content 2", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent2}</PictureInPictureDragger>);
});
it("should update the PiP content", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
});
describe("when rendering the dragger with PiP content 1 and 2", () => {
beforeEach(() => {
renderResult = render(
<PictureInPictureDragger draggable={true}>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>,
);
});
it("should render both contents", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
describe("when rendering the dragger", () => {
let clickSpy: jest.Mocked<MouseEventHandler>;
let target: HTMLElement;
beforeEach(() => {
clickSpy = jest.fn();
render(
<PictureInPictureDragger draggable={true}>
{[
({ onStartMoving }) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onMouseDown={onStartMoving} onClick={clickSpy}>
Hello
</div>
),
]}
</PictureInPictureDragger>,
);
target = screen.getByText("Hello");
});
it("and clicking without a drag motion, it should pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { keys: "[/MouseLeft]" }]);
expect(clickSpy).toHaveBeenCalled();
});
it("and clicking with a drag motion above the threshold of 5px, it should not pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 60, y: 2 } }, "[/MouseLeft]"]);
expect(clickSpy).not.toHaveBeenCalled();
});
it("and clickign with a drag motion below the threshold of 5px, it should pass the click to the children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 4, y: 4 } }, "[/MouseLeft]"]);
expect(clickSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,476 @@
/*
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, Mocked } from "jest-mock";
import { screen, render, act, cleanup } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, PendingEventOrdering, Room, MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { Widget, ClientWidgetApi } from "matrix-widget-api";
import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup";
import type { RoomMember } from "matrix-js-sdk/src/matrix";
import {
useMockedCalls,
MockedCall,
mkRoomMember,
stubClient,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
wrapInSdkContext,
mkRoomCreateEvent,
mockPlatformPeg,
flushPromises,
useMockMediaDevices,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { CallStore } from "../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
import { PipContainer as UnwrappedPipContainer } from "../../../src/components/structures/PipContainer";
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { TestSdkContext } from "../../TestSdkContext";
import {
VoiceBroadcastInfoState,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPreRecording,
VoiceBroadcastPreRecordingStore,
VoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { IRoomStateEventsActionPayload } from "../../../src/actions/MatrixActionCreators";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetType } from "../../../src/widgets/WidgetType";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
jest.mock("../../../src/stores/OwnProfileStore", () => ({
OwnProfileStore: {
instance: {
isProfileInfoFetched: true,
removeListener: jest.fn(),
getHttpAvatarUrl: jest.fn().mockReturnValue("http://avatar_url"),
},
},
}));
describe("PipContainer", () => {
useMockedCalls();
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let user: UserEvent;
let sdkContext: TestSdkContext;
let client: Mocked<MatrixClient>;
let room: Room;
let room2: Room;
let alice: RoomMember;
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore;
let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore;
const actFlushPromises = async () => {
await act(async () => {
await flushPromises();
});
};
beforeEach(async () => {
useMockMediaDevices();
user = userEvent.setup();
stubClient();
client = mocked(MatrixClientPeg.safeGet());
client.getUserId.mockReturnValue("@alice:example.org");
client.getSafeUserId.mockReturnValue("@alice:example.org");
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
room2 = new Room("!2:example.com", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId: string) => {
if (roomId === room.roomId) return room;
if (roomId === room2.roomId) return room2;
return null;
});
client.getRooms.mockReturnValue([room, room2]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
room.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room.roomId)]);
jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === alice.userId ? alice : null));
room2.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room2.roomId)]);
await Promise.all(
[CallStore.instance, WidgetMessagingStore.instance].map((store) =>
setupAsyncStoreWithClient(store, client),
),
);
sdkContext = new TestSdkContext();
// @ts-ignore PipContainer uses SDKContext in the constructor
SdkContextClass.instance = sdkContext;
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore();
voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore);
sdkContext.client = client;
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore;
sdkContext._VoiceBroadcastPlaybacksStore = voiceBroadcastPlaybacksStore;
});
afterEach(async () => {
cleanup();
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.clearAllMocks();
});
const renderPip = () => {
const PipContainer = wrapInMatrixClientContext(wrapInSdkContext(UnwrappedPipContainer, sdkContext));
render(<PipContainer />);
};
const viewRoom = (roomId: string) => {
defaultDispatcher.dispatch<ViewRoomPayload>(
{
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: undefined,
},
true,
);
};
const withCall = async (fn: (call: MockedCall) => Promise<void>): Promise<void> => {
MockedCall.create(room, "1");
const call = CallStore.instance.getCall(room.roomId);
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetStore.instance.addVirtualWidget(call.widget, room.roomId);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
await act(async () => {
await call.start();
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
});
await fn(call);
cleanup();
call.destroy();
ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId);
WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId);
};
const withWidget = async (fn: () => Promise<void>): Promise<void> => {
act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true));
await fn();
cleanup();
ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId);
};
const makeVoiceBroadcastInfoStateEvent = (): MatrixEvent => {
return mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Started,
alice.userId,
client.getDeviceId() || "",
);
};
const setUpVoiceBroadcastRecording = () => {
const infoEvent = makeVoiceBroadcastInfoStateEvent();
const voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client);
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
};
const setUpVoiceBroadcastPreRecording = () => {
const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording(
room,
alice,
client,
voiceBroadcastPlaybacksStore,
voiceBroadcastRecordingsStore,
);
voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording);
};
const setUpRoomViewStore = () => {
sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext);
};
const mkVoiceBroadcast = (room: Room): MatrixEvent => {
const infoEvent = makeVoiceBroadcastInfoStateEvent();
room.currentState.setStateEvents([infoEvent]);
defaultDispatcher.dispatch<IRoomStateEventsActionPayload>(
{
action: "MatrixActions.RoomState.events",
event: infoEvent,
state: room.currentState,
lastStateEvent: null,
},
true,
);
return infoEvent;
};
it("hides if there's no content", () => {
renderPip();
expect(screen.queryByRole("complementary")).toBeNull();
});
it("shows an active call with back and leave buttons", async () => {
renderPip();
await withCall(async (call) => {
screen.getByRole("complementary");
// The return button should jump to the call
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should disconnect from the call
const disconnectSpy = jest.spyOn(call, "disconnect");
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(disconnectSpy).toHaveBeenCalled();
});
});
it("shows a persistent widget with back button when viewing the room", async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
const widget = WidgetStore.instance.addVirtualWidget(
{
id: "1",
creatorUserId: "@alice:example.org",
type: WidgetType.CUSTOM.preferred,
url: "https://example.org",
name: "Example widget",
},
room.roomId,
);
renderPip();
await withWidget(async () => {
screen.getByRole("complementary");
// The return button should maximize the widget
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
expect(screen.queryByRole("button", { name: "Leave" })).toBeNull();
});
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
it("shows a persistent Jitsi widget with back and leave buttons when not viewing the room", async () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
setUpRoomViewStore();
viewRoom(room2.roomId);
const widget = WidgetStore.instance.addVirtualWidget(
{
id: "1",
creatorUserId: "@alice:example.org",
type: WidgetType.JITSI.preferred,
url: "https://meet.example.org",
name: "Jitsi example",
},
room.roomId,
);
renderPip();
await withWidget(async () => {
screen.getByRole("complementary");
// The return button should view the room
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should hangup the call
const sendSpy = jest
.fn<
ReturnType<ClientWidgetApi["transport"]["send"]>,
Parameters<ClientWidgetApi["transport"]["send"]>
>()
.mockResolvedValue({});
const mockMessaging = { transport: { send: sendSpy }, stop: () => {} } as unknown as ClientWidgetApi;
WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging);
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {});
});
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
describe("when there is a voice broadcast recording and pre-recording", () => {
beforeEach(async () => {
setUpVoiceBroadcastPreRecording();
setUpVoiceBroadcastRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast recording PiP", () => {
// check for the „Live“ badge to be present
expect(screen.queryByText("Live")).toBeInTheDocument();
});
it("and a call it should show both, the call and the recording", async () => {
await withCall(async () => {
// Broadcast: Check for the „Live“ badge to be present
expect(screen.queryByText("Live")).toBeInTheDocument();
// Call: Check for the „Leave“ button to be present
screen.getByRole("button", { name: "Leave" });
});
});
});
describe("when there is a voice broadcast playback and pre-recording", () => {
beforeEach(async () => {
mkVoiceBroadcast(room);
setUpVoiceBroadcastPreRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast pre-recording PiP", () => {
// check for the „Go live“ button
expect(screen.queryByText("Go live")).toBeInTheDocument();
});
});
describe("when there is a voice broadcast pre-recording", () => {
beforeEach(async () => {
setUpVoiceBroadcastPreRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast pre-recording PiP", () => {
// check for the „Go live“ button
expect(screen.queryByText("Go live")).toBeInTheDocument();
});
});
describe("when listening to a voice broadcast in a room and then switching to another room", () => {
beforeEach(async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
mkVoiceBroadcast(room);
await actFlushPromises();
expect(voiceBroadcastPlaybacksStore.getCurrent()).toBeTruthy();
await voiceBroadcastPlaybacksStore.getCurrent()?.start();
viewRoom(room2.roomId);
renderPip();
});
it("should render the small voice broadcast playback PiP", () => {
// check for the „pause voice broadcast“ button
expect(screen.getByLabelText("pause voice broadcast")).toBeInTheDocument();
// check for the absence of the „30s forward“ button
expect(screen.queryByLabelText("30s forward")).not.toBeInTheDocument();
});
});
describe("when viewing a room with a live voice broadcast", () => {
let startEvent!: MatrixEvent;
beforeEach(async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
startEvent = mkVoiceBroadcast(room);
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).toBeInTheDocument();
});
describe("and the broadcast stops", () => {
beforeEach(async () => {
const stopEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
alice.userId,
client.getDeviceId() || "",
startEvent,
);
await act(async () => {
room.currentState.setStateEvents([stopEvent]);
defaultDispatcher.dispatch<IRoomStateEventsActionPayload>(
{
action: "MatrixActions.RoomState.events",
event: stopEvent,
state: room.currentState,
lastStateEvent: stopEvent,
},
true,
);
await flushPromises();
});
});
it("should not render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument();
});
});
describe("and leaving the room", () => {
beforeEach(async () => {
await act(async () => {
viewRoom(room2.roomId);
await flushPromises();
});
});
it("should not render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,66 @@
/*
* 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 { ReleaseAnnouncement } from "../../../src/components/structures/ReleaseAnnouncement";
import Modal, { ModalManagerEvent } from "../../../src/Modal";
import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore";
describe("ReleaseAnnouncement", () => {
beforeEach(async () => {
// Reset the singleton instance of the ReleaseAnnouncementStore
// @ts-ignore
ReleaseAnnouncementStore.internalInstance = new ReleaseAnnouncementStore();
});
function renderReleaseAnnouncement() {
return render(
<ReleaseAnnouncement
feature="threadsActivityCentre"
header="header"
description="description"
closeLabel="close"
>
<div>content</div>
</ReleaseAnnouncement>,
);
}
test("render the release announcement and close it", async () => {
renderReleaseAnnouncement();
// The release announcement is displayed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
// Click on the close button in the release announcement
screen.getByRole("button", { name: "close" }).click();
// The release announcement should be hidden after the close button is clicked
await waitFor(() => expect(screen.queryByRole("dialog", { name: "header" })).toBeNull());
});
test("when a dialog is opened, the release announcement should not be displayed", async () => {
renderReleaseAnnouncement();
// The release announcement is displayed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
// Open a dialog
act(() => {
Modal.emit(ModalManagerEvent.Opened);
});
// The release announcement should be hidden after the dialog is opened
expect(screen.queryByRole("dialog", { name: "header" })).toBeNull();
// Close the dialog
act(() => {
Modal.emit(ModalManagerEvent.Closed);
});
// The release announcement should be displayed after the dialog is closed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
});
});

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 { render, screen, waitFor } from "jest-matrix-react";
import { jest } from "@jest/globals";
import { mocked, MockedObject } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import _RightPanel from "../../../src/components/structures/RightPanel";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient, wrapInMatrixClientContext, mkRoom, wrapInSdkContext } from "../../test-utils";
import { Action } from "../../../src/dispatcher/actions";
import dis from "../../../src/dispatcher/dispatcher";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import SettingsStore from "../../../src/settings/SettingsStore";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
const RightPanelBase = wrapInMatrixClientContext(_RightPanel);
describe("RightPanel", () => {
const resizeNotifier = new ResizeNotifier();
let cli: MockedObject<MatrixClient>;
let context: SdkContextClass;
let RightPanel: React.ComponentType<React.ComponentProps<typeof RightPanelBase>>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(cli);
context = new SdkContextClass();
context.client = cli;
RightPanel = wrapInSdkContext(RightPanelBase, context);
});
afterEach(async () => {
const roomChanged = new Promise<void>((resolve) => {
const ref = dis.register((payload) => {
if (payload.action === Action.ActiveRoomChanged) {
dis.unregister(ref);
resolve();
}
});
});
dis.fire(Action.ViewHomePage); // Stop viewing any rooms
await roomChanged;
dis.fire(Action.OnLoggedOut, true); // Shut down the stores
jest.restoreAllMocks();
});
const spinUpStores = async () => {
// Selectively spin up the stores we need
WidgetLayoutStore.instance.useUnitTestClient(cli);
// @ts-ignore
// This is private but it's the only way to selectively enable stores
await WidgetLayoutStore.instance.onReady();
// Make sure we start with a clean store
RightPanelStore.instance.reset();
RightPanelStore.instance.useUnitTestClient(cli);
// @ts-ignore
await RightPanelStore.instance.onReady();
};
const waitForRpsUpdate = () => new Promise<void>((resolve) => RightPanelStore.instance.once(UPDATE_EVENT, resolve));
it("renders info from only one room during room changes", async () => {
const r1 = mkRoom(cli, "r1");
const r2 = mkRoom(cli, "r2");
cli.getRoom.mockImplementation((roomId) => {
if (roomId === "r1") return r1;
if (roomId === "r2") return r2;
return null;
});
// Set up right panel state
const realGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => {
if (name !== "RightPanel.phases") return realGetValue(name, roomId);
if (roomId === "r1") {
return {
history: [{ phase: RightPanelPhases.RoomMemberList }],
isOpen: true,
};
}
if (roomId === "r2") {
return {
history: [{ phase: RightPanelPhases.RoomSummary }],
isOpen: true,
};
}
return null;
});
await spinUpStores();
// Run initial render with room 1, and also running lifecycle methods
const { container, rerender } = render(
<RightPanel
room={r1}
resizeNotifier={resizeNotifier}
permalinkCreator={new RoomPermalinkCreator(r1, r1.roomId)}
/>,
);
// Wait for RPS room 1 updates to fire
const rpsUpdated = waitForRpsUpdate();
dis.dispatch({
action: Action.ViewRoom,
room_id: "r1",
});
await rpsUpdated;
await waitFor(() => expect(screen.queryByTestId("spinner")).not.toBeInTheDocument());
// room one will be in the RoomMemberList phase - confirm this is rendered
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(1);
// wait for RPS room 2 updates to fire, then rerender
const _rpsUpdated = waitForRpsUpdate();
dis.dispatch({
action: Action.ViewRoom,
room_id: "r2",
});
await _rpsUpdated;
rerender(
<RightPanel
room={r2}
resizeNotifier={resizeNotifier}
permalinkCreator={new RoomPermalinkCreator(r2, r2.roomId)}
/>,
);
// After all that setup, now to the interesting part...
// We want to verify that as we change to room 2, we should always have
// the correct right panel state for whichever room we are showing, so we
// confirm we do not have the MemberList class on the page and that we have
// the expected room title
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(0);
expect(screen.getByRole("heading", { name: "r2" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,576 @@
/*
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 { render, screen } from "jest-matrix-react";
import {
Room,
MatrixClient,
IEvent,
MatrixEvent,
EventType,
SearchResult,
ISearchResults,
} from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import { RoomSearchView } from "../../../src/components/structures/RoomSearchView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient } from "../../test-utils";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { searchPagination, SearchScope } from "../../../src/Searching";
jest.mock("../../../src/Searching", () => ({
searchPagination: jest.fn(),
SearchScope: jest.requireActual("../../../src/Searching").SearchScope,
}));
describe("<RoomSearchView/>", () => {
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const resizeNotifier = new ResizeNotifier();
let client: MatrixClient;
let room: Room;
beforeEach(async () => {
stubClient();
client = MatrixClientPeg.safeGet();
client.supportsThreads = jest.fn().mockReturnValue(true);
room = new Room("!room:server", client, client.getSafeUserId());
mocked(client.getRoom).mockReturnValue(room);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(100);
});
afterEach(async () => {
jest.restoreAllMocks();
});
it("should show a spinner before the promise resolves", async () => {
const deferred = defer<ISearchResults>();
render(
<RoomSearchView
inProgress={true}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>,
);
await screen.findByTestId("messagePanelSearchSpinner");
});
it("should render results when the promise resolves", async () => {
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$1",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Before", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "After", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
],
highlights: [],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
await screen.findByText("Before");
await screen.findByText("Foo Test Bar");
await screen.findByText("After");
});
it("should highlight words correctly", async () => {
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.Room}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: ["test"],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const text = await screen.findByText("Test");
expect(text).toHaveClass("mx_EventTile_searchHighlight");
});
it("should show spinner above results when backpaginating", async () => {
const searchResults: ISearchResults = {
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: ["test"],
next_batch: "next_batch",
count: 2,
};
mocked(searchPagination).mockResolvedValue({
...searchResults,
results: [
...searchResults.results,
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$4",
sender: client.getSafeUserId(),
origin_server_ts: 4,
content: { body: "Potato", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
next_batch: undefined,
});
const onUpdate = jest.fn();
const { rerender } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={true}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={onUpdate}
/>
</MatrixClientContext.Provider>,
);
await screen.findByRole("progressbar");
await screen.findByText("Potato");
expect(onUpdate).toHaveBeenCalledWith(false, expect.objectContaining({}));
rerender(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
expect(screen.queryByRole("progressbar")).toBeFalsy();
});
it("should handle resolutions after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
unmount();
deferred.resolve({
results: [],
highlights: [],
});
});
it("should handle rejections after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
unmount();
deferred.reject({
results: [],
highlights: [],
});
});
it("should show modal if error is encountered", async () => {
const deferred = defer<ISearchResults>();
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
deferred.reject(new Error("Some error"));
await screen.findByText("Search failed");
await screen.findByText("Some error");
});
it("should combine search results when the query is present in multiple sucessive messages", async () => {
const searchResults: ISearchResults = {
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$4",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Foo2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Between", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$5",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "After", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Foo", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$1",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Before", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Between", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
],
highlights: [],
next_batch: "",
count: 1,
};
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const beforeNode = await screen.findByText("Before");
const fooNode = await screen.findByText("Foo");
const betweenNode = await screen.findByText("Between");
const foo2Node = await screen.findByText("Foo2");
const afterNode = await screen.findByText("After");
expect((await screen.findAllByText("Between")).length).toBe(1);
expect(beforeNode.compareDocumentPosition(fooNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(fooNode.compareDocumentPosition(betweenNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(betweenNode.compareDocumentPosition(foo2Node) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(foo2Node.compareDocumentPosition(afterNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("should pass appropriate permalink creator for all rooms search", async () => {
const room2 = new Room("!room2:server", client, client.getSafeUserId());
const room3 = new Room("!room3:server", client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation(
(roomId) => [room, room2, room3].find((r) => r.roomId === roomId) ?? null,
);
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 1", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 2,
result: {
room_id: room2.roomId,
event_id: "$22",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 2,
result: {
room_id: room2.roomId,
event_id: "$23",
sender: client.getSafeUserId(),
origin_server_ts: 2,
content: { body: "Room 2 message 2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 3,
result: {
room_id: room3.roomId,
event_id: "$32",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 3", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: [],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const event1 = await screen.findByText("Room 1");
expect(event1.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room.roomId}/$2`,
);
const event2 = await screen.findByText("Room 2");
expect(event2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room2.roomId}/$22`,
);
const event2Message2 = await screen.findByText("Room 2 message 2");
expect(event2Message2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room2.roomId}/$23`,
);
const event3 = await screen.findByText("Room 3");
expect(event3.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room3.roomId}/$32`,
);
});
});

View File

@@ -0,0 +1,150 @@
/*
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,
PendingEventOrdering,
EventStatus,
MatrixEvent,
Room,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import RoomStatusBar, { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkEvent, stubClient } from "../../test-utils/test-utils";
import { mkThread } from "../../test-utils/threads";
describe("RoomStatusBar", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
client.getSyncStateData = jest.fn().mockReturnValue({});
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.status = EventStatus.NOT_SENT;
});
const getComponent = () =>
render(<RoomStatusBar room={room} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
});
describe("getUnsentMessages", () => {
it("returns no unsent messages", () => {
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("checks the event status", () => {
room.addPendingEvent(event, "123");
expect(getUnsentMessages(room)).toHaveLength(1);
event.status = EventStatus.SENT;
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("only returns events related to a thread", () => {
room.addPendingEvent(event, "123");
const { rootEvent, events } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
});
rootEvent.status = EventStatus.NOT_SENT;
room.addPendingEvent(rootEvent, rootEvent.getId()!);
for (const event of events) {
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, Date.now() + Math.random() + "");
}
const pendingEvents = getUnsentMessages(room, rootEvent.getId());
expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId());
// Filters out the non thread events
expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true);
});
});
describe("<RoomStatusBar />", () => {
it("should render nothing when room has no error or unsent messages", () => {
const { container } = getComponent();
expect(container.firstChild).toBe(null);
});
describe("unsent messages", () => {
it("should render warning when messages are unsent due to consent", () => {
const unsentMessage = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
unsentMessage.status = EventStatus.NOT_SENT;
unsentMessage.error = new MatrixError({
errcode: "M_CONSENT_NOT_GIVEN",
data: { consent_uri: "terms.com" },
});
room.addPendingEvent(unsentMessage, "123");
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("should render warning when messages are unsent due to resource limit", () => {
const unsentMessage = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
unsentMessage.status = EventStatus.NOT_SENT;
unsentMessage.error = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: { limit_type: "monthly_active_user" },
});
room.addPendingEvent(unsentMessage, "123");
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,39 @@
/*
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 { RoomStatusBarUnsentMessages } from "../../../src/components/structures/RoomStatusBarUnsentMessages";
import { StaticNotificationState } from "../../../src/stores/notifications/StaticNotificationState";
describe("RoomStatusBarUnsentMessages", () => {
const title = "test title";
const description = "test description";
const buttonsText = "test buttons";
const buttons = <div>{buttonsText}</div>;
beforeEach(() => {
render(
<RoomStatusBarUnsentMessages
title={title}
description={description}
buttons={buttons}
notificationState={StaticNotificationState.RED_EXCLAMATION}
/>,
);
});
it("should render the values passed as props", () => {
screen.getByText(title);
screen.getByText(description);
screen.getByText(buttonsText);
// notification state
screen.getByText("!");
});
});

View File

@@ -0,0 +1,686 @@
/*
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, { createRef, RefObject } from "react";
import { mocked, MockedObject } from "jest-mock";
import {
ClientEvent,
MatrixClient,
Room,
RoomEvent,
EventType,
JoinRule,
MatrixError,
RoomStateEvent,
MatrixEvent,
SearchResult,
IEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
stubClient,
mockPlatformPeg,
unmockPlatformPeg,
wrapInMatrixClientContext,
flushPromises,
mkEvent,
setupAsyncStoreWithClient,
filterConsole,
mkRoomMemberJoinEvent,
mkThirdPartyInviteEvent,
emitPromise,
createTestClient,
untilDispatch,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { Action } from "../../../src/dispatcher/actions";
import dis, { defaultDispatcher } from "../../../src/dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { RoomView as _RoomView } from "../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { NotificationState } from "../../../src/stores/notifications/NotificationState";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
import { DirectoryMember } from "../../../src/utils/direct-messages";
import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { SDKContext, SdkContextClass } from "../../../src/contexts/SDKContext";
import VoipUserMapper from "../../../src/VoipUserMapper";
import WidgetUtils from "../../../src/utils/WidgetUtils";
import { WidgetType } from "../../../src/widgets/WidgetType";
import WidgetStore from "../../../src/stores/WidgetStore";
import { ViewRoomErrorPayload } from "../../../src/dispatcher/payloads/ViewRoomErrorPayload";
import { SearchScope } from "../../../src/Searching";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../src/utils/crypto";
const RoomView = wrapInMatrixClientContext(_RoomView);
describe("RoomView", () => {
let cli: MockedObject<MatrixClient>;
let room: Room;
let rooms: Map<string, Room>;
let roomCount = 0;
let stores: SdkContextClass;
// mute some noise
filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability");
beforeEach(() => {
mockPlatformPeg({ reload: () => {} });
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org");
jest.spyOn(room, "findPredecessor");
room.getPendingEvents = () => [];
rooms = new Map();
rooms.set(room.roomId, room);
cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null);
// Re-emit certain events on the mocked client
room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args));
room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args));
DMRoomMap.makeShared(cli);
stores = new SdkContextClass();
stores.client = cli;
stores.rightPanelStore.useUnitTestClient(cli);
jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined);
});
afterEach(() => {
unmockPlatformPeg();
jest.clearAllMocks();
});
const mountRoomView = async (ref?: RefObject<_RoomView>): Promise<RenderResult> => {
if (stores.roomViewStore.getRoomId() !== room.roomId) {
const switchedRoom = new Promise<void>((resolve) => {
const subFn = () => {
if (stores.roomViewStore.getRoomId()) {
stores.roomViewStore.off(UPDATE_EVENT, subFn);
resolve();
}
};
stores.roomViewStore.on(UPDATE_EVENT, subFn);
});
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
await switchedRoom;
}
const roomView = render(
<SDKContext.Provider value={stores}>
<RoomView
// threepidInvite should be optional on RoomView props
// it is treated as optional in RoomView
threepidInvite={undefined as any}
resizeNotifier={new ResizeNotifier()}
forceTimeline={false}
wrappedRef={ref as any}
/>
</SDKContext.Provider>,
);
await flushPromises();
return roomView;
};
const renderRoomView = async (switchRoom = true): Promise<ReturnType<typeof render>> => {
if (switchRoom && stores.roomViewStore.getRoomId() !== room.roomId) {
const switchedRoom = new Promise<void>((resolve) => {
const subFn = () => {
if (stores.roomViewStore.getRoomId()) {
stores.roomViewStore.off(UPDATE_EVENT, subFn);
resolve();
}
};
stores.roomViewStore.on(UPDATE_EVENT, subFn);
});
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
await switchedRoom;
}
const roomView = render(
<SDKContext.Provider value={stores}>
<RoomView
// threepidInvite should be optional on RoomView props
// it is treated as optional in RoomView
threepidInvite={undefined as any}
resizeNotifier={new ResizeNotifier()}
forceTimeline={false}
onRegistered={jest.fn()}
/>
</SDKContext.Provider>,
);
await flushPromises();
return roomView;
};
const getRoomViewInstance = async (): Promise<_RoomView> => {
const ref = createRef<_RoomView>();
await mountRoomView(ref);
return ref.current!;
};
it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => {
const instance = await getRoomViewInstance();
expect(instance.getHiddenHighlightCount()).toBe(0);
});
describe("when there is an old room", () => {
let instance: _RoomView;
let oldRoom: Room;
beforeEach(async () => {
instance = await getRoomViewInstance();
oldRoom = new Room("!old:example.com", cli, cli.getSafeUserId());
rooms.set(oldRoom.roomId, oldRoom);
jest.spyOn(room, "findPredecessor").mockReturnValue({ roomId: oldRoom.roomId });
});
it("and it has 0 unreads, getHiddenHighlightCount should return 0", async () => {
jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(0);
expect(instance.getHiddenHighlightCount()).toBe(0);
// assert that msc3946ProcessDynamicPredecessor is false by default
expect(room.findPredecessor).toHaveBeenCalledWith(false);
});
it("and it has 23 unreads, getHiddenHighlightCount should return 23", async () => {
jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(23);
expect(instance.getHiddenHighlightCount()).toBe(23);
});
describe("and feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
instance.setState({ msc3946ProcessDynamicPredecessor: true });
});
afterEach(() => {
instance.setState({ msc3946ProcessDynamicPredecessor: false });
});
it("should pass the setting to findPredecessor", async () => {
expect(instance.getHiddenHighlightCount()).toBe(0);
expect(room.findPredecessor).toHaveBeenCalledWith(true);
});
});
});
it("updates url preview visibility on encryption state change", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
// we should be starting unencrypted
expect(cli.isCryptoEnabled()).toEqual(false);
expect(cli.isRoomEncrypted(room.roomId)).toEqual(false);
const roomViewInstance = await getRoomViewInstance();
// in a default (non-encrypted room, it should start out with url previews enabled)
// This is a white-box test in that we're asserting things about the state, which
// is not ideal, but asserting that a URL preview just isn't there could risk the
// test being invalid because the previews just hasn't rendered yet. This feels
// like the safest way I think?
// This also relies on the default settings being URL previews on normally and
// off for e2e rooms because 1) it's probably useful to assert this and
// 2) SettingsStore is a static class and so very hard to mock out.
expect(roomViewInstance.state.showUrlPreview).toBe(true);
// now enable encryption
cli.isCryptoEnabled.mockReturnValue(true);
cli.isRoomEncrypted.mockReturnValue(true);
// and fake an encryption event into the room to prompt it to re-check
room.addLiveEvents([
new MatrixEvent({
type: "m.room.encryption",
sender: cli.getUserId()!,
content: {},
event_id: "someid",
room_id: room.roomId,
}),
]);
// URL previews should now be disabled
expect(roomViewInstance.state.showUrlPreview).toBe(false);
});
it("updates live timeline when a timeline reset happens", async () => {
const roomViewInstance = await getRoomViewInstance();
const oldTimeline = roomViewInstance.state.liveTimeline;
room.getUnfilteredTimelineSet().resetLiveTimeline();
expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline);
});
describe("with virtual rooms", () => {
it("checks for a virtual room on initial load", async () => {
const { container } = await renderRoomView();
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId);
// quick check that rendered without error
expect(container.querySelector(".mx_ErrorBoundary")).toBeFalsy();
});
it("checks for a virtual room on room event", async () => {
await renderRoomView();
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId);
cli.emit(ClientEvent.Room, room);
// called again after room event
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledTimes(2);
});
});
describe("video rooms", () => {
beforeEach(async () => {
// Make it a video room
room.isElementVideoRoom = () => true;
await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true);
});
it("normally doesn't open the chat panel", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false);
await mountRoomView();
expect(stores.rightPanelStore.isOpen).toEqual(false);
});
it("opens the chat panel if there are unread messages", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true);
await mountRoomView();
expect(stores.rightPanelStore.isOpen).toEqual(true);
expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline);
});
it("should render joined video room view", async () => {
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
const { asFragment } = await mountRoomView();
expect(asFragment()).toMatchSnapshot();
});
});
describe("for a local room", () => {
let localRoom: LocalRoom;
beforeEach(async () => {
localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]);
rooms.set(localRoom.roomId, localRoom);
cli.store.storeRoom(room);
});
it("should remove the room from the store on unmount", async () => {
const { unmount } = await renderRoomView();
unmount();
expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId);
});
describe("in state NEW", () => {
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
describe("that is encrypted", () => {
beforeEach(() => {
mocked(cli.isRoomEncrypted).mockReturnValue(true);
localRoom.encrypted = true;
localRoom.currentState.setStateEvents([
new MatrixEvent({
event_id: `~${localRoom.roomId}:${cli.makeTxnId()}`,
type: EventType.RoomEncryption,
content: {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
},
sender: cli.getUserId()!,
state_key: "",
room_id: localRoom.roomId,
origin_server_ts: Date.now(),
}),
]);
});
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
});
});
it("in state CREATING should match the snapshot", async () => {
localRoom.state = LocalRoomState.CREATING;
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
describe("in state ERROR", () => {
beforeEach(async () => {
localRoom.state = LocalRoomState.ERROR;
});
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
it("clicking retry should set the room state to new dispatch a local room event", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
const { getByText } = await renderRoomView();
fireEvent.click(getByText("Retry"));
expect(localRoom.state).toBe(LocalRoomState.NEW);
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "local_room_event",
roomId: room.roomId,
});
});
});
});
describe("when rendering a DM room with a single third-party invite", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId),
mkThirdPartyInviteEvent(cli.getSafeUserId(), "user@example.com", room.roomId),
]);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId());
jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId]));
mocked(cli).isRoomEncrypted.mockReturnValue(true);
await renderRoomView();
});
it("should render the »waiting for third-party« view", () => {
expect(screen.getByText("Waiting for users to join Element")).toBeInTheDocument();
expect(
screen.getByText(
"Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted",
),
).toBeInTheDocument();
// no message composer
expect(screen.queryByText("Send an encrypted message…")).not.toBeInTheDocument();
expect(screen.queryByText("Send a message…")).not.toBeInTheDocument();
});
});
describe("when there is a RoomView", () => {
const widget1Id = "widget1";
const widget2Id = "widget2";
const otherUserId = "@other:example.com";
const addJitsiWidget = async (id: string, user: string, ts?: number): Promise<void> => {
const widgetEvent = mkEvent({
event: true,
room: room.roomId,
user,
type: "im.vector.modular.widgets",
content: {
id,
name: "Jitsi",
type: WidgetType.JITSI.preferred,
url: "https://example.com",
},
skey: id,
ts,
});
room.addLiveEvents([widgetEvent]);
room.currentState.setStateEvents([widgetEvent]);
cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null);
await flushPromises();
};
beforeEach(async () => {
jest.spyOn(WidgetUtils, "setRoomWidget");
const widgetStore = WidgetStore.instance;
await setupAsyncStoreWithClient(widgetStore, cli);
getRoomViewInstance();
});
const itShouldNotRemoveTheLastWidget = (): void => {
it("should not remove the last widget", (): void => {
expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id);
});
};
describe("and there is a Jitsi widget from another user", () => {
beforeEach(async () => {
await addJitsiWidget(widget1Id, otherUserId, 10_000);
});
describe("and the current user adds a Jitsi widget after 10s", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000);
});
it("the last Jitsi widget should be removed", () => {
expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id);
});
});
describe("and the current user adds a Jitsi widget after two minutes", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000);
});
itShouldNotRemoveTheLastWidget();
});
describe("and the current user adds a Jitsi widget without timestamp", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId());
});
itShouldNotRemoveTheLastWidget();
});
});
describe("and there is a Jitsi widget from another user without timestamp", () => {
beforeEach(async () => {
await addJitsiWidget(widget1Id, otherUserId);
});
describe("and the current user adds a Jitsi widget", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000);
});
itShouldNotRemoveTheLastWidget();
});
});
});
it("should show error view if failed to look up room alias", async () => {
const { asFragment, findByText } = await renderRoomView(false);
defaultDispatcher.dispatch<ViewRoomErrorPayload>({
action: Action.ViewRoomError,
room_alias: "#addy:server",
room_id: null,
err: new MatrixError({ errcode: "M_NOT_FOUND" }),
});
await emitPromise(stores.roomViewStore, UPDATE_EVENT);
await findByText("Are you sure you're at the right place?");
expect(asFragment()).toMatchSnapshot();
});
describe("knock rooms", () => {
const client = createTestClient();
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join");
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
jest.spyOn(dis, "dispatch");
});
it("allows to request to join", async () => {
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId });
await mountRoomView();
fireEvent.click(screen.getByRole("button", { name: "Request access" }));
await untilDispatch(Action.SubmitAskToJoin, dis);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "submit_ask_to_join",
roomId: room.roomId,
opts: { reason: undefined },
});
});
it("allows to cancel a join request", async () => {
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "leave").mockResolvedValue({});
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
await mountRoomView();
fireEvent.click(screen.getByRole("button", { name: "Cancel request" }));
await untilDispatch(Action.CancelAskToJoin, dis);
expect(dis.dispatch).toHaveBeenCalledWith({ action: "cancel_ask_to_join", roomId: room.roomId });
});
});
it("should close search results when edit is clicked", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const roomViewRef = createRef<_RoomView>();
const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef);
// @ts-ignore - triggering a search organically is a lot of work
roomViewRef.current!.setState({
search: {
searchId: 1,
roomId: room.roomId,
term: "search term",
scope: SearchScope.Room,
promise: Promise.resolve({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
content: {
body: "search term",
msgtype: "m.text",
},
type: "m.room.message",
event_id: "$eventId",
sender: cli.getSafeUserId(),
origin_server_ts: 123456789,
room_id: room.roomId,
},
context: {
events_before: [],
events_after: [],
profile_info: {},
},
},
eventMapper,
),
],
highlights: [],
count: 1,
}),
inProgress: false,
count: 1,
},
});
await waitFor(() => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel"));
await userEvent.hover(getByText("search term"));
await userEvent.click(await findByLabelText("Edit"));
await prom;
});
it("should switch rooms when edit is clicked on a search result for a different room", async () => {
const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org");
rooms.set(room2.roomId, room2);
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const roomViewRef = createRef<_RoomView>();
const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef);
// @ts-ignore - triggering a search organically is a lot of work
roomViewRef.current!.setState({
search: {
searchId: 1,
roomId: room.roomId,
term: "search term",
scope: SearchScope.All,
promise: Promise.resolve({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
content: {
body: "search term",
msgtype: "m.text",
},
type: "m.room.message",
event_id: "$eventId",
sender: cli.getSafeUserId(),
origin_server_ts: 123456789,
room_id: room2.roomId,
},
context: {
events_before: [],
events_after: [],
profile_info: {},
},
},
eventMapper,
),
],
highlights: [],
count: 1,
}),
inProgress: false,
count: 1,
},
});
await waitFor(() => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
const prom = untilDispatch(Action.ViewRoom, dis);
await userEvent.hover(getByText("search term"));
await userEvent.click(await findByLabelText("Edit"));
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
});
it("fires Action.RoomLoaded", async () => {
jest.spyOn(dis, "dispatch");
await mountRoomView();
expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded });
});
});

View File

@@ -0,0 +1,328 @@
/*
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, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { HierarchyRoom, JoinRule, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkStubRoom, stubClient } from "../../test-utils";
import dispatcher from "../../../src/dispatcher/dispatcher";
import SpaceHierarchy, { showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy";
import { Action } from "../../../src/dispatcher/actions";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import SettingsStore from "../../../src/settings/SettingsStore";
describe("SpaceHierarchy", () => {
describe("showRoom", () => {
let client: MatrixClient;
let hierarchy: RoomHierarchy;
let room: Room;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.safeGet();
room = new Room("room-id", client, "@alice:example.com");
hierarchy = new RoomHierarchy(room);
jest.spyOn(client, "isGuest").mockReturnValue(false);
jest.spyOn(hierarchy.roomMap, "get").mockReturnValue({
children_state: [],
room_id: "room-id2",
canonical_alias: "canonical-alias",
aliases: ["uncanonical-alias", "canonical-alias"],
world_readable: true,
guest_can_join: false,
num_joined_members: 35,
});
jest.spyOn(dispatcher, "dispatch");
});
it("shows room", () => {
showRoom(client, hierarchy, "room-id2");
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
should_peek: true,
room_alias: "canonical-alias",
room_id: "room-id2",
via_servers: [],
oob_data: {
avatarUrl: undefined,
name: "canonical-alias",
},
roomType: undefined,
metricsTrigger: "RoomDirectory",
});
});
});
describe("toLocalRoom", () => {
stubClient();
const client = MatrixClientPeg.safeGet();
const roomV1 = mkStubRoom("room-id-1", "Room V1", client);
const roomV2 = mkStubRoom("room-id-2", "Room V2", client);
const roomV3 = mkStubRoom("room-id-3", "Room V3", client);
jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]);
it("grabs last room that is in hierarchy when latest version is in hierarchy", () => {
const hierarchy = {
roomMap: new Map([
[roomV1.roomId, { room_id: roomV1.roomId } as HierarchyRoom],
[roomV2.roomId, { room_id: roomV2.roomId } as HierarchyRoom],
[roomV3.roomId, { room_id: roomV3.roomId } as HierarchyRoom],
]),
} as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV3.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV3.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV3.roomId);
});
it("grabs last room that is in hierarchy when latest version is *not* in hierarchy", () => {
const hierarchy = {
roomMap: new Map([
[roomV1.roomId, { room_id: roomV1.roomId } as HierarchyRoom],
[roomV2.roomId, { room_id: roomV2.roomId } as HierarchyRoom],
]),
} as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV2.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV2.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV2.roomId);
});
it("returns specified room when none of the versions is in hierarchy", () => {
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV1.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV2.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV3.roomId);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
mocked(client.getRoomUpgradeHistory).mockClear();
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(roomV1.roomId, true, false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
mocked(client.getRoomUpgradeHistory).mockClear();
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(roomV1.roomId, true, true);
});
});
});
describe("<SpaceHierarchy />", () => {
beforeEach(() => {
// IntersectionObserver isn't available in test environment
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
} as ResizeObserver);
window.IntersectionObserver = mockIntersectionObserver;
});
stubClient();
const client = MatrixClientPeg.safeGet();
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
const root = mkStubRoom("space-id-1", "Space 1", client);
const room1 = mkStubRoom("room-id-2", "Room 1", client);
const room2 = mkStubRoom("room-id-3", "Room 2", client);
const space1 = mkStubRoom("space-id-4", "Space 2", client);
const room3 = mkStubRoom("room-id-5", "Room 3", client);
mocked(client.getRooms).mockReturnValue([root]);
mocked(client.getRoom).mockImplementation(
(roomId) => client.getRooms().find((room) => room.roomId === roomId) ?? null,
);
[room1, room2, space1, room3].forEach((r) => mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave));
const hierarchyRoot: HierarchyRoom = {
room_id: root.roomId,
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: room1.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: room2.roomId,
content: { order: "2" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: space1.roomId,
content: { order: "3" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: "!knock1:server",
content: { order: "4" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom1: HierarchyRoom = {
room_id: room1.roomId,
num_joined_members: 2,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom2: HierarchyRoom = {
room_id: room2.roomId,
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom3: HierarchyRoom = {
name: "Nested room",
room_id: room3.roomId,
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchySpace1: HierarchyRoom = {
room_id: space1.roomId,
name: "Nested space",
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: room3.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
const hierarchyKnockRoom1: HierarchyRoom = {
room_id: "!knock1:server",
name: "Knock room",
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
join_rule: JoinRule.Knock,
};
mocked(client.getRoomHierarchy).mockResolvedValue({
rooms: [
hierarchyRoot,
hierarchyRoom1,
hierarchyRoom2,
hierarchySpace1,
hierarchyRoom3,
hierarchyKnockRoom1,
],
});
const defaultProps = {
space: root,
showRoom: jest.fn(),
};
const getComponent = (props = {}): React.ReactElement => (
<MatrixClientContext.Provider value={client}>
<SpaceHierarchy {...defaultProps} {...props} />
</MatrixClientContext.Provider>
);
it("renders", async () => {
const { asFragment } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
expect(asFragment()).toMatchSnapshot();
});
it("should join subspace when joining nested room", async () => {
mocked(client.joinRoom).mockResolvedValue({} as Room);
const { getByText } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
const button = getByText("Nested room")!.closest("li")!.querySelector(".mx_AccessibleButton_kind_primary")!;
fireEvent.click(button);
await waitFor(() => {
expect(client.joinRoom).toHaveBeenCalledTimes(2);
});
// Joins subspace
expect(client.joinRoom).toHaveBeenCalledWith(space1.roomId, expect.any(Object));
expect(client.joinRoom).toHaveBeenCalledWith(room3.roomId, expect.any(Object));
});
it("should take user to view room for unjoined knockable rooms", async () => {
jest.spyOn(dispatcher, "dispatch");
const { getByText } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
const button = getByText("Knock room")!
.closest("li")!
.querySelector(".mx_AccessibleButton_kind_primary_outline")!;
fireEvent.click(button);
expect(defaultProps.showRoom).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
hierarchyKnockRoom1.room_id,
undefined,
);
});
});
});

View File

@@ -0,0 +1,74 @@
/*
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 TabbedView, { Tab, TabLocation } from "../../../src/components/structures/TabbedView";
import { NonEmptyArray } from "../../../src/@types/common";
import { _t } from "../../../src/languageHandler";
describe("<TabbedView />", () => {
const generalTab = new Tab("GENERAL", "common|general", "general", <div>general</div>);
const labsTab = new Tab("LABS", "common|labs", "labs", <div>labs</div>);
const securityTab = new Tab("SECURITY", "common|security", "security", <div>security</div>);
const defaultProps = {
tabLocation: TabLocation.LEFT,
tabs: [generalTab, labsTab, securityTab] as NonEmptyArray<Tab<any>>,
onChange: () => {},
};
const getComponent = (
props: {
activeTabId: "GENERAL" | "LABS" | "SECURITY";
onChange?: () => any;
tabs?: NonEmptyArray<Tab<any>>;
} = {
activeTabId: "GENERAL",
},
): React.ReactElement => <TabbedView {...defaultProps} {...props} />;
const getTabTestId = (tab: Tab<string>): string => `settings-tab-${tab.id}`;
const getActiveTab = (container: HTMLElement): Element | undefined =>
container.getElementsByClassName("mx_TabbedView_tabLabel_active")[0];
const getActiveTabBody = (container: HTMLElement): Element | undefined =>
container.getElementsByClassName("mx_TabbedView_tabPanel")[0];
it("renders tabs", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders activeTabId tab as active when valid", () => {
const { container } = render(getComponent({ activeTabId: securityTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(securityTab.label));
expect(getActiveTabBody(container)?.textContent).toEqual("security");
});
it("calls onchange on on tab click", () => {
const onChange = jest.fn();
const { getByTestId } = render(getComponent({ activeTabId: "GENERAL", onChange }));
act(() => {
fireEvent.click(getByTestId(getTabTestId(securityTab)));
});
expect(onChange).toHaveBeenCalledWith(securityTab.id);
});
it("keeps same tab active when order of tabs changes", () => {
// start with middle tab active
const { container, rerender } = render(getComponent({ activeTabId: labsTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));
rerender(getComponent({ tabs: [labsTab, generalTab, securityTab], activeTabId: labsTab.id }));
// labs tab still active
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));
});
});

View File

@@ -0,0 +1,288 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-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, fireEvent, waitFor, getByRole } from "jest-matrix-react";
import { mocked } from "jest-mock";
import {
MatrixClient,
MatrixEvent,
PendingEventOrdering,
Room,
FeatureSupport,
Thread,
} from "matrix-js-sdk/src/matrix";
import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from "../../../src/components/structures/ThreadPanel";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { _t } from "../../../src/languageHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils";
import { mkThread } from "../../test-utils/threads";
import { IRoomState } from "../../../src/components/structures/RoomView";
jest.mock("../../../src/utils/Feedback");
describe("ThreadPanel", () => {
describe("Header", () => {
it("expect that All filter for ThreadPanelHeader properly renders Show: All threads", () => {
const { asFragment } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("expect that My filter for ThreadPanelHeader properly renders Show: My threads", () => {
const { asFragment } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.My} setFilterOption={() => undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => {
const { container } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
const found = container.querySelector(".mx_ThreadPanel_dropdown");
expect(found).toBeTruthy();
expect(screen.queryByRole("menu")).toBeFalsy();
fireEvent.click(found!);
expect(screen.queryByRole("menu")).toBeTruthy();
});
it("expect that ThreadPanelHeader has the correct option selected in the context menu", () => {
const { container } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!);
const found = screen.queryAllByRole("menuitemradio");
expect(found).toHaveLength(2);
const foundButton = screen.queryByRole("menuitemradio", { checked: true });
expect(foundButton?.textContent).toEqual(
`${_t("threads|all_threads")}${_t("threads|all_threads_description")}`,
);
expect(foundButton).toMatchSnapshot();
});
it("sends an unthreaded read receipt when the Mark All Threads Read button is clicked", async () => {
const mockClient = createTestClient();
const mockEvent = {} as MatrixEvent;
const mockRoom = mkRoom(mockClient, "!roomId:example.org");
mockRoom.getLastLiveEvent.mockReturnValue(mockEvent);
const roomContextObject = {
room: mockRoom,
} as unknown as IRoomState;
const { container } = render(
<RoomContext.Provider value={roomContextObject}>
<MatrixClientContext.Provider value={mockClient}>
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />
</MatrixClientContext.Provider>
</RoomContext.Provider>,
);
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
await waitFor(() =>
expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(mockEvent, expect.anything(), true),
);
});
it("doesn't send a receipt if no room is in context", async () => {
const mockClient = createTestClient();
const { container } = render(
<MatrixClientContext.Provider value={mockClient}>
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />
</MatrixClientContext.Provider>,
);
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled());
});
});
describe("Filtering", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let mockClient: MatrixClient;
let room: Room;
const TestThreadPanel = () => (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider
value={getRoomContext(room, {
canSendMessages: true,
})}
>
<ThreadPanel
roomId={ROOM_ID}
onClose={jest.fn()}
resizeNotifier={new ResizeNotifier()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(async () => {
jest.clearAllMocks();
stubClient();
mockPlatformPeg();
mockClient = mocked(MatrixClientPeg.safeGet());
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(room, "fetchRoomThreads").mockReturnValue(Promise.resolve());
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
await room.createThreadsTimelineSets();
const [allThreads, myThreads] = room.threadsTimelineSets;
jest.spyOn(room, "createThreadsTimelineSets").mockReturnValue(Promise.resolve([allThreads!, myThreads!]));
});
function toggleThreadFilter(container: HTMLElement, newFilter: ThreadFilterType) {
fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!);
const found = screen.queryAllByRole("menuitemradio");
expect(found).toHaveLength(2);
const allThreadsContent = `${_t("threads|all_threads")}${_t("threads|all_threads_description")}`;
const myThreadsContent = `${_t("threads|my_threads")}${_t("threads|my_threads_description")}`;
const allThreadsOption = found.find((it) => it.textContent === allThreadsContent);
const myThreadsOption = found.find((it) => it.textContent === myThreadsContent);
expect(allThreadsOption).toBeTruthy();
expect(myThreadsOption).toBeTruthy();
const toSelect = newFilter === ThreadFilterType.My ? myThreadsOption : allThreadsOption;
fireEvent.click(toSelect!);
}
type EventData = { sender: string | null; content: string | null };
function findEvents(container: HTMLElement): EventData[] {
return Array.from(container.querySelectorAll(".mx_EventTile")).map((el) => {
const sender = el.querySelector(".mx_DisambiguatedProfile_displayName")?.textContent ?? null;
const content = el.querySelector(".mx_EventTile_body")?.textContent ?? null;
return { sender, content };
});
}
function toEventData(event: MatrixEvent): EventData {
return { sender: event.event.sender ?? null, content: event.event.content?.body ?? null };
}
it("correctly filters Thread List with multiple threads", async () => {
const otherThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [mockClient.getUserId()!],
});
const mixedThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [SENDER, mockClient.getUserId()!],
});
const ownThread = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
const threadRoots = [otherThread.rootEvent, mixedThread.rootEvent, ownThread.rootEvent];
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => {
const event = threadRoots.find((it) => it.getId() === eventId)?.event;
return event ? Promise.resolve(event) : Promise.reject();
});
const [allThreads, myThreads] = room.threadsTimelineSets;
allThreads!.addLiveEvent(otherThread.rootEvent);
allThreads!.addLiveEvent(mixedThread.rootEvent);
allThreads!.addLiveEvent(ownThread.rootEvent);
myThreads!.addLiveEvent(mixedThread.rootEvent);
myThreads!.addLiveEvent(ownThread.rootEvent);
let events: EventData[] = [];
const renderResult = render(<TestThreadPanel />);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(3);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
expect(events[1]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[2]).toEqual(toEventData(ownThread.rootEvent));
await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy());
toggleThreadFilter(renderResult.container, ThreadFilterType.My);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(2);
});
expect(events[0]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[1]).toEqual(toEventData(ownThread.rootEvent));
toggleThreadFilter(renderResult.container, ThreadFilterType.All);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(3);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
expect(events[1]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[2]).toEqual(toEventData(ownThread.rootEvent));
});
it("correctly filters Thread List with a single, unparticipated thread", async () => {
const otherThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [mockClient.getUserId()!],
});
const threadRoots = [otherThread.rootEvent];
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => {
const event = threadRoots.find((it) => it.getId() === eventId)?.event;
return event ? Promise.resolve(event) : Promise.reject();
});
const [allThreads] = room.threadsTimelineSets;
allThreads!.addLiveEvent(otherThread.rootEvent);
let events: EventData[] = [];
const renderResult = render(<TestThreadPanel />);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(1);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy());
toggleThreadFilter(renderResult.container, ThreadFilterType.My);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(0);
});
toggleThreadFilter(renderResult.container, ThreadFilterType.All);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(1);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
});
});
});

View File

@@ -0,0 +1,212 @@
/*
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, getByTestId, render, RenderResult, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import {
MsgType,
RelationType,
EventStatus,
MatrixEvent,
Room,
MatrixClient,
PendingEventOrdering,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import React, { useState } from "react";
import ThreadView from "../../../src/components/structures/ThreadView";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { Action } from "../../../src/dispatcher/actions";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { mockPlatformPeg } from "../../test-utils/platform";
import { getRoomContext } from "../../test-utils/room";
import { mkMessage, stubClient } from "../../test-utils/test-utils";
import { mkThread } from "../../test-utils/threads";
describe("ThreadView", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let mockClient: MatrixClient;
let room: Room;
let rootEvent: MatrixEvent;
let changeEvent: (event: MatrixEvent) => void;
function TestThreadView({ initialEvent }: { initialEvent?: MatrixEvent }) {
const [event, setEvent] = useState(rootEvent);
changeEvent = setEvent;
return (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider
value={getRoomContext(room, {
canSendMessages: true,
})}
>
<ThreadView
room={room}
onClose={jest.fn()}
mxEvent={event}
initialEvent={initialEvent}
resizeNotifier={new ResizeNotifier()}
/>
</RoomContext.Provider>
,
</MatrixClientContext.Provider>
);
}
async function getComponent(initialEvent?: MatrixEvent): Promise<RenderResult> {
const renderResult = render(<TestThreadView initialEvent={initialEvent} />);
await waitFor(() => {
expect(() => getByTestId(renderResult.container, "spinner")).toThrow();
});
return renderResult;
}
async function sendMessage(container: HTMLElement, text: string): Promise<void> {
const composer = getByTestId(container, "basicmessagecomposer");
await userEvent.click(composer);
await userEvent.keyboard(text);
const sendMessageBtn = getByTestId(container, "sendmessagebtn");
await userEvent.click(sendMessageBtn);
}
function expectedMessageBody(rootEvent: MatrixEvent, message: string) {
return {
"body": message,
"m.relates_to": {
"event_id": rootEvent.getId(),
"is_falling_back": true,
"m.in_reply_to": {
event_id: rootEvent
.getThread()!
.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name);
})!
.getId(),
},
"rel_type": RelationType.Thread,
},
"msgtype": MsgType.Text,
"m.mentions": {},
};
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
mockPlatformPeg();
mockClient = mocked(MatrixClientPeg.safeGet());
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const res = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
rootEvent = res.rootEvent;
DMRoomMap.makeShared(mockClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(SENDER);
});
it("does not include pending root event in the timeline twice", async () => {
rootEvent = mkMessage({
user: mockClient.getUserId()!,
event: true,
room: room.roomId,
msg: "root event message " + Math.random(),
});
rootEvent.status = EventStatus.SENDING;
rootEvent.setTxnId("1234");
room.addPendingEvent(rootEvent, "1234");
room.updatePendingEvent(rootEvent, EventStatus.SENT, rootEvent.getId());
const { container } = await getComponent();
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(1);
});
it("sends a message with the correct fallback", async () => {
const { container } = await getComponent();
await sendMessage(container, "Hello world!");
expect(mockClient.sendMessage).toHaveBeenCalledWith(
ROOM_ID,
rootEvent.getId(),
expectedMessageBody(rootEvent, "Hello world!"),
);
});
it("sends a thread message with the correct fallback", async () => {
const { container } = await getComponent();
const { rootEvent: rootEvent2 } = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
act(() => {
changeEvent(rootEvent2);
});
await sendMessage(container, "yolo");
expect(mockClient.sendMessage).toHaveBeenCalledWith(
ROOM_ID,
rootEvent2.getId(),
expectedMessageBody(rootEvent2, "yolo"),
);
});
it("sets the correct thread in the room view store", async () => {
// expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull();
const { unmount } = await getComponent();
waitFor(() => {
expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId());
});
unmount();
await waitFor(() => expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull());
});
it("clears highlight message in the room view store", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(room.roomId);
const mock = jest.spyOn(dispatcher, "dispatch");
const { unmount } = await getComponent(rootEvent);
mock.mockClear();
unmount();
expect(mock).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
/*
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 { jest } from "@jest/globals";
import { Room } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../test-utils";
import ContentMessages from "../../../src/ContentMessages";
import { RoomUpload } from "../../../src/models/RoomUpload";
import UploadBar from "../../../src/components/structures/UploadBar";
describe("UploadBar", () => {
const client = stubClient();
const room = new Room("!room;server", client, client.getSafeUserId());
it("should pluralise 5 files correctly", () => {
jest.spyOn(ContentMessages.sharedInstance(), "getCurrentUploads").mockReturnValue([
new RoomUpload(room.roomId, "file1.jpg", undefined, 420),
new RoomUpload(room.roomId, "file2"),
new RoomUpload(room.roomId, "file3"),
new RoomUpload(room.roomId, "file4"),
new RoomUpload(room.roomId, "file5"),
]);
const { getByText } = render(<UploadBar room={room} />);
expect(getByText("Uploading file1.jpg and 4 others (420 B)")).toBeInTheDocument();
});
it("should render a single upload correctly", () => {
jest.spyOn(ContentMessages.sharedInstance(), "getCurrentUploads").mockReturnValue([
new RoomUpload(room.roomId, "file1.zip", undefined, 420000000),
]);
const { getByText } = render(<UploadBar room={room} />);
expect(getByText("Uploading file1.zip (400.54 MB)")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,224 @@
/*
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, render, RenderResult, screen, waitFor } from "jest-matrix-react";
import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import UnwrappedUserMenu from "../../../src/components/structures/UserMenu";
import { stubClient, wrapInSdkContext } from "../../test-utils";
import {
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog";
import Modal from "../../../src/Modal";
import SettingsStore from "../../../src/settings/SettingsStore";
import { Features } from "../../../src/settings/Settings";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
import { Action } from "../../../src/dispatcher/actions";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
describe("<UserMenu>", () => {
let client: MatrixClient;
let renderResult: RenderResult;
let sdkContext: TestSdkContext;
beforeEach(() => {
sdkContext = new TestSdkContext();
});
describe("<UserMenu> when video broadcast", () => {
let voiceBroadcastInfoEvent: MatrixEvent;
let voiceBroadcastRecording: VoiceBroadcastRecording;
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
beforeAll(() => {
client = stubClient();
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
client.getUserId() || "",
client.getDeviceId() || "",
);
});
beforeEach(() => {
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client);
});
describe("when rendered", () => {
beforeEach(() => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
});
it("should render as expected", () => {
expect(renderResult.container).toMatchSnapshot();
});
describe("and a live voice broadcast starts", () => {
beforeEach(() => {
act(() => {
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
});
});
it("should render the live voice broadcast avatar addon", () => {
expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument();
});
describe("and the broadcast ends", () => {
beforeEach(() => {
act(() => {
voiceBroadcastRecordingsStore.clearCurrent();
});
});
it("should not render the live voice broadcast avatar addon", () => {
expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument();
});
});
});
});
});
describe("<UserMenu> logout", () => {
beforeEach(() => {
client = stubClient();
});
it("should logout directly if no crypto", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
jest.spyOn(client, "getCrypto").mockReturnValue(undefined);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({ action: "logout" });
});
});
it("should logout directly if no encrypted rooms", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({ action: "logout" });
});
});
it("should show dialog if some encrypted rooms", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockImplementation(async (roomId: string) => {
return roomId === "!room0";
});
const spy = jest.spyOn(Modal, "createDialog");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(LogoutDialog);
});
});
});
it("should render 'Link new device' button in OIDC native mode", async () => {
sdkContext.client = stubClient();
mocked(sdkContext.client.getAuthIssuer).mockResolvedValue({ issuer: "https://issuer/" });
const openIdMetadata = mockOpenIdConfiguration("https://issuer/");
openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE);
fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata);
fetchMock.get("https://issuer/jwks", {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
mocked(sdkContext.client.getVersions).mockResolvedValue({
versions: [],
unstable_features: {
"org.matrix.msc4108": true,
},
});
mocked(sdkContext.client.waitForClientWellKnown).mockResolvedValue({});
mocked(sdkContext.client.getCrypto).mockReturnValue({
isCrossSigningReady: jest.fn().mockResolvedValue(true),
exportSecretsBundle: jest.fn().mockResolvedValue({}),
} as unknown as CryptoApi);
await SettingsStore.setValue(Features.OidcNativeFlow, null, SettingLevel.DEVICE, true);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
render(<UserMenu isPanelCollapsed={true} />);
screen.getByRole("button", { name: /User menu/i }).click();
await expect(screen.findByText("Link new device")).resolves.toBeInTheDocument();
// Assert the QR code is shown directly
screen.getByRole("menuitem", { name: "Link new device" }).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
props: { showMsc4108QrCode: true },
});
});
});
});

View File

@@ -0,0 +1,63 @@
/*
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 { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import React from "react";
import ViewSource from "../../../src/components/structures/ViewSource";
import { mkEvent, stubClient, mkMessage } from "../../test-utils/test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
describe("ViewSource", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let redactedMessageEvent: MatrixEvent;
const redactionEvent = mkEvent({
user: SENDER,
event: true,
type: EventType.RoomRedaction,
content: {},
});
beforeEach(() => {
redactedMessageEvent = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
room_id: ROOM_ID,
sender: SENDER,
content: {},
state_key: undefined,
});
redactedMessageEvent.makeRedacted(redactionEvent, new Room(ROOM_ID, stubClient(), SENDER));
});
beforeEach(stubClient);
// See https://github.com/vector-im/element-web/issues/24165
it("doesn't error when viewing redacted encrypted messages", () => {
// Sanity checks
expect(redactedMessageEvent.isEncrypted()).toBeTruthy();
// @ts-ignore clearEvent is private, but it's being used directly <ViewSource />
expect(redactedMessageEvent.clearEvent).toBe(undefined);
expect(() => render(<ViewSource mxEvent={redactedMessageEvent} onFinished={() => {}} />)).not.toThrow();
});
it("should show edit button if we are the sender and can post an edit", () => {
const event = mkMessage({
msg: "Test",
user: MatrixClientPeg.get()!.getSafeUserId(),
room: ROOM_ID,
event: true,
});
const { getByRole } = render(<ViewSource mxEvent={event} onFinished={() => {}} />);
expect(getByRole("button", { name: "Edit" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilePanel renders empty state 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_FilePanel"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title_heading"
role="heading"
>
Files
</p>
</div>
<button
aria-labelledby="floating-ui-1"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
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="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>
</button>
</div>
<div
class="mx_RoomView_messagePanel mx_RoomView_messageListWrapper"
>
<div
class="mx_RoomView_empty"
>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
>
<svg
fill="currentColor"
height="32px"
viewBox="0 0 24 24"
width="32px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 4 20V4c0-.55.196-1.02.588-1.413A1.926 1.926 0 0 1 6 2h7.175a1.975 1.975 0 0 1 1.4.575l4.85 4.85a1.975 1.975 0 0 1 .575 1.4V20c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 18 22H6Zm7-14V4H6v16h12V9h-4a.968.968 0 0 1-.713-.287A.967.967 0 0 1 13 8Z"
/>
</svg>
<p
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
>
No files visible in this room
</p>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
Attach files from chat or just drag and drop them anywhere in a room.
</p>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MainSplit/> renders 1`] = `
<DocumentFragment>
<div
class="mx_MainSplit"
>
<div>
Child
<span>
Foo
</span>
Bar
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 320px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel
</div>
<div>
<div
class="mx_ResizeHandle--horizontal"
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; left: -5px; cursor: col-resize;"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<MainSplit/> respects defaultSize prop 1`] = `
<DocumentFragment>
<div
class="mx_MainSplit"
>
<div>
Child
<span>
Foo
</span>
Bar
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 500px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel
</div>
<div>
<div
class="mx_ResizeHandle--horizontal"
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; left: -5px; cursor: col-resize;"
/>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,430 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens after a session is restored 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens during crypto init 1`] = `
<body
style="--emoji-font-family: Twemoji;"
>
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
</body>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we are checking the sync store 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we were waiting for the lock ourselves 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 1`] = `
<div>
<div
class="mx_ConfirmSessionLockTheftView"
>
<div
class="mx_ConfirmSessionLockTheftView_body"
>
<p>
Test is open in another window. Click "Continue" to use Test here and disconnect the other window.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Continue
</div>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 2`] = `
<div>
<div
class="mx_MatrixChat_splash"
>
<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>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 3`] = `
<div>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
>
<div
class="mx_Welcome"
data-testid="mx_welcome_screen"
>
<div
class="mx_WelcomePage mx_WelcomePage_loggedIn"
>
<div
class="mx_WelcomePage_body"
>
<h1>
Hello
</h1>
</div>
</div>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
powered by Matrix
</a>
</footer>
</div>
</div>
`;
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
<div>
<div
class="mx_MatrixChat_splash"
>
<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>
`;
exports[`<MatrixChat /> with a soft-logged-out session should show the soft-logout page 1`] = `
<div>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
>
<div
class="mx_AuthHeader"
>
<aside
class="mx_AuthHeaderLogo"
>
Matrix
</aside>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
<main
class="mx_AuthBody"
>
<h1>
You're signed out
</h1>
<h2>
Sign in
</h2>
<div>
<form>
<p>
Enter your password to sign in and regain access to your account.
</p>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_Field_1"
label="Password"
placeholder="Password"
type="password"
value=""
/>
<label
for="mx_Field_1"
>
Password
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
type="submit"
>
Sign in
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Forgotten your password?
</div>
</form>
</div>
<h2>
Clear personal data
</h2>
<p>
Warning: your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.
</p>
<div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
role="button"
tabindex="0"
>
Clear all data
</div>
</div>
</main>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
powered by Matrix
</a>
</footer>
</div>
</div>
`;
exports[`<MatrixChat /> with an existing session onAction() room actions leave_room for a room should launch a confirmation modal 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"
>
Leave room
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<span>
Are you sure you want to leave the room '!room:server.org'?
</span>
</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"
>
Leave
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;
exports[`<MatrixChat /> with an existing session onAction() room actions leave_room for a space should launch a confirmation modal 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"
>
Leave space
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<span>
Are you sure you want to leave the space '!spaceRoom:server.org'?
</span>
</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"
>
Leave
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;

View File

@@ -0,0 +1,141 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MessagePanel should handle large numbers of hidden events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
/>
</div>
</div>
</DocumentFragment>
`;
exports[`MessagePanel should handle lots of membership events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
>
<li
data-scroll-tokens="__scroll_tokens__"
data-testid="__testid__"
>
<div
aria-label="Thu, Jan 1, 1970"
class="mx_TimelineSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Thu, Jan 1, 1970
</h2>
</div>
<hr
role="none"
/>
</div>
</li>
<div
class="mx_EventTileBubble mx_HistoryTile"
>
<div
class="mx_EventTileBubble_title"
>
You can't see earlier messages
</div>
</div>
<li
class="mx_GenericEventListSummary"
data-expanded="false"
data-layout="group"
data-scroll-tokens="__scroll_tokens__"
data-testid="__testid__"
>
<div
aria-expanded="false"
class="mx_AccessibleButton mx_GenericEventListSummary_toggle mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Expand
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_EventTile_info"
>
<span
class="mx_GenericEventListSummary_avatars"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="1"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@user:id"
>
u
</span>
</span>
<span
class="mx_TextualEvent mx_GenericEventListSummary_summary"
>
<span>
@user:id made no changes 100 times
</span>
</span>
</div>
</div>
</li>
</ol>
</div>
</div>
</DocumentFragment>
`;
exports[`MessagePanel should handle lots of room creation events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
/>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
<div>
content 2
<br />
content 2.2
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 2
<br />
content 2.2
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
</aside>
</div>
`;

View File

@@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to consent 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
<span>
You can't send any messages until you review and agree to
<a
class="mx_ExternalLink"
rel="noreferrer noopener"
target="_blank"
>
our terms and conditions
<i
class="mx_ExternalLink_icon"
/>
</a>
.
</span>
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentCancelAllBtn"
role="button"
tabindex="0"
>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
Retry all
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to resource limit 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentCancelAllBtn"
role="button"
tabindex="0"
>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
Retry all
</div>
</div>
</div>
</div>
</div>
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,426 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<DocumentFragment>
<div
class="mx_SearchBox mx_textinput"
>
<input
autocomplete="off"
class="mx_textinput_icon mx_textinput_search mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
data-testid="searchbox-input"
placeholder="Search names and descriptions"
type="text"
value=""
/>
<div
class="mx_AccessibleButton mx_SearchBox_closeButton"
role="button"
tabindex="-1"
/>
</div>
<div
class="mx_SpaceHierarchy_listHeader"
>
<h4
class="mx_SpaceHierarchy_listHeader_header"
>
Rooms and spaces
</h4>
<div
class="mx_SpaceHierarchy_listHeader_buttons"
>
<div
aria-disabled="true"
aria-label="Remove"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Remove
</div>
<div
aria-disabled="true"
aria-label="Mark as not suggested"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Mark as not suggested
</div>
</div>
</div>
<ul
aria-label="Space"
class="mx_SpaceHierarchy_list"
role="tree"
>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="0"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="5"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Unnamed Room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
2 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_vY7Q4uEh9K"
tabindex="0"
type="checkbox"
/>
<label
for="checkbox_vY7Q4uEh9K"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Unnamed Room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_38QgU2Pomx"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_38QgU2Pomx"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Knock room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="-1"
>
View
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_wKpa6hpi3Y"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_wKpa6hpi3Y"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
aria-expanded="true"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile mx_SpaceHierarchy_subspace"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_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: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Nested space
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
1 member · 1 room
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_EetmBG4yVC"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_EetmBG4yVC"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<div
class="mx_SpaceHierarchy_subspace_toggle mx_SpaceHierarchy_subspace_toggle_shown"
/>
</div>
<div
class="mx_SpaceHierarchy_subspace_children"
role="group"
/>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_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: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Nested room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="floating-ui-9"
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
disabled=""
id="checkbox_eEefiPqpMR"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_eEefiPqpMR"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</span>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;

View File

@@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TabbedView /> renders tabs 1`] = `
<div>
<div
class="mx_TabbedView mx_TabbedView_tabsOnLeft"
>
<ul
aria-orientation="vertical"
class="mx_TabbedView_tabLabels"
role="tablist"
>
<li
aria-controls="mx_tabpanel_GENERAL"
aria-selected="true"
class="mx_AccessibleButton mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active"
data-testid="settings-tab-GENERAL"
role="tab"
tabindex="0"
>
<span
class="mx_TabbedView_maskedIcon general"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_GENERAL_label"
>
General
</span>
</li>
<li
aria-controls="mx_tabpanel_LABS"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-LABS"
role="tab"
tabindex="-1"
>
<span
class="mx_TabbedView_maskedIcon labs"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_LABS_label"
>
Labs
</span>
</li>
<li
aria-controls="mx_tabpanel_SECURITY"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-SECURITY"
role="tab"
tabindex="-1"
>
<span
class="mx_TabbedView_maskedIcon security"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_SECURITY_label"
>
Security
</span>
</li>
</ul>
<div
aria-labelledby="mx_tabpanel_GENERAL_label"
class="mx_TabbedView_tabPanel"
id="mx_tabpanel_GENERAL"
>
<div
class="mx_AutoHideScrollbar mx_TabbedView_tabPanelContent"
tabindex="-1"
>
<div>
general
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard_header_title"
>
<button
aria-labelledby="floating-ui-1"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton mx_ThreadPanel_dropdown"
role="button"
tabindex="0"
>
Show: All threads
</div>
</div>
</DocumentFragment>
`;
exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly renders Show: My threads 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard_header_title"
>
<button
aria-labelledby="floating-ui-6"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton mx_ThreadPanel_dropdown"
role="button"
tabindex="0"
>
Show: My threads
</div>
</div>
</DocumentFragment>
`;
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
<div
aria-checked="true"
class="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
role="menuitemradio"
tabindex="0"
>
<span>
All threads
</span>
<span>
Shows all threads from current room
</span>
</div>
`;

View File

@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserMenu> <UserMenu> when video broadcast when rendered should render as expected 1`] = `
<div>
<div
class="mx_UserMenu"
>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="User menu"
class="mx_AccessibleButton mx_UserMenu_contextMenuButton"
role="button"
tabindex="0"
>
<div
class="mx_UserMenu_userAvatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
u
</span>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
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 } from "jest-mock";
import EventEmitter from "events";
import CompleteSecurity from "../../../../src/components/structures/auth/CompleteSecurity";
import { stubClient } from "../../../test-utils";
import { Phase, SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore";
import SdkConfig from "../../../../src/SdkConfig";
class MockSetupEncryptionStore extends EventEmitter {
public phase: Phase = Phase.Intro;
public lostKeys(): boolean {
return false;
}
public start: () => void = jest.fn();
public stop: () => void = jest.fn();
}
describe("CompleteSecurity", () => {
beforeEach(() => {
const client = stubClient();
const deviceIdToDevice = new Map();
deviceIdToDevice.set("DEVICE_ID", {
deviceId: "DEVICE_ID",
userId: "USER_ID",
});
const userIdToDevices = new Map();
userIdToDevices.set("USER_ID", deviceIdToDevice);
mocked(client.getCrypto()!.getUserDeviceInfo).mockResolvedValue(userIdToDevices);
const mockSetupEncryptionStore = new MockSetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(
mockSetupEncryptionStore as SetupEncryptionStore,
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("Renders with a cancel button by default", () => {
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders with a cancel button if forceVerification false", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "forceVerification") {
return false;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders without a cancel button if forceVerification true", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "force_verification") {
return true;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,427 @@
/*
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 { act, render, RenderResult, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
import {
clearAllModals,
filterConsole,
flushPromisesWithFakeTimers,
stubClient,
waitEnoughCyclesForModal,
} from "../../../test-utils";
import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
describe("<ForgotPassword>", () => {
const testEmail = "user@example.com";
const testSid = "sid42";
const testPassword = "cRaZyP4ssw0rd!";
let client: MatrixClient;
let serverConfig: ValidatedServerConfig;
let onComplete: () => void;
let onLoginClick: () => void;
let renderResult: RenderResult;
const typeIntoField = async (label: string, value: string): Promise<void> => {
await act(async () => {
await userEvent.type(screen.getByLabelText(label), value, { delay: null });
// the message is shown after some time
jest.advanceTimersByTime(500);
});
};
const click = async (element: Element): Promise<void> => {
await act(async () => {
await userEvent.click(element, { delay: null });
});
};
const itShouldCloseTheDialogAndShowThePasswordInput = (): void => {
it("should close the dialog and show the password input", () => {
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
expect(screen.getByText("Reset your password")).toBeInTheDocument();
});
};
filterConsole(
// not implemented by js-dom https://github.com/jsdom/jsdom/issues/1937
"Not implemented: HTMLFormElement.prototype.requestSubmit",
);
beforeEach(() => {
client = stubClient();
mocked(createClient).mockReturnValue(client);
serverConfig = { hsName: "example.com" } as ValidatedServerConfig;
onComplete = jest.fn();
onLoginClick = jest.fn();
jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig);
jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError");
});
afterEach(async () => {
// clean up modals
await clearAllModals();
});
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
describe("when starting a password reset flow", () => {
beforeEach(() => {
renderResult = render(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
it("should show the email input and mention the homeserver", () => {
expect(screen.queryByLabelText("Email address")).toBeInTheDocument();
expect(screen.queryByText("example.com")).toBeInTheDocument();
});
describe("and updating the server config", () => {
beforeEach(() => {
serverConfig.hsName = "example2.com";
renderResult.rerender(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
it("should show the new homeserver server name", () => {
expect(screen.queryByText("example2.com")).toBeInTheDocument();
});
});
describe("and clicking »Sign in instead«", () => {
beforeEach(async () => {
await click(screen.getByText("Sign in instead"));
});
it("should call onLoginClick()", () => {
expect(onLoginClick).toHaveBeenCalled();
});
});
describe("and entering a non-email value", () => {
beforeEach(async () => {
await typeIntoField("Email address", "not en email");
});
it("should show a message about the wrong format", () => {
expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument();
});
});
describe("and submitting an unknown email", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockRejectedValue({
errcode: "M_THREEPID_NOT_FOUND",
});
await click(screen.getByText("Send email"));
});
it("should show an email not found message", () => {
expect(screen.getByText("This email address was not found")).toBeInTheDocument();
});
});
describe("and a connection error occurs", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockRejectedValue({
name: "ConnectionError",
});
await click(screen.getByText("Send email"));
});
it("should show an info about that", () => {
expect(
screen.getByText(
"Cannot reach homeserver: " +
"Ensure you have a stable internet connection, or get in touch with the server admin",
),
).toBeInTheDocument();
});
});
describe("and the server liveness check fails", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({});
mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({
serverErrorIsFatal: true,
serverIsAlive: false,
serverDeadError: "server down",
});
await click(screen.getByText("Send email"));
});
it("should show the server error", () => {
expect(screen.queryByText("server down")).toBeInTheDocument();
});
});
describe("and submitting an known email", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockResolvedValue({
sid: testSid,
});
await click(screen.getByText("Send email"));
});
it("should send the mail and show the check email view", () => {
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
testEmail,
expect.any(String),
1, // second send attempt
);
expect(screen.getByText("Check your email to continue")).toBeInTheDocument();
expect(screen.getByText(testEmail)).toBeInTheDocument();
});
describe("and clicking »Re-enter email address«", () => {
beforeEach(async () => {
await click(screen.getByText("Re-enter email address"));
});
it("go back to the email input", () => {
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
});
});
describe("and clicking »Resend«", () => {
beforeEach(async () => {
await click(screen.getByText("Resend"));
// the message is shown after some time
jest.advanceTimersByTime(500);
});
it("should should resend the mail and show the tooltip", () => {
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
testEmail,
expect.any(String),
2, // second send attempt
);
expect(
screen.getByRole("tooltip", { name: "Verification link email resent!" }),
).toBeInTheDocument();
});
});
describe("and clicking »Next«", () => {
beforeEach(async () => {
await click(screen.getByText("Next"));
});
it("should show the password input view", () => {
expect(screen.getByText("Reset your password")).toBeInTheDocument();
});
describe("and entering different passwords", () => {
beforeEach(async () => {
await typeIntoField("New Password", testPassword);
await typeIntoField("Confirm new password", testPassword + "asd");
});
it("should show an info about that", () => {
expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument();
});
});
describe("and entering a new password", () => {
beforeEach(async () => {
mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 });
await typeIntoField("New Password", testPassword);
await typeIntoField("Confirm new password", testPassword);
});
describe("and submitting it running into rate limiting", () => {
beforeEach(async () => {
mocked(client.setPassword).mockRejectedValue({
message: "rate limit reached",
httpStatus: 429,
data: {
retry_after_ms: (13 * 60 + 37) * 1000,
},
});
await click(screen.getByText("Reset password"));
});
it("should show the rate limit error message", () => {
expect(
screen.getByText("Too many attempts in a short time. Retry after 13:37."),
).toBeInTheDocument();
});
});
describe("and confirm the email link and submitting the new password", () => {
beforeEach(async () => {
// fake link confirmed by resolving client.setPassword instead of raising an error
mocked(client.setPassword).mockResolvedValue({});
await click(screen.getByText("Reset password"));
});
it("should send the new password (once)", () => {
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
false,
);
// be sure that the next attempt to set the password would have been sent
jest.advanceTimersByTime(3000);
// it should not retry to set the password
expect(client.setPassword).toHaveBeenCalledTimes(1);
});
});
describe("and submitting it", () => {
beforeEach(async () => {
await click(screen.getByText("Reset password"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should send the new password and show the click validation link dialog", () => {
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
false,
);
expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
expect(screen.getByText(testEmail)).toBeInTheDocument();
});
describe("and dismissing the dialog by clicking the background", () => {
beforeEach(async () => {
await act(async () => {
await userEvent.click(screen.getByTestId("dialog-background"), { delay: null });
});
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and dismissing the dialog", () => {
beforeEach(async () => {
await click(screen.getByLabelText("Close dialog"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and clicking »Re-enter email address«", () => {
beforeEach(async () => {
await click(screen.getByText("Re-enter email address"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should close the dialog and go back to the email input", () => {
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
});
});
describe("and validating the link from the mail", () => {
beforeEach(async () => {
mocked(client.setPassword).mockResolvedValue({});
// be sure the next set password attempt was sent
jest.advanceTimersByTime(3000);
// quad flush promises for the modal to disappear
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
});
it("should display the confirm reset view and now show the dialog", () => {
expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument();
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
});
});
});
describe("and clicking »Sign out of all devices« and »Reset password«", () => {
beforeEach(async () => {
await click(screen.getByText("Sign out of all devices"));
await click(screen.getByText("Reset password"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should show the sign out warning dialog", async () => {
expect(
screen.getByText(
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
),
).toBeInTheDocument();
// confirm dialog
await click(screen.getByText("Continue"));
// expect setPassword with logoutDevices = true
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
true,
);
});
});
});
});
});
});
});

View File

@@ -0,0 +1,473 @@
/*
Copyright 2019-2024 New Vector Ltd.
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, waitForElementToBeRemoved } from "jest-matrix-react";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import SdkConfig from "../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import Login from "../../../../src/components/structures/auth/Login";
import BasePlatform from "../../../../src/BasePlatform";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Features } from "../../../../src/settings/Settings";
import * as registerClientUtils from "../../../../src/utils/oidc/registerClient";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
jest.useRealTimers();
const oidcStaticClientsConfig = {
"https://staticallyregisteredissuer.org/": {
client_id: "static-clientId-123",
},
};
describe("Login", function () {
let platform: MockedObject<BasePlatform>;
const mockClient = mocked({
login: jest.fn().mockResolvedValue({}),
loginFlows: jest.fn(),
} as unknown as Matrix.MatrixClient);
beforeEach(function () {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: true,
oidc_static_clients: oidcStaticClientsConfig,
});
mockClient.login.mockClear().mockResolvedValue({
access_token: "TOKEN",
device_id: "IAMADEVICE",
user_id: "@user:server",
});
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
jest.spyOn(Matrix, "createClient").mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.resetBehavior();
fetchMock.resetHistory();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
platform = mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: OidcClientConfig,
) {
return (
<Login
serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)}
onLoggedIn={() => {}}
onRegisterClick={() => {}}
onServerConfigChange={() => {}}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: OidcClientConfig) {
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
}
it("should show form with change server link", async () => {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: false,
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
});
it("should show form without change server link when custom URLs disabled", async () => {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelectorAll(".mx_ServerPicker_change")).toHaveLength(0);
});
it("should show SSO button if that flow is available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show both SSO button and username+password if both are available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }, { type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show multiple SSO buttons if multiple identity_providers are available", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
{
id: "a",
name: "Provider 1",
},
{
id: "b",
name: "Provider 2",
},
{
id: "c",
name: "Provider 3",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(3);
});
it("should show single SSO button if identity_providers is null", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[0][0].baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
});
it("should handle updating to a server with no supported flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// update the mock for the new server with no supported flows
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
// render with a new server
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
// no sso button because server2 doesnt support sso
expect(container.querySelector(".mx_SSOButton")).not.toBeInTheDocument();
});
it("should show single Continue button if OIDC MSC3824 compatibility is given by server", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
it("should show branded SSO buttons", async () => {
const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({
id: brand,
brand,
name: `Provider ${brand}`,
}));
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
...idpsWithIcons,
{
id: "foo",
name: "Provider foo",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
for (const idp of idpsWithIcons) {
const ssoButton = container.querySelector(`.mx_SSOButton.mx_SSOButton_brand_${idp.brand}`);
expect(ssoButton).toBeTruthy();
expect(ssoButton?.querySelector(`img[alt="${idp.brand}"]`)).toBeTruthy();
}
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(idpsWithIcons.length + 1);
});
it("should display an error when homeserver doesn't offer any supported login flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
});
it("should display a connection error when getting login flows fails", async () => {
mockClient.loginFlows.mockRejectedValue("oups");
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("There was a problem communicating with the homeserver, please try again later."),
).toBeInTheDocument();
});
it("should display an error when homeserver fails liveliness check", async () => {
fetchMock.resetBehavior();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 0,
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
});
it("should reset liveliness error when server config changes", async () => {
fetchMock.resetBehavior();
// matrix.org is not alive
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 400,
});
// but server2 is
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
const { rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error cleared
expect(screen.queryByText("Cannot reach homeserver")).not.toBeInTheDocument();
});
describe("OIDC native flow", () => {
const hsUrl = "https://matrix.org";
const isUrl = "https://vector.im";
const issuer = "https://test.com/";
const delegatedAuth = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
jest.spyOn(logger, "error");
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === Features.OidcNativeFlow,
);
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should not attempt registration when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
it("should attempt to register oidc client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
// called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
});
it("should fallback to normal login when client registration fails", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(new Error(OidcError.DynamicRegistrationFailed));
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// did not continue with matrix login
expect(mockClient.loginFlows).not.toHaveBeenCalled();
expect(screen.getByText("Continue")).toBeInTheDocument();
});
/**
* Oidc-aware flows still work while the oidc-native feature flag is disabled
*/
it("should show oidc-aware flow for oidc-enabled homeserver when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// oidc-aware 'continue' button displayed
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,67 @@
/*
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 { act, render, RenderResult } from "jest-matrix-react";
import React, { ComponentProps } from "react";
import EventEmitter from "events";
import { CryptoEvent } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { LoginSplashView } from "../../../../src/components/structures/auth/LoginSplashView";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
describe("<LoginSplashView />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = new EventEmitter() as unknown as MatrixClient;
});
function getComponent(props: Partial<ComponentProps<typeof LoginSplashView>> = {}): RenderResult {
const defaultProps = {
matrixClient,
onLogoutClick: () => {},
syncError: null,
};
return render(<LoginSplashView {...defaultProps} {...props} />);
}
it("Renders a spinner", () => {
const rendered = getComponent();
expect(rendered.getByTestId("spinner")).toBeInTheDocument();
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Renders an error message", () => {
const rendered = getComponent({ syncError: new Error("boohoo") });
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Calls onLogoutClick", () => {
const onLogoutClick = jest.fn();
const rendered = getComponent({ onLogoutClick });
expect(onLogoutClick).not.toHaveBeenCalled();
rendered.getByRole("button", { name: "Logout" }).click();
expect(onLogoutClick).toHaveBeenCalled();
});
it("Shows migration progress", async () => {
const rendered = getComponent();
act(() => {
matrixClient.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, 5, 10);
});
rendered.getByText("Hang tight.", { exact: false });
// Wait for the animation to update
await act(() => sleep(500));
const progress = rendered.getByRole("progressbar");
expect(progress.getAttribute("value")).toEqual("5");
expect(progress.getAttribute("max")).toEqual("10");
});
});

View File

@@ -0,0 +1,249 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
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, waitForElementToBeRemoved } from "jest-matrix-react";
import { createClient, MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import SdkConfig, { DEFAULTS } from "../../../../src/SdkConfig";
import { getMockClientWithEventEmitter, mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import Registration from "../../../../src/components/structures/auth/Registration";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Features } from "../../../../src/settings/Settings";
import { startOidcLogin } from "../../../../src/utils/oidc/authorize";
jest.mock("../../../../src/utils/oidc/authorize", () => ({
startOidcLogin: jest.fn(),
}));
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
describe("Registration", function () {
let mockClient!: MockedObject<MatrixClient>;
beforeEach(function () {
SdkConfig.put({
...DEFAULTS,
disable_custom_urls: true,
});
mockClient = getMockClientWithEventEmitter({
registerRequest: jest.fn(),
loginFlows: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }),
});
mockClient.registerRequest.mockRejectedValueOnce(
new MatrixError(
{
flows: [{ stages: [] }],
},
401,
),
);
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
mocked(createClient).mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.catch(404);
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
jest.restoreAllMocks();
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
const defaultProps = {
defaultDeviceDisplayName: "test-device-display-name",
onLoggedIn: jest.fn(),
onLoginClick: jest.fn(),
onServerConfigChange: jest.fn(),
};
const defaultHsUrl = "https://matrix.org";
const defaultIsUrl = "https://vector.im";
function getRawComponent(
hsUrl = defaultHsUrl,
isUrl = defaultIsUrl,
authConfig?: OidcClientConfig,
mobileRegister?: boolean,
) {
return (
<Registration
{...defaultProps}
serverConfig={mkServerConfig(hsUrl, isUrl, authConfig)}
mobileRegister={mobileRegister}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig, mobileRegister?: boolean) {
return render(getRawComponent(hsUrl, isUrl, authConfig, mobileRegister));
}
it("should show server picker", async function () {
const { container } = getComponent();
expect(container.querySelector(".mx_ServerPicker")).toBeTruthy();
});
it("should show form when custom URLs disabled", async function () {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
});
it("should show SSO options if those are available", async () => {
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://server2");
});
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const clientId = "test-client-id";
// @ts-ignore
authConfig.metadata["prompt_values_supported"] = ["create"];
beforeEach(() => {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.metadata.issuer]: {
client_id: clientId,
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.metadata.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata);
fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] });
});
describe("when oidc native flow is not enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("should display user/pass registration form", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(mockClient.loginFlows).toHaveBeenCalled();
expect(mockClient.registerRequest).toHaveBeenCalled();
});
});
describe("when oidc native flow is enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key) => key === Features.OidcNativeFlow);
});
it("should display oidc-native continue button", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// no form
expect(container.querySelector("form")).toBeFalsy();
expect(await screen.findByText("Continue")).toBeTruthy();
});
it("should start OIDC login flow as registration on button click", async () => {
getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(await screen.findByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
authConfig,
clientId,
defaultHsUrl,
defaultIsUrl,
// isRegistration
true,
);
});
});
describe("when is mobile registeration", () => {
it("should not show server picker", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
expect(container.querySelector(".mx_ServerPicker")).toBeFalsy();
});
it("should show username field with autocaps disabled", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() =>
expect(container.querySelector("#mx_RegistrationForm_username")).toHaveAttribute(
"autocapitalize",
"none",
),
);
});
it("should show password and confirm password fields in separate rows", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() => expect(container.querySelector("#mx_RegistrationForm_username")).toBeTruthy());
// when password and confirm password fields are in separate rows there should be 4 rather than 3
expect(container.querySelectorAll(".mx_AuthBody_fieldRow")).toHaveLength(4);
});
});
});
});

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginSplashView /> Renders a spinner 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<LoginSplashView /> Renders an error message 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_LoginSplashView_syncError"
>
<div>
Unable to connect to Homeserver. Retrying…
</div>
</div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;