Migrate more jest tests to vitest (#33922)

* Migrate more jest tests to vitest

* Fix jest config

* Fix jest config

* Make remaining jest tests type-happy

* Iterate

* Fix Notifier import cycle

* Fix tests

* Delint

* Iterate

* Handle SDKContextClass `client` initialisation internally

Rather than via MatrixChat - this is predominantly for Lifecycle tests as they don't use a MatrixChat and it doesn't make much sense for this component to own this state.

* Fix tests

* Iterate

* Simplify diff

* Improve coverage

* Improve coverage

* Iterate
This commit is contained in:
Michael Telatynski
2026-07-07 09:50:40 +00:00
committed by GitHub
parent 30c02ab5d7
commit 6fda0ad60f
10 changed files with 120 additions and 88 deletions
@@ -10,7 +10,9 @@ import React, { type ReactElement } from "react";
// eslint-disable-next-line no-restricted-imports
import { render, type RenderOptions } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { I18nContext } from "@element-hq/web-shared-components";
import { I18nApi, I18nContext } from "@element-hq/web-shared-components";
const i18nApi = new I18nApi();
/**
* Wraps the provided components in:
@@ -26,7 +28,7 @@ const wrapWithStandardContexts = (Wrapper: RenderOptions["wrapper"]) => {
if (Wrapper) {
return (
<Wrapper>
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<I18nContext.Provider value={i18nApi}>
<TooltipProvider>{children}</TooltipProvider>
</I18nContext.Provider>
</Wrapper>
@@ -34,7 +36,7 @@ const wrapWithStandardContexts = (Wrapper: RenderOptions["wrapper"]) => {
} else {
return (
<TooltipProvider>
<I18nContext.Provider value={window.mxModuleApi.i18n}>{children}</I18nContext.Provider>
<I18nContext.Provider value={i18nApi}>{children}</I18nContext.Provider>
</TooltipProvider>
);
}
@@ -1,386 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
formatSeconds,
formatRelativeTime,
formatDuration,
formatFullDateNoDayISO,
formatTimeLeft,
formatPreciseDuration,
formatLocalDateShort,
getDaysArray,
getMonthsArray,
formatFullDateNoDayNoTime,
formatTime,
formatFullTime,
formatFullDate,
formatFullDateNoTime,
formatDate,
HOUR_MS,
MINUTE_MS,
DAY_MS,
} from "../../../src/DateUtils";
import { REPEATABLE_DATE, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../test-utils";
import * as languageSettings from "../../../src/i18n/settings";
describe("getDaysArray", () => {
it("should return Sunday-Saturday in long mode", () => {
expect(getDaysArray("long")).toMatchInlineSnapshot(`
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
]
`);
});
it("should return Sun-Sat in short mode", () => {
expect(getDaysArray("short")).toMatchInlineSnapshot(`
[
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
]
`);
});
it("should return S-S in narrow mode", () => {
expect(getDaysArray("narrow")).toMatchInlineSnapshot(`
[
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]
`);
});
});
describe("getMonthsArray", () => {
it("should return January-December in long mode", () => {
expect(getMonthsArray("long")).toMatchInlineSnapshot(`
[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
`);
});
it("should return Jan-Dec in short mode", () => {
expect(getMonthsArray("short")).toMatchInlineSnapshot(`
[
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
`);
});
it("should return J-D in narrow mode", () => {
expect(getMonthsArray("narrow")).toMatchInlineSnapshot(`
[
"J",
"F",
"M",
"A",
"M",
"J",
"J",
"A",
"S",
"O",
"N",
"D",
]
`);
});
it("should return 1-12 in numeric mode", () => {
expect(getMonthsArray("numeric")).toMatchInlineSnapshot(`
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
]
`);
});
it("should return 01-12 in 2-digit mode", () => {
expect(getMonthsArray("2-digit")).toMatchInlineSnapshot(`
[
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
]
`);
});
});
describe("formatDate", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
});
afterAll(() => {
jest.setSystemTime(jest.getRealSystemTime());
jest.useRealTimers();
});
it("should return time string if date is within same day", () => {
const date = new Date(REPEATABLE_DATE.getTime() + 2 * HOUR_MS + 12 * MINUTE_MS);
// We use en-US for these tests because there was a change in Node 22.12 which removed
// the comma after the weekday for en-GB which makes the test output different things
// on different node versions. I'm not sure what a better fix would be, so let's just use
// a locale that happens to have a more stable formatting right now.
expect(formatDate(date, false, "en-US")).toMatchInlineSnapshot(`"19:10"`);
});
it("should return time string with weekday if date is within last 6 days", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 6 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-US")).toMatchInlineSnapshot(`"Fri 19:10"`);
});
it("should return time & date string without year if it is within the same year", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 66 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-US")).toMatchInlineSnapshot(`"Mon, Sep 12, 19:10"`);
});
it("should return full time & date string otherwise", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 666 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-US")).toMatchInlineSnapshot(`"Wed, Jan 20, 2021, 19:10"`);
});
});
describe("formatFullDateNoTime", () => {
it("should match given locale en-GB", () => {
expect(formatFullDateNoTime(REPEATABLE_DATE, "en-GB")).toMatchInlineSnapshot(`"Thu, 17 Nov 2022"`);
});
});
describe("formatFullDate", () => {
it("correctly formats with seconds", () => {
expect(formatFullDate(REPEATABLE_DATE, true, true, "en-GB")).toMatchInlineSnapshot(
`"Thu, 17 Nov 2022, 4:58:32 pm"`,
);
});
it("correctly formats without seconds", () => {
expect(formatFullDate(REPEATABLE_DATE, false, false, "en-GB")).toMatchInlineSnapshot(
`"Thu, 17 Nov 2022, 16:58"`,
);
});
});
describe("formatFullTime", () => {
it("correctly formats 12 hour mode", () => {
expect(formatFullTime(REPEATABLE_DATE, true, "en-GB")).toMatchInlineSnapshot(`"4:58:32 pm"`);
});
it("correctly formats 24 hour mode", () => {
expect(formatFullTime(REPEATABLE_DATE, false, "en-GB")).toMatchInlineSnapshot(`"16:58:32"`);
});
});
describe("formatTime", () => {
it("correctly formats 12 hour mode", () => {
expect(formatTime(REPEATABLE_DATE, true, "en-GB")).toMatchInlineSnapshot(`"4:58 pm"`);
});
it("correctly formats 24 hour mode", () => {
expect(formatTime(REPEATABLE_DATE, false, "en-GB")).toMatchInlineSnapshot(`"16:58"`);
});
});
describe("formatSeconds", () => {
it("correctly formats time with hours", () => {
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 55)).toBe("03:31:55");
expect(formatSeconds(60 * 60 * 3 + 60 * 0 + 55)).toBe("03:00:55");
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 0)).toBe("03:31:00");
expect(formatSeconds(-(60 * 60 * 3 + 60 * 31 + 0))).toBe("-03:31:00");
});
it("correctly formats time without hours", () => {
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 55)).toBe("31:55");
expect(formatSeconds(60 * 60 * 0 + 60 * 0 + 55)).toBe("00:55");
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 0)).toBe("31:00");
expect(formatSeconds(-(60 * 60 * 0 + 60 * 31 + 0))).toBe("-31:00");
});
});
describe("formatRelativeTime", () => {
beforeAll(() => {
jest.useFakeTimers();
// Tuesday, 2 November 2021 11:18:03 UTC
jest.setSystemTime(1635851883000);
});
afterAll(() => {
jest.setSystemTime(jest.getRealSystemTime());
jest.useRealTimers();
});
it("returns hour format for events created in the same day", () => {
// Tuesday, 2 November 2021 11:01:00 UTC
const date = new Date(2021, 10, 2, 11, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("11:01");
});
it("returns month and day for events created less than 24h ago but on a different day", () => {
// Monday, 1 November 2021 23:01:00 UTC
const date = new Date(2021, 10, 1, 23, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("Nov 1");
});
it("honours the hour format setting", () => {
const date = new Date(2021, 10, 2, 11, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("11:01");
expect(formatRelativeTime(date, false)).toBe("11:01");
expect(formatRelativeTime(date, true)).toBe("11:01 AM");
});
it("returns month and day for events created in the current year", () => {
const date = new Date(1632567741000);
expect(formatRelativeTime(date, true)).toBe("Sep 25");
});
it("does not return a leading 0 for single digit days", () => {
const date = new Date(1635764541000);
expect(formatRelativeTime(date, true)).toBe("Nov 1");
});
it("appends the year for events created in previous years", () => {
const date = new Date(1604142141000);
expect(formatRelativeTime(date, true)).toBe("Oct 31, 2020");
});
});
describe("formatDuration()", () => {
type TestCase = [string, string, number];
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS * 60;
it.each<TestCase>([
["rounds up to nearest day when more than 24h - 40 hours", "2d", 40 * HOUR_MS],
["rounds down to nearest day when more than 24h - 26 hours", "1d", 26 * HOUR_MS],
["24 hours", "1d", 24 * HOUR_MS],
["rounds to nearest hour when less than 24h - 23h", "23h", 23 * HOUR_MS],
["rounds to nearest hour when less than 24h - 6h and 10min", "6h", 6 * HOUR_MS + 10 * MINUTE_MS],
["rounds to nearest hours when less than 24h", "2h", 2 * HOUR_MS + 124234],
["rounds to nearest minute when less than 1h - 59 minutes", "59m", 59 * MINUTE_MS],
["rounds to nearest minute when less than 1h - 1 minute", "1m", MINUTE_MS],
["rounds to nearest second when less than 1min - 59 seconds", "59s", 59000],
["rounds to 0 seconds when less than a second - 123ms", "0s", 123],
])("%s formats to %s", (_description, expectedResult, input) => {
expect(formatDuration(input)).toEqual(expectedResult);
});
});
describe("formatPreciseDuration", () => {
const MINUTE_MS = 1000 * 60;
const HOUR_MS = MINUTE_MS * 60;
const DAY_MS = HOUR_MS * 24;
it.each<[string, string, number]>([
["3 days, 6 hours, 48 minutes, 59 seconds", "3d 6h 48m 59s", 3 * DAY_MS + 6 * HOUR_MS + 48 * MINUTE_MS + 59000],
["6 hours, 48 minutes, 59 seconds", "6h 48m 59s", 6 * HOUR_MS + 48 * MINUTE_MS + 59000],
["48 minutes, 59 seconds", "48m 59s", 48 * MINUTE_MS + 59000],
["59 seconds", "59s", 59000],
["0 seconds", "0s", 0],
])("%s formats to %s", (_description, expectedResult, input) => {
expect(formatPreciseDuration(input)).toEqual(expectedResult);
});
});
describe("formatFullDateNoDayISO", () => {
it("should return ISO format", () => {
expect(formatFullDateNoDayISO(REPEATABLE_DATE)).toEqual("2022-11-17T16:58:32.517Z");
});
});
describe("formatFullDateNoDayNoTime", () => {
it("should return a date formatted for en-GB locale", () => {
expect(formatFullDateNoDayNoTime(REPEATABLE_DATE, "en-GB")).toMatchInlineSnapshot(`"17/11/2022"`);
});
});
describe("formatTimeLeft", () => {
it.each([
[0, "0s left"],
[23, "23s left"],
[60 + 23, "1m 23s left"],
[60 * 60, "1h 0m 0s left"],
[60 * 60 + 23, "1h 0m 23s left"],
[5 * 60 * 60 + 7 * 60 + 23, "5h 7m 23s left"],
])("should format %s to %s", (seconds: number, expected: string) => {
expect(formatTimeLeft(seconds)).toBe(expected);
});
});
describe("formatLocalDateShort()", () => {
afterAll(() => {
unmockIntlDateTimeFormat();
});
const timestamp = new Date("Fri Dec 17 2021 09:09:00 GMT+0100 (Central European Standard Time)").getTime();
it("formats date correctly by locale", () => {
const locale = jest.spyOn(languageSettings, "getUserLanguage");
mockIntlDateTimeFormat();
// format is DD/MM/YY
locale.mockReturnValue("en-GB");
expect(formatLocalDateShort(timestamp)).toEqual("17/12/21");
// US date format is MM/DD/YY
locale.mockReturnValue("en-US");
expect(formatLocalDateShort(timestamp)).toEqual("12/17/21");
locale.mockReturnValue("de-DE");
expect(formatLocalDateShort(timestamp)).toEqual("17.12.21");
});
});
@@ -1,174 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ReactElement } from "react";
import { render } from "jest-matrix-react";
import { MatrixError, ConnectionError } from "matrix-js-sdk/src/matrix";
import {
adminContactStrings,
messageForConnectionError,
messageForLoginError,
messageForResourceLimitError,
messageForSyncError,
resourceLimitStrings,
} from "../../../src/utils/ErrorUtils";
describe("messageForResourceLimitError", () => {
it("should match snapshot for monthly_active_user", () => {
const { asFragment } = render(
messageForResourceLimitError("monthly_active_user", "some@email", resourceLimitStrings) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for admin contact links", () => {
const { asFragment } = render(
messageForResourceLimitError("", "some@email", adminContactStrings) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForSyncError", () => {
it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => {
const err = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: {
limit_type: "monthly_active_user",
admin_contact: "some@email",
},
});
const { asFragment } = render(messageForSyncError(err) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for other errors", () => {
const err = new MatrixError({
errcode: "OTHER_ERROR",
});
const { asFragment } = render(messageForSyncError(err) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForLoginError", () => {
it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => {
const err = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: {
limit_type: "monthly_active_user",
admin_contact: "some@email",
},
});
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for M_USER_DEACTIVATED", () => {
const err = new MatrixError(
{
errcode: "M_USER_DEACTIVATED",
},
403,
);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for 401", () => {
const err = new MatrixError(
{
errcode: "UNKNOWN",
},
401,
);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for unknown error", () => {
const err = new MatrixError({}, 400);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForConnectionError", () => {
it("should match snapshot for ConnectionError", () => {
const err = new ConnectionError("Internal Server Error", new MatrixError({}, 500));
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for MatrixError M_NOT_FOUND", () => {
const err = new MatrixError(
{
errcode: "M_NOT_FOUND",
},
404,
);
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for unknown error", () => {
const err = new Error("What even");
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for mixed content error", () => {
const err = new ConnectionError("Mixed content maybe?");
Object.defineProperty(window, "location", { value: { protocol: "https:" } });
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "http://server.com",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});
@@ -1,460 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
M_LOCATION,
EventStatus,
EventType,
type IEvent,
type MatrixClient,
MatrixEvent,
MsgType,
PendingEventOrdering,
RelationType,
Room,
Thread,
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import {
canCancel,
canEditContent,
canEditOwnEvent,
fetchInitialEvent,
findEditableEvent,
highlightEvent,
isContentActionable,
isLocationEvent,
isVoiceMessage,
} from "../../../src/utils/EventUtils";
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
jest.mock("../../../src/dispatcher/dispatcher");
describe("EventUtils", () => {
const userId = "@user:server";
const roomId = "!room:server";
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(userId),
});
beforeEach(() => {
mockClient.getUserId.mockClear().mockReturnValue(userId);
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
// setup events
const unsentEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
unsentEvent.status = EventStatus.ENCRYPTING;
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(
redactedEvent,
new Room(redactedEvent.getRoomId()!, mockClient, mockClient.getUserId()!),
);
const stateEvent = new MatrixEvent({
type: EventType.RoomTopic,
state_key: "",
});
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
const roomMemberEvent = new MatrixEvent({
type: EventType.RoomMember,
sender: userId,
});
const stickerEvent = new MatrixEvent({
type: EventType.Sticker,
sender: userId,
});
const pollStartEvent = makePollStartEvent("What?", userId);
const notDecryptedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: "m.bad.encrypted",
},
});
const noMsgType = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: undefined,
},
});
const noContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Image,
},
});
const emptyContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "",
},
});
const objectContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.File,
body: {},
},
});
const niceTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
const bobsTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: "@bob:server",
content: {
msgtype: MsgType.Text,
body: "Hello from Bob",
},
});
describe("isContentActionable()", () => {
type TestCase = [string, MatrixEvent];
it.each<TestCase>([
["unsent event", unsentEvent],
["redacted event", redactedEvent],
["state event", stateEvent],
["undecrypted event", notDecryptedEvent],
["room member event", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
])("returns false for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(false);
});
it.each<TestCase>([
["sticker event", stickerEvent],
["poll start event", pollStartEvent],
["event with empty content body", emptyContentBody],
["event with a content body", niceTextMessage],
["beacon_info event", beaconInfoEvent],
])("returns true for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(true);
});
});
describe("editable content helpers", () => {
const replaceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
"msgtype": MsgType.Text,
"body": "Hello",
["m.relates_to"]: {
rel_type: RelationType.Replace,
event_id: "1",
},
},
});
const referenceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
"msgtype": MsgType.Text,
"body": "Hello",
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: "1",
},
},
});
const emoteEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Emote,
body: "🧪",
},
});
type TestCase = [string, MatrixEvent];
const uneditableCases: TestCase[] = [
["redacted event", redactedEvent],
["state event", stateEvent],
["event that is not room message", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
["event with empty content body property", emptyContentBody],
["event with non-string body", objectContentBody],
["event not sent by current user", bobsTextMessage],
["event with a replace relation", replaceRelationEvent],
];
const editableCases: TestCase[] = [
["event with reference relation", referenceRelationEvent],
["emote event", emoteEvent],
["poll start event", pollStartEvent],
["event with a content body", niceTextMessage],
];
describe("canEditContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(true);
});
});
describe("canEditOwnContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(true);
});
});
});
describe("isVoiceMessage()", () => {
it("returns true for an event with msc2516.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc2516.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns true for an event with msc3245.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc3245.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns false for an event with voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "hello",
},
});
expect(isVoiceMessage(event)).toBe(false);
});
});
describe("isLocationEvent()", () => {
it("returns true for an event with m.location stable type", () => {
const event = new MatrixEvent({
type: M_LOCATION.altName,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for an event with m.location unstable prefixed type", () => {
const event = new MatrixEvent({
type: M_LOCATION.name,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with stable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.altName,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with unstable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.name,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns false for a non location event", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "Hello",
},
});
expect(isLocationEvent(event)).toBe(false);
});
});
describe("canCancel()", () => {
it.each([[EventStatus.QUEUED], [EventStatus.NOT_SENT], [EventStatus.ENCRYPTING]])(
"return true for status %s",
(status) => {
expect(canCancel(status)).toBe(true);
},
);
it.each([
[EventStatus.SENDING],
[EventStatus.CANCELLED],
[EventStatus.SENT],
["invalid-status" as unknown as EventStatus],
])("return false for status %s", (status) => {
expect(canCancel(status)).toBe(false);
});
});
describe("fetchInitialEvent", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
const NORMAL_EVENT = "$normalEvent";
const THREAD_ROOT = "$threadRoot";
const THREAD_REPLY = "$threadReply";
const events: Record<string, Partial<IEvent>> = {
[NORMAL_EVENT]: {
event_id: NORMAL_EVENT,
type: EventType.RoomMessage,
content: {
body: "Classic event",
msgtype: MsgType.Text,
},
},
[THREAD_ROOT]: {
event_id: THREAD_ROOT,
type: EventType.RoomMessage,
content: {
body: "Thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
[RelationType.Thread]: {
latest_event: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: "$threadRoot",
rel_type: RelationType.Thread,
},
},
},
count: 1,
current_user_participated: false,
},
},
},
},
[THREAD_REPLY]: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: THREAD_ROOT,
rel_type: RelationType.Thread,
},
},
},
};
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
return events[eventId] ?? Promise.reject();
});
});
it("returns null for unknown events", async () => {
expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
});
it("creates a thread when needed", async () => {
await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
});
});
describe("findEditableEvent", () => {
it("should not explode when given empty events array", () => {
expect(
findEditableEvent({
events: [],
isForward: true,
matrixClient: mockClient,
}),
).toBeUndefined();
});
});
describe("highlightEvent", () => {
const eventId = "$zLg9jResFQmMO_UKFeWpgLgOgyWrL8qIgLgZ5VywrCQ";
it("should dispatch an action to view the event", () => {
highlightEvent(roomId, eventId);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
});
});
});
});
@@ -1,60 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { downloadLabelForFile } from "../../../src/utils/FileUtils.ts";
describe("FileUtils", () => {
describe("downloadLabelForFile", () => {
it.each([
[
"File with size",
{
input: {
msgtype: "m.file",
body: "Test",
info: {
size: 102434566,
},
} as MediaEventContent,
output: "Download (97.69 MB)",
},
],
[
"Image",
{
input: {
msgtype: "m.image",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
[
"Video",
{
input: {
msgtype: "m.video",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
[
"Audio",
{
input: {
msgtype: "m.audio",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
])("should correctly label %s", (_d, { input, output }) => expect(downloadLabelForFile(input)).toBe(output));
});
});
@@ -1,141 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`messageForConnectionError should match snapshot for ConnectionError 1`] = `
<DocumentFragment>
<span>
<span>
Can't connect to homeserver - please check your connectivity, ensure your
<a
class="mx_ExternalLink"
href="hsUrl"
rel="noreferrer noopener"
target="_blank"
>
homeserver's SSL certificate
<svg
class="mx_ExternalLink_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2"
/>
</svg>
</a>
is trusted, and that a browser extension is not blocking requests.
</span>
</span>
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for MatrixError M_NOT_FOUND 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later.(M_NOT_FOUND)
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for mixed content error 1`] = `
<DocumentFragment>
<span>
<span>
Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or
<a
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
rel="noreferrer noopener"
target="_blank"
>
enable unsafe scripts
</a>
.
</span>
</span>
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for unknown error 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for 401 1`] = `
<DocumentFragment>
Incorrect username and/or password.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = `
<DocumentFragment>
<div>
<div>
This homeserver has exceeded one of its resource limits.
</div>
<div
class="mx_Login_smallError"
>
Please contact your service administrator to continue using this service.
</div>
</div>
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for M_USER_DEACTIVATED 1`] = `
<DocumentFragment>
This account has been deactivated.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for unknown error 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later. (HTTP 400)
</DocumentFragment>
`;
exports[`messageForResourceLimitError should match snapshot for admin contact links 1`] = `
<DocumentFragment>
<span>
Please
<a
href="some@email"
rel="noreferrer noopener"
target="_blank"
>
contact your service administrator
</a>
to continue using this service.
</span>
</DocumentFragment>
`;
exports[`messageForResourceLimitError should match snapshot for monthly_active_user 1`] = `
<DocumentFragment>
This homeserver has hit its Monthly Active User limit.
</DocumentFragment>
`;
exports[`messageForSyncError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = `
<DocumentFragment>
<div>
<div>
This homeserver has exceeded one of its resource limits.
</div>
<div>
Please contact your service administrator to continue using this service.
</div>
</div>
</DocumentFragment>
`;
exports[`messageForSyncError should match snapshot for other errors 1`] = `
<DocumentFragment>
<div>
Unable to connect to Homeserver. Retrying…
</div>
</DocumentFragment>
`;
@@ -1,174 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked, type Mocked } from "jest-mock-vitest-adapter";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkRoom, resetAsyncStoreWithClient, setupAsyncStoreWithClient, stubClient } from "../../test-utils";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../src/dispatcher/actions";
import { leaveRoomBehaviour } from "../../../src/utils/leave-behaviour";
import { SDKContextClass } from "../../../src/contexts/SDKContextClass";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../src/stores/spaces";
import { type ActionPayload } from "../../../src/dispatcher/payloads";
import SettingsStore from "../../../src/settings/SettingsStore";
import { CallStore } from "../../../src/stores/CallStore";
import { type Call } from "../../../src/models/Call";
import LegacyCallHandler from "../../../src/LegacyCallHandler";
describe("leaveRoomBehaviour", () => {
SDKContextClass.instance.constructEagerStores(); // Initialize RoomViewStore
let client: Mocked<MatrixClient>;
let room: Mocked<Room>;
let space: Mocked<Room>;
beforeEach(async () => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);
room = mkRoom(client, "!1:example.org");
space = mkRoom(client, "!2:example.org");
space.isSpaceRoom.mockReturnValue(true);
client.getRoom.mockImplementation((roomId) => {
switch (roomId) {
case room.roomId:
return room;
case space.roomId:
return space;
default:
return null;
}
});
await setupAsyncStoreWithClient(SpaceStore.instance, client);
});
afterEach(async () => {
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
await resetAsyncStoreWithClient(SpaceStore.instance);
jest.restoreAllMocks();
});
const viewRoom = (room: Room) =>
defaultDispatcher.dispatch<ViewRoomPayload>(
{
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
},
true,
);
const expectDispatch = async <T extends ActionPayload>(payload: T) => {
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await sleep(0);
expect(dispatcherSpy).toHaveBeenCalledWith(payload);
defaultDispatcher.unregister(dispatcherRef);
};
it("hangs up legacy calls when leaving a room", async () => {
const hangupSpy = jest.spyOn(LegacyCallHandler.instance, "hangupOrReject").mockImplementation(() => {});
viewRoom(room);
await leaveRoomBehaviour(client, room.roomId);
expect(hangupSpy).toHaveBeenCalledWith(room.roomId);
});
it("disconnects widget-based calls when leaving a room", async () => {
const mockCall = {
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getActiveCall").mockReturnValue(mockCall);
viewRoom(room);
await leaveRoomBehaviour(client, room.roomId);
expect(mockCall.disconnect).toHaveBeenCalled();
});
it("returns to the home page after leaving a room outside of a space that was being viewed", async () => {
viewRoom(room);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({ action: Action.ViewHomePage });
});
it("returns to the parent space after leaving a room inside of a space that was being viewed", async () => {
jest.spyOn(SpaceStore.instance, "getCanonicalParent").mockImplementation((roomId) =>
roomId === room.roomId ? space : null,
);
viewRoom(room);
SpaceStore.instance.setActiveSpace(space.roomId, false);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({
action: Action.ViewRoom,
room_id: space.roomId,
metricsTrigger: undefined,
});
});
it("returns to the home page after leaving a top-level space that was being viewed", async () => {
viewRoom(space);
SpaceStore.instance.setActiveSpace(space.roomId, false);
await leaveRoomBehaviour(client, space.roomId);
await expectDispatch({ action: Action.ViewHomePage });
});
it("returns to the parent space after leaving a subspace that was being viewed", async () => {
room.isSpaceRoom.mockReturnValue(true);
jest.spyOn(SpaceStore.instance, "getCanonicalParent").mockImplementation((roomId) =>
roomId === room.roomId ? space : null,
);
viewRoom(room);
SpaceStore.instance.setActiveSpace(room.roomId, false);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({
action: Action.ViewRoom,
room_id: space.roomId,
metricsTrigger: undefined,
});
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
await leaveRoomBehaviour(client, room.roomId);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(room.roomId, true, false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
await leaveRoomBehaviour(client, room.roomId);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(room.roomId, true, true);
});
});
});
@@ -1,111 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { type MockedObject } from "jest-mock-vitest-adapter";
import { createRef } from "react";
import { mkRoom, stubClient } from "../../test-utils";
import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewModel";
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
import { type Call } from "../../../src/models/Call";
const userId = "@example:example.org";
const widgetId = "test-widget-id";
type BackClickEvent = Parameters<WidgetPipViewModel["onBackClick"]>[0];
const createBackClickEvent = (): BackClickEvent =>
({
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
}) as unknown as BackClickEvent;
describe("WidgetPipViewModel", () => {
let client: MockedObject<MatrixClient>;
let vm: WidgetPipViewModel;
let room: MockedObject<Room>;
let widget: IApp;
beforeEach(() => {
client = stubClient() as MockedObject<MatrixClient>;
room = mkRoom(client, "!example");
widget = {
id: widgetId,
roomId: room.roomId,
creatorUserId: userId,
type: "m.custom",
name: "Test Widget",
data: {},
} as unknown as IApp;
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
vm = new WidgetPipViewModel({
room,
widgetId,
onStartMoving: () => {},
movePersistedElement: createRef(),
});
});
afterEach(() => {
vm.dispose();
jest.restoreAllMocks();
});
it("updates room name", () => {
room.name = "New Room Name";
room.emit(RoomEvent.Name, room);
expect(vm.getSnapshot().roomName).toBe("New Room Name");
});
it("updates onBackClick if call changes", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
dispatchSpy.mockClear();
const call = { widget: { id: widgetId } } as unknown as Call;
CallStore.instance.emit(CallStoreEvent.Call, call, room.roomId);
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: "WebFloatingCallWindow",
});
});
it("updates onBackClick if viewingRoom changes", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation(() => {});
vm.setViewingRoom(true);
vm.onBackClick(createBackClickEvent());
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
moveSpy.mockClear();
vm.setViewingRoom(false);
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
expect(moveSpy).not.toHaveBeenCalled();
});
});