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
+8
View File
@@ -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";
-1
View File
@@ -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";
+2
View File
@@ -34,6 +34,7 @@ const config: Config = {
"\\.(css|scss|pcss)(\\?raw)?$": "<rootDir>/__mocks__/cssMock.js",
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
"\\.svg$": "<rootDir>/__mocks__/svg.js",
"\\.svg\\?react$": "<rootDir>/__mocks__/svg-react.js",
"^matrix-js-sdk(.*)$": "<rootDir>/node_modules/matrix-js-sdk$1",
"^react$": "<rootDir>/node_modules/react",
"^react-dom$": "<rootDir>/node_modules/react-dom",
@@ -69,6 +70,7 @@ const config: Config = {
prettierPath: null,
moduleDirectories: ["node_modules", "test/test-utils"],
workerIdleMemoryLimit: "512MB",
snapshotSerializers: ["<rootDir>/src/test/react-use-id-serializer.ts"],
};
// if we're running under GHA, enable relevant reporters
+1
View File
@@ -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",
+5 -1
View File
@@ -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<React.SVGProps<SVGSVGElement>>;
export default path;
}
declare module "*.svg?react" {
const Icon: React.FC<React.SVGProps<SVGSVGElement>>;
export default Icon;
}
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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<SVGSVGElement> {
// use error styling when true
@@ -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<IProps> {
}
return (
// XXX: We can't import ModuleAPI here because it causes a dependency cycle - hack and
// use the copy on the window object :(
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<MatrixClientContext.Provider value={this.matrixClient}>
{this.props.screenName && <PosthogScreenTracker screenName={this.props.screenName} />}
<FocusLock
returnFocus={true}
lockProps={lockProps}
className={classNames(this.props.className, {
mx_Dialog_fixedWidth: this.props.fixedWidth,
<MatrixClientContext.Provider value={this.matrixClient}>
{this.props.screenName && <PosthogScreenTracker screenName={this.props.screenName} />}
<FocusLock
returnFocus={true}
lockProps={lockProps}
className={classNames(this.props.className, {
mx_Dialog_fixedWidth: this.props.fixedWidth,
})}
>
{this.props.top}
<div
className={classNames("mx_Dialog_header", {
mx_Dialog_headerWithButton: !!this.props.headerButton,
})}
>
{this.props.top}
<div
className={classNames("mx_Dialog_header", {
mx_Dialog_headerWithButton: !!this.props.headerButton,
})}
>
{!!(this.props.title || headerImage) && (
<Heading
size="3"
as="h1"
className={classNames("mx_Dialog_title", this.props.titleClass)}
id="mx_BaseDialog_title"
>
{headerImage}
{this.props.title}
</Heading>
)}
{this.props.headerButton}
</div>
{this.props.children}
{cancelButton}
</FocusLock>
</MatrixClientContext.Provider>
</I18nContext.Provider>
{!!(this.props.title || headerImage) && (
<Heading
size="3"
as="h1"
className={classNames("mx_Dialog_title", this.props.titleClass)}
id="mx_BaseDialog_title"
>
{headerImage}
{this.props.title}
</Heading>
)}
{this.props.headerButton}
</div>
{this.props.children}
{cancelButton}
</FocusLock>
</MatrixClientContext.Provider>
);
}
}
@@ -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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
expect(screen.queryByText("Invite to Space")).toBeTruthy();
});
it("should label with room name", () => {
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
expect(screen.getByText(`Invite to ${roomId}`)).toBeInTheDocument();
});
@@ -214,7 +231,7 @@ describe("InviteDialog", () => {
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
onFinished={vi.fn()}
initialText="@localpart:server.tld"
/>,
);
@@ -227,7 +244,7 @@ describe("InviteDialog", () => {
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
onFinished={vi.fn()}
initialText="@localpart:server:tld"
/>,
);
@@ -253,7 +270,7 @@ describe("InviteDialog", () => {
<InviteDialog
kind={kind}
roomId={kind === InviteKind.Invite ? roomId : ""}
onFinished={jest.fn()}
onFinished={vi.fn()}
initialText={aliceEmail}
/>,
);
@@ -275,20 +292,20 @@ describe("InviteDialog", () => {
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
onFinished={vi.fn()}
initialText="foobar@email.com"
/>,
);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Dm} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Dm} onFinished={vi.fn()} />);
// 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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Dm} onFinished={vi.fn()} />);
// 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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Dm} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={vi.fn()} />);
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(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);
vi.mocked(startDmOnFirstMessage).mockClear();
render(<InviteDialog kind={InviteKind.Dm} onFinished={vi.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
@@ -452,7 +469,7 @@ describe("InviteDialog", () => {
<InviteDialog
kind={InviteKind.Invite}
roomId={roomId}
onFinished={jest.fn()}
onFinished={vi.fn()}
initialText="@localpart:server.tld"
/>,
);
@@ -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", () => {
<InviteDialog
kind={kind as InviteKind.Invite | InviteKind.Dm}
roomId={roomId}
onFinished={jest.fn()}
onFinished={vi.fn()}
/>,
);
});
@@ -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();
});
});
});
@@ -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<Props, IInviteDial
initialText: "",
};
private debounceTimer: number | null = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef = createRef<Field>();
private unmounted = false;
@@ -256,6 +255,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
public componentWillUnmount(): void {
this.unmounted = true;
this.updateSuggestions.cancel();
}
private onConsultFirstChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
@@ -530,125 +530,127 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
this.props.onFinished(false);
};
private updateSuggestions = async (term: string): Promise<void> => {
MatrixClientPeg.safeGet()
.searchUserDirectory({ term })
.then(async (r): Promise<void> => {
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<void> => {
MatrixClientPeg.safeGet()
.searchUserDirectory({ term })
.then(async (r): Promise<void> => {
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<HTMLInputElement>): 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<Props, IInviteDial
);
}
const tiles = toRender.map((r) => (
<DMRoomTile
member={r.user}
lastActiveTs={lastActive(r)}
key={r.user.userId}
onToggle={this.toggleMember}
isSelected={this.state.targets.some((t) => t.userId === r.userId)}
/>
));
return (
<div className="mx_InviteDialog_section">
<RichList title={sectionName} titleAttributes={{ "role": "heading", "aria-level": 3 }}>
{tiles}
{toRender.map((r) => (
<DMRoomTile
member={r.user}
lastActiveTs={lastActive(r)}
key={r.user.userId}
onToggle={this.toggleMember}
isSelected={this.state.targets.some((t) => t.userId === r.userId)}
/>
))}
</RichList>
{showMore}
</div>
@@ -919,10 +919,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
private renderEditor(): JSX.Element {
const targets = this.state.targets.map((t) => (
<DMUserTile member={t} onRemove={this.state.busy ? undefined : this.removeMember} key={t.userId} />
));
return (
<PillInput
data-testid="invite-dialog-input-wrapper"
@@ -944,7 +940,9 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
!this.state.busy && this.removeMember(this.state.targets[this.state.targets.length - 1])
}
>
{targets}
{this.state.targets.map((t) => (
<DMUserTile member={t} onRemove={this.state.busy ? undefined : this.removeMember} key={t.userId} />
))}
</PillInput>
);
}
@@ -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;
@@ -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;
@@ -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<HTMLDivElement> {
@@ -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 = {
@@ -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.
@@ -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("<CallGuestLinkButton />", () => {
const roomId = "!room:server.org";
let sdkContext!: TestSDKContext;
let modalSpy: jest.SpyInstance;
let modalSpy: Mocked<any>;
let modalResolve: (value: unknown[] | PromiseLike<unknown[]>) => void;
let room: Room;
@@ -47,11 +46,11 @@ describe("<CallGuestLinkButton />", () => {
*/
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("<CallGuestLinkButton />", () => {
}));
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("<CallGuestLinkButton />", () => {
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<unknown[]>((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("<CallGuestLinkButton />", () => {
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("<CallGuestLinkButton />", () => {
});
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("<CallGuestLinkButton />", () => {
});
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("<CallGuestLinkButton />", () => {
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("<CallGuestLinkButton />", () => {
// 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("<CallGuestLinkButton />", () => {
});
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("<CallGuestLinkButton />", () => {
});
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("<JoinRuleDialog />", () => {
const onFinished = jest.fn();
const onFinished = vi.fn();
const getComponent = (room: Room, canInvite: boolean = true) =>
render(<JoinRuleDialog room={room} canInvite={canInvite} onFinished={onFinished} />, {
@@ -221,7 +220,7 @@ describe("<CallGuestLinkButton />", () => {
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("<CallGuestLinkButton />", () => {
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("<CallGuestLinkButton />", () => {
onFinished.mockClear();
sendStateSpy.mockClear();
container = getComponent(room).container;
let container = getComponent(room).container;
fireEvent.click(getByText(container, "Anyone"));
expect(sendStateSpy).toHaveBeenLastCalledWith(
"!room:server.org",
@@ -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<RightPanelStore["setCard"]> | 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, getWrapper());
@@ -649,22 +653,22 @@ describe("RoomHeader", () => {
mockRoomViewStore.isViewingCall.mockReturnValue(true);
render(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, 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(<RoomHeader room={room} />, getWrapper());
expect(screen.getByRole("heading", { name: "Asking to join" })).toBeInTheDocument();
@@ -975,7 +979,7 @@ describe("RoomHeader", () => {
const user = userEvent.setup();
render(<RoomHeader room={room} />, 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);
}
@@ -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("<VideoRoomChatButton />", () => {
const roomId = "!room:server.org";
@@ -32,9 +33,9 @@ describe("<VideoRoomChatButton />", () => {
*/
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("<VideoRoomChatButton />", () => {
// @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("<VideoRoomChatButton />", () => {
...mockClientMethodsUser(),
});
rightPanelStore = {
showOrHidePhase: jest.fn(),
showOrHidePhase: vi.fn(),
} as unknown as MockedObject<RightPanelStore>;
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", () => {
@@ -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`] = `
<DocumentFragment>
<header
class="_flex_4dswl_9 mx_RoomHeader light-panel"
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<VideoRoomChatButton /> renders button with an unread marker when room is unread 1`] = `
exports[`<VideoRoomChatButton /> > renders button with an unread marker when room is unread 1`] = `
<button
aria-label="Chat"
aria-labelledby="react-use-id-1"
@@ -15,8 +15,8 @@ import { _t } from "../../../languageHandler";
import { SettingLevel } from "../../../settings/SettingLevel";
import { ImageSize } from "../../../settings/enums/ImageSize";
import { SettingsSubsection } from "./shared/SettingsSubsection";
import { Icon as ImgSizeNormalIcon } from "../../../../res/img/element-icons/settings/img-size-normal.svg";
import { Icon as ImgSizeLargeIcon } from "../../../../res/img/element-icons/settings/img-size-large.svg";
import ImgSizeNormalIcon from "../../../../res/img/element-icons/settings/img-size-normal.svg?react";
import ImgSizeLargeIcon from "../../../../res/img/element-icons/settings/img-size-large.svg?react";
interface IState {
size: ImageSize;
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { Fragment } from "react";
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import InactiveIcon from "../../../../../res/img/element-icons/settings/inactive.svg?react";
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "../../../../components/views/settings/devices/filter";
import { type ExtendedDevice } from "../../../../components/views/settings/devices/types";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
@@ -10,7 +10,7 @@ import classNames from "classnames";
import React from "react";
import { ShieldIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import InactiveIcon from "../../../../../res/img/element-icons/settings/inactive.svg?react";
import { DeviceSecurityVariation } from "./types";
interface Props {
@@ -0,0 +1,52 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type SnapshotSerializer } from "vitest";
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;
const plugin = {
test: (value: unknown): value is Element | DocumentFragment =>
!isSerializingDomSnapshot &&
globalThis.Element &&
(value instanceof Element || value instanceof DocumentFragment),
print: (value: unknown, serialize: (value: unknown) => string): string => {
isSerializingDomSnapshot = true;
try {
return normaliseReactUseIds(serialize(value));
} finally {
isSerializingDomSnapshot = false;
}
},
} satisfies SnapshotSerializer;
export default plugin;
// Jest compatibility exports
export const test = plugin.test;
export const print = plugin.print;
+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);
+11
View File
@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { defineProject } from "vitest/config";
import svgr from "vite-plugin-svgr";
import { resolve } from "node:path";
export default defineProject({
@@ -53,5 +54,15 @@ export default defineProject({
url: "http://localhost/",
},
},
snapshotSerializers: [resolve(__dirname, "./src/test/react-use-id-serializer.ts")],
},
plugins: [
svgr({
svgrOptions: {
ref: true,
svgProps: { "role": "presentation", "aria-hidden": "true" },
expandProps: "end",
},
}),
],
});
+44 -52
View File
@@ -506,62 +506,54 @@ export default (env: string, argv: Record<string, any>): webpack.Configuration =
{
test: /\.svg$/,
issuer: /\.(js|ts|jsx|tsx|html)$/,
resourceQuery: { not: [/raw/] },
use: [
{
loader: "@svgr/webpack",
options: {
namedExport: "Icon",
svgProps: {
"role": "presentation",
"aria-hidden": true,
},
// props set on the svg will override defaults
expandProps: "end",
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false,
},
},
resourceQuery: /react/,
loader: "@svgr/webpack",
options: {
svgProps: {
"role": "presentation",
"aria-hidden": true,
},
// props set on the svg will override defaults
expandProps: "end",
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false,
},
// generates a viewbox if missing
{ name: "removeDimensions" },
// https://github.com/facebook/docusaurus/issues/8297
{ name: "prefixIds" },
],
},
},
/**
* Forwards the React ref to the root SVG element
* Useful when using things like `asChild` in
* radix-ui
*/
ref: true,
esModule: false,
name: "[name].[hash:7].[ext]",
outputPath: getAssetOutputPath,
publicPath: function (url: string, resourcePath: string) {
const outputPath = getAssetOutputPath(url, resourcePath);
return toPublicPath(outputPath);
},
},
// generates a viewbox if missing
{ name: "removeDimensions" },
// https://github.com/facebook/docusaurus/issues/8297
{ name: "prefixIds" },
],
},
{
loader: "file-loader",
options: {
esModule: false,
name: "[name].[hash:7].[ext]",
outputPath: getAssetOutputPath,
publicPath: function (url: string, resourcePath: string) {
const outputPath = getAssetOutputPath(url, resourcePath);
return toPublicPath(outputPath);
},
},
/**
* Forwards the React ref to the root SVG element
* Useful when using things like `asChild` in
* radix-ui
*/
ref: true,
esModule: false,
},
},
{
test: /\.svg$/,
issuer: /\.(js|ts|jsx|tsx|html)$/,
resourceQuery: { not: [/raw/, /react/] },
loader: "file-loader",
options: {
esModule: false,
name: "[name].[hash:7].[ext]",
outputPath: getAssetOutputPath,
publicPath: function (url: string, resourcePath: string) {
const outputPath = getAssetOutputPath(url, resourcePath);
return toPublicPath(outputPath);
},
],
},
},
{
test: /\.svg$/,
+14
View File
@@ -1018,6 +1018,9 @@ importers:
util:
specifier: ^0.12.5
version: 0.12.5
vite-plugin-svgr:
specifier: 'catalog:'
version: 5.2.0(@typescript/typescript6@6.0.2)(rollup@4.60.1)(vite@8.1.5(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.48.0)(yaml@2.9.0))
vitest:
specifier: 'catalog:'
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.0)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.1.5(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.48.0)(yaml@2.9.0))
@@ -26613,6 +26616,17 @@ snapshots:
transitivePeerDependencies:
- rollup
vite-plugin-svgr@5.2.0(@typescript/typescript6@6.0.2)(rollup@4.60.1)(vite@8.1.5(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1)
'@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2))
vite: 8.1.5(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.48.0)(yaml@2.9.0)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite-plugin-svgr@5.2.0(rollup@4.60.1)(typescript@7.0.2)(vite@8.1.5(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1)