Migrate batch of tests to vitest (#34106)

* Migrate autocomplete tests to vitest

* Migrate utils/beacon tests to vitest

* Migrate utils/device tests to vitest

* Migrate utils/crypto tests to vitest

* Migrate utils/localRoom tests to vitest

* Migrate notifications tests to vitest

* Fix types

* Make jest test happy

* Make sonar happier
This commit is contained in:
Michael Telatynski
2026-07-03 10:10:04 +00:00
committed by GitHub
parent 78273c569b
commit 275ea25eca
21 changed files with 195 additions and 147 deletions
@@ -0,0 +1,90 @@
/*
Copyright 2025 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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type Room } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils";
import CommandProvider from "./CommandProvider";
import { Command } from "../slash-commands/command";
import { CommandCategories } from "../slash-commands/interface";
import { _td } from "../languageHandler";
import * as SlashCommands from "../slash-commands/SlashCommands";
describe("CommandProvider", () => {
let room: Room;
beforeEach(() => {
stubClient();
room = {
roomId: "!room:server",
} as Room;
});
it("should filter out disabled commands when arguments are provided", async () => {
// Create a disabled command
const disabledCommand = new Command({
command: "disabled",
args: "<arg>",
description: _td("slash_command|spoiler"),
runFn: vi.fn(),
category: CommandCategories.messages,
isEnabled: () => false,
});
// Create an enabled command
const enabledCommand = new Command({
command: "enabled",
args: "<arg>",
description: _td("slash_command|shrug"),
runFn: vi.fn(),
category: CommandCategories.messages,
isEnabled: () => true,
});
// Mock the Commands array and CommandMap
Object.defineProperty(SlashCommands, "Commands", {
value: [disabledCommand, enabledCommand],
configurable: true,
});
const mockCommandMap = new Map<string, Command>();
mockCommandMap.set("disabled", disabledCommand);
mockCommandMap.set("enabled", enabledCommand);
Object.defineProperty(SlashCommands, "CommandMap", {
value: mockCommandMap,
configurable: true,
});
const provider = new CommandProvider(room);
// When we search for a disabled command with arguments
const completions = await provider.getCompletions("/disabled test", {
beginning: true,
start: 0,
end: 14,
});
// Then we should get no completions because the command is disabled
expect(completions).toEqual([]);
// When we search for an enabled command with arguments
const enabledCompletions = await provider.getCompletions("/enabled test", {
beginning: true,
start: 0,
end: 13,
});
// Then we should get the completion because the command is enabled
// The completion preserves the arguments when the command matches
expect(enabledCompletions).toHaveLength(1);
expect(enabledCompletions[0].completion).toBe("/enabled test");
});
});
@@ -0,0 +1,97 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Ryan Browne <code@commonlawfeature.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.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { stubClient, mkStubRoom } from "test-utils";
import EmojiProvider from "./EmojiProvider";
import { add } from "../emojipicker/recent";
import { MatrixClientPeg } from "../MatrixClientPeg";
const EMOJI_SHORTCODES = [
":+1",
":heart",
":grinning",
":hand",
":man",
":sweat",
":monkey",
":boat",
":mailbox",
":cop",
":bow",
":kiss",
":golf",
];
// Some emoji shortcodes are too short and do not actually trigger autocompletion until the ending `:`.
// This means that we cannot compare their autocompletion before and after the ending `:` and have
// to simply assert that the final completion with the colon is the exact emoji.
const TOO_SHORT_EMOJI_SHORTCODE = [{ emojiShortcode: ":o", expectedEmoji: "⭕️" }];
interface CompletionComponentProps {
title: string;
}
describe("EmojiProvider", function () {
const testRoom = mkStubRoom(undefined, undefined, undefined);
stubClient();
MatrixClientPeg.safeGet();
it.each(EMOJI_SHORTCODES)("Returns consistent results after final colon %s", async function (emojiShortcode) {
const ep = new EmojiProvider(testRoom);
const range = { beginning: true, start: 0, end: 3 };
const completionsBeforeColon = await ep.getCompletions(emojiShortcode, range);
const completionsAfterColon = await ep.getCompletions(emojiShortcode + ":", range);
const firstCompletionWithoutColon = completionsBeforeColon[0].completion;
const firstCompletionWithColon = completionsAfterColon[0].completion;
expect(firstCompletionWithoutColon).toEqual(firstCompletionWithColon);
});
it.each(TOO_SHORT_EMOJI_SHORTCODE)(
"Returns correct results after final colon $emojiShortcode",
async ({ emojiShortcode, expectedEmoji }) => {
const ep = new EmojiProvider(testRoom);
const range = { beginning: true, start: 0, end: 3 };
const completions = await ep.getCompletions(emojiShortcode + ":", range);
expect(completions[0].completion).toEqual(expectedEmoji);
},
);
it("Recently used emojis are correctly sorted", async function () {
add("😘"); //kissing_heart
add("💗"); //heartpulse
add("💗"); //heartpulse
add("😍"); //heart_eyes
const ep = new EmojiProvider(testRoom);
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
expect((completionsList[0]?.component?.props as CompletionComponentProps).title).toEqual(":heartpulse:");
expect((completionsList[1]?.component?.props as CompletionComponentProps).title).toEqual(":heart_eyes:");
});
it("Exact match in recently used takes the lead", async function () {
add("😘"); //kissing_heart
add("💗"); //heartpulse
add("💗"); //heartpulse
add("😍"); //heart_eyes
add("❤️"); //heart
const ep = new EmojiProvider(testRoom);
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
expect((completionsList[0]?.component?.props as CompletionComponentProps).title).toEqual(":heart:");
expect((completionsList[1]?.component?.props as CompletionComponentProps).title).toEqual(":heartpulse:");
expect((completionsList[2]?.component?.props as CompletionComponentProps).title).toEqual(":heart_eyes:");
});
});
@@ -0,0 +1,167 @@
/*
Copyright 2018-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 { describe, it, expect } from "vitest";
import QueryMatcher from "./QueryMatcher";
const OBJECTS = [
{ name: "Mel B", nick: "Scary" },
{ name: "Mel C", nick: "Sporty" },
{ name: "Emma", nick: "Baby" },
{ name: "Geri", nick: "Ginger" },
{ name: "Victoria", nick: "Posh" },
];
const NONWORDOBJECTS = [{ name: "B.O.B" }, { name: "bob" }];
describe("QueryMatcher", function () {
it("Returns results by key", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Geri");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Geri");
});
it("Returns results by prefix", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Ge");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Geri");
});
it("Matches case-insensitive", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("geri");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Geri");
});
it("Matches ignoring accents", function () {
const qm = new QueryMatcher([{ name: "Gëri", foo: 46 }], { keys: ["name"] });
const results = qm.match("geri");
expect(results).toHaveLength(1);
expect(results[0].foo).toBe(46);
});
it("Returns multiple results in order of search string appearance", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name", "nick"] });
const results = qm.match("or");
expect(results.length).toBe(2);
expect(results[0].name).toBe("Mel C");
expect(results[1].name).toBe("Victoria");
qm.setObjects(OBJECTS.slice().reverse());
const reverseResults = qm.match("or");
// should still be in the same order: search string position
// takes precedence over input order
expect(reverseResults.length).toBe(2);
expect(reverseResults[0].name).toBe("Mel C");
expect(reverseResults[1].name).toBe("Victoria");
});
it("Returns results with search string in same place according to key index", function () {
const objects = [
{ name: "a", first: "hit", second: "miss", third: "miss" },
{ name: "b", first: "miss", second: "hit", third: "miss" },
{ name: "c", first: "miss", second: "miss", third: "hit" },
];
const qm = new QueryMatcher(objects, { keys: ["second", "first", "third"] });
const results = qm.match("hit");
expect(results.length).toBe(3);
expect(results[0].name).toBe("b");
expect(results[1].name).toBe("a");
expect(results[2].name).toBe("c");
qm.setObjects(objects.slice().reverse());
const reverseResults = qm.match("hit");
// should still be in the same order: key index
// takes precedence over input order
expect(reverseResults.length).toBe(3);
expect(reverseResults[0].name).toBe("b");
expect(reverseResults[1].name).toBe("a");
expect(reverseResults[2].name).toBe("c");
});
it("Returns results with search string in same place and key in same place in insertion order", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Mel");
expect(results.length).toBe(2);
expect(results[0].name).toBe("Mel B");
expect(results[1].name).toBe("Mel C");
qm.setObjects(OBJECTS.slice().reverse());
const reverseResults = qm.match("Mel");
expect(reverseResults.length).toBe(2);
expect(reverseResults[0].name).toBe("Mel C");
expect(reverseResults[1].name).toBe("Mel B");
});
it("Returns numeric results in correct order (input pos)", function () {
// regression test for depending on object iteration order
const qm = new QueryMatcher([{ name: "123456badger" }, { name: "123456" }], { keys: ["name"] });
const results = qm.match("123456");
expect(results.length).toBe(2);
expect(results[0].name).toBe("123456badger");
expect(results[1].name).toBe("123456");
});
it("Returns numeric results in correct order (query pos)", function () {
const qm = new QueryMatcher([{ name: "999999123456" }, { name: "123456badger" }], { keys: ["name"] });
const results = qm.match("123456");
expect(results.length).toBe(2);
expect(results[0].name).toBe("123456badger");
expect(results[1].name).toBe("999999123456");
});
it("Returns results by function", function () {
const qm = new QueryMatcher(OBJECTS, {
keys: ["name"],
funcs: [(x) => x.name.replace("Mel", "Emma")],
});
const results = qm.match("Emma");
expect(results.length).toBe(3);
expect(results[0].name).toBe("Emma");
expect(results[1].name).toBe("Mel B");
expect(results[2].name).toBe("Mel C");
});
it("Matches words only by default", function () {
const qm = new QueryMatcher(NONWORDOBJECTS, { keys: ["name"] });
const results = qm.match("bob");
expect(results.length).toBe(2);
expect(results[0].name).toBe("B.O.B");
expect(results[1].name).toBe("bob");
});
it("Matches all chars with words-only off", function () {
const qm = new QueryMatcher(NONWORDOBJECTS, {
keys: ["name"],
shouldMatchWordsOnly: false,
});
const results = qm.match("bob");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("bob");
});
});
@@ -0,0 +1,129 @@
/*
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 } from "vitest";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mkRoom, mkSpace, stubClient } from "test-utils";
import RoomProvider from "./RoomProvider";
import SettingsStore from "../settings/SettingsStore";
describe("RoomProvider", () => {
it("suggests a room whose alias matches a prefix", async () => {
// Given a room
const client = stubClient();
const room = makeRoom(client, "room:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([room]);
// When we search for rooms starting with its prefix
const roomProvider = new RoomProvider(room);
const completions = await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
// Then we find it
expect(completions).toStrictEqual([
{
type: "room",
completion: room.getCanonicalAlias(),
completionId: room.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
it("suggests only rooms matching a prefix", async () => {
// Given some rooms with different names
const client = stubClient();
const room1 = makeRoom(client, "room1:e.com");
const room2 = makeRoom(client, "room2:e.com");
const other = makeRoom(client, "other:e.com");
const space = makeSpace(client, "room3:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([room1, room2, other, space]);
// When we search for rooms starting with a prefix
const roomProvider = new RoomProvider(room1);
const completions = await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
// Then we find the two rooms with that prefix, but not the other one
expect(completions).toStrictEqual([
{
type: "room",
completion: room1.getCanonicalAlias(),
completionId: room1.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room1:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
{
type: "room",
completion: room2.getCanonicalAlias(),
completionId: room2.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room2:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
vi.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const room = makeRoom(client, "room:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([room]);
vi.mocked(client.getVisibleRooms).mockClear();
const roomProvider = new RoomProvider(room);
await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
vi.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const room = makeRoom(client, "room:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([room]);
vi.mocked(client.getVisibleRooms).mockClear();
const roomProvider = new RoomProvider(room);
await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
});
function makeSpace(client: MatrixClient, name: string): Room {
const space = mkSpace(client, `!${name}`);
space.getCanonicalAlias.mockReturnValue(`#${name}`);
return space;
}
function makeRoom(client: MatrixClient, name: string): Room {
const room = mkRoom(client, `!${name}`);
room.getCanonicalAlias.mockReturnValue(`#${name}`);
return room;
}
@@ -0,0 +1,129 @@
/*
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 } from "vitest";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mkRoom, mkSpace, stubClient } from "test-utils";
import SpaceProvider from "./SpaceProvider";
import SettingsStore from "../settings/SettingsStore";
describe("SpaceProvider", () => {
it("suggests a space whose alias matches a prefix", async () => {
// Given a space
const client = stubClient();
const space = makeSpace(client, "space:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([space]);
// When we search for spaces starting with its prefix
const spaceProvider = new SpaceProvider(space);
const completions = await spaceProvider.getCompletions("#sp", { beginning: true, start: 0, end: 3 });
// Then we find it
expect(completions).toStrictEqual([
{
type: "room",
completion: space.getCanonicalAlias(),
completionId: space.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
it("suggests only spaces matching a prefix", async () => {
// Given some spaces with different names
const client = stubClient();
const space1 = makeSpace(client, "space1:e.com");
const space2 = makeSpace(client, "space2:e.com");
const other = makeSpace(client, "other:e.com");
const room = makeRoom(client, "space3:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([space1, space2, other, room]);
// When we search for spaces starting with a prefix
const spaceProvider = new SpaceProvider(space1);
const completions = await spaceProvider.getCompletions("#sp", { beginning: true, start: 0, end: 3 });
// Then we find the two spaces with that prefix, but not the other one
expect(completions).toStrictEqual([
{
type: "room",
completion: space1.getCanonicalAlias(),
completionId: space1.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space1:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
{
type: "room",
completion: space2.getCanonicalAlias(),
completionId: space2.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space2:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
vi.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const space = makeSpace(client, "space:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([space]);
vi.mocked(client.getVisibleRooms).mockClear();
const spaceProvider = new SpaceProvider(space);
await spaceProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_space_predecessors setting
vi.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const space = makeSpace(client, "space:e.com");
vi.mocked(client.getVisibleRooms).mockReturnValue([space]);
vi.mocked(client.getVisibleRooms).mockClear();
const spaceProvider = new SpaceProvider(space);
await spaceProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
});
function makeSpace(client: MatrixClient, name: string): Room {
const space = mkSpace(client, `!${name}`);
space.getCanonicalAlias.mockReturnValue(`#${name}`);
return space;
}
function makeRoom(client: MatrixClient, name: string): Room {
const room = mkRoom(client, `!${name}`);
room.getCanonicalAlias.mockReturnValue(`#${name}`);
return room;
}
@@ -0,0 +1,88 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket 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 { describe, it, expect } from "vitest";
import { TweakName, PushRuleActionName, type TweakHighlight, type TweakSound } from "matrix-js-sdk/src/matrix";
import { ContentRules, PushRuleVectorState } from ".";
const NORMAL_RULE = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false } as TweakHighlight],
default: false,
enabled: true,
pattern: "vdh2",
rule_id: "vdh2",
};
const LOUD_RULE = {
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight } as TweakHighlight,
{ set_tweak: TweakName.Sound, value: "default" } as TweakSound,
],
default: false,
enabled: true,
pattern: "vdh2",
rule_id: "vdh2",
};
const USERNAME_RULE = {
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: "default" } as TweakSound,
{ set_tweak: TweakName.Highlight } as TweakHighlight,
],
default: true,
enabled: true,
pattern: "richvdh",
rule_id: ".m.rule.contains_user_name",
};
describe("ContentRules", function () {
describe("parseContentRules", function () {
it("should handle there being no keyword rules", function () {
const rules = { global: { content: [USERNAME_RULE] } };
const parsed = ContentRules.parseContentRules(rules);
expect(parsed.rules).toEqual([]);
expect(parsed.vectorState).toEqual(PushRuleVectorState.ON);
expect(parsed.externalRules).toEqual([]);
});
it("should parse regular keyword notifications", function () {
const rules = { global: { content: [NORMAL_RULE, USERNAME_RULE] } };
const parsed = ContentRules.parseContentRules(rules);
expect(parsed.rules).toHaveLength(1);
expect(parsed.rules[0]).toEqual(NORMAL_RULE);
expect(parsed.vectorState).toEqual(PushRuleVectorState.ON);
expect(parsed.externalRules).toEqual([]);
});
it("should parse loud keyword notifications", function () {
const rules = { global: { content: [LOUD_RULE, USERNAME_RULE] } };
const parsed = ContentRules.parseContentRules(rules);
expect(parsed.rules).toHaveLength(1);
expect(parsed.rules[0]).toEqual(LOUD_RULE);
expect(parsed.vectorState).toEqual(PushRuleVectorState.LOUD);
expect(parsed.externalRules).toEqual([]);
});
it("should parse mixed keyword notifications", function () {
const rules = { global: { content: [LOUD_RULE, NORMAL_RULE, USERNAME_RULE] } };
const parsed = ContentRules.parseContentRules(rules);
expect(parsed.rules).toHaveLength(1);
expect(parsed.rules[0]).toEqual(LOUD_RULE);
expect(parsed.vectorState).toEqual(PushRuleVectorState.LOUD);
expect(parsed.externalRules).toHaveLength(1);
expect(parsed.externalRules[0]).toEqual(NORMAL_RULE);
});
});
});
@@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket 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 { describe, it, expect } from "vitest";
import { PushRuleActionName, type TweakHighlight, TweakName, type TweakSound } from "matrix-js-sdk/src/matrix";
import { PushRuleVectorState } from "./PushRuleVectorState";
describe("PushRuleVectorState", function () {
describe("contentRuleVectorStateKind", function () {
it("should understand normal notifications", function () {
const rule = {
actions: [PushRuleActionName.Notify],
default: false,
enabled: false,
rule_id: "1",
};
expect(PushRuleVectorState.contentRuleVectorStateKind(rule)).toEqual(PushRuleVectorState.ON);
});
it("should handle loud notifications", function () {
const rule = {
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true } as TweakHighlight,
{ set_tweak: TweakName.Sound, value: "default" } as TweakSound,
],
default: false,
enabled: false,
rule_id: "1",
};
expect(PushRuleVectorState.contentRuleVectorStateKind(rule)).toEqual(PushRuleVectorState.LOUD);
});
it("should understand missing highlight.value", function () {
const rule = {
actions: [
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight } as TweakHighlight,
{ set_tweak: TweakName.Sound, value: "default" } as TweakSound,
],
default: false,
enabled: false,
rule_id: "1",
};
expect(PushRuleVectorState.contentRuleVectorStateKind(rule)).toEqual(PushRuleVectorState.LOUD);
});
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
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.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { Beacon } from "matrix-js-sdk/src/matrix";
import { makeBeaconEvent, makeBeaconInfoEvent } from "test-utils";
import { type Bounds, getBeaconBounds } from "./bounds";
describe("getBeaconBounds()", () => {
const userId = "@user:server";
const roomId = "!room:server";
const makeBeaconWithLocation = (latLon: { lat: number; lon: number }) => {
const geoUri = `geo:${latLon.lat},${latLon.lon}`;
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true }));
// @ts-ignore private prop, sets internal live property so addLocations works
beacon.checkLiveness();
const location = makeBeaconEvent(userId, {
beaconInfoId: beacon.beaconInfoId,
geoUri,
timestamp: Date.now() + 1,
});
beacon.addLocations([location]);
return beacon;
};
const geo = {
// northern hemi
// west of greenwich
london: { lat: 51.5, lon: -0.14 },
reykjavik: { lat: 64.08, lon: -21.82 },
// east of greenwich
paris: { lat: 48.85, lon: 2.29 },
// southern hemi
// east
auckland: { lat: -36.85, lon: 174.76 }, // nz
// west
lima: { lat: -12.013843, lon: -77.008388 }, // peru
};
const london = makeBeaconWithLocation(geo.london);
const reykjavik = makeBeaconWithLocation(geo.reykjavik);
const paris = makeBeaconWithLocation(geo.paris);
const auckland = makeBeaconWithLocation(geo.auckland);
const lima = makeBeaconWithLocation(geo.lima);
it("should return undefined when there are no beacons", () => {
expect(getBeaconBounds([])).toBeUndefined();
});
it("should return undefined when no beacons have locations", () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId));
expect(getBeaconBounds([beacon])).toBeUndefined();
});
type TestCase = [string, Beacon[], Bounds];
it.each<TestCase>([
[
"one beacon",
[london],
{ north: geo.london.lat, south: geo.london.lat, east: geo.london.lon, west: geo.london.lon },
],
[
"beacons in the northern hemisphere, west of meridian",
[london, reykjavik],
{ north: geo.reykjavik.lat, south: geo.london.lat, east: geo.london.lon, west: geo.reykjavik.lon },
],
[
"beacons in the northern hemisphere, both sides of meridian",
[london, reykjavik, paris],
// reykjavik northmost and westmost, paris southmost and eastmost
{ north: geo.reykjavik.lat, south: geo.paris.lat, east: geo.paris.lon, west: geo.reykjavik.lon },
],
[
"beacons in the southern hemisphere",
[auckland, lima],
// lima northmost and westmost, auckland southmost and eastmost
{ north: geo.lima.lat, south: geo.auckland.lat, east: geo.auckland.lon, west: geo.lima.lon },
],
[
"beacons in both hemispheres",
[auckland, lima, paris],
{ north: geo.paris.lat, south: geo.auckland.lat, east: geo.auckland.lon, west: geo.lima.lon },
],
])("gets correct bounds for %s", (_description, beacons, expectedBounds) => {
expect(getBeaconBounds(beacons)).toEqual(expectedBounds);
});
});
+109
View File
@@ -0,0 +1,109 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import { M_TIMESTAMP, Beacon } from "matrix-js-sdk/src/matrix";
import { makeBeaconInfoEvent } from "test-utils";
import { msUntilExpiry, sortBeaconsByLatestExpiry, sortBeaconsByLatestCreation } from "./duration";
describe("beacon utils", () => {
// 14.03.2022 16:15
const now = 1647270879403;
const HOUR_MS = 3600000;
beforeEach(() => {
vi.spyOn(global.Date, "now").mockReturnValue(now);
});
afterAll(() => {
vi.spyOn(global.Date, "now").mockRestore();
});
describe("msUntilExpiry", () => {
it("returns remaining duration", () => {
const start = now - HOUR_MS;
const durationMs = HOUR_MS * 3;
expect(msUntilExpiry(start, durationMs)).toEqual(HOUR_MS * 2);
});
it("returns 0 when expiry has already passed", () => {
// created 3h ago
const start = now - HOUR_MS * 3;
// 1h durations
const durationMs = HOUR_MS;
expect(msUntilExpiry(start, durationMs)).toEqual(0);
});
});
describe("sortBeaconsByLatestExpiry()", () => {
const roomId = "!room:server";
const aliceId = "@alive:server";
// 12h old, 12h left
const beacon1 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, "$1"),
);
// 10h left
const beacon2 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 10, timestamp: now }, "$2"),
);
// 1ms left
const beacon3 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, "$3"),
);
const noTimestampEvent = makeBeaconInfoEvent(
aliceId,
roomId,
{ timeout: HOUR_MS + 1, timestamp: undefined },
"$3",
);
// beacon info helper defaults to date when timestamp is falsy
// hard set it to undefined
// @ts-ignore
noTimestampEvent.event.content[M_TIMESTAMP.name] = undefined;
const beaconNoTimestamp = new Beacon(noTimestampEvent);
it("sorts beacons by descending expiry time", () => {
expect([beacon2, beacon3, beacon1].sort(sortBeaconsByLatestExpiry)).toEqual([beacon1, beacon2, beacon3]);
});
it("sorts beacons with timestamps before beacons without", () => {
expect([beaconNoTimestamp, beacon3].sort(sortBeaconsByLatestExpiry)).toEqual([beacon3, beaconNoTimestamp]);
});
});
describe("sortBeaconsByLatestCreation()", () => {
const roomId = "!room:server";
const aliceId = "@alive:server";
// 12h old, 12h left
const beacon1 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, "$1"),
);
// 10h left
const beacon2 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 10, timestamp: now }, "$2"),
);
// 1ms left
const beacon3 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, "$3"),
);
it("sorts beacons by descending creation time", () => {
expect([beacon1, beacon2, beacon3].sort(sortBeaconsByLatestCreation)).toEqual([beacon2, beacon3, beacon1]);
});
});
});
@@ -0,0 +1,229 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach, afterEach, type Mocked } from "vitest";
import { logger } from "matrix-js-sdk/src/logger";
import { makeGeolocationPosition, mockGeolocation, getMockGeolocationPositionError } from "test-utils";
import {
type GenericPosition,
GeolocationError,
getGeoUri,
mapGeolocationError,
mapGeolocationPositionToTimedGeo,
watchPosition,
getCurrentPosition,
} from "./geolocation";
describe("geolocation utilities", () => {
let geolocation: Mocked<Geolocation>;
const defaultPosition = makeGeolocationPosition({});
// 14.03.2022 16:15
const now = 1647270879403;
beforeEach(() => {
geolocation = mockGeolocation();
vi.spyOn(Date, "now").mockReturnValue(now);
});
afterEach(() => {
vi.spyOn(Date, "now").mockRestore();
vi.spyOn(logger, "error").mockRestore();
});
describe("getGeoUri", () => {
it("Renders a URI with only lat and lon", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Nulls in location are not shown in URI", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Renders a URI with 3 coords", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: 332.54,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
});
it("Renders a URI with accuracy", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21");
});
it("Renders a URI with accuracy and altitude", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 12.3,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21");
});
});
describe("mapGeolocationError", () => {
beforeEach(() => {
// suppress expected errors from test log
vi.spyOn(logger, "error").mockImplementation(() => {});
});
it("returns default for other error", () => {
const error = new Error("oh no..");
expect(mapGeolocationError(error)).toEqual(GeolocationError.Default);
});
it("returns unavailable for unavailable error", () => {
const error = new Error(GeolocationError.Unavailable);
expect(mapGeolocationError(error)).toEqual(GeolocationError.Unavailable);
});
it("maps geo error permissiondenied correctly", () => {
const error = getMockGeolocationPositionError(1, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.PermissionDenied);
});
it("maps geo position unavailable error correctly", () => {
const error = getMockGeolocationPositionError(2, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.PositionUnavailable);
});
it("maps geo timeout error correctly", () => {
const error = getMockGeolocationPositionError(3, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.Timeout);
});
});
describe("mapGeolocationPositionToTimedGeo()", () => {
it("maps geolocation position correctly", () => {
expect(mapGeolocationPositionToTimedGeo(defaultPosition)).toEqual({
timestamp: now,
geoUri: "geo:54.001927,-8.253491;u=1",
});
});
});
describe("watchPosition()", () => {
it("throws with unavailable error when geolocation is not available", () => {
// suppress expected errors from test log
vi.spyOn(logger, "error").mockImplementation(() => {});
// remove the mock we added
vi.spyOn(navigator, "geolocation", "get").mockRestore();
const positionHandler = vi.fn();
const errorHandler = vi.fn();
expect(() => watchPosition(positionHandler, errorHandler)).toThrow(GeolocationError.Unavailable);
});
it("sets up position handler with correct options", () => {
const positionHandler = vi.fn();
const errorHandler = vi.fn();
watchPosition(positionHandler, errorHandler);
const [, , options] = geolocation.watchPosition.mock.calls[0];
expect(options).toEqual({
maximumAge: 60000,
timeout: 10000,
});
});
it("returns clearWatch function", () => {
const watchId = 1;
geolocation.watchPosition.mockReturnValue(watchId);
const positionHandler = vi.fn();
const errorHandler = vi.fn();
const clearWatch = watchPosition(positionHandler, errorHandler);
clearWatch();
expect(geolocation.clearWatch).toHaveBeenCalledWith(watchId);
});
it("calls position handler with position", () => {
const positionHandler = vi.fn();
const errorHandler = vi.fn();
watchPosition(positionHandler, errorHandler);
expect(positionHandler).toHaveBeenCalledWith(defaultPosition);
});
it("maps geolocation position error and calls error handler", () => {
// suppress expected errors from test log
vi.spyOn(logger, "error").mockImplementation(() => {});
geolocation.watchPosition.mockImplementation((_callback, error) => {
error!(getMockGeolocationPositionError(1, "message"));
return -1;
});
const positionHandler = vi.fn();
const errorHandler = vi.fn();
watchPosition(positionHandler, errorHandler);
expect(errorHandler).toHaveBeenCalledWith(GeolocationError.PermissionDenied);
});
});
describe("getCurrentPosition()", () => {
it("throws with unavailable error when geolocation is not available", async () => {
// suppress expected errors from test log
vi.spyOn(logger, "error").mockImplementation(() => {});
// remove the mock we added
vi.spyOn(navigator, "geolocation", "get").mockRestore();
await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Unavailable);
});
it("throws with geolocation error when geolocation.getCurrentPosition fails", async () => {
// suppress expected errors from test log
vi.spyOn(logger, "error").mockImplementation(() => {});
const timeoutError = getMockGeolocationPositionError(3, "message");
geolocation.getCurrentPosition.mockImplementation((callback, error) => error!(timeoutError));
await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Timeout);
});
it("resolves with current location", async () => {
geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition));
const result = await getCurrentPosition();
expect(result).toEqual(defaultPosition);
});
});
});
@@ -0,0 +1,41 @@
/*
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.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { makeBeaconInfoEvent, stubClient } from "test-utils";
import { shouldDisplayAsBeaconTile } from "./timeline";
describe("shouldDisplayAsBeaconTile", () => {
const userId = "@user:server";
const roomId = "!room:server";
const liveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: true });
const notLiveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: false });
const memberEvent = new MatrixEvent({ type: EventType.RoomMember });
const redactedBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: false });
redactedBeacon.makeRedacted(redactedBeacon, new Room(roomId, stubClient(), userId));
it("returns true for a beacon with live property set to true", () => {
expect(shouldDisplayAsBeaconTile(liveBeacon)).toBe(true);
});
it("returns true for a redacted beacon", () => {
expect(shouldDisplayAsBeaconTile(redactedBeacon)).toBe(true);
});
it("returns false for a beacon with live property set to false", () => {
expect(shouldDisplayAsBeaconTile(notLiveBeacon)).toBe(false);
});
it("returns false for a non beacon event", () => {
expect(shouldDisplayAsBeaconTile(memberEvent)).toBe(false);
});
});
@@ -0,0 +1,74 @@
/*
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 Device, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { getMockClientWithEventEmitter, mockClientMethodsCrypto } from "test-utils";
import { getDeviceCryptoInfo, getUserDeviceIds } from "./deviceInfo";
describe("getDeviceCryptoInfo()", () => {
let mockClient: Mocked<MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() });
});
it("should return undefined on clients with no crypto", async () => {
vi.spyOn(mockClient, "getCrypto").mockReturnValue(undefined);
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return undefined for unknown users", async () => {
vi.mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map());
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return undefined for unknown devices", async () => {
vi.mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map([["@user:id", new Map()]]));
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return the right result for known devices", async () => {
const mockDevice = { deviceId: "device_id" } as Device;
vi.mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([["@user:id", new Map([["device_id", mockDevice]])]]),
);
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBe(mockDevice);
expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"], undefined);
});
});
describe("getUserDeviceIds", () => {
let mockClient: Mocked<MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() });
});
it("should return empty set on clients with no crypto", async () => {
vi.spyOn(mockClient, "getCrypto").mockReturnValue(undefined);
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set());
});
it("should return empty set for unknown users", async () => {
vi.mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map());
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set());
});
it("should return the right result for known users", async () => {
const mockDevice = { deviceId: "device_id" } as Device;
vi.mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([["@user:id", new Map([["device_id", mockDevice]])]]),
);
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set(["device_id"]));
expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"]);
});
});
@@ -0,0 +1,64 @@
/*
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 } from "vitest";
import { getMockClientWithEventEmitter } from "test-utils";
import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption";
describe("shouldForceDisableEncryption()", () => {
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: vi.fn(),
});
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue(undefined);
});
it("should return false when there is no e2ee well known", () => {
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when there is no force_disable property", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
// empty
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when force_disable property is falsy", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: false,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when force_disable property is not equal to true", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: 1,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return true when force_disable property is true", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: true,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(true);
});
});
@@ -0,0 +1,124 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { getMockClientWithEventEmitter } from "test-utils";
import type BasePlatform from "../../BasePlatform";
import { type IConfigOptions } from "../../IConfigOptions";
import { getDeviceClientInformation, recordClientInformation } from "./clientInformation";
import { DEFAULTS } from "../../SdkConfig";
import { type DeepReadonly } from "../../@types/common";
describe("recordClientInformation()", () => {
const deviceId = "my-device-id";
const version = "1.2.3";
const isElectron = window.electron;
const mockClient = getMockClientWithEventEmitter({
getDeviceId: vi.fn().mockReturnValue(deviceId),
setAccountData: vi.fn(),
});
const sdkConfig: DeepReadonly<IConfigOptions> = {
...DEFAULTS,
brand: "Test Brand",
element_call: { use_exclusively: false, brand: "Element Call" },
};
const platform = {
getAppVersion: vi.fn().mockResolvedValue(version),
} as unknown as BasePlatform;
beforeEach(() => {
vi.clearAllMocks();
window.electron = undefined;
});
afterAll(() => {
// restore global
window.electron = isElectron;
});
it("saves client information without url for electron clients", async () => {
window.electron = {} as Electron;
await recordClientInformation(mockClient, sdkConfig, platform);
expect(mockClient.setAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`, {
name: sdkConfig.brand,
version,
url: undefined,
});
});
it("saves client information with url for non-electron clients", async () => {
await recordClientInformation(mockClient, sdkConfig, platform);
expect(mockClient.setAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`, {
name: sdkConfig.brand,
version,
url: "localhost",
});
});
});
describe("getDeviceClientInformation()", () => {
const deviceId = "my-device-id";
const mockClient = getMockClientWithEventEmitter({
getAccountData: vi.fn(),
});
beforeEach(() => {
vi.resetAllMocks();
});
it("returns an empty object when no event exists for the device", () => {
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({});
expect(mockClient.getAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`);
});
it("returns client information for the device", () => {
const eventContent = {
name: "Element Web",
version: "1.2.3",
url: "test.com",
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual(eventContent);
});
it("excludes values with incorrect types", () => {
const eventContent = {
extraField: "hello",
name: "Element Web",
// wrong format
version: { value: "1.2.3" },
url: "test.com",
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
// invalid fields excluded
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({
name: eventContent.name,
url: eventContent.url,
});
});
});
@@ -0,0 +1,137 @@
/*
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 { DeviceType, type ExtendedDeviceInformation, parseUserAgent } from "./parseUserAgent";
const makeDeviceExtendedInfo = (
deviceType: DeviceType,
deviceModel?: string,
deviceOperatingSystem?: string,
clientName?: string,
clientVersion?: string,
): ExtendedDeviceInformation => ({
deviceType,
deviceModel,
deviceOperatingSystem,
client: clientName && [clientName, clientVersion].filter(Boolean).join(" "),
});
/* eslint-disable max-len */
const ANDROID_UA = [
// New User Agent Implementation
"Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
// Legacy User Agent Implementation
"Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36",
];
const ANDROID_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Xiaomi Mi 9T", "Android 11"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G960F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "LG Nexus 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) (5)", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-A510F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G610M", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G973U", "Android 9", "Chrome", "69.0.3497.100"),
];
const IOS_UA = [
"Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)",
"Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
];
const IOS_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone XS Max", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (11-inch)", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (12.9-inch) (3rd generation)", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple iPad", "iOS", "Mobile Safari", "8.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 8.4.1", "Mobile Safari", "8.0"),
];
const DESKTOP_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" +
" Electron/20.1.1 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36",
];
const DESKTOP_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Desktop, "Apple Macintosh", "Mac OS", "Electron", "20.1.1"),
makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Windows", "Electron", "20.1.1"),
];
const WEB_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18",
"Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
];
const WEB_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Chrome", "104.0.5112.102"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Chrome", "104.0.5112.102"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Firefox", "39.0"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Safari", "8.0.3"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Firefox", "40.0"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Edge", "12.246"),
];
const MISC_UA = [
"AppleTV11,1/11.1",
"Curl Client/1.0",
"banana",
"",
// fluffy chat ios
"Dart/2.18 (dart:io)",
];
const MISC_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Unknown, "Apple Apple TV", undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
];
/* eslint-disable max-len */
describe("parseUserAgent()", () => {
it("returns deviceType unknown when user agent is falsy", () => {
expect(parseUserAgent(undefined)).toEqual({
deviceType: DeviceType.Unknown,
});
});
type TestCase = [string, ExtendedDeviceInformation];
const testPlatform = (platform: string, userAgents: string[], results: ExtendedDeviceInformation[]): void => {
const testCases: TestCase[] = userAgents.map((userAgent, index) => [userAgent, results[index]]);
describe(`on platform ${platform}`, () => {
it.each(testCases)("should parse the user agent correctly - %s", (userAgent, expectedResult) => {
expect(parseUserAgent(userAgent)).toEqual(expectedResult);
});
});
};
testPlatform("Android", ANDROID_UA, ANDROID_EXPECTED_RESULT);
testPlatform("iOS", IOS_UA, IOS_EXPECTED_RESULT);
testPlatform("Desktop", DESKTOP_UA, DESKTOP_EXPECTED_RESULT);
testPlatform("Web", WEB_UA, WEB_EXPECTED_RESULT);
testPlatform("Misc", MISC_UA, MISC_EXPECTED_RESULT);
});
@@ -0,0 +1,97 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import { logger } from "matrix-js-sdk/src/logger";
import {
isBulkUnverifiedDeviceReminderSnoozed,
snoozeBulkUnverifiedDeviceReminder,
} from "./snoozeBulkUnverifiedDeviceReminder";
const SNOOZE_KEY = "mx_snooze_bulk_unverified_device_nag";
describe("snooze bulk unverified device nag", () => {
const localStorageSetSpy = vi.spyOn(localStorage.__proto__, "setItem");
const localStorageGetSpy = vi.spyOn(localStorage.__proto__, "getItem");
const localStorageRemoveSpy = vi.spyOn(localStorage.__proto__, "removeItem");
// 14.03.2022 16:15
const now = 1647270879403;
beforeEach(() => {
localStorageSetSpy.mockClear().mockImplementation(() => {});
localStorageGetSpy.mockClear().mockReturnValue(null);
localStorageRemoveSpy.mockClear().mockImplementation(() => {});
vi.spyOn(Date, "now").mockReturnValue(now);
});
afterAll(() => {
vi.restoreAllMocks();
});
describe("snoozeBulkUnverifiedDeviceReminder()", () => {
it("sets the current time in local storage", () => {
snoozeBulkUnverifiedDeviceReminder();
expect(localStorageSetSpy).toHaveBeenCalledWith(SNOOZE_KEY, now.toString());
});
it("catches an error from localstorage", () => {
const loggerErrorSpy = vi.spyOn(logger, "error");
localStorageSetSpy.mockImplementation(() => {
throw new Error("oups");
});
snoozeBulkUnverifiedDeviceReminder();
expect(loggerErrorSpy).toHaveBeenCalled();
});
});
describe("isBulkUnverifiedDeviceReminderSnoozed()", () => {
it("returns false when there is no snooze in storage", () => {
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(localStorageGetSpy).toHaveBeenCalledWith(SNOOZE_KEY);
expect(result).toBe(false);
});
it("catches an error from localstorage and returns false", () => {
const loggerErrorSpy = vi.spyOn(logger, "error");
localStorageGetSpy.mockImplementation(() => {
throw new Error("oups");
});
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
expect(loggerErrorSpy).toHaveBeenCalled();
});
it("returns false when snooze timestamp in storage is not a number", () => {
localStorageGetSpy.mockReturnValue("test");
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
});
it("returns false when snooze timestamp in storage is over a week ago", () => {
const msDay = 1000 * 60 * 60 * 24;
// snoozed 8 days ago
localStorageGetSpy.mockReturnValue(now - msDay * 8);
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
});
it("returns true when snooze timestamp in storage is less than a week ago", () => {
const msDay = 1000 * 60 * 60 * 24;
// snoozed 8 days ago
localStorageGetSpy.mockReturnValue(now - msDay * 6);
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,43 @@
/*
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.
*/
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest";
import { Room } from "matrix-js-sdk/src/matrix";
import { createTestClient } from "test-utils";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom";
import { isLocalRoom } from "./isLocalRoom";
describe("isLocalRoom", () => {
let room: Room;
let localRoom: LocalRoom;
beforeEach(() => {
const client = createTestClient();
room = new Room("!room:example.com", client, client.getUserId()!);
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, client.getUserId()!);
});
it("should return false for a Room", () => {
expect(isLocalRoom(room)).toBe(false);
});
it("should return false for a non-local room ID", () => {
expect(isLocalRoom(room.roomId)).toBe(false);
});
it("should return true for LocalRoom", () => {
expect(isLocalRoom(localRoom)).toBe(true);
});
it("should return true for local room ID", () => {
expect(isLocalRoom(LOCAL_ROOM_ID_PREFIX + "test")).toBe(true);
});
});
@@ -0,0 +1,125 @@
/*
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.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { EventType, type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { createTestClient, makeMembershipEvent, mkEvent } from "test-utils";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom";
import { DirectoryMember } from "../direct-messages";
import { isRoomReady } from "./isRoomReady";
describe("isRoomReady", () => {
const userId1 = "@user1:example.com";
const member1 = new DirectoryMember({ user_id: userId1 });
const userId2 = "@user2:example.com";
let room1: Room;
let localRoom: LocalRoom;
let client: MatrixClient;
beforeEach(() => {
client = createTestClient();
room1 = new Room("!room1:example.com", client, userId1);
room1.getMyMembership = () => KnownMembership.Join;
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com");
});
beforeEach(() => {
localRoom.targets = [member1];
});
it("should return false if the room has no actual room id", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("for a room with an actual room id", () => {
beforeEach(() => {
localRoom.actualRoomId = room1.roomId;
vi.mocked(client.getRoom).mockReturnValue(null);
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and the room is known to the client", () => {
beforeEach(() => {
vi.mocked(client.getRoom).mockImplementation((roomId?: string) => {
if (roomId === room1.roomId) return room1;
return null;
});
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and all members have been invited or joined", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
makeMembershipEvent(room1.roomId, userId1, KnownMembership.Join),
makeMembershipEvent(room1.roomId, userId2, KnownMembership.Invite),
]);
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and a RoomHistoryVisibility event", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
mkEvent({
user: userId1,
event: true,
type: EventType.RoomHistoryVisibility,
room: room1.roomId,
content: {},
}),
]);
});
it("should return true", () => {
expect(isRoomReady(client, localRoom)).toBe(true);
});
describe("and an encrypted room", () => {
beforeEach(() => {
localRoom.encrypted = true;
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and a room encryption state event", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
mkEvent({
user: userId1,
event: true,
type: EventType.RoomEncryption,
room: room1.roomId,
content: {},
}),
]);
});
it("should return true", () => {
expect(isRoomReady(client, localRoom)).toBe(true);
});
});
});
});
});
});
});
});