Move some of the slowest tests from jest to vitest (#34277)

* Remove doubled-up I18n Provider. ModalManager already provides this.

* Reconfigure svgr into a vite-friendly `?react` resource query

* Move InviteDialog test to vitest

* Move RoomHeader tests to Vitest

* Share serializer between Jest & Vitest

* Attempt to stabilise InviteDialog test

* Fix async leaks

* Iterate based on copilot review

* Fix InviteDialog throttle

* Iterate

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix lockfile

* Iterate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Telatynski
2026-07-22 13:18:04 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 5b0fd416fd
commit 2430e0ab8b
35 changed files with 612 additions and 548 deletions
+4 -2
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { vi, expect as viExpect } from "vitest";
import { vi, expect as viExpect, beforeAll as viBeforeAll, afterAll as viAfterAll } from "vitest";
import { mocked as jestMocked } from "jest-mock";
export const isJest = typeof jest !== "undefined";
@@ -26,6 +26,8 @@ const mocked = adapter.mocked;
export { adapter as vi, mocked };
const _expect = isJest ? (expect as unknown as typeof viExpect) : viExpect;
export { _expect as expect };
const _beforeAll = isJest ? (beforeAll as unknown as typeof viBeforeAll) : viBeforeAll;
const _afterAll = isJest ? (afterAll as unknown as typeof viAfterAll) : viAfterAll;
export { _expect as expect, _beforeAll as beforeAll, _afterAll as afterAll };
export { type Mocked, type MockedObject } from "vitest";
-37
View File
@@ -21,43 +21,6 @@ declare global {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const REACT_USE_ID = /_r_[a-z0-9]+_/g;
function normaliseReactUseIds(snapshot: string): string {
// React useId values can vary between runs and make snapshots flaky:
// https://github.com/element-hq/element-web/issues/31765
// Avoid running the regex for DOM snapshots without React useId output.
if (!snapshot.includes("_r_")) return snapshot;
const ids = new Map<string, string>();
let nextId = 1;
return snapshot.replace(REACT_USE_ID, (id) => {
let replacement = ids.get(id);
if (!replacement) {
replacement = `react-use-id-${nextId++}`;
ids.set(id, replacement);
}
return replacement;
});
}
// Prevent this serializer from recursively matching the same DOM node when it calls serialize().
let isSerializingDomSnapshot = false;
expect.addSnapshotSerializer({
test: (value: unknown): value is Element | DocumentFragment =>
!isSerializingDomSnapshot && (value instanceof Element || value instanceof DocumentFragment),
print: (value: unknown, serialize: (value: unknown) => string): string => {
isSerializingDomSnapshot = true;
try {
return normaliseReactUseIds(serialize(value));
} finally {
isSerializingDomSnapshot = false;
}
},
});
// Fake random strings to give a predictable snapshot for IDs
jest.mock("matrix-js-sdk/src/randomstring");
beforeEach(() => {
+2
View File
@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { beforeAll, afterAll } from "../setup/adapter.ts";
type FilteredConsole = Pick<Console, "log" | "error" | "info" | "debug" | "warn">;
/**
+1
View File
@@ -366,6 +366,7 @@ export function createTestClient(): MatrixClient {
setRoomTag: vi.fn().mockResolvedValue({}),
getExtendedProfileProperty: vi.fn(),
setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: vi.fn(),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);
@@ -1,502 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { findByText, fireEvent, render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type MatrixClient, MatrixError, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { sleep } from "matrix-js-sdk/src/utils";
import { mocked, type Mocked } from "jest-mock-vitest-adapter";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import InviteDialog from "../../../../../src/components/views/dialogs/InviteDialog";
import { InviteKind } from "../../../../../src/components/views/dialogs/InviteDialogTypes";
import {
clearAllModals,
filterConsole,
flushPromises,
getMockClientWithEventEmitter,
mkMembership,
mkMessage,
mkRoomCreateEvent,
} from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import SdkConfig from "../../../../../src/SdkConfig";
import { type ValidatedServerConfig } from "../../../../../src/utils/ValidatedServerConfig";
import { type IConfigOptions } from "../../../../../src/IConfigOptions";
import { SDKContextClass } from "../../../../../src/contexts/SDKContextClass";
import { type IProfileInfo } from "../../../../../src/hooks/useProfileInfo";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages";
import { TestSDKContext } from "../../../TestSDKContext.ts";
const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken");
jest.mock("../../../../../src/IdentityAuthClient", () =>
jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})),
);
jest.mock("../../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../../src/utils/direct-messages"),
__esModule: true,
startDmOnFirstMessage: jest.fn(),
}));
const getSearchField = () => screen.getByTestId("invite-dialog-input");
const enterIntoSearchField = async (value: string) => {
const searchField = getSearchField();
await userEvent.clear(searchField);
await userEvent.type(searchField, value + "{enter}");
};
const pasteIntoSearchField = async (value: string) => {
const searchField = getSearchField();
await userEvent.clear(searchField);
searchField.focus();
await userEvent.paste(value);
};
const expectPill = (value: string) => {
expect(screen.getByText(value)).toBeInTheDocument();
expect(getSearchField()).toHaveValue("");
};
const expectNoPill = (value: string) => {
expect(screen.queryByText(value)).not.toBeInTheDocument();
expect(getSearchField()).toHaveValue(value);
};
const serverDomain = "example.org";
const roomId = "!111111111111111111:example.org";
const aliceId = "@alice:example.org";
const aliceEmail = "foobar@email.com";
const bobId = "@bob:example.org";
const bobEmail = "bobbob@example.com"; // bob@example.com is already used as an example in the invite dialog
const carolId = "@carol:example.com";
const bobbob = "bobbob";
const aliceProfileInfo: IProfileInfo = {
user_id: aliceId,
display_name: "Alice",
};
const bobProfileInfo: IProfileInfo = {
user_id: bobId,
display_name: "Bob",
};
describe("InviteDialog", () => {
let mockClient: Mocked<MatrixClient>;
let room: Room;
let sdkContext: TestSDKContext;
filterConsole(
"Error retrieving profile for userId @carol:example.com",
"Error retrieving profile for userId @localpart:server.tld",
"Error retrieving profile for userId @localpart:server:tld",
"[Invite:Recents] Excluding @alice:example.org from recents",
);
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
getCrypto: jest.fn().mockReturnValue({
getUserVerificationStatus: jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, true, false)),
}),
getDomain: jest.fn().mockReturnValue(serverDomain),
getUserId: jest.fn().mockReturnValue(bobId),
getSafeUserId: jest.fn().mockReturnValue(bobId),
isGuest: jest.fn().mockReturnValue(false),
getVisibleRooms: jest.fn().mockReturnValue([]),
getRoom: jest.fn(),
getRooms: jest.fn(),
getAccountData: jest.fn(),
getPushActionsForEvent: jest.fn(),
mxcUrlToHttp: jest.fn().mockReturnValue(""),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getProfileInfo: jest.fn().mockImplementation(async (userId: string) => {
if (userId === aliceId) return aliceProfileInfo;
if (userId === bobId) return bobProfileInfo;
throw new MatrixError({
errcode: "M_UNKNOWN",
error: "Profile not found",
});
}),
getIdentityServerUrl: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({}),
lookupThreePid: jest.fn(),
registerWithIdentityServer: jest.fn().mockResolvedValue({
access_token: "access_token",
token: "token",
}),
getOpenIdToken: jest.fn().mockResolvedValue({}),
getIdentityAccount: jest.fn().mockResolvedValue({}),
getTerms: jest.fn().mockResolvedValue({ policies: [] }),
supportsThreads: jest.fn().mockReturnValue(false),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
getClientWellKnown: jest.fn().mockResolvedValue({}),
invite: jest.fn(),
});
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
DMRoomMap.makeShared(mockClient);
jest.clearAllMocks();
room = new Room(roomId, mockClient, mockClient.getSafeUserId());
room.addLiveEvents(
[
mkMessage({
msg: "Hello",
relatesTo: undefined,
event: true,
room: roomId,
user: mockClient.getSafeUserId(),
ts: Date.now(),
}),
],
{ addToState: true },
);
room.currentState.setStateEvents([
mkRoomCreateEvent(bobId, roomId),
mkMembership({
event: true,
room: roomId,
mship: KnownMembership.Join,
user: aliceId,
skey: aliceId,
}),
]);
jest.spyOn(DMRoomMap.shared(), "getUniqueRoomsWithIndividuals").mockReturnValue({
[aliceId]: room,
});
mockClient.getRooms.mockReturnValue([room]);
mockClient.getRoom.mockReturnValue(room);
sdkContext = new TestSDKContext();
// @ts-ignore UserMenuViewModel uses SDKContext in the constructor
SDKContextClass.instance = sdkContext;
sdkContext._client = mockClient;
});
afterEach(async () => {
await clearAllModals();
SDKContextClass.instance.onLoggedOut();
});
afterAll(() => {
jest.restoreAllMocks();
});
it("should label with space name", () => {
room.isSpaceRoom = jest.fn().mockReturnValue(true);
room.getType = jest.fn().mockReturnValue(RoomType.Space);
room.name = "Space";
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
expect(screen.queryByText("Invite to Space")).toBeTruthy();
});
it("should label with room name", () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
expect(screen.getByText(`Invite to ${roomId}`)).toBeInTheDocument();
});
it("should not suggest valid unknown MXIDs", async () => {
render(
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
initialText="@localpart:server.tld"
/>,
);
await flushPromises();
expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument();
});
it("should not suggest invalid MXIDs", () => {
render(
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
initialText="@localpart:server:tld"
/>,
);
expect(screen.queryByText("@localpart:server:tld")).toBeFalsy();
});
it.each([[InviteKind.Dm], [InviteKind.Invite]] as [typeof InviteKind.Dm | typeof InviteKind.Invite][])(
"should lookup inputs which look like email addresses (%s)",
async (kind: typeof InviteKind.Dm | typeof InviteKind.Invite) => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({
address: aliceEmail,
medium: "email",
mxid: aliceId,
});
mockClient.getProfileInfo.mockResolvedValue({
displayname: "Mrs Alice",
avatar_url: "mxc://foo/bar",
});
render(
<InviteDialog
kind={kind}
roomId={kind === InviteKind.Invite ? roomId : ""}
onFinished={jest.fn()}
initialText={aliceEmail}
/>,
);
await screen.findByText("Mrs Alice");
// expect the email and MXID to be visible
await screen.findByText(aliceId);
await screen.findByText(aliceEmail);
expect(mockClient.lookupThreePid).toHaveBeenCalledWith("email", aliceEmail, expect.anything());
expect(mockClient.getProfileInfo).toHaveBeenCalledWith(aliceId);
},
);
it("should suggest e-mail even if lookup fails", async () => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({});
render(
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
initialText="foobar@email.com"
/>,
);
await screen.findByText("foobar@email.com");
await screen.findByText("Invite by email");
});
it("should add pasted values", async () => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({});
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
const input = screen.getByTestId("invite-dialog-input");
input.focus();
await userEvent.paste(`${bobId} ${aliceEmail}`);
await screen.findAllByText(bobId);
await screen.findByText(aliceEmail);
expect(input).toHaveValue("");
});
it("should support pasting one username that is not a mx id or email", async () => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({});
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
const input = screen.getByTestId("invite-dialog-input");
input.focus();
await userEvent.paste(`${bobbob}`);
await screen.findAllByText(bobId);
expect(input).toHaveValue(`${bobbob}`);
});
it("should allow to invite multiple emails to a room", async () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
await enterIntoSearchField(aliceEmail);
expectPill(aliceEmail);
await enterIntoSearchField(bobEmail);
expectPill(bobEmail);
});
describe("when encryption by default is disabled", () => {
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
default: false,
},
});
});
it("should allow to invite more than one email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
await enterIntoSearchField(aliceEmail);
expectPill(aliceEmail);
await enterIntoSearchField(bobEmail);
expectPill(bobEmail);
});
});
it("should not allow to invite more than one email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
// Start with an email → should convert to a pill
await enterIntoSearchField(aliceEmail);
expect(screen.getByText("Invites by email can only be sent one at a time")).toBeInTheDocument();
expectPill(aliceEmail);
// Everything else from now on should not convert to a pill
await enterIntoSearchField(bobEmail);
expectNoPill(bobEmail);
await enterIntoSearchField(aliceId);
expectNoPill(aliceId);
await pasteIntoSearchField(bobEmail);
expectNoPill(bobEmail);
});
it("should not allow to invite a MXID and an email to a DM", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
// Start with a MXID → should convert to a pill
await enterIntoSearchField(carolId);
expect(screen.queryByText("Invites by email can only be sent one at a time")).not.toBeInTheDocument();
expectPill(carolId);
// Add an email → should not convert to a pill
await enterIntoSearchField(bobEmail);
expect(screen.getByText("Invites by email can only be sent one at a time")).toBeInTheDocument();
expectNoPill(bobEmail);
});
it("should start a DM if the profile is available", async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
await enterIntoSearchField(aliceId);
await userEvent.click(screen.getByRole("button", { name: "Go" }));
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [
new DirectoryMember({
user_id: aliceId,
}),
]);
});
it("should not allow pasting the same user multiple times", async () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
const input = screen.getByTestId("invite-dialog-input");
input.focus();
await userEvent.paste(`${bobId}`);
await userEvent.paste(`${bobId}`);
await userEvent.paste(`${bobId}`);
expect(input).toHaveValue("");
await expect(screen.findAllByText(bobId, { selector: "a" })).resolves.toHaveLength(1);
});
it("should add to selection on click of user tile", async () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
const input = screen.getByTestId("invite-dialog-input");
input.focus();
await userEvent.keyboard(`${aliceId}`);
const btn = await screen.findByRole("option", { name: aliceId });
fireEvent.click(btn);
const tile = await findByText(screen.getByTestId("invite-dialog-input-wrapper"), aliceId);
expect(tile).toBeInTheDocument();
});
describe("while the invite is in progress", () => {
it("should show a spinner", async () => {
mockClient.invite.mockReturnValue(new Promise(() => {}));
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
await enterIntoSearchField(bobId);
await userEvent.click(screen.getByRole("button", { name: "Invite" }));
await screen.findByText("Preparing invitations...");
});
});
describe("when inviting a user with an unknown profile", () => {
beforeEach(async () => {
mocked(startDmOnFirstMessage).mockClear();
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
await enterIntoSearchField(carolId);
await userEvent.click(screen.getByRole("button", { name: "Go" }));
// modal rendering has some weird sleeps - fake timers will mess up the entire test
await sleep(100);
});
it("should start the DM directly", () => {
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [
new DirectoryMember({
user_id: carolId,
}),
]);
});
});
it("should not suggest users from other server when room has m.federate=false", async () => {
room.currentState.setStateEvents([mkRoomCreateEvent(bobId, roomId, { "m.federate": false })]);
render(
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
initialText="@localpart:server.tld"
/>,
);
await flushPromises();
expect(screen.queryByText("@localpart:server.tld")).not.toBeInTheDocument();
});
describe("when inviting a user whose cryptographic identity we do not know", () => {
beforeEach(() => {
mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => {
return new UserVerificationStatus(false, false, false, false);
});
});
describe.each([InviteKind.Invite, InviteKind.Dm])("with invitekind '%s'", (kind) => {
const goButtonName = kind == InviteKind.Invite ? "Invite" : "Go";
beforeEach(() => {
render(
<InviteDialog
kind={kind as InviteKind.Invite | InviteKind.Dm}
roomId={roomId}
onFinished={jest.fn()}
/>,
);
});
it("should show a warning when inviting by user id", async () => {
await enterIntoSearchField(aliceId);
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1);
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId);
});
it("should show a warning when inviting by email address", async () => {
await enterIntoSearchField("aaa@bbb");
await userEvent.click(screen.getByRole("button", { name: goButtonName }));
await screen.findByText("Confirm inviting them", { exact: false });
// We shouldn't call getUserVerificationStatus on an email address
expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled();
});
});
});
});
@@ -1,278 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, getByLabelText, getByText, render, screen, waitFor } from "jest-matrix-react";
import { type EventTimeline, JoinRule, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { SDKContext } from "../../../../../../src/contexts/SDKContext";
import { TestSDKContext } from "../../../../TestSDKContext.ts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import {
CallGuestLinkButton,
JoinRuleDialog,
} from "../../../../../../src/components/views/rooms/RoomHeader/CallGuestLinkButton";
import Modal from "../../../../../../src/Modal";
import SdkConfig from "../../../../../../src/SdkConfig";
import { ShareDialog } from "../../../../../../src/components/views/dialogs/ShareDialog";
import { _t } from "../../../../../../src/languageHandler";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
describe("<CallGuestLinkButton />", () => {
const roomId = "!room:server.org";
let sdkContext!: TestSDKContext;
let modalSpy: jest.SpyInstance;
let modalResolve: (value: unknown[] | PromiseLike<unknown[]>) => void;
let room: Room;
const targetUnencrypted =
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&viaServers=example.org";
const targetEncrypted =
"https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
const expectedShareDialogProps = {
target: targetEncrypted,
customTitle: "Conference invite link",
subtitle: "Link for external users to join the call without a matrix account:",
};
/**
* Create a room using mocked client
* And mock isElementVideoRoom
*/
const makeRoom = (isVideoRoom = true): Room => {
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
sdkContext.client!.getRoomDirectoryVisibility = jest.fn().mockResolvedValue("public");
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
// stub
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
jest.spyOn(room, "getVersion").mockReturnValue("9");
return room;
};
function mockRoomMembers(room: Room, count: number) {
const members = Array(count)
.fill(0)
.map((_, index) => ({
userId: `@user-${index}:example.org`,
roomId: room.roomId,
membership: KnownMembership.Join,
}));
room.currentState.setJoinedMemberCount(members.length);
room.getJoinedMembers = jest.fn().mockReturnValue(members);
}
const getComponent = (room: Room) =>
render(<CallGuestLinkButton room={room} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
const oldGet = SdkConfig.get;
beforeEach(() => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
sendStateEvent: jest.fn(),
getVisibleRooms: jest.fn().mockReturnValue([]),
});
sdkContext = new TestSDKContext();
sdkContext._client = client;
const modalPromise = new Promise<unknown[]>((resolve) => {
modalResolve = resolve;
});
modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: modalPromise, close: jest.fn() });
room = makeRoom();
mockRoomMembers(room, 3);
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
}
return oldGet(key);
});
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
jest.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("shows the JoinRuleDialog on click with private join rules", async () => {
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
expect(modalSpy).toHaveBeenCalledWith(JoinRuleDialog, { room, canInvite: false });
// pretend public was selected
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
modalResolve([]);
await new Promise(process.nextTick);
const callParams = modalSpy.mock.calls[1];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("shows the ShareDialog on click with public join rules", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
const callParams = modalSpy.mock.calls[0];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("shows the ShareDialog on click with knock join rules", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
jest.spyOn(room, "canInvite").mockReturnValue(true);
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Share call link" }));
const callParams = modalSpy.mock.calls[0];
expect(callParams[0]).toEqual(ShareDialog);
expect(callParams[1].target.toString()).toEqual(expectedShareDialogProps.target);
expect(callParams[1].subtitle).toEqual(expectedShareDialogProps.subtitle);
expect(callParams[1].customTitle).toEqual(expectedShareDialogProps.customTitle);
});
it("don't show external conference button if room not public nor knock and the user cannot change join rules", () => {
// preparation for if we refactor the related code to not use currentState.
jest.spyOn(room, "getLiveTimeline").mockReturnValue({
getState: jest.fn().mockReturnValue({
maySendStateEvent: jest.fn().mockReturnValue(false),
}),
} as unknown as EventTimeline);
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
getComponent(room);
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
});
it("don't show external conference button if now guest spa link is configured", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true);
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { url: "https://example2.com" };
}
return oldGet(key);
});
getComponent(room);
// We only change the SdkConfig and show that this everything else is
// configured so that the call link button is shown.
expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument();
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
if (key === "element_call") {
return { guest_spa_url: "https://guest_spa_url.com", url: "https://example2.com" };
}
return oldGet(key);
});
getComponent(room);
expect(getByLabelText(document.body, "Share call link")).toBeInTheDocument();
});
it("opens the share dialog with the correct share link in an encrypted room", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true);
getComponent(room);
const modalSpy = jest.spyOn(Modal, "createDialog");
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
// const target =
// "https://guest_spa_url.com/room/#/!room:server.org?roomId=%21room%3Aserver.org&perParticipantE2EE=true&viaServers=example.org";
expect(modalSpy).toHaveBeenCalled();
const arg0 = modalSpy.mock.calls[0][0];
const arg1 = modalSpy.mock.calls[0][1] as any;
expect(arg0).toEqual(ShareDialog);
const { customTitle, subtitle } = arg1;
expect({ customTitle, subtitle }).toEqual({
customTitle: "Conference invite link",
subtitle: _t("share|share_call_subtitle"),
});
expect(arg1.target.toString()).toEqual(targetEncrypted);
});
it("share dialog has correct link in an unencrypted room", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(false);
jest.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true);
getComponent(room);
const modalSpy = jest.spyOn(Modal, "createDialog");
fireEvent.click(getByLabelText(document.body, _t("voip|get_call_link")));
const arg1 = modalSpy.mock.calls[0][1] as any;
expect(arg1.target.toString()).toEqual(targetUnencrypted);
});
describe("<JoinRuleDialog />", () => {
const onFinished = jest.fn();
const getComponent = (room: Room, canInvite: boolean = true) =>
render(<JoinRuleDialog room={room} canInvite={canInvite} onFinished={onFinished} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
// feature_ask_to_join enabled
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
});
it("shows ask to join if feature is enabled", () => {
getComponent(room);
expect(screen.getByRole("radio", { name: "Ask to join ( Recommended )" })).toBeInTheDocument();
});
it("dont show ask to join if feature is enabled but cannot invite", () => {
getComponent(room, false);
expect(screen.queryByRole("radio", { name: "Ask to join ( Recommended )" })).not.toBeInTheDocument();
});
it("doesn't show ask to join if feature is disabled", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
getComponent(room);
expect(screen.queryByRole("radio", { name: "Ask to join ( Recommended )" })).not.toBeInTheDocument();
});
it("sends correct state event on click", async () => {
const sendStateSpy = jest.spyOn(sdkContext.client!, "sendStateEvent");
let container;
container = getComponent(room).container;
fireEvent.click(screen.getByRole("radio", { name: "Ask to join ( Recommended )" }));
expect(sendStateSpy).toHaveBeenCalledWith(
"!room:server.org",
"m.room.join_rules",
{ join_rule: "knock" },
"",
);
expect(sendStateSpy).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1), { timeout: 3000 });
onFinished.mockClear();
sendStateSpy.mockClear();
container = getComponent(room).container;
fireEvent.click(getByText(container, "Anyone"));
expect(sendStateSpy).toHaveBeenLastCalledWith(
"!room:server.org",
"m.room.join_rules",
{ join_rule: "public" },
"",
);
expect(sendStateSpy).toHaveBeenCalledTimes(1);
container = getComponent(room).container;
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1), { timeout: 3000 });
onFinished.mockClear();
sendStateSpy.mockClear();
fireEvent.click(getByText(container, _t("update_room_access_modal|no_change")));
await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1));
// Don't call sendStateEvent if no change is clicked.
expect(sendStateSpy).toHaveBeenCalledTimes(0);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,127 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MockedObject } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { VideoRoomChatButton } from "../../../../../../src/components/views/rooms/RoomHeader/VideoRoomChatButton";
import { SDKContext } from "../../../../../../src/contexts/SDKContext";
import { TestSDKContext } from "../../../../TestSDKContext.ts";
import type RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
import { NotificationStateEvents } from "../../../../../../src/stores/notifications/NotificationState";
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
describe("<VideoRoomChatButton />", () => {
const roomId = "!room:server.org";
let sdkContext!: TestSDKContext;
let rightPanelStore!: MockedObject<RightPanelStore>;
/**
* Create a room using mocked client
* And mock isElementVideoRoom
*/
const makeRoom = (isVideoRoom = true): Room => {
const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId());
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom);
// stub
jest.spyOn(room, "getPendingEvents").mockReturnValue([]);
return room;
};
const mockRoomNotificationState = (room: Room, level: NotificationLevel): RoomNotificationState => {
const roomNotificationState = new RoomNotificationState(room, false);
// @ts-ignore ugly mocking
roomNotificationState._level = level;
jest.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState);
return roomNotificationState;
};
const getComponent = (room: Room) =>
render(<VideoRoomChatButton room={room} />, {
wrapper: ({ children }) => <SDKContext.Provider value={sdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
});
rightPanelStore = {
showOrHidePhase: jest.fn(),
} as unknown as MockedObject<RightPanelStore>;
sdkContext = new TestSDKContext();
sdkContext._client = client;
jest.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("toggles timeline in right panel on click", () => {
const room = makeRoom();
getComponent(room);
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
expect(sdkContext.rightPanelStore.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.Timeline);
});
it("renders button with an unread marker when room is unread", () => {
const room = makeRoom();
mockRoomNotificationState(room, NotificationLevel.Activity);
getComponent(room);
// snapshot includes `data-indicator` attribute
expect(screen.getByRole("button", { name: "Chat" })).toMatchSnapshot();
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
});
it("adds unread marker when room notification state changes to unread", async () => {
const room = makeRoom();
// start in read state
const notificationState = mockRoomNotificationState(room, NotificationLevel.None);
getComponent(room);
// no unread marker
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy();
// @ts-ignore ugly mocking
notificationState._level = NotificationLevel.Highlight;
notificationState.emit(NotificationStateEvents.Update);
// unread marker
await waitFor(() =>
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy(),
);
});
it("clears unread marker when room notification state changes to read", async () => {
const room = makeRoom();
// start in unread state
const notificationState = mockRoomNotificationState(room, NotificationLevel.Highlight);
getComponent(room);
// unread marker
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeTruthy();
// @ts-ignore ugly mocking
notificationState._level = NotificationLevel.None;
notificationState.emit(NotificationStateEvents.Update);
// unread marker cleared
await waitFor(() =>
expect(screen.getByRole("button", { name: "Chat" }).hasAttribute("data-indicator")).toBeFalsy(),
);
});
});
@@ -1,165 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<DocumentFragment>
<header
class="_flex_4dswl_9 mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
aria-live="off"
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="button"
style="--cpd-avatar-size: 40px;"
tabindex="-1"
>
!
</button>
<button
aria-label="Room info"
class="mx_RoomHeader_infoWrapper"
tabindex="0"
>
<div
class="mx_RoomHeader_info _box-flex_1odfs_9"
style="--mx-box-flex: 1;"
>
<div
aria-level="1"
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 mx_RoomHeader_heading"
dir="auto"
role="heading"
>
<span
class="mx_RoomHeader_truncated mx_lineClamp"
>
!1:example.org
</span>
</div>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Video call"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-1"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="react-use-id-2"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Voice call"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-3"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="react-use-id-4"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
/>
</svg>
</div>
</button>
<button
aria-label="Threads"
aria-labelledby="react-use-id-5"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
</button>
<button
aria-label="Room info"
aria-labelledby="react-use-id-6"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16v-4a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 11a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 12v4q0 .424.287.712.288.288.713.288m0-8q.424 0 .713-.287A.97.97 0 0 0 13 8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8q0 .424.287.713Q11.576 9 12 9m0 13a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
</div>
</button>
</header>
</DocumentFragment>
`;
@@ -1,33 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = `
<button
aria-label="Chat"
aria-labelledby="react-use-id-1"
class="_icon-button_1215g_8"
data-indicator="default"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
data-indicator="default"
style="--cpd-icon-button-size: 100%;"
>
<svg
class=""
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.95 16.3 1.5 21.25a.94.94 0 0 0 .25 1 .94.94 0 0 0 1 .25l4.95-1.45a10.2 10.2 0 0 0 2.1.712Q10.875 22 12 22a9.7 9.7 0 0 0 3.9-.788 10.1 10.1 0 0 0 3.175-2.137q1.35-1.35 2.137-3.175A9.7 9.7 0 0 0 22 12a9.7 9.7 0 0 0-.788-3.9 10.1 10.1 0 0 0-2.137-3.175q-1.35-1.35-3.175-2.137A9.7 9.7 0 0 0 12 2a9.7 9.7 0 0 0-3.9.788 10.1 10.1 0 0 0-3.175 2.137Q3.575 6.275 2.788 8.1A9.7 9.7 0 0 0 2 12q0 1.125.238 2.2.237 1.076.712 2.1"
/>
</svg>
</div>
</button>
`;