Migrate more tests to vitest (#34349)

This commit is contained in:
Michael Telatynski
2026-07-20 13:07:35 +00:00
committed by GitHub
parent 6c2c962588
commit b719f531dd
20 changed files with 310 additions and 271 deletions
@@ -0,0 +1,96 @@
/*
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 { vi, describe, it, expect, beforeEach } from "vitest";
import React from "react";
import { formatList, formatCount, formatCountLong } from "./FormattingUtils";
import SettingsStore from "../settings/SettingsStore";
vi.mock("../dispatcher/dispatcher");
describe("FormattingUtils", () => {
describe("formatCount", () => {
it.each([
{ count: 999, expectedCount: "999" },
{ count: 9999, expectedCount: "10K" },
{ count: 99999, expectedCount: "100K" },
{ count: 999999, expectedCount: "1M" },
{ count: 9999999, expectedCount: "10M" },
{ count: 99999999, expectedCount: "100M" },
{ count: 999999999, expectedCount: "1B" },
{ count: 9999999999, expectedCount: "10B" },
])("formats $count as $expectedCount", ({ count, expectedCount }) => {
expect(formatCount(count)).toBe(expectedCount);
});
});
describe("formatCountLong", () => {
it("formats numbers according to the locale", () => {
expect(formatCountLong(1000)).toBe("1,000");
});
});
describe("formatList", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(SettingsStore, "getValue").mockReturnValue("en-GB");
});
it("should return empty string when given empty list", () => {
expect(formatList([])).toEqual("");
});
it("should return only item when given list of length 1", () => {
expect(formatList(["abc"])).toEqual("abc");
});
it("should return expected sentence in English without item limit", () => {
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def and ghi");
});
it("should return expected sentence in German without item limit", () => {
vi.spyOn(SettingsStore, "getValue").mockReturnValue("de");
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def und ghi");
});
it("should return expected sentence in English with item limit", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 2)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 3)).toEqual("abc, def, ghi and one other");
});
it("should return expected sentence in English with item limit and includeCount", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 3, true)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 4, true)).toEqual("abc, def, ghi and jkl");
});
it("should return expected sentence in ReactNode when given 2 React children", () => {
expect(formatList([<span key="a">a</span>, <span key="b">b</span>])).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when given more React children", () => {
expect(
formatList([
<span key="a">a</span>,
<span key="b">b</span>,
<span key="c">c</span>,
<span key="d">d</span>,
]),
).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when using itemLimit", () => {
expect(
formatList(
[<span key="a">a</span>, <span key="b">b</span>, <span key="c">c</span>, <span key="d">d</span>],
2,
),
).toMatchSnapshot();
});
});
});
@@ -0,0 +1,86 @@
/*
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.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { render } from "test-utils-rtl";
import type { IContent } from "matrix-js-sdk/src/matrix";
import type React from "react";
import { editBodyDiffToHtml } from "./MessageDiffUtils";
describe("editBodyDiffToHtml", () => {
function buildContent(message: string): IContent {
return {
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
};
}
function renderDiff(before: string, after: string) {
const node = editBodyDiffToHtml(buildContent(before), buildContent(after));
return render(node as React.ReactElement);
}
it.each([
["simple word changes", "hello", "world"],
["central word changes", "beginning middle end", "beginning :smile: end"],
["text deletions", "<b>hello</b> world", "<b>hello</b>"],
["text additions", "<b>hello</b>", "<b>hello</b> world"],
["block element additions", "hello", "hello <p>world</p>"],
["inline element additions", "hello", "hello <q>world</q>"],
["block element deletions", `hi <blockquote>there</blockquote>`, "hi"],
["inline element deletions", `hi <em>there</em>`, "hi"],
["element replacements", `hi <i>there</i>`, "hi <em>there</em>"],
["attribute modifications", `<a href="#hi">hi</a>`, `<a href="#bye">hi</a>`],
["attribute deletions", `<a href="#hi">hi</a>`, `<a>hi</a>`],
["attribute additions", `<a>hi</a>`, `<a href="#/room/!123">hi</a>`],
["handles empty tags", `<a>hi</a>`, `<a><h1></h1></a> hi`],
])("renders %s", (_label, before, after) => {
const { container } = renderDiff(before, after);
expect(container).toMatchSnapshot();
});
// see https://github.com/fiduswriter/diffDOM/issues/90
// fixed in diff-dom in 4.2.2+
it("deduplicates diff steps", () => {
const { container } = renderDiff("<div><em>foo</em> bar baz</div>", "<div><em>foo</em> bar bay</div>");
expect(container).toMatchSnapshot();
});
it("handles non-html input", () => {
const before: IContent = {
body: "who knows what's going on <strong>here</strong>",
format: "org.exotic.encoding",
formatted_body: "who knows what's going on <strong>here</strong>",
msgtype: "m.text",
};
const after: IContent = {
...before,
body: "who knows what's going on <strong>there</strong>",
formatted_body: "who knows what's going on <strong>there</strong>",
};
const { container } = render(editBodyDiffToHtml(before, after) as React.ReactElement);
expect(container).toMatchSnapshot();
});
// see https://github.com/vector-im/element-web/issues/23665
it("handles complex transformations", () => {
const { container } = renderDiff(
'<span data-mx-maths="{☃️}^\\infty"><code>{☃️}^\\infty</code></span>',
'<span data-mx-maths="{😃}^\\infty"><code>{😃}^\\infty</code></span>',
);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,72 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when given 2 React children 1`] = `
<React.Fragment>
<React.Fragment>
<span>
a
</span>
</React.Fragment>
<React.Fragment>
and
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
</React.Fragment>
`;
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when given more React children 1`] = `
<React.Fragment>
<React.Fragment>
<span>
a
</span>
</React.Fragment>
<React.Fragment>
,
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
<React.Fragment>
,
</React.Fragment>
<React.Fragment>
<span>
c
</span>
</React.Fragment>
<React.Fragment>
and
</React.Fragment>
<React.Fragment>
<span>
d
</span>
</React.Fragment>
</React.Fragment>
`;
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when using itemLimit 1`] = `
<span>
<React.Fragment>
<React.Fragment>
<span>
a
</span>
,
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
</React.Fragment>
and 2 others
</span>
`;
@@ -0,0 +1,500 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`editBodyDiffToHtml > deduplicates diff steps 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<div>
<em>
foo
</em>
<span>
bar ba
<span
class="mx_EditHistoryMessage_deletion"
>
z
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
y
</span>
</span>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > handles complex transformations 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':snowman:'>☃️</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':smiley:'>😃</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > handles non-html input 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
who knows what's going on &lt;strong&gt;
<span
class="mx_EditHistoryMessage_insertion"
>
t
</span>
here&lt;/strong&gt;
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
target="undefined"
>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute modifications 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="#bye"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders block element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<p>
world
</p>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders block element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_deletion"
>
<blockquote>
there
</blockquote>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders central word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
beginning
<span
class="mx_EditHistoryMessage_insertion"
>
:s
</span>
mi
<span
class="mx_EditHistoryMessage_deletion"
>
dd
</span>
le
<span
class="mx_EditHistoryMessage_insertion"
>
:
</span>
end
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders element replacements 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
hi
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<i>
there
</i>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<em>
there
</em>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders handles empty tags 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<a
rel="noreferrer noopener"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hi
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<h1 />
</div>
</span>
</a>
<span
class="mx_EditHistoryMessage_insertion"
>
hi
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders inline element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders inline element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<span
class="mx_EditHistoryMessage_deletion"
>
<em>
there
</em>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders simple word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hello
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders text additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders text deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_deletion"
>
world
</span>
</span>
</div>
`;
@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`createVoiceMessageContent > should create a voice message content 1`] = `
{
"body": "Voice message",
"file": {},
"info": {
"duration": 23000,
"mimetype": "ogg/opus",
"size": 42000,
},
"msgtype": "m.audio",
"org.matrix.msc1767.audio": {
"duration": 23000,
"waveform": [
1,
2,
3,
],
},
"org.matrix.msc1767.file": {
"file": {},
"mimetype": "ogg/opus",
"name": "Voice message.ogg",
"size": 42000,
"url": "mxc://example.com/file",
},
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc3245.voice": {},
"url": "mxc://example.com/file",
}
`;
@@ -0,0 +1,27 @@
/*
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 { describe, it, expect } from "vitest";
import { type EncryptedFile } from "matrix-js-sdk/src/types";
import { createVoiceMessageContent } from "./createVoiceMessageContent";
describe("createVoiceMessageContent", () => {
it("should create a voice message content", () => {
expect(
createVoiceMessageContent(
"mxc://example.com/file",
"ogg/opus",
23000,
42000,
{} as unknown as EncryptedFile,
[1, 2, 3],
),
).toMatchSnapshot();
});
});
@@ -0,0 +1,24 @@
/*
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 { describe, it, expect } from "vitest";
import { PermalinkParts } from "./PermalinkConstructor";
import MatrixSchemePermalinkConstructor from "./MatrixSchemePermalinkConstructor";
describe("MatrixSchemePermalinkConstructor", () => {
const peramlinkConstructor = new MatrixSchemePermalinkConstructor();
describe("parsePermalink", () => {
it("should strip ?action=chat from user links", () => {
expect(peramlinkConstructor.parsePermalink("matrix:u/user:example.com?action=chat")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});
@@ -0,0 +1,54 @@
/*
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 { describe, it, expect } from "vitest";
import MatrixToPermalinkConstructor from "./MatrixToPermalinkConstructor";
import { PermalinkParts } from "./PermalinkConstructor";
describe("MatrixToPermalinkConstructor", () => {
const peramlinkConstructor = new MatrixToPermalinkConstructor();
describe("parsePermalink", () => {
it.each([
["empty URL", ""],
["something that is not an URL", "hello"],
["should raise an error for a non-matrix.to URL", "https://example.com/#/@user:example.com"],
])("should raise an error for %s", (name: string, url: string) => {
expect(() => peramlinkConstructor.parsePermalink(url)).toThrow(
new Error("Does not appear to be a permalink"),
);
});
it.each([
["(https)", "https://matrix.to/#/@user:example.com"],
["(http)", "http://matrix.to/#/@user:example.com"],
["without protocol", "matrix.to/#/@user:example.com"],
])("should parse an MXID %s", (name: string, url: string) => {
expect(peramlinkConstructor.parsePermalink(url)).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
describe("forRoom", () => {
it("constructs a link given a room ID and via servers", () => {
expect(peramlinkConstructor.forRoom("!myroom:example.com", ["one.example.com", "two.example.com"])).toEqual(
"https://matrix.to/#/!myroom:example.com?via=one.example.com&via=two.example.com",
);
});
});
describe("forEvent", () => {
it("constructs a link given an event ID, room ID and via servers", () => {
expect(
peramlinkConstructor.forEvent("!myroom:example.com", "$event4", ["one.example.com", "two.example.com"]),
).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com");
});
});
});
@@ -0,0 +1,459 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2018 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 { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import { getMockClientWithEventEmitter } from "test-utils/client";
import { type EventEmitter } from "events";
import { Room, RoomMember, EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { PermalinkParts } from "./PermalinkConstructor";
import { makeRoomPermalink, makeUserPermalink, parsePermalink, RoomPermalinkCreator } from "./Permalinks";
import { type IConfigOptions } from "../../IConfigOptions";
import SdkConfig from "../../SdkConfig";
describe("Permalinks", function () {
const userId = "@test:example.com";
const mockClient = getMockClientWithEventEmitter({
getUserId: vi.fn().mockReturnValue(userId),
getRoom: vi.fn(),
});
mockClient.credentials = { userId };
const makeMemberWithPL = (roomId: Room["roomId"], userId: string, powerLevel: number): RoomMember => {
const member = new RoomMember(roomId, userId);
member.powerLevel = powerLevel;
return member;
};
function mockRoom(
roomId: Room["roomId"],
members: RoomMember[],
serverACLContent?: { deny?: string[]; allow?: string[] },
): Room {
members.forEach((m) => (m.membership = KnownMembership.Join));
const powerLevelsUsers = members.reduce<Record<string, number>>((pl, member) => {
if (Number.isFinite(member.powerLevel)) {
pl[member.userId] = member.powerLevel;
}
return pl;
}, {});
const room = new Room(roomId, mockClient, userId);
const powerLevels = new MatrixEvent({
type: EventType.RoomPowerLevels,
room_id: roomId,
state_key: "",
content: {
users: powerLevelsUsers,
users_default: 0,
},
});
const serverACL = serverACLContent
? new MatrixEvent({
type: EventType.RoomServerAcl,
room_id: roomId,
state_key: "",
content: serverACLContent,
})
: undefined;
const stateEvents = serverACL ? [powerLevels, serverACL] : [powerLevels];
room.currentState.setStateEvents(stateEvents);
vi.spyOn(room, "getCanonicalAlias").mockReturnValue(null);
vi.spyOn(room, "getJoinedMembers").mockReturnValue(members);
vi.spyOn(room, "getMember").mockImplementation((userId) => members.find((m) => m.userId === userId) || null);
return room;
}
beforeEach(function () {
vi.clearAllMocks();
});
afterAll(() => {
vi.spyOn(MatrixClientPeg, "get").mockRestore();
});
it("should not clean up listeners even if start was called multiple times", () => {
const room = mockRoom("!fake:example.org", []);
const getListenerCount = (emitter: EventEmitter) =>
emitter
.eventNames()
.map((e) => emitter.listenerCount(e))
.reduce((a, b) => a + b, 0);
const listenerCountBefore = getListenerCount(room.currentState);
const creator = new RoomPermalinkCreator(room);
creator.start();
creator.start();
creator.start();
creator.start();
expect(getListenerCount(room.currentState)).toBeGreaterThan(listenerCountBefore);
creator.stop();
expect(getListenerCount(room.currentState)).toBe(listenerCountBefore);
});
it("should pick no candidate servers when the room has no members", function () {
const room = mockRoom("!fake:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should gracefully handle invalid MXIDs", () => {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl-50:org", 50);
const room = mockRoom(roomId, [alice50]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
});
it("should pick a candidate server for the highest power level user in the room", function () {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50", 50);
const alice75 = makeMemberWithPL(roomId, "@alice:pl_75", 75);
const alice95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom("!fake:example.org", [alice50, alice75, alice95]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("pl_95");
// we don't check the 2nd and 3rd servers because that is done by the next test
});
it("should change candidate server when highest power level user leaves the room", function () {
const roomId = "!fake:example.org";
const member95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:pl_50", 50),
makeMemberWithPL(roomId, "@alice:pl_75", 75),
member95,
]);
const creator = new RoomPermalinkCreator(room, null);
creator.load();
expect(creator.serverCandidates![0]).toBe("pl_95");
member95.membership = KnownMembership.Leave;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_75");
member95.membership = KnownMembership.Join;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_95");
});
it("should pick candidate servers based on user population", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 0),
makeMemberWithPL(roomId, "@bob:first", 0),
makeMemberWithPL(roomId, "@charlie:first", 0),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick prefer candidate servers with higher power levels", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick a maximum of 3 candidate servers", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:alpha", 100),
makeMemberWithPL(roomId, "@alice:bravo", 0),
makeMemberWithPL(roomId, "@alice:charlie", 0),
makeMemberWithPL(roomId, "@alice:delta", 0),
makeMemberWithPL(roomId, "@alice:echo", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
});
it("should not consider IPv4 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv4 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should work with hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:example.org:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toBe("example.org:8448");
});
it("should not consider servers explicitly denied by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["evilcorp.com", "*.evilcorp.com"],
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider servers not allowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: [], // implies "ban everyone"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should consider servers not explicitly banned by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["*.evilcorp.com"], // evilcorp.com is still good though
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should consider servers not disallowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
"!fake:example.org",
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: ["evilcorp.com"], // implies "ban everyone else"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should handle when ACL allow is not an array", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
deny: ["*.evilcorp.com"],
allow: "not-an-array" as any, // Test malformed data
});
const creator = new RoomPermalinkCreator(room);
creator.load();
// Should fall back to default behavior (no allowed servers list = allow none)
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should handle when ACL deny is not an array", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
deny: "not-an-array" as any, // Test malformed data
allow: ["*"],
});
const creator = new RoomPermalinkCreator(room);
creator.load();
// Should not crash and still allow the server
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("goodcorp.com");
});
it("should generate an event permalink for room IDs with no candidate servers", function () {
const room = mockRoom("!somewhere:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com");
});
it("should generate an event permalink for room IDs with some candidate servers", function () {
const roomId = "!somewhere:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@bob:second", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com?via=first&via=second");
});
it("should generate a room permalink for room IDs with some candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId?: string) => {
return mockRoom(roomId!, [
makeMemberWithPL(roomId!, "@alice:first", 100),
makeMemberWithPL(roomId!, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "!somewhere:example.org");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org?via=first&via=second");
});
it("should generate a room permalink for room aliases with no candidate servers", function () {
mockClient.getRoom.mockReturnValue(null);
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a room permalink for room aliases without candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId?: string) => {
return mockRoom(roomId!, [
makeMemberWithPL(roomId!, "@alice:first", 100),
makeMemberWithPL(roomId!, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a user permalink", function () {
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://matrix.to/#/@someone:example.org");
});
it("should use permalink_prefix for permalinks", function () {
const sdkConfigGet = SdkConfig.get;
vi.spyOn(SdkConfig, "get").mockImplementation((key: keyof IConfigOptions, altCaseName?: string) => {
if (key === "permalink_prefix") {
return "https://element.fs.tld";
} else return sdkConfigGet(key, altCaseName);
});
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://element.fs.tld/#/user/@someone:example.org");
});
describe("parsePermalink", () => {
it("should correctly parse room permalinks with a via argument", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=some.org");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["some.org"]);
});
it("should correctly parse room permalink via arguments", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=foo.bar&via=bar.foo");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["foo.bar", "bar.foo"]);
});
it("should correctly parse event permalink via arguments", () => {
const result = parsePermalink(
"https://matrix.to/#/!room_id:server/$event_id/some_thing_here/foobar" + "?via=m1.org&via=m2.org",
);
expect(result?.eventId).toBe("$event_id/some_thing_here/foobar");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["m1.org", "m2.org"]);
});
it("should correctly parse permalinks with http protocol", () => {
expect(parsePermalink("http://matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
it("should correctly parse permalinks without protocol", () => {
expect(parsePermalink("matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach, type Mocked } from "vitest";
import { type IIdentityServerProvider, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils";
import { DirectoryMember, ThreepidMember } from "./direct-messages";
import { lookupThreePids, resolveThreePids } from "./threepids";
describe("threepids", () => {
let client: Mocked<MatrixClient>;
const accessToken = "s3cr3t";
let identityServer: Mocked<IIdentityServerProvider>;
beforeEach(() => {
client = stubClient() as Mocked<MatrixClient>;
identityServer = {
getAccessToken: vi.fn().mockResolvedValue(accessToken),
} as unknown as Mocked<IIdentityServerProvider>;
});
describe("resolveThreePids", () => {
const userId = "@user1:example.com";
const directoryMember = new DirectoryMember({
user_id: userId,
});
const threePid1Id = "three1@example.com";
const threePid1MXID = "@three1:example.com";
const threePid1Member = new ThreepidMember(threePid1Id);
const threePid1Displayname = "Three Pid 1";
const threePid2Id = "three2@example.com";
const threePid2MXID = "@three2:example.com";
const threePid2Member = new ThreepidMember(threePid2Id);
const threePid3Id = "three3@example.com";
const threePid3Member = new ThreepidMember(threePid3Id);
const threePidPhoneId = "8801500121121";
const threePidPhoneMember = new ThreepidMember(threePidPhoneId);
it("should return an empty list for an empty input", async () => {
expect(await resolveThreePids([], client)).toEqual([]);
});
it("should return the same list for non-3rd-party members", async () => {
expect(await resolveThreePids([directoryMember], client)).toEqual([directoryMember]);
});
it("should return the same list for if no identity server is configured", async () => {
expect(await resolveThreePids([directoryMember, threePid1Member], client)).toEqual([
directoryMember,
threePid1Member,
]);
});
describe("when an identity server is configured", () => {
beforeEach(() => {
client.identityServer = identityServer;
});
it("should return the same list if the lookup doesn't return any results", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePidPhoneMember],
client,
),
).toEqual([directoryMember, threePid1Member, threePid2Member, threePidPhoneMember]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["msisdn", threePidPhoneId],
],
accessToken,
);
});
describe("and some 3-rd party members can be resolved", () => {
beforeEach(() => {
client.bulkLookupThreePids.mockResolvedValue({
threepids: [
["email", threePid1Id, threePid1MXID],
["email", threePid2Id, threePid2MXID],
],
});
});
it("should return the resolved members", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["email", threePid3Id],
],
accessToken,
);
});
describe("and some 3rd-party members have a profile", () => {
beforeEach(() => {
client.getProfileInfo.mockImplementation((matrixId: string) => {
if (matrixId === threePid1MXID)
return Promise.resolve({ displayname: threePid1Displayname });
throw new Error("Profile not found");
});
});
it("should resolve the profiles", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID, display_name: threePid1Displayname }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
});
});
});
});
});
describe("lookupThreePids", () => {
it("should return an empty list for an empty list", async () => {
client.identityServer = identityServer;
expect(await lookupThreePids([], client)).toEqual([]);
});
});
});
+158
View File
@@ -0,0 +1,158 @@
/*
Copyright 2026 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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils";
import {
clearUserStatus,
fetchUserStatus,
setUserStatus,
userStatusFromProfile,
userStatusTextWithinMaxLength,
} from "./userStatus";
describe("userStatus utils", () => {
describe("userStatusFromProfile", () => {
it("returns the user status if it is valid", () => {
expect(userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, undefined)).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the user status is invalid and there is no call status", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, undefined)).toBeUndefined();
});
it("returns the call status if the user status is invalid but the call status is valid", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, { call_joined_ts: 12345 })).toEqual({
emoji: "📞",
text: "On a call",
});
});
it("prefers the user status over the call status if both are valid", () => {
expect(
userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, { call_joined_ts: 12345 }),
).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the call status has a non-positive call_joined_ts", () => {
expect(userStatusFromProfile(undefined, { call_joined_ts: 0 })).toBeUndefined();
});
it("returns undefined if neither status is valid", () => {
expect(userStatusFromProfile(undefined, undefined)).toBeUndefined();
});
});
describe("userStatusTextWithinMaxLength", () => {
it("returns true for text within the max length", () => {
const text = "a".repeat(256);
expect(userStatusTextWithinMaxLength(text)).toBe(true);
});
it("returns false for text exceeding the max length", () => {
const text = "a".repeat(257);
expect(userStatusTextWithinMaxLength(text)).toBe(false);
});
});
describe("setUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("sets the user status with valid input", async () => {
setUserStatus(client, { emoji: "🐳", text: "Feeling a little blue" });
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: "🐳",
text: "Feeling a little blue",
});
});
});
describe("fetchUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
client.doesServerSupportExtendedProfiles = vi.fn();
});
it("returns undefined if the server does not support extended profiles", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(false);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
expect(client.getExtendedProfileProperty).not.toHaveBeenCalled();
});
it("returns the validated status if the server supports extended profiles and has a status set", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({
emoji: "🐳",
text: "Feeling a little blue",
});
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(
"@alice:example.com",
"org.matrix.msc4426.status",
);
});
it("returns undefined if the status is invalid", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ text: "Feeling a little blue" });
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
it("returns undefined if the user has no status set", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
it("returns undefined and logs a warning if fetching the status fails unexpectedly", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
const error = new Error("network error");
vi.mocked(client.getExtendedProfileProperty).mockRejectedValue(error);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
});
describe("clearUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("clears the user status", async () => {
clearUserStatus(client);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
});
});
});