From 2430e0ab8b43fbdbc26f9ae4f7e705e6cbad083f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 22 Jul 2026 13:18:04 +0000 Subject: [PATCH] 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> --- apps/web/__mocks__/svg-react.js | 8 + apps/web/__mocks__/svg.js | 1 - apps/web/jest.config.ts | 2 + apps/web/package.json | 1 + apps/web/src/@types/svg.d.ts | 6 +- .../src/components/structures/ThreadPanel.tsx | 2 +- .../auth/forgot-password/CheckEmail.tsx | 2 +- .../auth/forgot-password/VerifyEmailModal.tsx | 2 +- .../views/beacon/BeaconViewDialog.tsx | 2 +- .../beacon/LeftPanelLiveShareWarning.tsx | 2 +- .../views/beacon/StyledLiveBeaconIcon.tsx | 2 +- .../components/views/dialogs/BaseDialog.tsx | 65 +++-- .../views/dialogs/InviteDialog.test.tsx} | 179 ++++++------ .../components/views/dialogs/InviteDialog.tsx | 254 +++++++++--------- .../views/dialogs/invite/DMRoomTile.tsx | 2 +- .../components/views/elements/SSOButtons.tsx | 10 +- .../components/views/location/MapFallback.tsx | 2 +- .../src/components/views/polls/PollOption.tsx | 2 +- .../EventTile/MessageTimestampAdapter.tsx | 2 +- .../RoomHeader/CallGuestLinkButton.test.tsx} | 102 ++++--- .../rooms/RoomHeader/RoomHeader.test.tsx} | 238 ++++++++-------- .../RoomHeader/VideoRoomChatButton.test.tsx} | 39 +-- .../__snapshots__/RoomHeader.test.tsx.snap} | 4 +- .../VideoRoomChatButton.test.tsx.snap} | 4 +- .../views/settings/ImageSizePanel.tsx | 4 +- .../views/settings/devices/DeviceMetaData.tsx | 2 +- .../settings/devices/DeviceSecurityCard.tsx | 2 +- apps/web/src/test/react-use-id-serializer.ts | 52 ++++ apps/web/test/setup/adapter.ts | 6 +- apps/web/test/setupTests.ts | 37 --- apps/web/test/test-utils/console.ts | 2 + apps/web/test/test-utils/test-utils.ts | 1 + apps/web/vitest.config.ts | 11 + apps/web/webpack.config.ts | 96 +++---- pnpm-lock.yaml | 14 + 35 files changed, 612 insertions(+), 548 deletions(-) create mode 100644 apps/web/__mocks__/svg-react.js rename apps/web/{test/unit-tests/components/views/dialogs/InviteDialog-test.tsx => src/components/views/dialogs/InviteDialog.test.tsx} (76%) rename apps/web/{test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx => src/components/views/rooms/RoomHeader/CallGuestLinkButton.test.tsx} (74%) rename apps/web/{test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx => src/components/views/rooms/RoomHeader/RoomHeader.test.tsx} (79%) rename apps/web/{test/unit-tests/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx => src/components/views/rooms/RoomHeader/VideoRoomChatButton.test.tsx} (73%) rename apps/web/{test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap => src/components/views/rooms/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap} (97%) rename apps/web/{test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton-test.tsx.snap => src/components/views/rooms/RoomHeader/__snapshots__/VideoRoomChatButton.test.tsx.snap} (85%) create mode 100644 apps/web/src/test/react-use-id-serializer.ts diff --git a/apps/web/__mocks__/svg-react.js b/apps/web/__mocks__/svg-react.js new file mode 100644 index 0000000000..16ec267a35 --- /dev/null +++ b/apps/web/__mocks__/svg-react.js @@ -0,0 +1,8 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +export default "div"; diff --git a/apps/web/__mocks__/svg.js b/apps/web/__mocks__/svg.js index 73925a7909..bc2b312afe 100644 --- a/apps/web/__mocks__/svg.js +++ b/apps/web/__mocks__/svg.js @@ -5,5 +5,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -export const Icon = "div"; export default "image-file-stub"; diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index b3636e6a38..35f0541da5 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -34,6 +34,7 @@ const config: Config = { "\\.(css|scss|pcss)(\\?raw)?$": "/__mocks__/cssMock.js", "\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js", "\\.svg$": "/__mocks__/svg.js", + "\\.svg\\?react$": "/__mocks__/svg-react.js", "^matrix-js-sdk(.*)$": "/node_modules/matrix-js-sdk$1", "^react$": "/node_modules/react", "^react-dom$": "/node_modules/react-dom", @@ -69,6 +70,7 @@ const config: Config = { prettierPath: null, moduleDirectories: ["node_modules", "test/test-utils"], workerIdleMemoryLimit: "512MB", + snapshotSerializers: ["/src/test/react-use-id-serializer.ts"], }; // if we're running under GHA, enable relevant reporters diff --git a/apps/web/package.json b/apps/web/package.json index 44116723a1..37bebd7685 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -209,6 +209,7 @@ "testcontainers": "^12.0.0", "typescript": "catalog:ts6", "util": "^0.12.5", + "vite-plugin-svgr": "catalog:", "vitest": "catalog:", "vitest-canvas-mock": "^1.1.4", "web-streams-polyfill": "^4.0.0", diff --git a/apps/web/src/@types/svg.d.ts b/apps/web/src/@types/svg.d.ts index 8a8990645b..9cc4de6c19 100644 --- a/apps/web/src/@types/svg.d.ts +++ b/apps/web/src/@types/svg.d.ts @@ -9,6 +9,10 @@ Please see LICENSE files in the repository root for full details. declare module "*.svg" { const path: string; - export const Icon: React.FC>; export default path; } + +declare module "*.svg?react" { + const Icon: React.FC>; + export default Icon; +} diff --git a/apps/web/src/components/structures/ThreadPanel.tsx b/apps/web/src/components/structures/ThreadPanel.tsx index df89d41406..c1da58b692 100644 --- a/apps/web/src/components/structures/ThreadPanel.tsx +++ b/apps/web/src/components/structures/ThreadPanel.tsx @@ -12,7 +12,7 @@ import { IconButton, Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; import { ThreadsIcon, CheckIcon, ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg"; +import MarkAllThreadsReadIcon from "../../../res/img/element-icons/check-all.svg?react"; import BaseCard from "../views/right_panel/BaseCard"; import type ResizeNotifier from "../../utils/ResizeNotifier"; import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext"; diff --git a/apps/web/src/components/structures/auth/forgot-password/CheckEmail.tsx b/apps/web/src/components/structures/auth/forgot-password/CheckEmail.tsx index 8fb135b3c6..6f3b696ab8 100644 --- a/apps/web/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/apps/web/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -11,7 +11,7 @@ import { Button, Tooltip } from "@vector-im/compound-web"; import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import AccessibleButton from "../../../views/elements/AccessibleButton"; -import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; +import EMailPromptIcon from "../../../../../res/img/element-icons/email-prompt.svg?react"; import { _t } from "../../../../languageHandler"; import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle"; import { ErrorMessage } from "../../ErrorMessage"; diff --git a/apps/web/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/apps/web/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index 460495e317..7e216e8381 100644 --- a/apps/web/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/apps/web/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -12,7 +12,7 @@ import { CloseIcon, RestartIcon } from "@vector-im/compound-design-tokens/assets import { _t } from "../../../../languageHandler"; import AccessibleButton from "../../../views/elements/AccessibleButton"; -import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; +import EmailPromptIcon from "../../../../../res/img/element-icons/email-prompt.svg?react"; import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle"; import { ErrorMessage } from "../../ErrorMessage"; diff --git a/apps/web/src/components/views/beacon/BeaconViewDialog.tsx b/apps/web/src/components/views/beacon/BeaconViewDialog.tsx index ab6c6cf3c0..0dac669489 100644 --- a/apps/web/src/components/views/beacon/BeaconViewDialog.tsx +++ b/apps/web/src/components/views/beacon/BeaconViewDialog.tsx @@ -10,7 +10,7 @@ import React, { useState, useEffect } from "react"; import { type MatrixClient, type Beacon, type Room } from "matrix-js-sdk/src/matrix"; import type * as maplibregl from "maplibre-gl"; -import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg"; +import LiveLocationIcon from "../../../../res/img/location/live-location.svg?react"; import { useLiveBeacons } from "../../../utils/beacon/useLiveBeacons"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import BaseDialog from "../dialogs/BaseDialog"; diff --git a/apps/web/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/apps/web/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index d5338cf28f..f7cd133541 100644 --- a/apps/web/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/apps/web/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -13,7 +13,7 @@ import { type Beacon, type BeaconIdentifier } from "matrix-js-sdk/src/matrix"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { _t } from "../../../languageHandler"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; -import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg"; +import LiveLocationIcon from "../../../../res/img/location/live-location.svg?react"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import dispatcher from "../../../dispatcher/dispatcher"; diff --git a/apps/web/src/components/views/beacon/StyledLiveBeaconIcon.tsx b/apps/web/src/components/views/beacon/StyledLiveBeaconIcon.tsx index c73931918f..caec87b794 100644 --- a/apps/web/src/components/views/beacon/StyledLiveBeaconIcon.tsx +++ b/apps/web/src/components/views/beacon/StyledLiveBeaconIcon.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; -import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg"; +import LiveLocationIcon from "../../../../res/img/location/live-location.svg?react"; interface Props extends React.SVGProps { // use error styling when true diff --git a/apps/web/src/components/views/dialogs/BaseDialog.tsx b/apps/web/src/components/views/dialogs/BaseDialog.tsx index 22cbbedfaa..075de1e3b0 100644 --- a/apps/web/src/components/views/dialogs/BaseDialog.tsx +++ b/apps/web/src/components/views/dialogs/BaseDialog.tsx @@ -12,7 +12,6 @@ import React, { type JSX } from "react"; import FocusLock from "react-focus-lock"; import classNames from "classnames"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { I18nContext } from "@element-hq/web-shared-components"; import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import AccessibleButton from "../elements/AccessibleButton"; @@ -172,42 +171,38 @@ export default class BaseDialog extends React.Component { } return ( - // XXX: We can't import ModuleAPI here because it causes a dependency cycle - hack and - // use the copy on the window object :( - - - {this.props.screenName && } - + {this.props.screenName && } + + {this.props.top} +
- {this.props.top} -
- {!!(this.props.title || headerImage) && ( - - {headerImage} - {this.props.title} - - )} - {this.props.headerButton} -
- {this.props.children} - {cancelButton} - - - + {!!(this.props.title || headerImage) && ( + + {headerImage} + {this.props.title} + + )} + {this.props.headerButton} +
+ {this.props.children} + {cancelButton} +
+
); } } diff --git a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx b/apps/web/src/components/views/dialogs/InviteDialog.test.tsx similarity index 76% rename from apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx rename to apps/web/src/components/views/dialogs/InviteDialog.test.tsx index 1c603ab165..b29b88e2c1 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/InviteDialog-test.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.test.tsx @@ -6,17 +6,17 @@ 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. */ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, afterAll, beforeEach, afterEach, type Mocked } from "vitest"; + import React from "react"; -import { findByText, fireEvent, render, screen } from "jest-matrix-react"; +import { findByText, fireEvent, render, screen } from "test-utils-rtl"; 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, @@ -25,27 +25,43 @@ import { 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"; + TestSDKContext, +} from "test-utils"; -const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken"); -jest.mock("../../../../../src/IdentityAuthClient", () => - jest.fn().mockImplementation(() => ({ - getAccessToken: mockGetAccessToken, - })), -); +import InviteDialog from "./InviteDialog"; +import { InviteKind } from "./InviteDialogTypes"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import SdkConfig from "../../../SdkConfig"; +import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import { type IConfigOptions } from "../../../IConfigOptions"; +import { SDKContextClass } from "../../../contexts/SDKContextClass"; +import { type IProfileInfo } from "../../../hooks/useProfileInfo"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; +import { MatrixClientPeg } from "../../../MatrixClientPeg.ts"; -jest.mock("../../../../../src/utils/direct-messages", () => ({ - ...jest.requireActual("../../../../../src/utils/direct-messages"), - __esModule: true, - startDmOnFirstMessage: jest.fn(), +const mockGetAccessToken = vi.fn().mockResolvedValue("getAccessToken"); +vi.mock("../../../IdentityAuthClient", () => ({ + default: vi.fn().mockImplementation(function () { + return { + getAccessToken: mockGetAccessToken, + }; + }), +})); + +vi.mock("../../../utils/direct-messages", async () => ({ + ...(await vi.importActual("../../../utils/direct-messages")), + startDmOnFirstMessage: vi.fn(), +})); + +vi.mock("../../../dispatcher/dispatcher"); + +vi.mock("lodash", async () => ({ + ...(await vi.importActual("lodash")), + // Stub out the debounce to prevent async leaks + debounce: vi.fn((fn) => { + fn.cancel = vi.fn(); + return fn; + }), })); const getSearchField = () => screen.getByTestId("invite-dialog-input"); @@ -106,23 +122,23 @@ describe("InviteDialog", () => { beforeEach(() => { mockClient = getMockClientWithEventEmitter({ - getCrypto: jest.fn().mockReturnValue({ - getUserVerificationStatus: jest + getCrypto: vi.fn().mockReturnValue({ + getUserVerificationStatus: vi .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) => { + getDomain: vi.fn().mockReturnValue(serverDomain), + getUserId: vi.fn().mockReturnValue(bobId), + getSafeUserId: vi.fn().mockReturnValue(bobId), + isGuest: vi.fn().mockReturnValue(false), + getVisibleRooms: vi.fn().mockReturnValue([]), + getRoom: vi.fn(), + getRooms: vi.fn(), + getAccountData: vi.fn(), + getPushActionsForEvent: vi.fn(), + mxcUrlToHttp: vi.fn().mockReturnValue(""), + isRoomEncrypted: vi.fn().mockReturnValue(false), + getProfileInfo: vi.fn().mockImplementation(async (userId: string) => { if (userId === aliceId) return aliceProfileInfo; if (userId === bobId) return bobProfileInfo; @@ -131,24 +147,25 @@ describe("InviteDialog", () => { error: "Profile not found", }); }), - getIdentityServerUrl: jest.fn(), - searchUserDirectory: jest.fn().mockResolvedValue({}), - lookupThreePid: jest.fn(), - registerWithIdentityServer: jest.fn().mockResolvedValue({ + getIdentityServerUrl: vi.fn(), + searchUserDirectory: vi.fn().mockResolvedValue({}), + lookupThreePid: vi.fn(), + registerWithIdentityServer: vi.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(), + getOpenIdToken: vi.fn().mockResolvedValue({}), + getIdentityAccount: vi.fn().mockResolvedValue({}), + getTerms: vi.fn().mockResolvedValue({ policies: [] }), + supportsThreads: vi.fn().mockReturnValue(false), + isInitialSyncComplete: vi.fn().mockReturnValue(true), + getClientWellKnown: vi.fn().mockResolvedValue({}), + invite: vi.fn(), }); SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions); DMRoomMap.makeShared(mockClient); - jest.clearAllMocks(); + vi.clearAllMocks(); + vi.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient); room = new Room(roomId, mockClient, mockClient.getSafeUserId()); room.addLiveEvents( @@ -174,7 +191,7 @@ describe("InviteDialog", () => { skey: aliceId, }), ]); - jest.spyOn(DMRoomMap.shared(), "getUniqueRoomsWithIndividuals").mockReturnValue({ + vi.spyOn(DMRoomMap.shared(), "getUniqueRoomsWithIndividuals").mockReturnValue({ [aliceId]: room, }); mockClient.getRooms.mockReturnValue([room]); @@ -192,20 +209,20 @@ describe("InviteDialog", () => { }); afterAll(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it("should label with space name", () => { - room.isSpaceRoom = jest.fn().mockReturnValue(true); - room.getType = jest.fn().mockReturnValue(RoomType.Space); + room.isSpaceRoom = vi.fn().mockReturnValue(true); + room.getType = vi.fn().mockReturnValue(RoomType.Space); room.name = "Space"; - render(); + render(); expect(screen.queryByText("Invite to Space")).toBeTruthy(); }); it("should label with room name", () => { - render(); + render(); expect(screen.getByText(`Invite to ${roomId}`)).toBeInTheDocument(); }); @@ -214,7 +231,7 @@ describe("InviteDialog", () => { , ); @@ -227,7 +244,7 @@ describe("InviteDialog", () => { , ); @@ -253,7 +270,7 @@ describe("InviteDialog", () => { , ); @@ -275,20 +292,20 @@ describe("InviteDialog", () => { , ); - await screen.findByText("foobar@email.com"); - await screen.findByText("Invite by email"); + await expect(screen.findByText("foobar@email.com")).resolves.toBeVisible(); + await expect(screen.findByText("Invite by email")).resolves.toBeVisible(); }); it("should add pasted values", async () => { mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); mockClient.lookupThreePid.mockResolvedValue({}); - render(); + render(); const input = screen.getByTestId("invite-dialog-input"); input.focus(); @@ -302,7 +319,7 @@ describe("InviteDialog", () => { mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); mockClient.lookupThreePid.mockResolvedValue({}); - render(); + render(); const input = screen.getByTestId("invite-dialog-input"); input.focus(); @@ -313,7 +330,7 @@ describe("InviteDialog", () => { }); it("should allow to invite multiple emails to a room", async () => { - render(); + render(); await enterIntoSearchField(aliceEmail); expectPill(aliceEmail); @@ -332,7 +349,7 @@ describe("InviteDialog", () => { }); it("should allow to invite more than one email to a DM", async () => { - render(); + render(); await enterIntoSearchField(aliceEmail); expectPill(aliceEmail); @@ -343,7 +360,7 @@ describe("InviteDialog", () => { }); it("should not allow to invite more than one email to a DM", async () => { - render(); + render(); // Start with an email → should convert to a pill await enterIntoSearchField(aliceEmail); @@ -363,7 +380,7 @@ describe("InviteDialog", () => { }); it("should not allow to invite a MXID and an email to a DM", async () => { - render(); + render(); // Start with a MXID → should convert to a pill await enterIntoSearchField(carolId); @@ -377,7 +394,7 @@ describe("InviteDialog", () => { }); it("should start a DM if the profile is available", async () => { - render(); + render(); await enterIntoSearchField(aliceId); await userEvent.click(screen.getByRole("button", { name: "Go" })); expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [ @@ -388,7 +405,7 @@ describe("InviteDialog", () => { }); it("should not allow pasting the same user multiple times", async () => { - render(); + render(); const input = screen.getByTestId("invite-dialog-input"); input.focus(); @@ -401,7 +418,7 @@ describe("InviteDialog", () => { }); it("should add to selection on click of user tile", async () => { - render(); + render(); const input = screen.getByTestId("invite-dialog-input"); input.focus(); @@ -418,18 +435,18 @@ describe("InviteDialog", () => { it("should show a spinner", async () => { mockClient.invite.mockReturnValue(new Promise(() => {})); - render(); + render(); await enterIntoSearchField(bobId); await userEvent.click(screen.getByRole("button", { name: "Invite" })); - await screen.findByText("Preparing invitations..."); + await expect(screen.findByText("Preparing invitations...")).resolves.toBeVisible(); }); }); describe("when inviting a user with an unknown profile", () => { beforeEach(async () => { - mocked(startDmOnFirstMessage).mockClear(); - render(); + vi.mocked(startDmOnFirstMessage).mockClear(); + render(); 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 @@ -452,7 +469,7 @@ describe("InviteDialog", () => { , ); @@ -462,7 +479,7 @@ describe("InviteDialog", () => { describe("when inviting a user whose cryptographic identity we do not know", () => { beforeEach(() => { - mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => { + vi.mocked(mockClient.getCrypto()!.getUserVerificationStatus).mockImplementation(async (u) => { return new UserVerificationStatus(false, false, false, false); }); }); @@ -475,7 +492,7 @@ describe("InviteDialog", () => { , ); }); @@ -485,8 +502,8 @@ describe("InviteDialog", () => { 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); + expect(vi.mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockClient.getCrypto()!.getUserVerificationStatus)).toHaveBeenCalledWith(aliceId); }); it("should show a warning when inviting by email address", async () => { @@ -495,7 +512,7 @@ describe("InviteDialog", () => { await screen.findByText("Confirm inviting them", { exact: false }); // We shouldn't call getUserVerificationStatus on an email address - expect(mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled(); + expect(vi.mocked(mockClient.getCrypto()!.getUserVerificationStatus)).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/components/views/dialogs/InviteDialog.tsx b/apps/web/src/components/views/dialogs/InviteDialog.tsx index 3f1caffda4..71eab828b4 100644 --- a/apps/web/src/components/views/dialogs/InviteDialog.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.tsx @@ -11,7 +11,7 @@ import { EventType, type Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import { uniqBy } from "lodash"; +import { debounce, uniqBy } from "lodash"; import { Pill, PillInput, RichList } from "@element-hq/web-shared-components"; import { DialPadIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -187,7 +187,6 @@ export default class InviteDialog extends React.PureComponent(); private numberEntryFieldRef = createRef(); private unmounted = false; @@ -256,6 +255,7 @@ export default class InviteDialog extends React.PureComponent): void => { @@ -530,125 +530,127 @@ export default class InviteDialog extends React.PureComponent => { - MatrixClientPeg.safeGet() - .searchUserDirectory({ term }) - .then(async (r): Promise => { - if (term !== this.state.filterText) { - // Discard the results - we were probably too slow on the server-side to make - // these results useful. This is a race we want to avoid because we could overwrite - // more accurate results. - return; - } - - if (!r.results) r.results = []; - - // While we're here, try and autocomplete a search result for the mxid itself - // if there's no matches (and the input looks like a mxid). - if (term[0] === "@" && term.indexOf(":") > 1) { - try { - const profile = await this.profilesStore.getOrFetchProfile(term, { shouldThrow: true }); - - if (profile) { - // If we have a profile, we have enough information to assume that - // the mxid can be invited - add it to the list. We stick it at the - // top so it is most obviously presented to the user. - r.results.splice(0, 0, { - user_id: term, - display_name: profile["displayname"], - avatar_url: profile["avatar_url"], - }); - } - } catch (e) { - logger.warn("Non-fatal error trying to make an invite for a user ID", e); + private updateSuggestions = debounce( + async (term: string): Promise => { + MatrixClientPeg.safeGet() + .searchUserDirectory({ term }) + .then(async (r): Promise => { + if (term !== this.state.filterText) { + // Discard the results - we were probably too slow on the server-side to make + // these results useful. This is a race we want to avoid because we could overwrite + // more accurate results. + return; } - } - this.setState({ - serverResultsMixin: r.results.map((u) => ({ - userId: u.user_id, - user: new DirectoryMember(u), - })), + if (!r.results) r.results = []; + + // While we're here, try and autocomplete a search result for the mxid itself + // if there's no matches (and the input looks like a mxid). + if (term[0] === "@" && term.indexOf(":") > 1) { + try { + const profile = await this.profilesStore.getOrFetchProfile(term, { shouldThrow: true }); + + if (profile) { + // If we have a profile, we have enough information to assume that + // the mxid can be invited - add it to the list. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: profile["displayname"], + avatar_url: profile["avatar_url"], + }); + } + } catch (e) { + logger.warn("Non-fatal error trying to make an invite for a user ID", e); + } + } + + if (this.unmounted) return; + this.setState({ + serverResultsMixin: r.results.map((u) => ({ + userId: u.user_id, + user: new DirectoryMember(u), + })), + }); + }) + .catch((e) => { + logger.error("Error searching user directory:"); + logger.error(e); + if (this.unmounted) return; + this.setState({ serverResultsMixin: [] }); // clear results because it's moderately fatal }); - }) - .catch((e) => { - logger.error("Error searching user directory:"); - logger.error(e); - this.setState({ serverResultsMixin: [] }); // clear results because it's moderately fatal - }); - // Whenever we search the directory, also try to search the identity server. It's - // all debounced the same anyways. - if (!this.state.canUseIdentityServer) { - // The user doesn't have an identity server set - warn them of that. - this.setState({ tryingIdentityServer: true }); - return; - } - if (Email.looksValid(term) && this.canInviteThirdParty() && SettingsStore.getValue(UIFeature.IdentityServer)) { - // Start off by suggesting the plain email while we try and resolve it - // to a real account. - this.setState({ - // per above: the userId is a lie here - it's just a regular identifier - threepidResultsMixin: [{ user: new ThreepidMember(term), userId: term }], - }); - try { - const authClient = new IdentityAuthClient(); - const token = await authClient.getAccessToken(); - // No token → unable to try a lookup - if (!token) return; - - if (term !== this.state.filterText) return; // abandon hope - - const lookup = await MatrixClientPeg.safeGet().lookupThreePid("email", term, token); - if (term !== this.state.filterText) return; // abandon hope - - if (!lookup || !("mxid" in lookup)) { - // We weren't able to find anyone - we're already suggesting the plain email - // as an alternative, so do nothing. - return; - } - - // We append the user suggestion to give the user an option to click - // the email anyways, and so we don't cause things to jump around. In - // theory, the user would see the user pop up and think "ah yes, that - // person!" - const profile = await this.profilesStore.getOrFetchProfile(lookup.mxid); - if (term !== this.state.filterText || !profile) return; // abandon hope - this.setState({ - threepidResultsMixin: [ - ...this.state.threepidResultsMixin, - { - user: new DirectoryMember({ - user_id: lookup.mxid, - display_name: profile.displayname, - avatar_url: profile.avatar_url, - }), - // Use the search term as identifier, so that it shows up in suggestions. - userId: term, - }, - ], - }); - } catch (e) { - logger.error("Error searching identity server:"); - logger.error(e); - this.setState({ threepidResultsMixin: [] }); // clear results because it's moderately fatal + // Whenever we search the directory, also try to search the identity server. It's + // all debounced the same anyways. + if (!this.state.canUseIdentityServer) { + // The user doesn't have an identity server set - warn them of that. + this.setState({ tryingIdentityServer: true }); + return; } - } - }; + if ( + Email.looksValid(term) && + this.canInviteThirdParty() && + SettingsStore.getValue(UIFeature.IdentityServer) + ) { + // Start off by suggesting the plain email while we try and resolve it + // to a real account. + this.setState({ + // per above: the userId is a lie here - it's just a regular identifier + threepidResultsMixin: [{ user: new ThreepidMember(term), userId: term }], + }); + try { + const authClient = new IdentityAuthClient(); + const token = await authClient.getAccessToken(); + // No token → unable to try a lookup + if (!token) return; + + if (term !== this.state.filterText) return; // abandon hope + + const lookup = await MatrixClientPeg.safeGet().lookupThreePid("email", term, token); + if (term !== this.state.filterText) return; // abandon hope + + if (!lookup || !("mxid" in lookup)) { + // We weren't able to find anyone - we're already suggesting the plain email + // as an alternative, so do nothing. + return; + } + + // We append the user suggestion to give the user an option to click + // the email anyways, and so we don't cause things to jump around. In + // theory, the user would see the user pop up and think "ah yes, that + // person!" + const profile = await this.profilesStore.getOrFetchProfile(lookup.mxid); + if (term !== this.state.filterText || !profile) return; // abandon hope + if (this.unmounted) return; + this.setState({ + threepidResultsMixin: [ + ...this.state.threepidResultsMixin, + { + user: new DirectoryMember({ + user_id: lookup.mxid, + display_name: profile.displayname, + avatar_url: profile.avatar_url, + }), + // Use the search term as identifier, so that it shows up in suggestions. + userId: term, + }, + ], + }); + } catch (e) { + logger.error("Error searching identity server:"); + logger.error(e); + if (this.unmounted) return; + this.setState({ threepidResultsMixin: [] }); // clear results because it's moderately fatal + } + } + }, + 150, // 150ms debounce (human reaction time + some) + ); private updateFilter = (e: React.ChangeEvent): void => { const term = e.target.value; this.setState({ filterText: term }); - - // Debounce server lookups to reduce spam. We don't clear the existing server - // results because they might still be vaguely accurate, likewise for races which - // could happen here. - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.updateSuggestions(term); - }, 150); // 150ms debounce (human reaction time + some) + this.updateSuggestions(term); }; private showMoreRecents = (): void => { @@ -898,20 +900,18 @@ export default class InviteDialog extends React.PureComponent ( - t.userId === r.userId)} - /> - )); - return (
- {tiles} + {toRender.map((r) => ( + t.userId === r.userId)} + /> + ))} {showMore}
@@ -919,10 +919,6 @@ export default class InviteDialog extends React.PureComponent ( - - )); - return ( - {targets} + {this.state.targets.map((t) => ( + + ))} ); } diff --git a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx index 66cadba6d8..78024188f0 100644 --- a/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx +++ b/apps/web/src/components/views/dialogs/invite/DMRoomTile.tsx @@ -14,7 +14,7 @@ import BaseAvatar from "../../avatars/BaseAvatar.tsx"; import { mediaFromMxc } from "../../../../customisations/Media.ts"; import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier.ts"; import { _t } from "../../../../languageHandler"; -import { Icon as EmailPillAvatarIcon } from "../../../../../res/img/icon-email-pill-avatar.svg"; +import EmailPillAvatarIcon from "../../../../../res/img/icon-email-pill-avatar.svg?react"; interface IDMRoomTileProps { member: Member; diff --git a/apps/web/src/components/views/elements/SSOButtons.tsx b/apps/web/src/components/views/elements/SSOButtons.tsx index a58715918c..774b0a3837 100644 --- a/apps/web/src/components/views/elements/SSOButtons.tsx +++ b/apps/web/src/components/views/elements/SSOButtons.tsx @@ -25,11 +25,11 @@ import PlatformPeg from "../../../PlatformPeg"; import { _t } from "../../../languageHandler"; import { mediaFromMxc } from "../../../customisations/Media"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; -import { Icon as FacebookIcon } from "../../../../res/img/element-icons/brands/facebook.svg"; -import { Icon as GithubIcon } from "../../../../res/img/element-icons/brands/github.svg"; -import { Icon as GitlabIcon } from "../../../../res/img/element-icons/brands/gitlab.svg"; -import { Icon as GoogleIcon } from "../../../../res/img/element-icons/brands/google.svg"; -import { Icon as TwitterIcon } from "../../../../res/img/element-icons/brands/twitter.svg"; +import FacebookIcon from "../../../../res/img/element-icons/brands/facebook.svg?react"; +import GithubIcon from "../../../../res/img/element-icons/brands/github.svg?react"; +import GitlabIcon from "../../../../res/img/element-icons/brands/gitlab.svg?react"; +import GoogleIcon from "../../../../res/img/element-icons/brands/google.svg?react"; +import TwitterIcon from "../../../../res/img/element-icons/brands/twitter.svg?react"; interface ISSOButtonProps extends IProps { idp?: IIdentityProvider; diff --git a/apps/web/src/components/views/location/MapFallback.tsx b/apps/web/src/components/views/location/MapFallback.tsx index d6b5f8e18e..25082a5532 100644 --- a/apps/web/src/components/views/location/MapFallback.tsx +++ b/apps/web/src/components/views/location/MapFallback.tsx @@ -10,7 +10,7 @@ import React from "react"; import classNames from "classnames"; import LocationMarkerIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; -import { Icon as MapFallbackImage } from "../../../../res/img/location/map.svg"; +import MapFallbackImage from "../../../../res/img/location/map.svg?react"; import Spinner from "../elements/Spinner"; interface Props extends React.HTMLAttributes { diff --git a/apps/web/src/components/views/polls/PollOption.tsx b/apps/web/src/components/views/polls/PollOption.tsx index d2af3f93ce..80b3cd5210 100644 --- a/apps/web/src/components/views/polls/PollOption.tsx +++ b/apps/web/src/components/views/polls/PollOption.tsx @@ -12,7 +12,7 @@ import { type PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/ import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; -import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; +import TrophyIcon from "../../../../res/img/element-icons/trophy.svg?react"; import StyledRadioButton from "../elements/StyledRadioButton"; type PollOptionContentProps = { diff --git a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx index d4193412be..dbdae82278 100644 --- a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx +++ b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx @@ -10,7 +10,7 @@ import { MessageTimestampView } from "@element-hq/web-shared-components"; import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; import { type MessageTimestampViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { Icon as LateIcon } from "../../../../../res/img/sensor.svg"; +import LateIcon from "../../../../../res/img/sensor.svg?react"; /** * Props for the {@link MessageTimestampAdapter} component. diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx b/apps/web/src/components/views/rooms/RoomHeader/CallGuestLinkButton.test.tsx similarity index 74% rename from apps/web/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx rename to apps/web/src/components/views/rooms/RoomHeader/CallGuestLinkButton.test.tsx index fbeba4a764..dfb0f434cd 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/CallGuestLinkButton.test.tsx @@ -6,28 +6,27 @@ 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. */ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach, afterEach, type Mocked } from "vitest"; import React from "react"; -import { fireEvent, getByLabelText, getByText, render, screen, waitFor } from "jest-matrix-react"; +import { fireEvent, getByLabelText, getByText, render, screen, waitFor } from "test-utils-rtl"; import { type EventTimeline, JoinRule, Room } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; +import { getMockClientWithEventEmitter, mockClientMethodsUser, TestSDKContext } from "test-utils"; -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"; +import { SDKContext } from "../../../../contexts/SDKContext"; +import { CallGuestLinkButton, JoinRuleDialog } from "./CallGuestLinkButton"; +import Modal from "../../../../Modal"; +import SdkConfig from "../../../../SdkConfig"; +import { ShareDialog } from "../../dialogs/ShareDialog"; +import { _t } from "../../../../languageHandler"; +import SettingsStore from "../../../../settings/SettingsStore"; describe("", () => { const roomId = "!room:server.org"; let sdkContext!: TestSDKContext; - let modalSpy: jest.SpyInstance; + let modalSpy: Mocked; let modalResolve: (value: unknown[] | PromiseLike) => void; let room: Room; @@ -47,11 +46,11 @@ describe("", () => { */ 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); + sdkContext.client!.getRoomDirectoryVisibility = vi.fn().mockResolvedValue("public"); + vi.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom); // stub - jest.spyOn(room, "getPendingEvents").mockReturnValue([]); - jest.spyOn(room, "getVersion").mockReturnValue("9"); + vi.spyOn(room, "getPendingEvents").mockReturnValue([]); + vi.spyOn(room, "getVersion").mockReturnValue("9"); return room; }; function mockRoomMembers(room: Room, count: number) { @@ -64,7 +63,7 @@ describe("", () => { })); room.currentState.setJoinedMemberCount(members.length); - room.getJoinedMembers = jest.fn().mockReturnValue(members); + room.getJoinedMembers = vi.fn().mockReturnValue(members); } const getComponent = (room: Room) => @@ -76,29 +75,29 @@ describe("", () => { beforeEach(() => { const client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(), - sendStateEvent: jest.fn(), - getVisibleRooms: jest.fn().mockReturnValue([]), + sendStateEvent: vi.fn(), + getVisibleRooms: vi.fn().mockReturnValue([]), }); sdkContext = new TestSDKContext(); sdkContext._client = client; const modalPromise = new Promise((resolve) => { modalResolve = resolve; }); - modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ finished: modalPromise, close: jest.fn() }); + modalSpy = vi.spyOn(Modal, "createDialog").mockReturnValue({ finished: modalPromise, close: vi.fn() }); room = makeRoom(); mockRoomMembers(room, 3); - jest.spyOn(SdkConfig, "get").mockImplementation((key) => { + vi.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); + vi.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true); + vi.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true); }); afterEach(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it("shows the JoinRuleDialog on click with private join rules", async () => { @@ -106,7 +105,7 @@ describe("", () => { 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); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); modalResolve([]); await new Promise(process.nextTick); const callParams = modalSpy.mock.calls[1]; @@ -117,7 +116,7 @@ describe("", () => { }); it("shows the ShareDialog on click with public join rules", () => { - jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); getComponent(room); fireEvent.click(screen.getByRole("button", { name: "Share call link" })); const callParams = modalSpy.mock.calls[0]; @@ -128,8 +127,8 @@ describe("", () => { }); it("shows the ShareDialog on click with knock join rules", () => { - jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); - jest.spyOn(room, "canInvite").mockReturnValue(true); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); + vi.spyOn(room, "canInvite").mockReturnValue(true); getComponent(room); fireEvent.click(screen.getByRole("button", { name: "Share call link" })); const callParams = modalSpy.mock.calls[0]; @@ -141,21 +140,21 @@ describe("", () => { 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), + vi.spyOn(room, "getLiveTimeline").mockReturnValue({ + getState: vi.fn().mockReturnValue({ + maySendStateEvent: vi.fn().mockReturnValue(false), }), } as unknown as EventTimeline); - jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false); + vi.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); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); + vi.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true); - jest.spyOn(SdkConfig, "get").mockImplementation((key) => { + vi.spyOn(SdkConfig, "get").mockImplementation((key) => { if (key === "element_call") { return { url: "https://example2.com" }; } @@ -167,7 +166,7 @@ describe("", () => { // configured so that the call link button is shown. expect(screen.queryByLabelText("Share call link")).not.toBeInTheDocument(); - jest.spyOn(SdkConfig, "get").mockImplementation((key) => { + vi.spyOn(SdkConfig, "get").mockImplementation((key) => { if (key === "element_call") { return { guest_spa_url: "https://guest_spa_url.com", url: "https://example2.com" }; } @@ -179,11 +178,11 @@ describe("", () => { }); 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); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); + vi.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true); getComponent(room); - const modalSpy = jest.spyOn(Modal, "createDialog"); + const modalSpy = vi.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"; @@ -200,19 +199,19 @@ describe("", () => { }); 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); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); + vi.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(false); + vi.spyOn(sdkContext.roomViewStore, "isViewingCall").mockReturnValue(true); getComponent(room); - const modalSpy = jest.spyOn(Modal, "createDialog"); + const modalSpy = vi.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("", () => { - const onFinished = jest.fn(); + const onFinished = vi.fn(); const getComponent = (room: Room, canInvite: boolean = true) => render(, { @@ -221,7 +220,7 @@ describe("", () => { beforeEach(() => { // feature_ask_to_join enabled - jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + vi.spyOn(SettingsStore, "getValue").mockReturnValue(true); }); it("shows ask to join if feature is enabled", () => { @@ -233,16 +232,15 @@ describe("", () => { 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); + vi.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"); + const sendStateSpy = vi.spyOn(sdkContext.client!, "sendStateEvent"); - let container; - container = getComponent(room).container; + getComponent(room); fireEvent.click(screen.getByRole("radio", { name: "Ask to join ( Recommended )" })); expect(sendStateSpy).toHaveBeenCalledWith( "!room:server.org", @@ -255,7 +253,7 @@ describe("", () => { onFinished.mockClear(); sendStateSpy.mockClear(); - container = getComponent(room).container; + let container = getComponent(room).container; fireEvent.click(getByText(container, "Anyone")); expect(sendStateSpy).toHaveBeenLastCalledWith( "!room:server.org", diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.test.tsx similarity index 79% rename from apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx rename to apps/web/src/components/views/rooms/RoomHeader/RoomHeader.test.tsx index e5a56f4563..643fc0eea2 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.test.tsx @@ -7,6 +7,10 @@ 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. */ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach, afterEach, type Mocked } from "vitest"; + import React from "react"; import { CallType, type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { @@ -34,41 +38,41 @@ import { type RenderOptions, screen, waitFor, -} from "jest-matrix-react"; +} from "test-utils-rtl"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; -import { mocked } from "jest-mock"; import userEvent from "@testing-library/user-event"; +import { filterConsole, setupAsyncStoreWithClient, stubClient } from "test-utils"; -import { filterConsole, setupAsyncStoreWithClient, stubClient } from "../../../../../test-utils"; -import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader"; -import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; -import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; -import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext"; -import RoomContext, { type RoomContextType } from "../../../../../../src/contexts/RoomContext"; -import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore"; -import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases"; -import SettingsStore from "../../../../../../src/settings/SettingsStore"; -import SdkConfig from "../../../../../../src/SdkConfig"; -import dispatcher from "../../../../../../src/dispatcher/dispatcher"; -import { CallStore } from "../../../../../../src/stores/CallStore"; -import { type Call } from "../../../../../../src/models/Call"; -import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils"; -import { WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore"; -import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -import { _t } from "../../../../../../src/languageHandler"; -import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore"; -import { UIFeature } from "../../../../../../src/settings/UIFeature"; -import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; -import { ElementCallMemberEventType } from "../../../../../../src/call-types"; -import { SDKContext } from "../../../../../../src/contexts/SDKContext"; -import { SDKContextClass } from "../../../../../../src/contexts/SDKContextClass.ts"; +import RoomHeader from "./RoomHeader"; +import DMRoomMap from "../../../../utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import { ScopedRoomContextProvider } from "../../../../contexts/ScopedRoomContext"; +import RoomContext, { type RoomContextType } from "../../../../contexts/RoomContext"; +import RightPanelStore from "../../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; +import SettingsStore from "../../../../settings/SettingsStore"; +import SdkConfig from "../../../../SdkConfig"; +import dispatcher from "../../../../dispatcher/dispatcher"; +import { CallStore } from "../../../../stores/CallStore"; +import { type Call } from "../../../../models/Call"; +import * as ShieldUtils from "../../../../utils/ShieldUtils"; +import { WidgetLayoutStore } from "../../../../stores/widgets/WidgetLayoutStore"; +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { _t } from "../../../../languageHandler"; +import WidgetStore, { type IApp } from "../../../../stores/WidgetStore"; +import { UIFeature } from "../../../../settings/UIFeature"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import { ElementCallMemberEventType } from "../../../../call-types"; +import { SDKContext } from "../../../../contexts/SDKContext"; +import { SDKContextClass } from "../../../../contexts/SDKContextClass.ts"; -jest.mock("../../../../../../src/utils/ShieldUtils"); -jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({ +vi.mock("../../../../utils/ShieldUtils"); +vi.mock("../../../../hooks/right-panel/useCurrentPhase", () => ({ useCurrentPhase: () => { return { currentPhase: "foo", isOpen: false }; }, })); +vi.mock("../../../../Modal"); describe("RoomHeader", () => { filterConsole( @@ -79,13 +83,13 @@ describe("RoomHeader", () => { let room: Room; const ROOM_ID = "!1:example.org"; - let setCardSpy: jest.SpyInstance | undefined; + let setCardSpy: Mocked | undefined; const mockRoomViewStore = { - isViewingCall: jest.fn().mockReturnValue(false), - on: jest.fn(), - off: jest.fn(), - emit: jest.fn(), + isViewingCall: vi.fn().mockReturnValue(false), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), }; let client: MatrixClient; @@ -110,15 +114,15 @@ describe("RoomHeader", () => { pendingEventOrdering: PendingEventOrdering.Detached, }); DMRoomMap.setShared({ - getUserIdForRoomId: jest.fn(), + getUserIdForRoomId: vi.fn(), } as unknown as DMRoomMap); - setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard"); - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Normal); + setCardSpy = vi.spyOn(RightPanelStore.instance, "setCard"); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Normal); // Mock CallStore.instance.getCall to return null by default // Individual tests can override this when they need a specific Call object - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(null); // Reset the mock RoomViewStore mockRoomViewStore.isViewingCall.mockReturnValue(false); @@ -132,7 +136,7 @@ describe("RoomHeader", () => { }); afterEach(() => { - jest.restoreAllMocks(); + vi.resetAllMocks(); SettingsStore.reset(); }); @@ -190,7 +194,7 @@ describe("RoomHeader", () => { }, ]; room.currentState.setJoinedMemberCount(members.length); - room.getJoinedMembers = jest.fn().mockReturnValue(members); + room.getJoinedMembers = vi.fn().mockReturnValue(members); const { container } = render(, getWrapper()); @@ -222,7 +226,7 @@ describe("RoomHeader", () => { it("opens the notifications panel", async () => { const user = userEvent.setup(); - SettingsStore.setValue("feature_notifications", null, SettingLevel.DEVICE, true); + await SettingsStore.setValue("feature_notifications", null, SettingLevel.DEVICE, true); render(, getWrapper()); @@ -242,7 +246,7 @@ describe("RoomHeader", () => { it("should not show voice call button in managed hybrid environments", async () => { mockRoomMembers(room, 2); - jest.spyOn(SdkConfig, "get").mockReturnValue({ widget_build_url: "https://widget.build.url" }); + vi.spyOn(SdkConfig, "get").mockReturnValue({ widget_build_url: "https://widget.build.url" }); render(, getWrapper()); const videoButton = screen.getByRole("button", { name: "Video call" }); @@ -270,7 +274,7 @@ describe("RoomHeader", () => { afterEach(() => { SdkConfig.reset(); - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it("should not show call buttons in rooms smaller than 3 members", async () => { @@ -369,7 +373,7 @@ describe("RoomHeader", () => { expect(voiceButton).not.toHaveAttribute("aria-disabled", "true"); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - const placeCallSpy = jest.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); + const placeCallSpy = vi.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); await user.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); @@ -380,7 +384,7 @@ describe("RoomHeader", () => { it("you can't call if there's already a call", () => { mockRoomMembers(room, 2); - jest.spyOn(SDKContextClass.instance.legacyCallHandler, "getCallForRoom").mockReturnValue( + vi.spyOn(SDKContextClass.instance.legacyCallHandler, "getCallForRoom").mockReturnValue( // The JS-SDK does not export the class `MatrixCall` only the type {} as MatrixCall, ); @@ -392,7 +396,7 @@ describe("RoomHeader", () => { it("can call in large rooms if able to edit widgets", () => { mockRoomMembers(room, 10); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); render(, getWrapper()); const videoCallButton = screen.getByRole("button", { name: "Video call" }); @@ -401,7 +405,7 @@ describe("RoomHeader", () => { it("disable calls in large rooms by default", () => { mockRoomMembers(room, 10); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); render(, getWrapper()); expect( getByLabelText(document.body, "You do not have permission to start video calls", { @@ -415,7 +419,7 @@ describe("RoomHeader", () => { beforeEach(async () => { SdkConfig.put({}); // Enable Element Call - client._unstable_getRTCTransports = jest + client._unstable_getRTCTransports = vi .fn() .mockResolvedValue([{ type: "livekit", livekit_service_url: "https://example.org" }]); // And ensure the CallStore has the transports configured. @@ -424,7 +428,7 @@ describe("RoomHeader", () => { afterEach(() => { SdkConfig.reset(); - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it("renders only the video call element", async () => { @@ -436,7 +440,7 @@ describe("RoomHeader", () => { }, }); // allow element calls - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); render(, getWrapper()); @@ -445,7 +449,7 @@ describe("RoomHeader", () => { const videoCallButton = screen.getByRole("button", { name: "Video call" }); expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true"); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); + const dispatcherSpy = vi.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); await user.click(videoCallButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); @@ -458,15 +462,15 @@ describe("RoomHeader", () => { }, }); // allow element calls - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); - jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + vi.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true); const widget = { type: "m.jitsi" } as IApp; - jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ + vi.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget, on: () => {}, off: () => {}, } as unknown as Call); - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); + vi.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); render(, getWrapper()); // Voice and video for (const button of screen.getAllByRole("button", { name: "Ongoing call" })) { @@ -484,17 +488,17 @@ describe("RoomHeader", () => { }, }); // allow calls - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); - jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); - const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + vi.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false); + const spy = vi.spyOn(WidgetLayoutStore.instance, "moveToContainer"); const widget = { type: "m.jitsi" } as IApp; - jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ + vi.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget, on: () => {}, off: () => {}, } as unknown as Call); - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); + vi.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]); render(, getWrapper()); @@ -506,7 +510,7 @@ describe("RoomHeader", () => { it("disables calling if there's a jitsi call", () => { mockRoomMembers(room, 2); - jest.spyOn(SDKContextClass.instance.legacyCallHandler, "getCallForRoom").mockReturnValue( + vi.spyOn(SDKContextClass.instance.legacyCallHandler, "getCallForRoom").mockReturnValue( // The JS-SDK does not export the class `MatrixCall` only the type {} as MatrixCall, ); @@ -519,7 +523,7 @@ describe("RoomHeader", () => { it("calls using legacy or jitsi", async () => { const user = userEvent.setup(); mockRoomMembers(room, 2); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === "im.vector.modular.widgets") return true; return false; }); @@ -530,7 +534,7 @@ describe("RoomHeader", () => { expect(voiceButton).not.toHaveAttribute("aria-disabled", "true"); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - const placeCallSpy = jest.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); + const placeCallSpy = vi.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); await user.click(voiceButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); @@ -542,7 +546,7 @@ describe("RoomHeader", () => { const user = userEvent.setup(); mockRoomMembers(room, 3); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === "im.vector.modular.widgets") return true; return false; }); @@ -552,7 +556,7 @@ describe("RoomHeader", () => { const videoButton = screen.getByRole("button", { name: "Video call" }); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - const placeCallSpy = jest.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); + const placeCallSpy = vi.spyOn(SDKContextClass.instance.legacyCallHandler, "placeCall"); await user.click(videoButton); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); }); @@ -561,7 +565,7 @@ describe("RoomHeader", () => { const user = userEvent.setup(); mockRoomMembers(room, 3); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === ElementCallMemberEventType.name) return true; return false; }); @@ -571,7 +575,7 @@ describe("RoomHeader", () => { const videoButton = screen.getByRole("button", { name: "Video call" }); expect(videoButton).not.toHaveAttribute("aria-disabled", "true"); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); + const dispatcherSpy = vi.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); await user.click(videoButton); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); @@ -579,7 +583,7 @@ describe("RoomHeader", () => { it("buttons are disabled if there is an ongoing call", async () => { mockRoomMembers(room, 3); - jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue( + vi.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue( new Set([{ roomId: "some_other_room" } as Call]), ); const { container } = render(, getWrapper()); @@ -592,7 +596,7 @@ describe("RoomHeader", () => { it("join video call button is shown if there is an ongoing call", async () => { mockRoomMembers(room, 3); // Mock CallStore to return a call with 3 participants - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); render(, getWrapper()); const joinButton = getByLabelText(document.body, "Join video call"); expect(joinButton).not.toHaveAttribute("aria-disabled", "true"); @@ -601,7 +605,7 @@ describe("RoomHeader", () => { it("join voice call button is shown if there is an ongoing call", async () => { mockRoomMembers(room, 3); // Mock CallStore to return a call with 3 participants - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice)); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice)); render(, getWrapper()); const joinButton = getByLabelText(document.body, "Join voice call"); expect(joinButton).not.toHaveAttribute("aria-disabled", "true"); @@ -610,10 +614,10 @@ describe("RoomHeader", () => { it("clicking the join button of an ongoing video call joins as a video call", async () => { const user = userEvent.setup(); mockRoomMembers(room, 3); - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Video, true)); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Video, true)); render(, getWrapper()); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); + const dispatcherSpy = vi.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); await user.click(getByLabelText(document.body, "Join video call")); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true, voiceOnly: false })); @@ -622,10 +626,10 @@ describe("RoomHeader", () => { it("clicking the join button of an ongoing voice call joins as a voice call", async () => { const user = userEvent.setup(); mockRoomMembers(room, 3); - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice, true)); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice, true)); render(, getWrapper()); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation(); + const dispatcherSpy = vi.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); await user.click(getByLabelText(document.body, "Join voice call")); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true, voiceOnly: true })); @@ -634,8 +638,8 @@ describe("RoomHeader", () => { it("join button is disabled if there is an other ongoing call", async () => { mockRoomMembers(room, 3); // Mock CallStore to return a call with 3 participants - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); - jest.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue( + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); + vi.spyOn(CallStore.prototype, "connectedCalls", "get").mockReturnValue( new Set([{ roomId: "some_other_room" } as Call]), ); render(, getWrapper()); @@ -649,22 +653,22 @@ describe("RoomHeader", () => { mockRoomViewStore.isViewingCall.mockReturnValue(true); render(, getWrapper()); - getByLabelText(document.body, "Close lobby"); + expect(getByLabelText(document.body, "Close lobby")).toBeVisible(); }); it("close lobby button is shown if there is an ongoing call but we are viewing the lobby", async () => { mockRoomMembers(room, 3); // Mock CallStore to return a call with 3 participants - jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); + vi.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3)); mockRoomViewStore.isViewingCall.mockReturnValue(true); render(, getWrapper()); - getByLabelText(document.body, "Close lobby"); + expect(getByLabelText(document.body, "Close lobby")).toBeVisible(); }); it("don't show external conference button if the call is not shown", () => { mockRoomViewStore.isViewingCall.mockReturnValue(false); - jest.spyOn(SdkConfig, "get").mockImplementation((key) => { + vi.spyOn(SdkConfig, "get").mockImplementation((key) => { return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" }; }); render(, getWrapper()); @@ -680,7 +684,7 @@ describe("RoomHeader", () => { it("gives the option of element call or legacy calling for video", async () => { const user = userEvent.setup(); mockRoomMembers(room, 2); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === ElementCallMemberEventType.name) return true; return false; }); @@ -697,7 +701,7 @@ describe("RoomHeader", () => { it("gives the option of element call or legacy calling for voice in DM rooms", async () => { const user = userEvent.setup(); mockRoomMembers(room, 2); - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + vi.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { if (key === ElementCallMemberEventType.name) return true; return false; }); @@ -731,9 +735,9 @@ describe("RoomHeader", () => { }); it("does not show a user status for non-DM rooms", async () => { - SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); - client.doesServerSupportExtendedProfiles = jest.fn().mockResolvedValue(true); - mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); + await SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); + vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true); + vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); render(, getWrapper()); @@ -742,7 +746,7 @@ describe("RoomHeader", () => { }); it("shows a history icon if the room is encrypted and has shared history", async () => { - mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true); + vi.mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true); await room.addLiveEvents( [ new MatrixEvent({ @@ -757,11 +761,11 @@ describe("RoomHeader", () => { ); render(, getWrapper()); - await waitFor(() => getByLabelText(document.body, "New members see history")); + await waitFor(() => expect(getByLabelText(document.body, "New members see history")).toBeVisible()); }); it("shows a user icon if the room is encrypted and has world readable history", async () => { - mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true); + vi.mocked(client.getCrypto()!).isEncryptionEnabledInRoom.mockResolvedValue(true); await room.addLiveEvents( [ new MatrixEvent({ @@ -776,17 +780,17 @@ describe("RoomHeader", () => { ); render(, getWrapper()); - await waitFor(() => getByLabelText(document.body, "Anyone can see history")); + await waitFor(() => expect(getByLabelText(document.body, "Anyone can see history")).toBeVisible()); }); describe("dm", () => { beforeEach(() => { // Make the mocked room a DM - mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId) => { + vi.mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId) => { if (roomId === room.roomId) return "@user:example.com"; }); - room.getMember = jest.fn((userId) => new RoomMember(room.roomId, userId)); - room.getJoinedMembers = jest.fn().mockReturnValue([ + room.getMember = vi.fn((userId) => new RoomMember(room.roomId, userId)); + room.getJoinedMembers = vi.fn().mockReturnValue([ { userId: "@me:example.org", name: "Member", @@ -816,7 +820,7 @@ describe("RoomHeader", () => { [ShieldUtils.E2EStatus.Verified, "Verified"], [ShieldUtils.E2EStatus.Warning, "Untrusted"], ])("shows the %s icon", async (value: ShieldUtils.E2EStatus, expectedLabel: string) => { - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(value); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(value); render(, getWrapper()); @@ -824,9 +828,9 @@ describe("RoomHeader", () => { }); it("shows the user status", async () => { - SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); - client.doesServerSupportExtendedProfiles = jest.fn().mockResolvedValue(true); - mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); + await SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); + vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true); + vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); render(, getWrapper()); @@ -835,13 +839,13 @@ describe("RoomHeader", () => { }); it("updates user status when it changes", async () => { - SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); - client.doesServerSupportExtendedProfiles = jest.fn().mockResolvedValue(true); - mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); + await SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, true); + vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true); + vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); render(, getWrapper()); - mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐴", text: "is a horse" }); + vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐴", text: "is a horse" }); client.emit(ClientEvent.UserProfileUpdate, "@bob:example.org", { emoji: "🐴", text: "is a horse" }); await waitFor(() => expect(screen.getByText("is a horse")).toBeInTheDocument()); @@ -849,9 +853,9 @@ describe("RoomHeader", () => { }); it("does not show the user status when the feature is disabled", async () => { - SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, false); - client.doesServerSupportExtendedProfiles = jest.fn().mockResolvedValue(true); - mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); + await SettingsStore.setValue("feature_user_status", null, SettingLevel.DEVICE, false); + vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true); + vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐎", text: "on a horse" }); render(, getWrapper()); @@ -872,12 +876,12 @@ describe("RoomHeader", () => { it("updates the icon when the encryption status changes", async () => { // The room starts verified - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); render(, getWrapper()); await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); // A new member joins, and the room becomes unverified - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); act(() => { room.emit( RoomStateEvent.Members, @@ -898,7 +902,7 @@ describe("RoomHeader", () => { await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument()); // The user becomes verified - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); act(() => { MatrixClientPeg.get()!.emit( CryptoEvent.UserTrustStatusChanged, @@ -909,7 +913,7 @@ describe("RoomHeader", () => { await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); // An unverified device is added - jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + vi.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); act(() => { MatrixClientPeg.get()!.emit(CryptoEvent.DevicesUpdated, ["@alice:example.org"], false); }); @@ -931,7 +935,7 @@ describe("RoomHeader", () => { }); it("calls onClick-callback on legacyAdditionalButtons", () => { - const callback = jest.fn(); + const callback = vi.fn(); const additionalButtons: ViewRoomOpts["buttons"] = [ { icon: () => <>test-icon, @@ -945,7 +949,7 @@ describe("RoomHeader", () => { const button = screen.getByRole("button", { name: "test-label" }); const event = createEvent.click(button); - event.stopPropagation = jest.fn(); + event.stopPropagation = vi.fn(); fireEvent(button, event); expect(callback).toHaveBeenCalled(); @@ -960,11 +964,11 @@ describe("RoomHeader", () => { }); describe("ask to join enabled", () => { - it("does render the RoomKnocksBar", () => { - SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true); - jest.spyOn(room, "canInvite").mockReturnValue(true); - jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); - jest.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]); + it("does render the RoomKnocksBar", async () => { + await SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true); + vi.spyOn(room, "canInvite").mockReturnValue(true); + vi.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); + vi.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]); render(, getWrapper()); expect(screen.getByRole("heading", { name: "Asking to join" })).toBeInTheDocument(); @@ -975,7 +979,7 @@ describe("RoomHeader", () => { const user = userEvent.setup(); render(, getWrapper()); - const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); + const dispatcherSpy = vi.spyOn(dispatcher, "dispatch"); await user.click(getByLabelText(document.body, "Open room settings")); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ action: "open_room_settings" })); }); @@ -1009,9 +1013,9 @@ function createMockCall( widget: { id: "test-widget", type: isElementCall ? "m.call" : undefined }, connectionState: "disconnected", callType, - on: jest.fn(), - off: jest.fn(), - emit: jest.fn(), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), } as unknown as Call; } @@ -1033,5 +1037,5 @@ function mockRoomMembers(room: Room, count: number) { })); room.currentState.setJoinedMemberCount(members.length); - room.getJoinedMembers = jest.fn().mockReturnValue(members); + room.getJoinedMembers = vi.fn().mockReturnValue(members); } diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx b/apps/web/src/components/views/rooms/RoomHeader/VideoRoomChatButton.test.tsx similarity index 73% rename from apps/web/test/unit-tests/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx rename to apps/web/src/components/views/rooms/RoomHeader/VideoRoomChatButton.test.tsx index 5a31ddd4c0..7aed269991 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/VideoRoomChatButton-test.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/VideoRoomChatButton.test.tsx @@ -6,20 +6,21 @@ 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 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"; +// @vitest-environment happy-dom -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"; +import { vi, describe, it, expect, beforeEach, afterEach, type MockedObject } from "vitest"; +import React from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; +import { fireEvent, render, screen, waitFor } from "test-utils-rtl"; +import { getMockClientWithEventEmitter, mockClientMethodsUser, TestSDKContext } from "test-utils"; + +import { VideoRoomChatButton } from "./VideoRoomChatButton"; +import { SDKContext } from "../../../../contexts/SDKContext"; +import type RightPanelStore from "../../../../stores/right-panel/RightPanelStore"; +import { RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState"; +import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; +import { NotificationStateEvents } from "../../../../stores/notifications/NotificationState"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; describe("", () => { const roomId = "!room:server.org"; @@ -32,9 +33,9 @@ describe("", () => { */ const makeRoom = (isVideoRoom = true): Room => { const room = new Room(roomId, sdkContext.client!, sdkContext.client!.getSafeUserId()); - jest.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom); + vi.spyOn(room, "isElementVideoRoom").mockReturnValue(isVideoRoom); // stub - jest.spyOn(room, "getPendingEvents").mockReturnValue([]); + vi.spyOn(room, "getPendingEvents").mockReturnValue([]); return room; }; @@ -43,7 +44,7 @@ describe("", () => { // @ts-ignore ugly mocking roomNotificationState._level = level; - jest.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState); + vi.spyOn(sdkContext.roomNotificationStateStore, "getRoomState").mockReturnValue(roomNotificationState); return roomNotificationState; }; @@ -57,15 +58,15 @@ describe("", () => { ...mockClientMethodsUser(), }); rightPanelStore = { - showOrHidePhase: jest.fn(), + showOrHidePhase: vi.fn(), } as unknown as MockedObject; sdkContext = new TestSDKContext(); sdkContext._client = client; - jest.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore); + vi.spyOn(sdkContext, "rightPanelStore", "get").mockReturnValue(rightPanelStore); }); afterEach(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it("toggles timeline in right panel on click", () => { diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap b/apps/web/src/components/views/rooms/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap similarity index 97% rename from apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap rename to apps/web/src/components/views/rooms/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap index 328ce5ed63..d6fc667f7f 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap +++ b/apps/web/src/components/views/rooms/RoomHeader/__snapshots__/RoomHeader.test.tsx.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`RoomHeader dm does not show the face pile for DMs 1`] = ` +exports[`RoomHeader > dm > does not show the face pile for DMs 1`] = `
renders button with an unread marker when room is unread 1`] = ` +exports[` > renders button with an unread marker when room is unread 1`] = `