Prepare for repo merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 11:35:21 +01:00
parent 0f670b8dc0
commit b084ff2313
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
807 changed files with 0 additions and 0 deletions

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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../src/Avatar";
import { Media, mediaFromMxc } from "../src/customisations/Media";
import DMRoomMap from "../src/utils/DMRoomMap";
jest.mock("../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(),
}));
const roomId = "!room:example.com";
const avatarUrl1 = "https://example.com/avatar1";
const avatarUrl2 = "https://example.com/avatar2";
describe("avatarUrlForRoom", () => {
let getThumbnailOfSourceHttp: jest.Mock;
let room: Room;
let roomMember: RoomMember;
let dmRoomMap: DMRoomMap;
beforeEach(() => {
getThumbnailOfSourceHttp = jest.fn();
mocked(mediaFromMxc).mockImplementation((): Media => {
return {
getThumbnailOfSourceHttp,
} as unknown as Media;
});
room = {
roomId,
getMxcAvatarUrl: jest.fn(),
isSpaceRoom: jest.fn(),
getType: jest.fn(),
getAvatarFallbackMember: jest.fn(),
} as unknown as Room;
dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
roomMember = {
getMxcAvatarUrl: jest.fn(),
} as unknown as RoomMember;
});
it("should return null for a null room", () => {
expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
});
it("should return the HTTP source if the room provides a MXC url", () => {
mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
it("should return null for a space room", () => {
mocked(room.isSpaceRoom).mockReturnValue(true);
mocked(room.getType).mockReturnValue(RoomType.Space);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the room is not a DM", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(null);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId);
});
it("should return null if there is no other member in the room", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(undefined);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the other member has no avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return the other member's avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
});

View file

@ -0,0 +1,418 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix";
import { ImageInfo } from "matrix-js-sdk/src/types";
import { defer } from "matrix-js-sdk/src/utils";
import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment";
import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages";
import { doMaybeLocalRoomAction } from "../src/utils/local-room";
import { createTestClient, mkEvent } from "./test-utils";
import { BlurhashEncoder } from "../src/BlurhashEncoder";
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
jest.mock("../src/BlurhashEncoder", () => ({
BlurhashEncoder: {
instance: {
getBlurhash: jest.fn(),
},
},
}));
jest.mock("../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(),
}));
const createElement = document.createElement.bind(document);
describe("ContentMessages", () => {
const stickerUrl = "https://example.com/sticker";
const roomId = "!room:example.com";
const imageInfo = {} as unknown as ImageInfo;
const text = "test sticker";
let client: MatrixClient;
let contentMessages: ContentMessages;
let prom: Promise<ISendEventResponse>;
beforeEach(() => {
client = {
getSafeUserId: jest.fn().mockReturnValue("@alice:test"),
sendStickerMessage: jest.fn(),
sendMessage: jest.fn(),
isRoomEncrypted: jest.fn().mockReturnValue(false),
uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }),
} as unknown as MatrixClient;
contentMessages = new ContentMessages();
prom = Promise.resolve<ISendEventResponse>({ event_id: "$event_id" });
});
describe("sendStickerContentToRoom", () => {
beforeEach(() => {
mocked(client.sendStickerMessage).mockReturnValue(prom);
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>, client?: MatrixClient) => {
return fn(roomId);
},
);
});
it("should forward the call to doMaybeLocalRoomAction", async () => {
await contentMessages.sendStickerContentToRoom(stickerUrl, roomId, null, imageInfo, text, client);
expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text);
});
});
describe("sendContentToRoom", () => {
const roomId = "!roomId:server";
beforeEach(() => {
Object.defineProperty(global.Image.prototype, "src", {
// Define the property setter
set(src) {
window.setTimeout(() => this.onload());
},
});
Object.defineProperty(global.Image.prototype, "height", {
get() {
return 600;
},
});
Object.defineProperty(global.Image.prototype, "width", {
get() {
return 800;
},
});
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
);
mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue("blurhashstring");
});
it("should use m.image for image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.image",
}),
);
});
it("should use m.image for PNG files which cannot be parsed but successfully thumbnail", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/png" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.image",
}),
);
});
it("should fall back to m.file for invalid image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
mocked(BlurhashEncoder.instance.getBlurhash).mockRejectedValue("NOT_AN_IMAGE");
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}),
);
});
it("should use m.video for video files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "video") {
(<HTMLVideoElement>element).load = jest.fn();
(<HTMLVideoElement>element).play = () => element.onloadeddata!(new Event("loadeddata"));
(<HTMLVideoElement>element).pause = jest.fn();
Object.defineProperty(element, "videoHeight", {
get() {
return 600;
},
});
Object.defineProperty(element, "videoWidth", {
get() {
return 800;
},
});
Object.defineProperty(element, "duration", {
get() {
return 123;
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "video/mp4" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.video",
info: expect.objectContaining({
duration: 123000,
}),
}),
);
});
it("should use m.audio for audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "duration", {
get() {
return 621;
},
});
Object.defineProperty(element, "src", {
set() {
element.onloadedmetadata!(new Event("loadedmetadata"));
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.audio",
info: expect.objectContaining({
duration: 621000,
}),
}),
);
});
it("should fall back to m.file for invalid audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "src", {
set() {
element.onerror!("fail");
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}),
);
});
it("should default to name 'Attachment' if file doesn't have a name", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
body: "Attachment",
}),
);
});
it("should keep RoomUpload's total and loaded values up to date", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
const [upload] = contentMessages.getCurrentUploads();
expect(upload.loaded).toBe(0);
expect(upload.total).toBe(file.size);
const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]!;
progressHandler!({ loaded: 123, total: 1234 });
expect(upload.loaded).toBe(123);
expect(upload.total).toBe(1234);
await prom;
});
it("properly handles replies", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
const replyToEvent = mkEvent({
type: "m.room.message",
user: "@bob:test",
room: roomId,
content: {},
event: true,
});
await contentMessages.sendContentToRoom(file, roomId, undefined, client, replyToEvent);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
"url": "mxc://server/file",
"msgtype": "m.image",
"m.mentions": {
user_ids: ["@bob:test"],
},
}),
);
});
});
describe("getCurrentUploads", () => {
const file1 = new File([], "file1");
const file2 = new File([], "file2");
const roomId = "!roomId:server";
beforeEach(() => {
mocked(doMaybeLocalRoomAction).mockImplementation(
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
);
});
it("should return only uploads for the given relation", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads(relation);
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(relation);
expect(uploads[0].fileName).toEqual("file1");
await Promise.all([p1, p2]);
});
it("should return only uploads for no relation when not passed one", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads();
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(undefined);
expect(uploads[0].fileName).toEqual("file2");
await Promise.all([p1, p2]);
});
});
describe("cancelUpload", () => {
it("should cancel in-flight upload", async () => {
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file1 = new File([], "file1");
const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined);
const { abortController } = mocked(client.uploadContent).mock.calls[0][1]!;
expect(abortController!.signal.aborted).toBeFalsy();
const [upload] = contentMessages.getCurrentUploads();
contentMessages.cancelUpload(upload);
expect(abortController!.signal.aborted).toBeTruthy();
deferred.resolve({} as UploadResponse);
await prom;
});
});
});
describe("uploadFile", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const client = createTestClient();
it("should not encrypt the file if the room isn't encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const progressHandler = jest.fn();
const file = new Blob([]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBe("mxc://server/file");
expect(res.file).toBeFalsy();
expect(encrypt.encryptAttachment).not.toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler }));
});
it("should encrypt the file if the room is encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(true);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
mocked(encrypt.encryptAttachment).mockResolvedValue({
data: new ArrayBuffer(123),
info: {} as IEncryptedFile,
});
const progressHandler = jest.fn();
const file = new Blob(["123"]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBeFalsy();
expect(res.file).toEqual(
expect.objectContaining({
url: "mxc://server/file",
}),
);
expect(encrypt.encryptAttachment).toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(
expect.any(Blob),
expect.objectContaining({
progressHandler,
includeFilename: false,
type: "application/octet-stream",
}),
);
expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file);
});
it("should throw UploadCanceledError upon aborting the upload", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file = new Blob([]);
const prom = uploadFile(client, "!roomId:server", file);
mocked(client.uploadContent).mock.calls[0][1]!.abortController!.abort();
deferred.resolve({ content_uri: "mxc://foo/bar" });
await expect(prom).rejects.toThrow(UploadCanceledError);
});
});

View file

@ -0,0 +1,734 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked, Mocked, MockedObject } from "jest-mock";
import { CryptoEvent, HttpApiEvent, MatrixClient, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { decryptExistingEvent, mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
import { CryptoApi, DecryptionFailureCode, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { sleep } from "matrix-js-sdk/src/utils";
import { DecryptionFailureTracker, ErrorProperties } from "../src/DecryptionFailureTracker";
import { stubClient } from "./test-utils";
import * as Lifecycle from "../src/Lifecycle";
async function createFailedDecryptionEvent(opts: { sender?: string; code?: DecryptionFailureCode } = {}) {
return await mkDecryptionFailureMatrixEvent({
roomId: "!room:id",
sender: opts.sender ?? "@alice:example.com",
code: opts.code ?? DecryptionFailureCode.UNKNOWN_ERROR,
msg: ":(",
});
}
// wrap tracker.eventDecrypted so that we don't need to have so many `ts-ignore`s
function eventDecrypted(tracker: DecryptionFailureTracker, e: MatrixEvent, nowTs: number): void {
// @ts-ignore access to private member
return tracker.eventDecrypted(e, nowTs);
}
describe("DecryptionFailureTracker", function () {
afterEach(() => {
localStorage.clear();
});
it("tracks a failed decryption for a visible event", async function () {
const failedDecryptionEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
false,
);
tracker.addVisibleEvent(failedDecryptionEvent);
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// should track a failure for an event that failed decryption
expect(count).not.toBe(0);
});
it("tracks a failed decryption with expected raw error for a visible event", async function () {
const failedDecryptionEvent = await createFailedDecryptionEvent({
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
});
let count = 0;
let reportedRawCode = "";
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(_errCode: string, rawCode: string) => {
count++;
reportedRawCode = rawCode;
},
() => "UnknownError",
false,
);
tracker.addVisibleEvent(failedDecryptionEvent);
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// should track a failure for an event that failed decryption
expect(count).not.toBe(0);
// Should add the rawCode to the event context
expect(reportedRawCode).toBe("OLM_UNKNOWN_MESSAGE_INDEX");
});
it("tracks a failed decryption for an event that becomes visible later", async function () {
const failedDecryptionEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
false,
);
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
tracker.addVisibleEvent(failedDecryptionEvent);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// should track a failure for an event that failed decryption
expect(count).not.toBe(0);
});
it("tracks visible vs. not visible events", async () => {
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string, rawError: string, properties: ErrorProperties) => {
propertiesByErrorCode[errorCode] = properties;
},
(error: string) => error,
false,
);
// use three different errors so that we can distinguish the reports
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
const error2 = DecryptionFailureCode.MEGOLM_BAD_ROOM;
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
// event that will be marked as visible before it's marked as undecryptable
const markedVisibleFirst = await createFailedDecryptionEvent({ code: error1 });
// event that will be marked as undecryptable before it's marked as visible
const markedUndecryptableFirst = await createFailedDecryptionEvent({ code: error2 });
// event that is never marked as visible
const neverVisible = await createFailedDecryptionEvent({ code: error3 });
tracker.addVisibleEvent(markedVisibleFirst);
const now = Date.now();
eventDecrypted(tracker, markedVisibleFirst, now);
eventDecrypted(tracker, markedUndecryptableFirst, now);
eventDecrypted(tracker, neverVisible, now);
tracker.addVisibleEvent(markedUndecryptableFirst);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(propertiesByErrorCode[error1].wasVisibleToUser).toBe(true);
expect(propertiesByErrorCode[error2].wasVisibleToUser).toBe(true);
expect(propertiesByErrorCode[error3].wasVisibleToUser).toBe(false);
});
it("does not track a failed decryption where the event is subsequently successfully decrypted", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => {
// should not track an event that has since been decrypted correctly
expect(true).toBe(false);
},
() => "UnknownError",
false,
);
tracker.addVisibleEvent(decryptedEvent);
eventDecrypted(tracker, decryptedEvent, Date.now());
// Indicate successful decryption.
await decryptExistingEvent(decryptedEvent, {
plainType: "m.room.message",
plainContent: { body: "success" },
});
eventDecrypted(tracker, decryptedEvent, Date.now());
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
});
it(
"does not track a failed decryption where the event is subsequently successfully decrypted " +
"and later becomes visible",
async () => {
const decryptedEvent = await createFailedDecryptionEvent();
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => {
// should not track an event that has since been decrypted correctly
expect(true).toBe(false);
},
() => "UnknownError",
false,
);
eventDecrypted(tracker, decryptedEvent, Date.now());
// Indicate successful decryption.
await decryptExistingEvent(decryptedEvent, {
plainType: "m.room.message",
plainContent: { body: "success" },
});
eventDecrypted(tracker, decryptedEvent, Date.now());
tracker.addVisibleEvent(decryptedEvent);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
},
);
it("only tracks a single failure per event, despite multiple failed decryptions for multiple events", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
const decryptedEvent2 = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
false,
);
tracker.addVisibleEvent(decryptedEvent);
// Arbitrary number of failed decryptions for both events
const now = Date.now();
eventDecrypted(tracker, decryptedEvent, now);
eventDecrypted(tracker, decryptedEvent, now);
eventDecrypted(tracker, decryptedEvent, now);
eventDecrypted(tracker, decryptedEvent, now);
eventDecrypted(tracker, decryptedEvent, now);
eventDecrypted(tracker, decryptedEvent2, now);
eventDecrypted(tracker, decryptedEvent2, now);
tracker.addVisibleEvent(decryptedEvent2);
eventDecrypted(tracker, decryptedEvent2, now);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// Simulated polling of `checkFailures`, an arbitrary number ( > 2 ) times
tracker.checkFailures(Infinity);
tracker.checkFailures(Infinity);
// should only track a single failure per event
expect(count).toBe(2);
});
it("should not track a failure for an event that was tracked previously", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await tracker.start(mockClient());
tracker.addVisibleEvent(decryptedEvent);
// Indicate decryption
eventDecrypted(tracker, decryptedEvent, Date.now());
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// Indicate a second decryption, after having tracked the failure
eventDecrypted(tracker, decryptedEvent, Date.now());
tracker.checkFailures(Infinity);
// should only track a single failure per event
expect(count).toBe(1);
});
it("should not report a failure for an event that was reported in a previous session", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await tracker.start(mockClient());
tracker.addVisibleEvent(decryptedEvent);
// Indicate decryption
eventDecrypted(tracker, decryptedEvent, Date.now());
// Pretend "now" is Infinity
// NB: This saves to localStorage specific to DFT
tracker.checkFailures(Infinity);
// Simulate the browser refreshing by destroying tracker and creating a new tracker
// @ts-ignore access to private constructor
const secondTracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await secondTracker.start(mockClient());
secondTracker.addVisibleEvent(decryptedEvent);
eventDecrypted(secondTracker, decryptedEvent, Date.now());
secondTracker.checkFailures(Infinity);
// should only track a single failure per event
expect(count).toBe(1);
});
it("should report a failure for an event that was tracked but not reported in a previous session", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await tracker.start(mockClient());
tracker.addVisibleEvent(decryptedEvent);
// Indicate decryption
eventDecrypted(tracker, decryptedEvent, Date.now());
// we do *not* call `checkFailures` here
expect(count).toBe(0);
// Simulate the browser refreshing by destroying tracker and creating a new tracker
// @ts-ignore access to private constructor
const secondTracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await secondTracker.start(mockClient());
secondTracker.addVisibleEvent(decryptedEvent);
eventDecrypted(secondTracker, decryptedEvent, Date.now());
secondTracker.checkFailures(Infinity);
expect(count).toBe(1);
});
it("should report a failure for an event that was reported before a logout/login cycle", async () => {
const decryptedEvent = await createFailedDecryptionEvent();
let count = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
() => count++,
() => "UnknownError",
);
await tracker.start(mockClient());
tracker.addVisibleEvent(decryptedEvent);
// Indicate decryption
eventDecrypted(tracker, decryptedEvent, Date.now());
tracker.checkFailures(Infinity);
expect(count).toBe(1);
// Simulate a logout/login cycle
await Lifecycle.onLoggedOut();
await tracker.start(mockClient());
tracker.addVisibleEvent(decryptedEvent);
eventDecrypted(tracker, decryptedEvent, Date.now());
tracker.checkFailures(Infinity);
expect(count).toBe(2);
});
it("should count different error codes separately for multiple failures with different error codes", async () => {
const counts: Record<string, number> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
(error: DecryptionFailureCode) =>
error === DecryptionFailureCode.UNKNOWN_ERROR ? "UnknownError" : "OlmKeysNotSentError",
false,
);
const decryptedEvent1 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.UNKNOWN_ERROR,
});
const decryptedEvent2 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
});
const decryptedEvent3 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
});
tracker.addVisibleEvent(decryptedEvent1);
tracker.addVisibleEvent(decryptedEvent2);
tracker.addVisibleEvent(decryptedEvent3);
// One failure of UNKNOWN_ERROR, and effectively two for MEGOLM_UNKNOWN_INBOUND_SESSION_ID
const now = Date.now();
eventDecrypted(tracker, decryptedEvent1, now);
eventDecrypted(tracker, decryptedEvent2, now);
eventDecrypted(tracker, decryptedEvent2, now);
eventDecrypted(tracker, decryptedEvent3, now);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
//expect(counts['UnknownError']).toBe(1, 'should track one UnknownError');
expect(counts["OlmKeysNotSentError"]).toBe(2);
});
it("should aggregate error codes correctly", async () => {
const counts: Record<string, number> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
(_errorCode: string) => "OlmUnspecifiedError",
false,
);
const decryptedEvent1 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
});
const decryptedEvent2 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
});
const decryptedEvent3 = await createFailedDecryptionEvent({
code: DecryptionFailureCode.UNKNOWN_ERROR,
});
tracker.addVisibleEvent(decryptedEvent1);
tracker.addVisibleEvent(decryptedEvent2);
tracker.addVisibleEvent(decryptedEvent3);
const now = Date.now();
eventDecrypted(tracker, decryptedEvent1, now);
eventDecrypted(tracker, decryptedEvent2, now);
eventDecrypted(tracker, decryptedEvent3, now);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(counts["OlmUnspecifiedError"]).toBe(3);
});
it("should remap error codes correctly", async () => {
const counts: Record<string, number> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
(errorCode: string) => Array.from(errorCode).reverse().join(""),
false,
);
const decryptedEvent = await createFailedDecryptionEvent({
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
});
tracker.addVisibleEvent(decryptedEvent);
eventDecrypted(tracker, decryptedEvent, Date.now());
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// should track remapped error code
expect(counts["XEDNI_EGASSEM_NWONKNU_MLO"]).toBe(1);
});
it("default error code mapper maps error codes correctly", async () => {
const errorCodes: string[] = [];
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string) => {
errorCodes.push(errorCode);
},
// @ts-ignore access to private member
DecryptionFailureTracker.instance.errorCodeMapFn,
false,
);
const now = Date.now();
async function createAndTrackEventWithError(code: DecryptionFailureCode) {
const event = await createFailedDecryptionEvent({ code });
tracker.addVisibleEvent(event);
eventDecrypted(tracker, event, now);
return event;
}
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID);
await createAndTrackEventWithError(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED);
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD);
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE);
await createAndTrackEventWithError(DecryptionFailureCode.UNKNOWN_ERROR);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(errorCodes).toEqual([
"OlmKeysNotSentError",
"OlmIndexError",
"HistoricalMessage",
"HistoricalMessage",
"HistoricalMessage",
"ExpectedDueToMembership",
"OlmKeysNotSentError",
"RoomKeysWithheldForUnverifiedDevice",
"UnknownError",
]);
});
it("tracks late decryptions vs. undecryptable", async () => {
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string, rawError: string, properties: ErrorProperties) => {
propertiesByErrorCode[errorCode] = properties;
},
(error: string) => error,
false,
);
// use three different errors so that we can distinguish the reports
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
const error2 = DecryptionFailureCode.MEGOLM_BAD_ROOM;
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
// event that will be slow to decrypt
const lateDecryption = await createFailedDecryptionEvent({ code: error1 });
// event that will be so slow to decrypt, it gets counted as undecryptable
const veryLateDecryption = await createFailedDecryptionEvent({ code: error2 });
// event that never gets decrypted
const neverDecrypted = await createFailedDecryptionEvent({ code: error3 });
tracker.addVisibleEvent(lateDecryption);
tracker.addVisibleEvent(veryLateDecryption);
tracker.addVisibleEvent(neverDecrypted);
const now = Date.now();
eventDecrypted(tracker, lateDecryption, now);
eventDecrypted(tracker, veryLateDecryption, now);
eventDecrypted(tracker, neverDecrypted, now);
await decryptExistingEvent(lateDecryption, {
plainType: "m.room.message",
plainContent: { body: "success" },
});
await decryptExistingEvent(veryLateDecryption, {
plainType: "m.room.message",
plainContent: { body: "success" },
});
eventDecrypted(tracker, lateDecryption, now + 40000);
eventDecrypted(tracker, veryLateDecryption, now + 100000);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(propertiesByErrorCode[error1].timeToDecryptMillis).toEqual(40000);
expect(propertiesByErrorCode[error2].timeToDecryptMillis).toEqual(-1);
expect(propertiesByErrorCode[error3].timeToDecryptMillis).toEqual(-1);
});
it("listens for client events", async () => {
// Test that the decryption failure tracker registers the right event
// handlers on start, and unregisters them when the client logs out.
const client = mockClient();
let errorCount: number = 0;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string, rawError: string, properties: ErrorProperties) => {
errorCount++;
},
(error: string) => error,
false,
);
// Calling .start will start some intervals. This test shouldn't run
// long enough for the timers to fire, but we'll use fake timers just
// to be safe.
jest.useFakeTimers();
await tracker.start(client);
// If the client fails to decrypt, it should get tracked
const failedDecryption = await createFailedDecryptionEvent();
client.emit(MatrixEventEvent.Decrypted, failedDecryption);
tracker.checkFailures(Infinity);
expect(errorCount).toEqual(1);
client.emit(HttpApiEvent.SessionLoggedOut, {} as any);
// After the client has logged out, we shouldn't be listening to events
// any more, so even if the client emits an event regarding a failed
// decryption, we won't track it.
const anotherFailedDecryption = await createFailedDecryptionEvent();
client.emit(MatrixEventEvent.Decrypted, anotherFailedDecryption);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(errorCount).toEqual(1);
jest.useRealTimers();
});
it("tracks client information", async () => {
const client = mockClient();
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string, rawError: string, properties: ErrorProperties) => {
propertiesByErrorCode[errorCode] = properties;
},
(error: string) => error,
false,
);
// @ts-ignore access to private method
await tracker.calculateClientProperties(client);
// @ts-ignore access to private method
await tracker.registerHandlers(client);
// use three different errors so that we can distinguish the reports
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
const error2 = DecryptionFailureCode.MEGOLM_BAD_ROOM;
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
// event from a federated user (@alice:example.com)
const federatedDecryption = await createFailedDecryptionEvent({
code: error1,
});
// event from a local user
const localDecryption = await createFailedDecryptionEvent({
sender: "@bob:matrix.org",
code: error2,
});
tracker.addVisibleEvent(federatedDecryption);
tracker.addVisibleEvent(localDecryption);
const now = Date.now();
eventDecrypted(tracker, federatedDecryption, now);
mocked(client.getCrypto()!.getUserVerificationStatus).mockResolvedValue(
new UserVerificationStatus(true, true, false),
);
client.emit(CryptoEvent.KeysChanged, {});
await sleep(100);
eventDecrypted(tracker, localDecryption, now);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(propertiesByErrorCode[error1].isMatrixDotOrg).toBe(true);
expect(propertiesByErrorCode[error1].cryptoSDK).toEqual("Rust");
expect(propertiesByErrorCode[error1].isFederated).toBe(true);
expect(propertiesByErrorCode[error1].userTrustsOwnIdentity).toEqual(false);
expect(propertiesByErrorCode[error2].isFederated).toBe(false);
expect(propertiesByErrorCode[error2].userTrustsOwnIdentity).toEqual(true);
// change client params, and make sure the reports the right values
client.getDomain.mockReturnValue("example.com");
mocked(client.getCrypto()!.getVersion).mockReturnValue("Olm 0.0.0");
// @ts-ignore access to private method
await tracker.calculateClientProperties(client);
const anotherFailure = await createFailedDecryptionEvent({
code: error3,
});
tracker.addVisibleEvent(anotherFailure);
eventDecrypted(tracker, anotherFailure, now);
tracker.checkFailures(Infinity);
expect(propertiesByErrorCode[error3].isMatrixDotOrg).toBe(false);
expect(propertiesByErrorCode[error3].cryptoSDK).toEqual("Legacy");
});
it("keeps the original timestamp after repeated decryption failures", async () => {
const failedDecryptionEvent = await createFailedDecryptionEvent();
let failure: ErrorProperties | undefined;
// @ts-ignore access to private constructor
const tracker = new DecryptionFailureTracker(
(errorCode: string, rawError: string, properties: ErrorProperties) => {
failure = properties;
},
() => "UnknownError",
false,
);
tracker.addVisibleEvent(failedDecryptionEvent);
const now = Date.now();
eventDecrypted(tracker, failedDecryptionEvent, now);
eventDecrypted(tracker, failedDecryptionEvent, now + 20000);
await decryptExistingEvent(failedDecryptionEvent, {
plainType: "m.room.message",
plainContent: { body: "success" },
});
eventDecrypted(tracker, failedDecryptionEvent, now + 50000);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
// the time to decrypt should be relative to the first time we failed
// to decrypt, not the second
expect(failure?.timeToDecryptMillis).toEqual(50000);
});
});
function mockClient(): MockedObject<MatrixClient> {
const client = mocked(stubClient());
const mockCrypto = {
getVersion: jest.fn().mockReturnValue("Rust SDK 0.7.0 (61b175b), Vodozemac 0.5.1"),
getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)),
} as unknown as Mocked<CryptoApi>;
client.getCrypto.mockReturnValue(mockCrypto);
// @ts-ignore
client.stopClient = jest.fn(() => {});
// @ts-ignore
client.removeAllListeners = jest.fn(() => {});
client.store = { destroy: jest.fn(() => {}) } as any;
return client;
}

View file

@ -0,0 +1,913 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Mocked, mocked } from "jest-mock";
import { MatrixEvent, Room, MatrixClient, Device, ClientStoppedError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { CrossSigningStatus, CryptoApi, DeviceVerificationStatus, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import * as SetupEncryptionToast from "../src/toasts/SetupEncryptionToast";
import * as UnverifiedSessionToast from "../src/toasts/UnverifiedSessionToast";
import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessionsToast";
import { isSecretStorageBeingAccessed } from "../src/SecurityManager";
import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions";
import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel";
import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";
import { UIFeature } from "../src/settings/UIFeature";
import { isBulkUnverifiedDeviceReminderSnoozed } from "../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
import { PosthogAnalytics } from "../src/PosthogAnalytics";
// don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger");
jest.mock("../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
register: jest.fn(),
}));
jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(),
accessSecretStorage: jest.fn(),
}));
jest.mock("../src/utils/device/snoozeBulkUnverifiedDeviceReminder", () => ({
isBulkUnverifiedDeviceReminderSnoozed: jest.fn(),
}));
const userId = "@user:server";
const deviceId = "my-device-id";
const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick);
describe("DeviceListener", () => {
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
jest.resetAllMocks();
// spy on various toasts' hide and show functions
// easier than mocking
jest.spyOn(SetupEncryptionToast, "showToast").mockReturnValue(undefined);
jest.spyOn(SetupEncryptionToast, "hideToast").mockReturnValue(undefined);
jest.spyOn(BulkUnverifiedSessionsToast, "showToast").mockReturnValue(undefined);
jest.spyOn(BulkUnverifiedSessionsToast, "hideToast").mockReturnValue(undefined);
jest.spyOn(UnverifiedSessionToast, "showToast").mockResolvedValue(undefined);
jest.spyOn(UnverifiedSessionToast, "hideToast").mockReturnValue(undefined);
mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue("1.2.3"),
});
mockCrypto = {
getDeviceVerificationStatus: jest.fn().mockResolvedValue({
crossSigningVerified: false,
}),
getCrossSigningKeyId: jest.fn(),
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
isCrossSigningReady: jest.fn().mockResolvedValue(true),
isSecretStorageReady: jest.fn().mockResolvedValue(true),
userHasCrossSigningKeys: jest.fn(),
getActiveSessionBackupVersion: jest.fn(),
getCrossSigningStatus: jest.fn().mockReturnValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
}),
getSessionBackupPrivateKey: jest.fn(),
} as unknown as Mocked<CryptoApi>;
mockClient = getMockClientWithEventEmitter({
isGuest: jest.fn(),
getUserId: jest.fn().mockReturnValue(userId),
getSafeUserId: jest.fn().mockReturnValue(userId),
getKeyBackupVersion: jest.fn().mockResolvedValue(undefined),
getRooms: jest.fn().mockReturnValue([]),
isVersionSupported: jest.fn().mockResolvedValue(true),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
waitForClientWellKnown: jest.fn(),
isRoomEncrypted: jest.fn(),
getClientWellKnown: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
getAccountData: jest.fn(),
deleteAccountData: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
secretStorage: {
isStored: jest.fn().mockReturnValue(null),
getDefaultKeyId: jest.fn().mockReturnValue("00"),
},
});
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mocked(isBulkUnverifiedDeviceReminderSnoozed).mockClear().mockReturnValue(false);
});
const createAndStart = async (): Promise<DeviceListener> => {
const instance = new DeviceListener();
instance.start(mockClient);
await flushPromises();
return instance;
};
describe("client information", () => {
it("watches device client information setting", async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, "watchSetting");
const unwatchSettingSpy = jest.spyOn(SettingsStore, "unwatchSetting");
const deviceListener = await createAndStart();
expect(watchSettingSpy).toHaveBeenCalledWith("deviceClientInformationOptIn", null, expect.any(Function));
deviceListener.stop();
expect(unwatchSettingSpy).toHaveBeenCalled();
});
describe("when device client information feature is enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "deviceClientInformationOptIn",
);
});
it("saves client information on start", async () => {
await createAndStart();
expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: "Element", url: "localhost", version: "1.2.3" },
);
});
it("catches error and logs when saving client information fails", async () => {
const errorLogSpy = jest.spyOn(logger, "error");
const error = new Error("oups");
mockClient!.setAccountData.mockRejectedValue(error);
// doesn't throw
await createAndStart();
expect(errorLogSpy).toHaveBeenCalledWith("Failed to update client information", error);
});
it("saves client information on logged in action", async () => {
const instance = await createAndStart();
mockClient!.setAccountData.mockClear();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: "Element", url: "localhost", version: "1.2.3" },
);
});
});
describe("when device client information feature is disabled", () => {
const clientInfoEvent = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: { name: "hello" },
});
const emptyClientInfoEvent = new MatrixEvent({ type: `io.element.matrix_client_information.${deviceId}` });
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mockClient!.getAccountData.mockReturnValue(undefined);
});
it("does not save client information on start", async () => {
await createAndStart();
expect(mockClient!.setAccountData).not.toHaveBeenCalled();
});
it("removes client information on start if it exists", async () => {
mockClient!.getAccountData.mockReturnValue(clientInfoEvent);
await createAndStart();
expect(mockClient!.deleteAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
);
});
it("does not try to remove client info event that are already empty", async () => {
mockClient!.getAccountData.mockReturnValue(emptyClientInfoEvent);
await createAndStart();
expect(mockClient!.deleteAccountData).not.toHaveBeenCalled();
});
it("does not save client information on logged in action", async () => {
const instance = await createAndStart();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient!.setAccountData).not.toHaveBeenCalled();
});
it("saves client information after setting is enabled", async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, "watchSetting");
await createAndStart();
const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0];
expect(settingName).toEqual("deviceClientInformationOptIn");
expect(roomId).toBeNull();
callback("deviceClientInformationOptIn", null, SettingLevel.DEVICE, SettingLevel.DEVICE, true);
await flushPromises();
expect(mockClient!.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: "Element", url: "localhost", version: "1.2.3" },
);
});
});
});
describe("recheck", () => {
it("does nothing when cross signing feature is not supported", async () => {
mockClient!.isVersionSupported.mockResolvedValue(false);
await createAndStart();
expect(mockClient!.isVersionSupported).toHaveBeenCalledWith("v1.1");
expect(mockCrypto!.isCrossSigningReady).not.toHaveBeenCalled();
});
it("does nothing when crypto is not enabled", async () => {
mockClient!.getCrypto.mockReturnValue(undefined);
await createAndStart();
expect(mockCrypto!.isCrossSigningReady).not.toHaveBeenCalled();
});
it("does nothing when initial sync is not complete", async () => {
mockClient!.isInitialSyncComplete.mockReturnValue(false);
await createAndStart();
expect(mockCrypto!.isCrossSigningReady).not.toHaveBeenCalled();
});
it("correctly handles the client being stopped", async () => {
mockCrypto!.isCrossSigningReady.mockImplementation(() => {
throw new ClientStoppedError();
});
await createAndStart();
expect(logger.error).not.toHaveBeenCalled();
});
it("correctly handles other errors", async () => {
mockCrypto!.isCrossSigningReady.mockImplementation(() => {
throw new Error("blah");
});
await createAndStart();
expect(logger.error).toHaveBeenCalledTimes(1);
});
describe("set up encryption", () => {
const rooms = [{ roomId: "!room1" }, { roomId: "!room2" }] as unknown as Room[];
beforeEach(() => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(false);
mockCrypto!.isSecretStorageReady.mockResolvedValue(false);
mockClient!.getRooms.mockReturnValue(rooms);
mockClient!.isRoomEncrypted.mockReturnValue(true);
});
it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
await createAndStart();
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
});
it("hides setup encryption toast when it is dismissed", async () => {
const instance = await createAndStart();
instance.dismissEncryptionSetup();
await flushPromises();
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
});
it("does not show any toasts when secret storage is being accessed", async () => {
mocked(isSecretStorageBeingAccessed).mockReturnValue(true);
await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled();
});
it("does not show any toasts when no rooms are encrypted", async () => {
mockClient!.isRoomEncrypted.mockReturnValue(false);
await createAndStart();
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled();
});
describe("when user does not have a cross signing id on this device", () => {
beforeEach(() => {
mockCrypto!.getCrossSigningKeyId.mockResolvedValue(null);
});
it("shows verify session toast when account has cross signing", async () => {
mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true);
await createAndStart();
expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.VERIFY_THIS_SESSION,
);
});
it("checks key backup status when when account has cross signing", async () => {
mockCrypto!.getCrossSigningKeyId.mockResolvedValue(null);
mockCrypto!.userHasCrossSigningKeys.mockResolvedValue(true);
await createAndStart();
expect(mockCrypto!.getActiveSessionBackupVersion).toHaveBeenCalled();
});
});
describe("when user does have a cross signing id on this device", () => {
beforeEach(() => {
mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc");
});
it("shows upgrade encryption toast when user has a key backup available", async () => {
// non falsy response
mockClient!.getKeyBackupVersion.mockResolvedValue({} as unknown as KeyBackupInfo);
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.UPGRADE_ENCRYPTION,
);
});
});
});
describe("key backup status", () => {
it("checks keybackup status when cross signing and secret storage are ready", async () => {
// default mocks set cross signing and secret storage to ready
await createAndStart();
expect(mockCrypto.getActiveSessionBackupVersion).toHaveBeenCalled();
});
it("checks keybackup status when setup encryption toast has been dismissed", async () => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(false);
const instance = await createAndStart();
instance.dismissEncryptionSetup();
await flushPromises();
expect(mockCrypto.getActiveSessionBackupVersion).toHaveBeenCalled();
});
it("dispatches keybackup event when key backup is not enabled", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
await createAndStart();
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ReportKeyBackupNotEnabled });
});
it("does not check key backup status again after check is complete", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
const instance = await createAndStart();
expect(mockCrypto.getActiveSessionBackupVersion).toHaveBeenCalled();
// trigger a recheck
instance.dismissEncryptionSetup();
await flushPromises();
// not called again, check was complete last time
expect(mockCrypto.getActiveSessionBackupVersion).toHaveBeenCalledTimes(1);
});
});
describe("unverified sessions toasts", () => {
const currentDevice = new Device({ deviceId, userId: userId, algorithms: [], keys: new Map() });
const device2 = new Device({ deviceId: "d2", userId: userId, algorithms: [], keys: new Map() });
const device3 = new Device({ deviceId: "d3", userId: userId, algorithms: [], keys: new Map() });
const deviceTrustVerified = new DeviceVerificationStatus({ crossSigningVerified: true });
const deviceTrustUnverified = new DeviceVerificationStatus({});
beforeEach(() => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
mockCrypto!.getUserDeviceInfo.mockResolvedValue(
new Map([[userId, new Map([currentDevice, device2, device3].map((d) => [d.deviceId, d]))]]),
);
// all devices verified by default
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(deviceTrustVerified);
mockClient!.deviceId = currentDevice.deviceId;
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === UIFeature.BulkUnverifiedSessionsReminder,
);
});
describe("bulk unverified sessions toasts", () => {
it("hides toast when cross signing is not ready", async () => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(false);
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it("hides toast when all devices at app start are verified", async () => {
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it("hides toast when feature is disabled", async () => {
// BulkUnverifiedSessionsReminder set to false
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
// currentDevice, device2 are verified, device3 is unverified
// ie if reminder was enabled it should be shown
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
});
it("hides toast when current device is unverified", async () => {
// device2 verified, current and device3 unverified
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
it("hides toast when reminder is snoozed", async () => {
mocked(isBulkUnverifiedDeviceReminderSnoozed).mockReturnValue(true);
// currentDevice, device2 are verified, device3 is unverified
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
await createAndStart();
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
});
it("shows toast with unverified devices at app start", async () => {
// currentDevice, device2 are verified, device3 is unverified
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
await createAndStart();
expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith(
new Set<string>([device3.deviceId]),
);
expect(BulkUnverifiedSessionsToast.hideToast).not.toHaveBeenCalled();
});
it("hides toast when unverified sessions at app start have been dismissed", async () => {
// currentDevice, device2 are verified, device3 is unverified
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
const instance = await createAndStart();
expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith(
new Set<string>([device3.deviceId]),
);
await instance.dismissUnverifiedSessions([device3.deviceId]);
await flushPromises();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
});
it("hides toast when unverified sessions are added after app start", async () => {
// currentDevice, device2 are verified, device3 is unverified
mockCrypto!.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => {
switch (deviceId) {
case currentDevice.deviceId:
case device2.deviceId:
return deviceTrustVerified;
default:
return deviceTrustUnverified;
}
});
mockCrypto!.getUserDeviceInfo.mockResolvedValue(
new Map([[userId, new Map([currentDevice, device2].map((d) => [d.deviceId, d]))]]),
);
await createAndStart();
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
// add an unverified device
mockCrypto!.getUserDeviceInfo.mockResolvedValue(
new Map([[userId, new Map([currentDevice, device2, device3].map((d) => [d.deviceId, d]))]]),
);
// trigger a recheck
mockClient!.emit(CryptoEvent.DevicesUpdated, [userId], false);
await flushPromises();
// bulk unverified sessions toast only shown for devices that were
// there at app start
// individual nags are shown for new unverified devices
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalledTimes(2);
expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled();
});
});
});
describe("Report verification and recovery state to Analytics", () => {
let setPropertySpy: jest.SpyInstance;
let trackEventSpy: jest.SpyInstance;
beforeEach(() => {
setPropertySpy = jest.spyOn(PosthogAnalytics.instance, "setProperty");
trackEventSpy = jest.spyOn(PosthogAnalytics.instance, "trackEvent");
});
describe("Report crypto verification state to analytics", () => {
type VerificationTestCases = [string, Partial<DeviceVerificationStatus>, "Verified" | "NotVerified"];
const testCases: VerificationTestCases[] = [
[
"Identity trusted and device is signed by owner",
{
signedByOwner: true,
crossSigningVerified: true,
},
"Verified",
],
[
"Identity is trusted, but device is not signed",
{
signedByOwner: false,
crossSigningVerified: true,
},
"NotVerified",
],
[
"Identity is not trusted, device not signed",
{
signedByOwner: false,
crossSigningVerified: false,
},
"NotVerified",
],
[
"Identity is not trusted, and device signed",
{
signedByOwner: true,
crossSigningVerified: false,
},
"NotVerified",
],
];
beforeEach(() => {
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
mockCrypto.isSecretStorageReady.mockResolvedValue(false);
});
it.each(testCases)("Does report session verification state when %s", async (_, status, expected) => {
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(status as DeviceVerificationStatus);
await createAndStart();
// Should have updated user properties
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", expected);
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: expected,
recoveryState: "Disabled",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
});
it("should not report a status event if no changes", async () => {
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus);
await createAndStart();
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: "Disabled",
};
expect(trackEventSpy).toHaveBeenCalledTimes(1);
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
// simulate a recheck
mockClient.emit(CryptoEvent.DevicesUpdated, [userId], false);
await flushPromises();
expect(trackEventSpy).toHaveBeenCalledTimes(1);
// Now simulate a change
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({
signedByOwner: false,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus);
// simulate a recheck
mockClient.emit(CryptoEvent.DevicesUpdated, [userId], false);
await flushPromises();
expect(trackEventSpy).toHaveBeenCalledTimes(2);
});
});
describe("Report crypto recovery state to analytics", () => {
beforeEach(() => {
// During all these tests we want verification state to be verified.
mockCrypto!.getDeviceVerificationStatus.mockResolvedValue({
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus);
});
describe("When Room Key Backup is not enabled", () => {
beforeEach(() => {
// no backup
mockClient.getKeyBackupVersion.mockResolvedValue(null);
});
it("Should report recovery state as Enabled", async () => {
// 4S is enabled
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00");
// Session trusted and cross signing secrets in 4S and stored locally
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
await createAndStart();
// Should have updated user properties
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified");
expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Enabled");
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: "Enabled",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
});
it("Should report recovery state as Incomplete if secrets not cached locally", async () => {
// 4S is enabled
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00");
// Session trusted and cross signing secrets in 4S and stored locally
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
// no backup
mockClient.getKeyBackupVersion.mockResolvedValue(null);
await createAndStart();
// Should have updated user properties
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified");
expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete");
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: "Incomplete",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
});
const baseState: CrossSigningStatus = {
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
};
type MissingSecretsInCacheTestCases = [string, CrossSigningStatus];
const partialTestCases: MissingSecretsInCacheTestCases[] = [
[
"MSK not cached",
{
...baseState,
privateKeysCachedLocally: { ...baseState.privateKeysCachedLocally, masterKey: false },
},
],
[
"SSK not cached",
{
...baseState,
privateKeysCachedLocally: {
...baseState.privateKeysCachedLocally,
selfSigningKey: false,
},
},
],
[
"USK not cached",
{
...baseState,
privateKeysCachedLocally: {
...baseState.privateKeysCachedLocally,
userSigningKey: false,
},
},
],
[
"MSK/USK not cached",
{
...baseState,
privateKeysCachedLocally: {
...baseState.privateKeysCachedLocally,
masterKey: false,
userSigningKey: false,
},
},
],
[
"MSK/SSK not cached",
{
...baseState,
privateKeysCachedLocally: {
...baseState.privateKeysCachedLocally,
masterKey: false,
selfSigningKey: false,
},
},
],
[
"USK/SSK not cached",
{
...baseState,
privateKeysCachedLocally: {
...baseState.privateKeysCachedLocally,
userSigningKey: false,
selfSigningKey: false,
},
},
],
];
it.each(partialTestCases)(
"Should report recovery state as Incomplete when %s",
async (_, status) => {
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00");
// Session trusted and cross signing secrets in 4S and stored locally
mockCrypto!.getCrossSigningStatus.mockResolvedValue(status);
await createAndStart();
// Should have updated user properties
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified");
expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete");
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: "Incomplete",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
},
);
it("Should report recovery state as Incomplete when some secrets are not in 4S", async () => {
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00");
// Some missing secret in 4S
mockCrypto.isSecretStorageReady.mockResolvedValue(false);
// Session trusted and secrets known locally.
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
} as unknown as CrossSigningStatus);
await createAndStart();
// Should have updated user properties
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified");
expect(setPropertySpy).toHaveBeenCalledWith("recoveryState", "Incomplete");
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: "Incomplete",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
});
});
describe("When Room Key Backup is enabled", () => {
beforeEach(() => {
// backup enabled - just need a mock object
mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo);
});
const testCases = [
["as Incomplete if backup key not cached locally", false],
["as Enabled if backup key is cached locally", true],
];
it.each(testCases)("Should report recovery state as %s", async (_, isCached) => {
// 4S is enabled
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("00");
// Session trusted and cross signing secrets in 4S and stored locally
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
});
mockCrypto.getSessionBackupPrivateKey.mockResolvedValue(isCached ? new Uint8Array() : null);
await createAndStart();
expect(setPropertySpy).toHaveBeenCalledWith("verificationState", "Verified");
expect(setPropertySpy).toHaveBeenCalledWith(
"recoveryState",
isCached ? "Enabled" : "Incomplete",
);
// Should have reported a status change event
const expectedTrackedEvent: CryptoSessionStateChange = {
eventName: "CryptoSessionState",
verificationState: "Verified",
recoveryState: isCached ? "Enabled" : "Incomplete",
};
expect(trackEventSpy).toHaveBeenCalledWith(expectedTrackedEvent);
});
});
});
});
});
});

View file

@ -0,0 +1,200 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactElement } from "react";
import { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import { IContent } from "matrix-js-sdk/src/matrix";
import { bodyToSpan, formatEmojis, topicToHtml } from "../src/HtmlUtils";
import SettingsStore from "../src/settings/SettingsStore";
jest.mock("../src/settings/SettingsStore");
const enableHtmlTopicFeature = () => {
mocked(SettingsStore).getValue.mockImplementation((arg): any => {
return arg === "feature_html_topic";
});
};
describe("topicToHtml", () => {
function getContent() {
return screen.getByRole("contentinfo").children[0].innerHTML;
}
it("converts plain text topic to HTML", () => {
render(<div role="contentinfo">{topicToHtml("pizza", undefined, null, false)}</div>);
expect(getContent()).toEqual("pizza");
});
it("converts plain text topic with emoji to HTML", () => {
render(<div role="contentinfo">{topicToHtml("pizza 🍕", undefined, null, false)}</div>);
expect(getContent()).toEqual('pizza <span class="mx_Emoji" title=":pizza:">🍕</span>');
});
it("converts literal HTML topic to HTML", async () => {
enableHtmlTopicFeature();
render(<div role="contentinfo">{topicToHtml("<b>pizza</b>", undefined, null, false)}</div>);
expect(getContent()).toEqual("&lt;b&gt;pizza&lt;/b&gt;");
});
it("converts true HTML topic to HTML", async () => {
enableHtmlTopicFeature();
render(<div role="contentinfo">{topicToHtml("**pizza**", "<b>pizza</b>", null, false)}</div>);
expect(getContent()).toEqual("<b>pizza</b>");
});
it("converts true HTML topic with emoji to HTML", async () => {
enableHtmlTopicFeature();
render(<div role="contentinfo">{topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false)}</div>);
expect(getContent()).toEqual('<b>pizza</b> <span class="mx_Emoji" title=":pizza:">🍕</span>');
});
});
describe("bodyToHtml", () => {
function getHtml(content: IContent, highlights?: string[]): string {
return (bodyToSpan(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html;
}
it("should apply highlights to HTML messages", () => {
const html = getHtml(
{
body: "test **foo** bar",
msgtype: "m.text",
formatted_body: "test <b>foo</b> bar",
format: "org.matrix.custom.html",
},
["test"],
);
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> <b>foo</b> bar"`);
});
it("should apply highlights to plaintext messages", () => {
const html = getHtml(
{
body: "test foo bar",
msgtype: "m.text",
},
["test"],
);
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo bar"`);
});
it("should not respect HTML tags in plaintext message highlighting", () => {
const html = getHtml(
{
body: "test foo <b>bar",
msgtype: "m.text",
},
["test"],
);
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`);
});
it("generates big emoji for emoji made of multiple characters", () => {
const { asFragment } = render(bodyToSpan({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
it("should generate big emoji for an emoji-only reply to a message", () => {
const { asFragment } = render(
bodyToSpan(
{
"body": "> <@sender1:server> Test\n\n🥰",
"format": "org.matrix.custom.html",
"formatted_body":
'<mx-reply><blockquote><a href="https://matrix.to/#/!roomId:server/$eventId">In reply to</a> <a href="https://matrix.to/#/@sender1:server">@sender1:server</a><br>Test</blockquote></mx-reply>🥰',
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
[],
{
stripReplyFallback: true,
},
) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("does not mistake characters in text presentation mode for emoji", () => {
const { asFragment } = render(bodyToSpan({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
describe("feature_latex_maths", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_latex_maths");
});
it("should render inline katex", () => {
const html = getHtml({
body: "hello \\xi world",
msgtype: "m.text",
formatted_body: 'hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world',
format: "org.matrix.custom.html",
});
expect(html).toMatchSnapshot();
});
it("should render block katex", () => {
const html = getHtml({
body: "hello \\xi world",
msgtype: "m.text",
formatted_body: '<p>hello</p><div data-mx-maths="\\xi"><code>\\xi</code></div><p>world</p>',
format: "org.matrix.custom.html",
});
expect(html).toMatchSnapshot();
});
it("should not mangle code blocks", () => {
const html = getHtml({
body: "hello \\xi world",
msgtype: "m.text",
formatted_body: "<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>",
format: "org.matrix.custom.html",
});
expect(html).toMatchSnapshot();
});
it("should not mangle divs", () => {
const html = getHtml({
body: "hello world",
msgtype: "m.text",
formatted_body: "<p>hello</p><div>world</div>",
format: "org.matrix.custom.html",
});
expect(html).toMatchSnapshot();
});
});
});
describe("formatEmojis", () => {
it.each([
["🏴󠁧󠁢󠁥󠁮󠁧󠁿", [["🏴󠁧󠁢󠁥󠁮󠁧󠁿", "flag-england"]]],
["🏴󠁧󠁢󠁳󠁣󠁴󠁿", [["🏴󠁧󠁢󠁳󠁣󠁴󠁿", "flag-scotland"]]],
["🏴󠁧󠁢󠁷󠁬󠁳󠁿", [["🏴󠁧󠁢󠁷󠁬󠁳󠁿", "flag-wales"]]],
])("%s emoji", (emoji, expectations) => {
const res = formatEmojis(emoji, false);
expect(res).toHaveLength(expectations.length);
for (let i = 0; i < res.length; i++) {
const [emoji, title] = expectations[i];
expect(res[i].props.children).toEqual(emoji);
expect(res[i].props.title).toEqual(`:${title}:`);
}
});
});

View file

@ -0,0 +1,66 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import fs from "fs";
import path from "path";
import { blobIsAnimated, mayBeAnimated } from "../src/utils/Image";
describe("Image", () => {
describe("mayBeAnimated", () => {
it("image/gif", async () => {
expect(mayBeAnimated("image/gif")).toBeTruthy();
});
it("image/webp", async () => {
expect(mayBeAnimated("image/webp")).toBeTruthy();
});
it("image/png", async () => {
expect(mayBeAnimated("image/png")).toBeTruthy();
});
it("image/apng", async () => {
expect(mayBeAnimated("image/apng")).toBeTruthy();
});
it("image/jpeg", async () => {
expect(mayBeAnimated("image/jpeg")).toBeFalsy();
});
});
describe("blobIsAnimated", () => {
it("Animated GIF", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]);
expect(await blobIsAnimated("image/gif", img)).toBeTruthy();
});
it("Static GIF", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]);
expect(await blobIsAnimated("image/gif", img)).toBeFalsy();
});
it("Animated WEBP", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]);
expect(await blobIsAnimated("image/webp", img)).toBeTruthy();
});
it("Static WEBP", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]);
expect(await blobIsAnimated("image/webp", img)).toBeFalsy();
});
it("Animated PNG", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.apng"))]);
expect(await blobIsAnimated("image/png", img)).toBeTruthy();
expect(await blobIsAnimated("image/apng", img)).toBeTruthy();
});
it("Static PNG", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.png"))]);
expect(await blobIsAnimated("image/png", img)).toBeFalsy();
expect(await blobIsAnimated("image/apng", img)).toBeFalsy();
});
});
});

View file

@ -0,0 +1,165 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Clemens Zeidler
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { isKeyComboMatch, KeyCombo } from "../src/KeyBindingsManager";
function mockKeyEvent(
key: string,
modifiers?: {
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
},
): KeyboardEvent {
return {
key,
ctrlKey: modifiers?.ctrlKey ?? false,
altKey: modifiers?.altKey ?? false,
shiftKey: modifiers?.shiftKey ?? false,
metaKey: modifiers?.metaKey ?? false,
} as KeyboardEvent;
}
describe("KeyBindingsManager", () => {
it("should match basic key combo", () => {
const combo1: KeyCombo = {
key: "k",
};
expect(isKeyComboMatch(mockKeyEvent("k"), combo1, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n"), combo1, false)).toBe(false);
});
it("should match key + modifier key combo", () => {
const combo: KeyCombo = {
key: "k",
ctrlKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k"), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true }), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true, metaKey: true }), combo, false)).toBe(false);
const combo2: KeyCombo = {
key: "k",
metaKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo2, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n", { metaKey: true }), combo2, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k"), combo2, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { altKey: true, metaKey: true }), combo2, false)).toBe(false);
const combo3: KeyCombo = {
key: "k",
altKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { altKey: true }), combo3, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n", { altKey: true }), combo3, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k"), combo3, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo3, false)).toBe(false);
const combo4: KeyCombo = {
key: "k",
shiftKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true }), combo4, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n", { shiftKey: true }), combo4, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k"), combo4, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true, ctrlKey: true }), combo4, false)).toBe(false);
});
it("should match key + multiple modifiers key combo", () => {
const combo: KeyCombo = {
key: "k",
ctrlKey: true,
altKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true, altKey: true }), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true, shiftKey: true }), combo, false)).toBe(
false,
);
const combo2: KeyCombo = {
key: "k",
ctrlKey: true,
shiftKey: true,
altKey: true,
};
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true }), combo2, false)).toBe(
true,
);
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true, shiftKey: true, altKey: true }), combo2, false)).toBe(
false,
);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo2, false)).toBe(false);
expect(
isKeyComboMatch(
mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
combo2,
false,
),
).toBe(false);
const combo3: KeyCombo = {
key: "k",
ctrlKey: true,
shiftKey: true,
altKey: true,
metaKey: true,
};
expect(
isKeyComboMatch(
mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
combo3,
false,
),
).toBe(true);
expect(
isKeyComboMatch(
mockKeyEvent("n", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
combo3,
false,
),
).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true }), combo3, false)).toBe(
false,
);
});
it("should match ctrlOrMeta key combo", () => {
const combo: KeyCombo = {
key: "k",
ctrlOrCmdKey: true,
};
// PC:
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo, false)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, false)).toBe(false);
// MAC:
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo, true)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, true)).toBe(false);
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, true)).toBe(false);
});
it("should match advanced ctrlOrMeta key combo", () => {
const combo: KeyCombo = {
key: "k",
ctrlOrCmdKey: true,
altKey: true,
};
// PC:
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, false)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true, altKey: true }), combo, false)).toBe(false);
// MAC:
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true, altKey: true }), combo, true)).toBe(true);
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, true)).toBe(false);
});
});

View file

@ -0,0 +1,706 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
IProtocol,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixEvent,
PushRuleKind,
Room,
RuleId,
TweakName,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import EventEmitter from "events";
import { mocked } from "jest-mock";
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
import fetchMock from "fetch-mock-jest";
import { waitFor } from "jest-matrix-react";
import LegacyCallHandler, {
AudioID,
LegacyCallHandlerEvent,
PROTOCOL_PSTN,
PROTOCOL_PSTN_PREFIXED,
PROTOCOL_SIP_NATIVE,
PROTOCOL_SIP_VIRTUAL,
} from "../src/LegacyCallHandler";
import { mkStubRoom, stubClient, untilDispatch } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import DMRoomMap from "../src/utils/DMRoomMap";
import SdkConfig from "../src/SdkConfig";
import { Action } from "../src/dispatcher/actions";
import { getFunctionalMembers } from "../src/utils/room/getFunctionalMembers";
import SettingsStore from "../src/settings/SettingsStore";
import { UIFeature } from "../src/settings/UIFeature";
import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecording } from "../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
import { SdkContextClass } from "../src/contexts/SDKContext";
import Modal from "../src/Modal";
import { createAudioContext } from "../src/audio/compat";
import * as ManagedHybrid from "../src/widgets/ManagedHybrid";
jest.mock("../src/Modal");
// mock VoiceRecording because it contains all the audio APIs
jest.mock("../src/audio/VoiceRecording", () => ({
VoiceRecording: jest.fn().mockReturnValue({
disableMaxLength: jest.fn(),
liveData: {
onUpdate: jest.fn(),
},
off: jest.fn(),
on: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
destroy: jest.fn(),
contentType: "audio/ogg",
}),
}));
jest.mock("../src/utils/room/getFunctionalMembers", () => ({
getFunctionalMembers: jest.fn(),
}));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
// The Matrix IDs that the user sees when talking to Alice & Bob
const NATIVE_ALICE = "@alice:example.org";
const NATIVE_BOB = "@bob:example.org";
const NATIVE_CHARLIE = "@charlie:example.org";
// Virtual user for Bob
const VIRTUAL_BOB = "@virtual_bob:example.org";
//const REAL_ROOM_ID = "$room1:example.org";
// The rooms the user sees when they're communicating with these users
const NATIVE_ROOM_ALICE = "$alice_room:example.org";
const NATIVE_ROOM_BOB = "$bob_room:example.org";
const NATIVE_ROOM_CHARLIE = "$charlie_room:example.org";
const FUNCTIONAL_USER = "@bot:example.com";
// The room we use to talk to virtual Bob (but that the user does not see)
// Bob has a virtual room, but Alice doesn't
const VIRTUAL_ROOM_BOB = "$virtual_bob_room:example.org";
// Bob's phone number
const BOB_PHONE_NUMBER = "01818118181";
function mkStubDM(roomId: string, userId: string) {
const room = mkStubRoom(roomId, "room", MatrixClientPeg.safeGet());
room.getJoinedMembers = jest.fn().mockReturnValue([
{
userId: "@me:example.org",
name: "Member",
rawDisplayName: "Member",
roomId: roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: userId,
name: "Member",
rawDisplayName: "Member",
roomId: roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
{
userId: FUNCTIONAL_USER,
name: "Bot user",
rawDisplayName: "Bot user",
roomId: roomId,
membership: KnownMembership.Join,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
]);
room.currentState.getMembers = room.getJoinedMembers;
return room;
}
class FakeCall extends EventEmitter {
roomId: string;
callId = "fake call id";
constructor(roomId: string) {
super();
this.roomId = roomId;
}
setRemoteOnHold() {}
setRemoteAudioElement() {}
placeVoiceCall() {
this.emit(CallEvent.State, CallState.Connected, null);
}
}
function untilCallHandlerEvent(callHandler: LegacyCallHandler, event: LegacyCallHandlerEvent): Promise<void> {
return new Promise<void>((resolve) => {
callHandler.addListener(event, () => {
resolve();
});
});
}
describe("LegacyCallHandler", () => {
let dmRoomMap;
let callHandler: LegacyCallHandler;
let audioElement: HTMLAudioElement;
let fakeCall: MatrixCall | null;
// what addresses the app has looked up via pstn and native lookup
let pstnLookup: string | null;
let nativeLookup: string | null;
const deviceId = "my-device";
beforeEach(async () => {
stubClient();
fakeCall = null;
MatrixClientPeg.safeGet().createCall = (roomId: string): MatrixCall | null => {
if (fakeCall && fakeCall.roomId !== roomId) {
throw new Error("Only one call is supported!");
}
fakeCall = new FakeCall(roomId) as unknown as MatrixCall;
return fakeCall as unknown as MatrixCall;
};
MatrixClientPeg.safeGet().deviceId = deviceId;
MatrixClientPeg.safeGet().getThirdpartyProtocols = () => {
return Promise.resolve({
"m.id.phone": {} as IProtocol,
"im.vector.protocol.sip_native": {} as IProtocol,
"im.vector.protocol.sip_virtual": {} as IProtocol,
});
};
callHandler = new LegacyCallHandler();
callHandler.start();
mocked(getFunctionalMembers).mockReturnValue([FUNCTIONAL_USER]);
const nativeRoomAlice = mkStubDM(NATIVE_ROOM_ALICE, NATIVE_ALICE);
const nativeRoomBob = mkStubDM(NATIVE_ROOM_BOB, NATIVE_BOB);
const nativeRoomCharie = mkStubDM(NATIVE_ROOM_CHARLIE, NATIVE_CHARLIE);
const virtualBobRoom = mkStubDM(VIRTUAL_ROOM_BOB, VIRTUAL_BOB);
MatrixClientPeg.safeGet().getRoom = (roomId: string): Room | null => {
switch (roomId) {
case NATIVE_ROOM_ALICE:
return nativeRoomAlice;
case NATIVE_ROOM_BOB:
return nativeRoomBob;
case NATIVE_ROOM_CHARLIE:
return nativeRoomCharie;
case VIRTUAL_ROOM_BOB:
return virtualBobRoom;
}
return null;
};
dmRoomMap = {
getUserIdForRoomId: (roomId: string) => {
if (roomId === NATIVE_ROOM_ALICE) {
return NATIVE_ALICE;
} else if (roomId === NATIVE_ROOM_BOB) {
return NATIVE_BOB;
} else if (roomId === NATIVE_ROOM_CHARLIE) {
return NATIVE_CHARLIE;
} else if (roomId === VIRTUAL_ROOM_BOB) {
return VIRTUAL_BOB;
} else {
return null;
}
},
getDMRoomsForUserId: (userId: string) => {
if (userId === NATIVE_ALICE) {
return [NATIVE_ROOM_ALICE];
} else if (userId === NATIVE_BOB) {
return [NATIVE_ROOM_BOB];
} else if (userId === NATIVE_CHARLIE) {
return [NATIVE_ROOM_CHARLIE];
} else if (userId === VIRTUAL_BOB) {
return [VIRTUAL_ROOM_BOB];
} else {
return [];
}
},
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
pstnLookup = null;
nativeLookup = null;
MatrixClientPeg.safeGet().getThirdpartyUser = (proto: string, params: any) => {
if ([PROTOCOL_PSTN, PROTOCOL_PSTN_PREFIXED].includes(proto)) {
pstnLookup = params["m.id.phone"];
return Promise.resolve([
{
userid: VIRTUAL_BOB,
protocol: "m.id.phone",
fields: {
is_native: true,
lookup_success: true,
},
},
]);
} else if (proto === PROTOCOL_SIP_NATIVE) {
nativeLookup = params["virtual_mxid"];
if (params["virtual_mxid"] === VIRTUAL_BOB) {
return Promise.resolve([
{
userid: NATIVE_BOB,
protocol: "im.vector.protocol.sip_native",
fields: {
is_native: true,
lookup_success: true,
},
},
]);
}
return Promise.resolve([]);
} else if (proto === PROTOCOL_SIP_VIRTUAL) {
if (params["native_mxid"] === NATIVE_BOB) {
return Promise.resolve([
{
userid: VIRTUAL_BOB,
protocol: "im.vector.protocol.sip_virtual",
fields: {
is_virtual: true,
lookup_success: true,
},
},
]);
}
return Promise.resolve([]);
}
return Promise.resolve([]);
};
audioElement = document.createElement("audio");
audioElement.id = "remoteAudio";
document.body.appendChild(audioElement);
});
afterEach(() => {
callHandler.stop();
// @ts-ignore
DMRoomMap.setShared(null);
// @ts-ignore
window.mxLegacyCallHandler = null;
MatrixClientPeg.unset();
document.body.removeChild(audioElement);
SdkConfig.reset();
jest.restoreAllMocks();
});
it("should look up the correct user and start a call in the room when a phone number is dialled", async () => {
await callHandler.dialNumber(BOB_PHONE_NUMBER);
expect(pstnLookup).toEqual(BOB_PHONE_NUMBER);
expect(nativeLookup).toEqual(VIRTUAL_BOB);
// we should have switched to the native room for Bob
const viewRoomPayload = await untilDispatch(Action.ViewRoom);
expect(viewRoomPayload.room_id).toEqual(NATIVE_ROOM_BOB);
// Check that a call was started: its room on the protocol level
// should be the virtual room
expect(fakeCall).not.toBeNull();
expect(fakeCall?.roomId).toEqual(VIRTUAL_ROOM_BOB);
// but it should appear to the user to be in thw native room for Bob
expect(callHandler.roomIdForCall(fakeCall!)).toEqual(NATIVE_ROOM_BOB);
});
it("should look up the correct user and start a call in the room when a call is transferred", async () => {
// we can pass a very minimal object as as the call since we pass consultFirst=true:
// we don't need to actually do any transferring
const mockTransferreeCall = { type: CallType.Voice } as unknown as MatrixCall;
await callHandler.startTransferToPhoneNumber(mockTransferreeCall, BOB_PHONE_NUMBER, true);
// same checks as above
const viewRoomPayload = await untilDispatch(Action.ViewRoom);
expect(viewRoomPayload.room_id).toEqual(NATIVE_ROOM_BOB);
expect(fakeCall).not.toBeNull();
expect(fakeCall!.roomId).toEqual(VIRTUAL_ROOM_BOB);
expect(callHandler.roomIdForCall(fakeCall!)).toEqual(NATIVE_ROOM_BOB);
});
it("should move calls between rooms when remote asserted identity changes", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState);
// We placed the call in Alice's room so it should start off there
expect(callHandler.getCallForRoom(NATIVE_ROOM_ALICE)).toBe(fakeCall);
let callRoomChangeEventCount = 0;
const roomChangePromise = new Promise<void>((resolve) => {
callHandler.addListener(LegacyCallHandlerEvent.CallChangeRoom, () => {
++callRoomChangeEventCount;
resolve();
});
});
// Now emit an asserted identity for Bob: this should be ignored
// because we haven't set the config option to obey asserted identity
expect(fakeCall).not.toBeNull();
fakeCall!.getRemoteAssertedIdentity = jest.fn().mockReturnValue({
id: NATIVE_BOB,
});
fakeCall!.emit(CallEvent.AssertedIdentityChanged, fakeCall!);
// Now set the config option
SdkConfig.add({
voip: {
obey_asserted_identity: true,
},
});
// ...and send another asserted identity event for a different user
fakeCall!.getRemoteAssertedIdentity = jest.fn().mockReturnValue({
id: NATIVE_CHARLIE,
});
fakeCall!.emit(CallEvent.AssertedIdentityChanged, fakeCall!);
await roomChangePromise;
callHandler.removeAllListeners();
// If everything's gone well, we should have seen only one room change
// event and the call should now be in Charlie's room.
// If it's not obeying any, the call will still be in NATIVE_ROOM_ALICE.
// If it incorrectly obeyed both asserted identity changes, either it will
// have just processed one and the call will be in the wrong room, or we'll
// have seen two room change dispatches.
expect(callRoomChangeEventCount).toEqual(1);
expect(callHandler.getCallForRoom(NATIVE_ROOM_BOB)).toBeNull();
expect(callHandler.getCallForRoom(NATIVE_ROOM_CHARLIE)).toBe(fakeCall);
});
it("should place calls using managed hybrid widget if enabled", async () => {
const spy = jest.spyOn(ManagedHybrid, "addManagedHybridWidget");
jest.spyOn(ManagedHybrid, "isManagedHybridWidgetEnabled").mockReturnValue(true);
await callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
expect(spy).toHaveBeenCalledWith(MatrixClientPeg.safeGet().getRoom(NATIVE_ROOM_ALICE));
});
describe("when listening to a voice broadcast", () => {
let voiceBroadcastPlayback: VoiceBroadcastPlayback;
beforeEach(() => {
voiceBroadcastPlayback = new VoiceBroadcastPlayback(
mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
MatrixClientPeg.safeGet().getSafeUserId(),
"d42",
),
MatrixClientPeg.safeGet(),
SdkContextClass.instance.voiceBroadcastRecordingsStore,
);
SdkContextClass.instance.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback);
jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation();
});
it("and placing a call should pause the broadcast", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState);
expect(voiceBroadcastPlayback.pause).toHaveBeenCalled();
});
});
describe("when recording a voice broadcast", () => {
beforeEach(() => {
SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(
new VoiceBroadcastRecording(
mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
MatrixClientPeg.safeGet().getSafeUserId(),
"d42",
),
MatrixClientPeg.safeGet(),
),
);
});
it("and placing a call should show the info dialog", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
expect(Modal.createDialog).toMatchSnapshot();
});
});
});
describe("LegacyCallHandler without third party protocols", () => {
let dmRoomMap;
let callHandler: LegacyCallHandler;
let audioElement: HTMLAudioElement;
let fakeCall: MatrixCall | null;
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn().mockResolvedValue({}),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
stubClient();
fakeCall = null;
MatrixClientPeg.safeGet().createCall = (roomId) => {
if (fakeCall && fakeCall.roomId !== roomId) {
throw new Error("Only one call is supported!");
}
fakeCall = new FakeCall(roomId) as unknown as MatrixCall;
return fakeCall;
};
MatrixClientPeg.safeGet().getThirdpartyProtocols = () => {
throw new Error("Endpoint unsupported.");
};
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
callHandler = new LegacyCallHandler();
callHandler.start();
const nativeRoomAlice = mkStubDM(NATIVE_ROOM_ALICE, NATIVE_ALICE);
MatrixClientPeg.safeGet().getRoom = (roomId: string): Room | null => {
switch (roomId) {
case NATIVE_ROOM_ALICE:
return nativeRoomAlice;
}
return null;
};
dmRoomMap = {
getUserIdForRoomId: (roomId: string) => {
if (roomId === NATIVE_ROOM_ALICE) {
return NATIVE_ALICE;
} else {
return null;
}
},
getDMRoomsForUserId: (userId: string) => {
if (userId === NATIVE_ALICE) {
return [NATIVE_ROOM_ALICE];
} else {
return [];
}
},
} as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
MatrixClientPeg.safeGet().getThirdpartyUser = (_proto, _params) => {
throw new Error("Endpoint unsupported.");
};
audioElement = document.createElement("audio");
audioElement.id = "remoteAudio";
document.body.appendChild(audioElement);
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
fetchMock.get(
"/media/ring.mp3",
{ body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) },
{ sendAsJson: false },
);
});
afterEach(() => {
callHandler.stop();
// @ts-ignore
DMRoomMap.setShared(null);
// @ts-ignore
window.mxLegacyCallHandler = null;
MatrixClientPeg.unset();
document.body.removeChild(audioElement);
SdkConfig.reset();
});
it("should cache sounds between playbacks", async () => {
await callHandler.play(AudioID.Ring);
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
await callHandler.play(AudioID.Ring);
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
});
it("should allow silencing an incoming call ring", async () => {
await callHandler.play(AudioID.Ring);
await callHandler.silenceCall("call123");
expect(mockAudioBufferSourceNode.stop).toHaveBeenCalled();
});
it("should still start a native call", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState);
// Check that a call was started: its room on the protocol level
// should be the virtual room
expect(fakeCall).not.toBeNull();
expect(fakeCall!.roomId).toEqual(NATIVE_ROOM_ALICE);
// but it should appear to the user to be in thw native room for Bob
expect(callHandler.roomIdForCall(fakeCall!)).toEqual(NATIVE_ROOM_ALICE);
});
describe("incoming calls", () => {
const roomId = "test-room-id";
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip);
jest.spyOn(MatrixClientPeg.safeGet(), "supportsVoip").mockReturnValue(true);
MatrixClientPeg.safeGet().isFallbackICEServerAllowed = jest.fn();
MatrixClientPeg.safeGet().pushRules = {
global: {
[PushRuleKind.Override]: [
{
rule_id: RuleId.IncomingCall,
default: false,
enabled: true,
actions: [
{
set_tweak: TweakName.Sound,
value: "ring",
},
],
},
],
},
};
// silence local notifications by default
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => {
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: true,
},
});
}
});
});
it("listens for incoming call events when voip is enabled", () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
// call added to call map
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
});
it("rings when incoming call state is ringing and notifications set to ring", async () => {
// remove local notification silencing mock for this test
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined);
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
// call added to call map
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio started
await waitFor(() => expect(mockAudioBufferSourceNode.start).toHaveBeenCalled());
});
it("does not ring when incoming call state is ringing but local notifications are silenced", () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
// call added to call map
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio element started
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
});
it("should force calls to silent when local notifications are silenced", async () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
cli.emit(CallEventHandlerEvent.Incoming, call);
expect(callHandler.isForcedSilent()).toEqual(true);
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
});
it("does not unsilence calls when local notifications are silenced", async () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
roomId,
});
const cli = MatrixClientPeg.safeGet();
const callHandlerEmitSpy = jest.spyOn(callHandler, "emit");
cli.emit(CallEventHandlerEvent.Incoming, call);
// reset emit call count
callHandlerEmitSpy.mockClear();
callHandler.unSilenceCall(call.callId);
expect(callHandlerEmitSpy).not.toHaveBeenCalled();
// call still silenced
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
// ringer not played
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,960 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Crypto } from "@peculiar/webcrypto";
import { logger } from "matrix-js-sdk/src/logger";
import * as MatrixJs from "matrix-js-sdk/src/matrix";
import { decodeBase64, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix";
import * as encryptAESSecretStorageItemModule from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
import { logout, restoreSessionFromStorage, setLoggedIn } from "../src/Lifecycle";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import Modal from "../src/Modal";
import * as StorageAccess from "../src/utils/StorageAccess";
import { idbSave } from "../src/utils/StorageAccess";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { OidcClientStore } from "../src/stores/oidc/OidcClientStore";
import { makeDelegatedAuthConfig } from "./test-utils/oidc";
import { persistOidcAuthenticatedSettings } from "../src/utils/oidc/persistOidcSettings";
import { Action } from "../src/dispatcher/actions";
import PlatformPeg from "../src/PlatformPeg";
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../src/utils/tokens/tokens";
import { encryptPickleKey } from "../src/utils/tokens/pickling";
const webCrypto = new Crypto();
const windowCrypto = window.crypto;
describe("Lifecycle", () => {
const mockPlatform = mockPlatformPeg();
const realLocalStorage = global.localStorage;
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
stopClient: jest.fn(),
removeAllListeners: jest.fn(),
clearStores: jest.fn(),
getAccountData: jest.fn(),
getDeviceId: jest.fn(),
isVersionSupported: jest.fn().mockResolvedValue(true),
getCrypto: jest.fn(),
getClientWellKnown: jest.fn(),
waitForClientWellKnown: jest.fn(),
getThirdpartyProtocols: jest.fn(),
store: {
destroy: jest.fn(),
},
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
logout: jest.fn().mockResolvedValue(undefined),
getAccessToken: jest.fn(),
getRefreshToken: jest.fn(),
});
// stub this
jest.spyOn(MatrixClientPeg, "replaceUsingCreds").mockImplementation(() => {});
jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
// reset any mocking
// @ts-ignore mocking
delete global.localStorage;
global.localStorage = realLocalStorage;
// @ts-ignore mocking
delete window.crypto;
window.crypto = webCrypto;
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRestore();
});
afterAll(() => {
// @ts-ignore unmocking
delete window.crypto;
window.crypto = windowCrypto;
});
const initLocalStorageMock = (mockStore: Record<string, unknown> = {}): void => {
jest.spyOn(localStorage.__proto__, "getItem")
.mockClear()
.mockImplementation((key: unknown) => mockStore[key as string] ?? null);
jest.spyOn(localStorage.__proto__, "removeItem")
.mockClear()
.mockImplementation((key: unknown) => {
const { [key as string]: toRemove, ...newStore } = mockStore;
mockStore = newStore;
return toRemove;
});
jest.spyOn(localStorage.__proto__, "setItem")
.mockClear()
.mockImplementation((key: unknown, value: unknown) => {
mockStore[key as string] = value;
});
};
const initSessionStorageMock = (mockStore: Record<string, unknown> = {}): void => {
jest.spyOn(sessionStorage.__proto__, "getItem")
.mockClear()
.mockImplementation((key: unknown) => mockStore[key as string] ?? null);
jest.spyOn(sessionStorage.__proto__, "removeItem")
.mockClear()
.mockImplementation((key: unknown) => {
const { [key as string]: toRemove, ...newStore } = mockStore;
mockStore = newStore;
return toRemove;
});
jest.spyOn(sessionStorage.__proto__, "setItem")
.mockClear()
.mockImplementation((key: unknown, value: unknown) => {
mockStore[key as string] = value;
});
jest.spyOn(sessionStorage.__proto__, "clear").mockClear();
};
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
jest.spyOn(StorageAccess, "idbLoad")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
);
jest.spyOn(StorageAccess, "idbSave")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
async (tableKey: string, key: string, value: unknown) => {
const table = mockStore[tableKey] || {};
table[key as string] = value;
mockStore[tableKey] = table;
},
);
jest.spyOn(StorageAccess, "idbDelete")
.mockClear()
.mockImplementation(async (tableKey: string, key: string | string[]) => {
const table = mockStore[tableKey];
delete table?.[key as string];
});
};
const homeserverUrl = "https://server.org";
const identityServerUrl = "https://is.org";
const userId = "@alice:server.org";
const deviceId = "abc123";
const accessToken = "test-access-token";
const localStorageSession = {
mx_hs_url: homeserverUrl,
mx_is_url: identityServerUrl,
mx_user_id: userId,
mx_device_id: deviceId,
};
const idbStorageSession = {
account: {
mx_access_token: accessToken,
},
};
const credentials = {
homeserverUrl,
identityServerUrl,
userId,
deviceId,
accessToken,
};
const refreshToken = "test-refresh-token";
const encryptedTokenShapedObject = {
ciphertext: expect.any(String),
iv: expect.any(String),
mac: expect.any(String),
};
describe("restoreSessionFromStorage()", () => {
beforeEach(() => {
initLocalStorageMock();
initSessionStorageMock();
initIdbMock();
jest.clearAllMocks();
jest.spyOn(logger, "log").mockClear();
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
// stub this out
jest.spyOn(Modal, "createDialog").mockReturnValue(
// @ts-ignore allow bad mock
{ finished: Promise.resolve([true]) },
);
});
it("should return false when localStorage is not available", async () => {
// @ts-ignore dirty mocking
delete global.localStorage;
// @ts-ignore dirty mocking
global.localStorage = undefined;
expect(await restoreSessionFromStorage()).toEqual(false);
});
it("should return false when no session data is found in local storage", async () => {
expect(await restoreSessionFromStorage()).toEqual(false);
expect(logger.log).toHaveBeenCalledWith("No previous session found.");
});
it("should abort login when we expect to find an access token but don't", async () => {
initLocalStorageMock({ mx_has_access_token: "true" });
await expect(() => restoreSessionFromStorage()).rejects.toThrow();
expect(Modal.createDialog).toHaveBeenCalledWith(StorageEvictedDialog);
expect(mockClient.clearStores).toHaveBeenCalled();
});
describe("when session is found in storage", () => {
describe("guest account", () => {
beforeEach(() => {
initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });
initIdbMock(idbStorageSession);
});
it("should ignore guest accounts when ignoreGuest is true", async () => {
expect(await restoreSessionFromStorage({ ignoreGuest: true })).toEqual(false);
expect(logger.log).toHaveBeenCalledWith(`Ignoring stored guest account: ${userId}`);
});
it("should restore guest accounts when ignoreGuest is false", async () => {
expect(await restoreSessionFromStorage({ ignoreGuest: false })).toEqual(true);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId,
guest: true,
}),
undefined,
);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
});
});
describe("without a pickle key", () => {
beforeEach(() => {
initLocalStorageMock(localStorageSession);
initIdbMock(idbStorageSession);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should persist access token when idb is not available", async () => {
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
expect(await restoreSessionFromStorage()).toEqual(true);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// put accessToken in localstorage as fallback
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should create and start new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: undefined,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({});
});
it("should remove fresh login flag from session storage", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
});
it("should start matrix client", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(MatrixClientPeg.start).toHaveBeenCalled();
});
describe("with a refresh token", () => {
beforeEach(() => {
initLocalStorageMock({
...localStorageSession,
mx_refresh_token: refreshToken,
});
initIdbMock(idbStorageSession);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
// refresh token from storage is re-persisted
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
});
it("should create new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
// refreshToken included in credentials
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: undefined,
},
undefined,
);
});
});
});
describe("with a normal pickle key", () => {
let pickleKey: string;
beforeEach(async () => {
initLocalStorageMock(localStorageSession);
initIdbMock({});
// Create a pickle key, and store it, encrypted, in IDB.
pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
// Indicate that we should have a pickle key
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
// token encrypted and persisted
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
});
it("should persist access token when idb is not available", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
expect(await restoreSessionFromStorage()).toEqual(true);
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
// put accessToken in localstorage as fallback
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should create and start new matrix client with credentials", async () => {
// Check that the rust crypto key is as expected. We have to do this during the call, as
// the buffer is cleared afterwards.
mocked(MatrixClientPeg.start).mockImplementation(async (opts) => {
expect(opts?.rustCryptoStoreKey).toEqual(decodeBase64(pickleKey));
});
// Perform the restore
expect(await restoreSessionFromStorage()).toEqual(true);
// Ensure that the expected calls were made
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
// decrypted accessToken
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Buffer) });
});
describe("with a refresh token", () => {
beforeEach(async () => {
await persistRefreshTokenInStorage(refreshToken, pickleKey);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
// refresh token from storage is re-persisted
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_refresh_token",
encryptedTokenShapedObject,
);
});
it("should create new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
// refreshToken included in credentials
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: pickleKey,
},
undefined,
);
});
});
});
describe("with a non-standard pickle key", () => {
// Most pickle keys are 43 bytes of base64. Test what happens when it is something else.
let pickleKey: string;
beforeEach(async () => {
initLocalStorageMock(localStorageSession);
initIdbMock({});
// Generate the pickle key. I don't *think* it's possible for there to be a pickle key
// which is not some amount of base64.
const rawPickleKey = new Uint8Array(10);
crypto.getRandomValues(rawPickleKey);
pickleKey = encodeUnpaddedBase64(rawPickleKey);
// Store it, encrypted, in the db
await idbSave(
"pickleKey",
[userId, deviceId],
(await encryptPickleKey(rawPickleKey, userId, deviceId))!,
);
// Indicate that we should have a pickle key
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
});
it("should create and start new matrix client with credentials", async () => {
// Perform the restore
expect(await restoreSessionFromStorage()).toEqual(true);
// Ensure that the expected calls were made
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
// decrypted accessToken
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStorePassword: pickleKey });
});
});
it("should proceed if server is not accessible", async () => {
initLocalStorageMock(localStorageSession);
initIdbMock(idbStorageSession);
mockClient.isVersionSupported.mockRejectedValue(new Error("Oh, noes, the server is down!"));
expect(await restoreSessionFromStorage()).toEqual(true);
});
it("should throw if the token was persisted with a pickle key but there is no pickle key available now", async () => {
initLocalStorageMock(localStorageSession);
initIdbMock({});
// Create a pickle key, and store it, encrypted, in IDB.
const pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
// Now destroy the pickle key
await PlatformPeg.get()!.destroyPickleKey(credentials.userId, credentials.deviceId);
await expect(restoreSessionFromStorage()).rejects.toThrow(
"Error decrypting secret access_token: no pickle key found.",
);
});
});
});
describe("setLoggedIn()", () => {
beforeEach(() => {
initLocalStorageMock();
initSessionStorageMock();
initIdbMock();
jest.clearAllMocks();
jest.spyOn(logger, "log").mockClear();
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
// remove any mock implementations
jest.spyOn(mockPlatform, "createPickleKey").mockRestore();
// but still spy and call through
jest.spyOn(mockPlatform, "createPickleKey");
});
const refreshToken = "test-refresh-token";
it("should remove fresh login flag from session storage", async () => {
await setLoggedIn(credentials);
expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
});
it("should start matrix client", async () => {
await setLoggedIn(credentials);
expect(MatrixClientPeg.start).toHaveBeenCalled();
});
describe("without a pickle key", () => {
beforeEach(() => {
jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
});
it("should persist credentials", async () => {
await setLoggedIn(credentials);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should persist a refreshToken when present", async () => {
await setLoggedIn({
...credentials,
refreshToken,
});
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
await setLoggedIn({
...credentials,
// @ts-ignore
accessToken: undefined,
});
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
});
it("should clear stores", async () => {
await setLoggedIn(credentials);
expect(StorageAccess.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
expect(sessionStorage.clear).toHaveBeenCalled();
expect(mockClient.clearStores).toHaveBeenCalled();
});
it("should create new matrix client with credentials", async () => {
expect(await setLoggedIn(credentials)).toEqual(mockClient);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: true,
guest: false,
pickleKey: null,
},
undefined,
);
});
});
describe("with a pickle key", () => {
it("should not create a pickle key when credentials do not include deviceId", async () => {
await setLoggedIn({
...credentials,
deviceId: undefined,
});
// unpickled access token saved
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
});
it("creates a pickle key with userId and deviceId", async () => {
await setLoggedIn(credentials);
expect(mockPlatform.createPickleKey).toHaveBeenCalledWith(userId, deviceId);
});
it("should persist credentials", async () => {
await setLoggedIn(credentials);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object));
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should persist token when encrypting the token fails", async () => {
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRejectedValue("MOCK REJECT ENCRYPTAES");
await setLoggedIn(credentials);
// persist the unencrypted token
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
});
it("should persist token in localStorage when idb fails to save token", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
await setLoggedIn(credentials);
// put plain accessToken in localstorage when we dont have idb
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
await setLoggedIn({
...credentials,
// @ts-ignore
accessToken: undefined,
});
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
});
it("should create new matrix client with credentials", async () => {
expect(await setLoggedIn(credentials)).toEqual(mockClient);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: true,
guest: false,
pickleKey: expect.any(String),
},
undefined,
);
});
});
describe("when authenticated via OIDC native flow", () => {
const clientId = "test-client-id";
const issuer = "https://auth.com/";
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
const idToken =
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg";
beforeAll(() => {
fetchMock.get(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
fetchMock.get(`${delegatedAuthConfig.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});
beforeEach(() => {
initSessionStorageMock();
// set values in session storage as they would be after a successful oidc authentication
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
});
it("should not try to create a token refresher without a refresh token", async () => {
await setLoggedIn(credentials);
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without a deviceId", async () => {
await setLoggedIn({
...credentials,
refreshToken,
deviceId: undefined,
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without an issuer in session storage", async () => {
persistOidcAuthenticatedSettings(
clientId,
// @ts-ignore set undefined issuer
undefined,
idToken,
);
await setLoggedIn({
...credentials,
refreshToken,
});
// didn't try to initialise token refresher
expect(fetchMock).not.toHaveFetched(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
);
});
it("should create a client with a tokenRefreshFunction", async () => {
expect(
await setLoggedIn({
...credentials,
refreshToken,
}),
).toEqual(mockClient);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
expect.objectContaining({
accessToken,
refreshToken,
}),
expect.any(Function),
);
});
it("should create a client when creating token refresher fails", async () => {
// set invalid value in session storage for a malformed oidc authentication
persistOidcAuthenticatedSettings(null as any, issuer, idToken);
// succeeded
expect(
await setLoggedIn({
...credentials,
refreshToken,
}),
).toEqual(mockClient);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
expect.objectContaining({
accessToken,
refreshToken,
}),
// no token refresh function
undefined,
);
});
});
});
describe("logout()", () => {
let oidcClientStore!: OidcClientStore;
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
beforeEach(() => {
oidcClientStore = new OidcClientStore(mockClient);
// stub
jest.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined);
mockClient.getAccessToken.mockReturnValue(accessToken);
mockClient.getRefreshToken.mockReturnValue(refreshToken);
});
it("should call logout on the client when oidcClientStore is falsy", async () => {
logout();
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
});
it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled();
});
it("should revoke tokens when user is authenticated with oidc", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).not.toHaveBeenCalled();
expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken);
});
});
describe("overwritelogin", () => {
beforeEach(async () => {
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
});
it("should replace the current login with a new one", async () => {
const stopSpy = jest.spyOn(mockClient, "stopClient").mockReturnValue(undefined);
const dis = window.mxDispatcher;
const firstLoginEvent: Promise<void> = new Promise((resolve) => {
dis.register(({ action }) => {
if (action === Action.OnLoggedIn) {
resolve();
}
});
});
// set a logged in state
await setLoggedIn(credentials);
await firstLoginEvent;
expect(stopSpy).toHaveBeenCalledTimes(1);
// important the overwrite action should not call unset before replacing.
// So spy on it and make sure it's not called.
jest.spyOn(MatrixClientPeg, "unset").mockReturnValue(undefined);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId,
}),
undefined,
);
const otherCredentials = {
...credentials,
userId: "@bob:server.org",
deviceId: "def456",
};
const secondLoginEvent: Promise<void> = new Promise((resolve) => {
dis.register(({ action }) => {
if (action === Action.OnLoggedIn) {
resolve();
}
});
});
// Trigger the overwrite login action
dis.dispatch(
{
action: "overwrite_login",
credentials: otherCredentials,
},
true,
);
await secondLoginEvent;
// the client should have been stopped
expect(stopSpy).toHaveBeenCalledTimes(2);
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId: otherCredentials.userId,
}),
undefined,
);
expect(MatrixClientPeg.unset).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,184 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import Markdown from "../src/Markdown";
describe("Markdown parser test", () => {
describe("fixing HTML links", () => {
const testString = [
"Test1:",
"#_foonetic_xkcd:matrix.org",
"http://google.com/_thing_",
"https://matrix.org/_matrix/client/foo/123_",
"#_foonetic_xkcd:matrix.org",
"",
"Test1A:",
"#_foonetic_xkcd:matrix.org",
"http://google.com/_thing_",
"https://matrix.org/_matrix/client/foo/123_",
"#_foonetic_xkcd:matrix.org",
"",
"Test2:",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"",
"Test3:",
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
].join("\n");
it("tests that links with markdown empasis in them are getting properly HTML formatted", () => {
/* eslint-disable max-len */
const expectedResult = [
"<p>Test1:<br />#_foonetic_xkcd:matrix.org<br />http://google.com/_thing_<br />https://matrix.org/_matrix/client/foo/123_<br />#_foonetic_xkcd:matrix.org</p>",
"<p>Test1A:<br />#_foonetic_xkcd:matrix.org<br />http://google.com/_thing_<br />https://matrix.org/_matrix/client/foo/123_<br />#_foonetic_xkcd:matrix.org</p>",
"<p>Test2:<br />http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg<br />http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</p>",
"<p>Test3:<br />https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org<br />https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</p>",
"",
].join("\n");
/* eslint-enable max-len */
const md = new Markdown(testString);
expect(md.toHTML()).toEqual(expectedResult);
});
it("tests that links with autolinks are not touched at all and are still properly formatted", () => {
const test = [
"Test1:",
"<#_foonetic_xkcd:matrix.org>",
"<http://google.com/_thing_>",
"<https://matrix.org/_matrix/client/foo/123_>",
"<#_foonetic_xkcd:matrix.org>",
"",
"Test1A:",
"<#_foonetic_xkcd:matrix.org>",
"<http://google.com/_thing_>",
"<https://matrix.org/_matrix/client/foo/123_>",
"<#_foonetic_xkcd:matrix.org>",
"",
"Test2:",
"<http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg>",
"<http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg>",
"",
"Test3:",
"<https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org>",
"<https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org>",
].join("\n");
/* eslint-disable max-len */
/**
* NOTE: I'm not entirely sure if those "<"" and ">" should be visible in here for #_foonetic_xkcd:matrix.org
* but it seems to be actually working properly
*/
const expectedResult = [
'<p>Test1:<br />&lt;#_foonetic_xkcd:matrix.org&gt;<br /><a href="http://google.com/_thing_">http://google.com/_thing_</a><br /><a href="https://matrix.org/_matrix/client/foo/123_">https://matrix.org/_matrix/client/foo/123_</a><br />&lt;#_foonetic_xkcd:matrix.org&gt;</p>',
'<p>Test1A:<br />&lt;#_foonetic_xkcd:matrix.org&gt;<br /><a href="http://google.com/_thing_">http://google.com/_thing_</a><br /><a href="https://matrix.org/_matrix/client/foo/123_">https://matrix.org/_matrix/client/foo/123_</a><br />&lt;#_foonetic_xkcd:matrix.org&gt;</p>',
'<p>Test2:<br /><a href="http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg">http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</a><br /><a href="http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg">http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</a></p>',
'<p>Test3:<br /><a href="https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org">https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</a><br /><a href="https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org">https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</a></p>',
"",
].join("\n");
/* eslint-enable max-len */
const md = new Markdown(test);
expect(md.toHTML()).toEqual(expectedResult);
});
it("expects that links in codeblock are not modified", () => {
const expectedResult = [
'<pre><code class="language-Test1:">#_foonetic_xkcd:matrix.org',
"http://google.com/_thing_",
"https://matrix.org/_matrix/client/foo/123_",
"#_foonetic_xkcd:matrix.org",
"",
"Test1A:",
"#_foonetic_xkcd:matrix.org",
"http://google.com/_thing_",
"https://matrix.org/_matrix/client/foo/123_",
"#_foonetic_xkcd:matrix.org",
"",
"Test2:",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"",
"Test3:",
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org```",
"</code></pre>",
"",
].join("\n");
const md = new Markdown("```" + testString + "```");
expect(md.toHTML()).toEqual(expectedResult);
});
it('expects that links with emphasis are "escaped" correctly', () => {
/* eslint-disable max-len */
const testString = [
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg" +
" " +
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg" +
" " +
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"https://example.com/_test_test2_-test3",
"https://example.com/_test_test2_test3_",
"https://example.com/_test__test2_test3_",
"https://example.com/_test__test2__test3_",
"https://example.com/_test__test2_test3__",
"https://example.com/_test__test2",
].join("\n");
const expectedResult = [
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
"https://example.com/_test_test2_-test3",
"https://example.com/_test_test2_test3_",
"https://example.com/_test__test2_test3_",
"https://example.com/_test__test2__test3_",
"https://example.com/_test__test2_test3__",
"https://example.com/_test__test2",
].join("<br />");
/* eslint-enable max-len */
const md = new Markdown(testString);
expect(md.toHTML()).toEqual(expectedResult);
});
it("expects that the link part will not be accidentally added to <strong>", () => {
/* eslint-disable max-len */
const testString = `https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py`;
const expectedResult = "https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py";
/* eslint-enable max-len */
const md = new Markdown(testString);
expect(md.toHTML()).toEqual(expectedResult);
});
it("expects that the link part will not be accidentally added to <strong> for multiline links", () => {
/* eslint-disable max-len */
const testString = [
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
" " +
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
" " +
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
].join("\n");
const expectedResult = [
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
" " +
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
" " +
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
].join("<br />");
/* eslint-enable max-len */
const md = new Markdown(testString);
expect(md.toHTML()).toEqual(expectedResult);
});
it("resumes applying formatting to the rest of a message after a link", () => {
const testString = "http://google.com/_thing_ *does* __not__ exist";
const expectedResult = "http://google.com/_thing_ <em>does</em> <strong>not</strong> exist";
const md = new Markdown(testString);
expect(md.toHTML()).toEqual(expectedResult);
});
});
});

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
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import fetchMockJest from "fetch-mock-jest";
import { advanceDateAndTime, stubClient } from "./test-utils";
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel";
jest.useFakeTimers();
const PegClass = Object.getPrototypeOf(peg).constructor;
describe("MatrixClientPeg", () => {
beforeEach(() => {
// stub out Logger.log which gets called a lot and clutters up the test output
jest.spyOn(logger, "log").mockImplementation(() => {});
});
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
// some of the tests assign `MatrixClientPeg.matrixClient`: clear it, to prevent leakage between tests
peg.unset();
});
it("setJustRegisteredUserId", () => {
stubClient();
(peg as any).matrixClient = peg.get();
peg.setJustRegisteredUserId("@userId:matrix.org");
expect(peg.safeGet().credentials.userId).toBe("@userId:matrix.org");
expect(peg.currentUserIsJustRegistered()).toBe(true);
expect(peg.userRegisteredWithinLastHours(0)).toBe(false);
expect(peg.userRegisteredWithinLastHours(1)).toBe(true);
expect(peg.userRegisteredWithinLastHours(24)).toBe(true);
advanceDateAndTime(1 * 60 * 60 * 1000 + 1);
expect(peg.userRegisteredWithinLastHours(0)).toBe(false);
expect(peg.userRegisteredWithinLastHours(1)).toBe(false);
expect(peg.userRegisteredWithinLastHours(24)).toBe(true);
advanceDateAndTime(24 * 60 * 60 * 1000);
expect(peg.userRegisteredWithinLastHours(0)).toBe(false);
expect(peg.userRegisteredWithinLastHours(1)).toBe(false);
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
});
it("setJustRegisteredUserId(null)", () => {
stubClient();
(peg as any).matrixClient = peg.get();
peg.setJustRegisteredUserId(null);
expect(peg.currentUserIsJustRegistered()).toBe(false);
expect(peg.userRegisteredWithinLastHours(0)).toBe(false);
expect(peg.userRegisteredWithinLastHours(1)).toBe(false);
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
advanceDateAndTime(1 * 60 * 60 * 1000 + 1);
expect(peg.userRegisteredWithinLastHours(0)).toBe(false);
expect(peg.userRegisteredWithinLastHours(1)).toBe(false);
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
});
describe(".start", () => {
let testPeg: IMatrixClientPeg;
beforeEach(() => {
// instantiate a MatrixClientPegClass instance, with a new MatrixClient
testPeg = new PegClass();
fetchMockJest.get("http://example.com/_matrix/client/versions", {});
testPeg.replaceUsingCreds({
accessToken: "SEKRET",
homeserverUrl: "http://example.com",
userId: "@user:example.com",
deviceId: "TEST_DEVICE_ID",
});
});
it("should initialise the rust crypto library by default", async () => {
const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
const mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined);
const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
const cryptoStoreKey = new Uint8Array([1, 2, 3, 4]);
await testPeg.start({ rustCryptoStoreKey: cryptoStoreKey });
expect(mockInitCrypto).not.toHaveBeenCalled();
expect(mockInitRustCrypto).toHaveBeenCalledWith({ storageKey: cryptoStoreKey });
// we should have stashed the setting in the settings store
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true);
});
it("Should migrate existing login", async () => {
const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
await testPeg.start();
expect(mockInitRustCrypto).toHaveBeenCalledTimes(1);
// we should have stashed the setting in the settings store
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true);
});
});
});

View file

@ -0,0 +1,55 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { SettingLevel } from "../src/settings/SettingLevel";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import { stubClient } from "./test-utils";
import MediaDeviceHandler from "../src/MediaDeviceHandler";
import SettingsStore from "../src/settings/SettingsStore";
jest.mock("../src/settings/SettingsStore");
const SettingsStoreMock = mocked(SettingsStore);
describe("MediaDeviceHandler", () => {
beforeEach(() => {
stubClient();
});
afterEach(() => {
jest.clearAllMocks();
});
it("sets audio settings", async () => {
const expectedAudioSettings = new Map<string, boolean>([
["webrtc_audio_autoGainControl", false],
["webrtc_audio_echoCancellation", true],
["webrtc_audio_noiseSuppression", false],
]);
SettingsStoreMock.getValue.mockImplementation((settingName): any => {
return expectedAudioSettings.get(settingName);
});
await MediaDeviceHandler.setAudioAutoGainControl(false);
await MediaDeviceHandler.setAudioEchoCancellation(true);
await MediaDeviceHandler.setAudioNoiseSuppression(false);
expectedAudioSettings.forEach((value, key) => {
expect(SettingsStoreMock.setValue).toHaveBeenCalledWith(key, null, SettingLevel.DEVICE, value);
});
expect(MatrixClientPeg.safeGet().getMediaHandler().setAudioSettings).toHaveBeenCalledWith({
autoGainControl: false,
echoCancellation: true,
noiseSuppression: false,
});
});
});

View file

@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import Modal from "../src/Modal";
import QuestionDialog from "../src/components/views/dialogs/QuestionDialog";
import defaultDispatcher from "../src/dispatcher/dispatcher";
describe("Modal", () => {
test("forceCloseAllModals should close all open modals", () => {
Modal.createDialog(QuestionDialog, {
title: "Test dialog",
description: "This is a test dialog",
button: "Word",
});
expect(Modal.hasDialogs()).toBe(true);
Modal.forceCloseAllModals();
expect(Modal.hasDialogs()).toBe(false);
});
test("open modals should be closed on logout", () => {
const modal1OnFinished = jest.fn();
const modal2OnFinished = jest.fn();
Modal.createDialog(QuestionDialog, {
title: "Test dialog 1",
description: "This is a test dialog",
button: "Word",
onFinished: modal1OnFinished,
});
Modal.createDialog(QuestionDialog, {
title: "Test dialog 2",
description: "This is a test dialog",
button: "Word",
onFinished: modal2OnFinished,
});
defaultDispatcher.dispatch({ action: "logout" }, true);
expect(modal1OnFinished).toHaveBeenCalled();
expect(modal2OnFinished).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,634 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked, MockedObject } from "jest-mock";
import {
ClientEvent,
MatrixClient,
Room,
RoomEvent,
EventType,
MsgType,
IContent,
MatrixEvent,
SyncState,
} from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import BasePlatform from "../src/BasePlatform";
import Notifier from "../src/Notifier";
import SettingsStore from "../src/settings/SettingsStore";
import ToastStore from "../src/stores/ToastStore";
import {
createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType,
} from "../src/utils/notifications";
import {
getMockClientWithEventEmitter,
mkEvent,
mkMessage,
mockClientMethodsUser,
mockPlatformPeg,
} from "./test-utils";
import { getIncomingCallToastKey, IncomingCallToast } from "../src/toasts/IncomingCallToast";
import { SdkContextClass } from "../src/contexts/SDKContext";
import UserActivity from "../src/UserActivity";
import Modal from "../src/Modal";
import { mkThread } from "./test-utils/threads";
import dis from "../src/dispatcher/dispatcher";
import { ThreadPayload } from "../src/dispatcher/payloads/ThreadPayload";
import { Action } from "../src/dispatcher/actions";
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
import { addReplyToMessageContent } from "../src/utils/Reply";
jest.mock("../src/utils/notifications", () => ({
// @ts-ignore
...jest.requireActual("../src/utils/notifications"),
createLocalNotificationSettingsIfNeeded: jest.fn(),
}));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
describe("Notifier", () => {
const roomId = "!room1:server";
const testEvent = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: {},
});
let MockPlatform: MockedObject<BasePlatform>;
let mockClient: MockedObject<MatrixClient>;
let testRoom: Room;
let accountDataEventKey: string;
let accountDataStore: Record<string, MatrixEvent | undefined> = {};
let mockSettings: Record<string, boolean> = {};
const userId = "@bob:example.org";
const emitLiveEvent = (event: MatrixEvent): void => {
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: true,
timeline: testRoom.getLiveTimeline(),
});
};
const mkAudioEvent = (broadcastChunkContent?: object): MatrixEvent => {
const chunkContent = broadcastChunkContent ? { [VoiceBroadcastChunkEventType]: broadcastChunkContent } : {};
return mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@user:example.com",
room: "!room:example.com",
content: {
...chunkContent,
msgtype: MsgType.Audio,
body: "test audio message",
},
});
};
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
accountDataStore = {};
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation((eventType) => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = content
? new MatrixEvent({
type: eventType,
content,
})
: undefined;
}),
decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(),
supportsThreads: jest.fn().mockReturnValue(false),
matrixRTC: {
on: jest.fn(),
off: jest.fn(),
getRoomSession: jest.fn(),
},
});
mockClient.pushRules = {
global: {},
};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
testRoom = new Room(roomId, mockClient, mockClient.getSafeUserId());
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn(),
loudNotification: jest.fn(),
});
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
mockClient.getRoom.mockImplementation((id: string | undefined): Room | null => {
if (id === roomId) return testRoom;
if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
return null;
});
// @ts-ignore
Notifier.backgroundAudio.audioContext = mockAudioContext;
});
describe("triggering notification from events", () => {
let hasStartedNotiferBefore = false;
const event = new MatrixEvent({
sender: "@alice:server.org",
type: "m.room.message",
room_id: "!room:server.org",
content: {
body: "hey",
},
});
beforeEach(() => {
// notifier defines some listener functions in start
// and references them in stop
// so blows up if stopped before it was started
if (hasStartedNotiferBefore) {
Notifier.stop();
}
Notifier.start();
hasStartedNotiferBefore = true;
mockClient.getRoom.mockReturnValue(testRoom);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
mockSettings = {
notificationsEnabled: true,
audioNotificationsEnabled: true,
};
// enable notifications by default
jest.spyOn(SettingsStore, "getValue")
.mockReset()
.mockImplementation((settingName) => mockSettings[settingName] ?? false);
});
afterAll(() => {
Notifier.stop();
});
it("does not create notifications before syncing has started", () => {
emitLiveEvent(event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for own event", () => {
const ownEvent = new MatrixEvent({ sender: userId });
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(ownEvent);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for non-live events (scrollback)", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: false,
timeline: testRoom.getLiveTimeline(),
});
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for rooms which cannot be obtained via client.getRoom", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
mockClient.getRoom.mockReturnValue(null);
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: true,
timeline: testRoom.getLiveTimeline(),
});
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications when event does not have notify push action", () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: false,
tweaks: {
sound: true,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("creates desktop notification when enabled", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(testRoom.name, "hey", null, testRoom, event);
});
it("creates a loud notification when enabled", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.loudNotification).toHaveBeenCalledWith(event, testRoom);
});
it("does not create loud notification when event does not have sound tweak in push actions", () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: false,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
// desktop notification created
expect(MockPlatform.displayNotification).toHaveBeenCalled();
// without noisy
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
});
describe("displayPopupNotification", () => {
const testCases: { event: IContent | undefined; count: number }[] = [
{ event: { is_silenced: true }, count: 0 },
{ event: { is_silenced: false }, count: 1 },
{ event: undefined, count: 1 },
];
it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => {
mockClient.setAccountData(accountDataEventKey, event!);
Notifier.displayPopupNotification(testEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count);
});
it("should display a notification for a voice message", () => {
const audioEvent = mkAudioEvent();
Notifier.displayPopupNotification(audioEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@user:example.com (!room1:server)",
"@user:example.com: test audio message",
"data:image/png;base64,00",
testRoom,
audioEvent,
);
});
it("should display the expected notification for a broadcast chunk with sequence = 1", () => {
const audioEvent = mkAudioEvent({ sequence: 1 });
Notifier.displayPopupNotification(audioEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@user:example.com (!room1:server)",
"@user:example.com started a voice broadcast",
"data:image/png;base64,00",
testRoom,
audioEvent,
);
});
it("should display the expected notification for a broadcast chunk with sequence = 2", () => {
const audioEvent = mkAudioEvent({ sequence: 2 });
Notifier.displayPopupNotification(audioEvent, testRoom);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
});
it("should strip reply fallback", () => {
const event = mkMessage({
msg: "Test",
event: true,
user: mockClient.getSafeUserId(),
room: testRoom.roomId,
});
const reply = mkMessage({
msg: "This was a triumph",
event: true,
user: mockClient.getSafeUserId(),
room: testRoom.roomId,
});
addReplyToMessageContent(reply.getContent(), event, { includeLegacyFallback: true });
Notifier.displayPopupNotification(reply, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@bob:example.org (!room1:server)",
"This was a triumph",
expect.any(String),
testRoom,
reply,
);
});
});
describe("getSoundForRoom", () => {
it("should not explode if given invalid url", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
return { url: { content_uri: "foobar" } };
});
expect(Notifier.getSoundForRoom("!roomId:server")).toBeNull();
});
});
describe("_playAudioNotification", () => {
const testCases: { event: IContent | undefined; count: number }[] = [
{ event: { is_silenced: true }, count: 0 },
{ event: { is_silenced: false }, count: 1 },
{ event: undefined, count: 1 },
];
it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => {
// It's not ideal to only look at whether this function has been called
// but avoids starting to look into DOM stuff
Notifier.getSoundForRoom = jest.fn();
mockClient.setAccountData(accountDataEventKey, event!);
Notifier.playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
});
});
describe("group call notifications", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {},
});
Notifier.start();
Notifier.onSyncStateChange(SyncState.Syncing, null);
});
afterEach(() => {
jest.resetAllMocks();
});
const emitCallNotifyEvent = (type?: string, roomMention = true) => {
const callEvent = mkEvent({
type: type ?? EventType.CallNotify,
user: "@alice:foo",
room: roomId,
content: {
"application": "m.call",
"m.mentions": { user_ids: [], room: roomMention },
"notify_type": "ring",
"call_id": "abc123",
},
event: true,
});
emitLiveEvent(callEvent);
return callEvent;
};
it("shows group call toast", () => {
const notifyEvent = emitCallNotifyEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notifyEvent },
}),
);
});
it("should not show toast when group call is already connected", () => {
const spyCallMemberships = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([
new CallMembership(
mkEvent({
event: true,
room: testRoom.roomId,
user: userId,
type: EventType.GroupCallMemberPrefix,
content: {},
}),
{
call_id: "123",
application: "m.call",
focus_active: { type: "livekit" },
foci_preferred: [],
device_id: "DEVICE",
},
),
]);
const roomSession = MatrixRTCSession.roomSessionForRoom(mockClient, testRoom);
mockClient.matrixRTC.getRoomSession.mockReturnValue(roomSession);
emitCallNotifyEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
spyCallMemberships.mockRestore();
});
it("should not show toast when calling with non-group call event", () => {
emitCallNotifyEvent("event_type");
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
describe("local notification settings", () => {
const createLocalNotificationSettingsIfNeededMock = mocked(createLocalNotificationSettingsIfNeeded);
let hasStartedNotiferBefore = false;
beforeEach(() => {
// notifier defines some listener functions in start
// and references them in stop
// so blows up if stopped before it was started
if (hasStartedNotiferBefore) {
Notifier.stop();
}
Notifier.start();
hasStartedNotiferBefore = true;
createLocalNotificationSettingsIfNeededMock.mockClear();
});
afterAll(() => {
Notifier.stop();
});
it("does not create local notifications event after a sync error", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("does not create local notifications event after sync stops", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Stopped, SyncState.Syncing);
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("does not create local notifications event after a cached sync", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {
fromCache: true,
});
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("creates local notifications event after a non-cached sync", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {});
expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled();
});
});
describe("evaluateEvent", () => {
beforeEach(() => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(testRoom.roomId);
jest.spyOn(UserActivity.sharedInstance(), "userActiveRecently").mockReturnValue(true);
jest.spyOn(Modal, "hasDialogs").mockReturnValue(false);
jest.spyOn(Notifier, "displayPopupNotification").mockReset();
jest.spyOn(Notifier, "isEnabled").mockReturnValue(true);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
});
it("should show a pop-up", () => {
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(testEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
const eventFromOtherRoom = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!otherroom:example.org",
content: {},
});
Notifier.evaluateEvent(eventFromOtherRoom);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
});
it("should a pop-up for thread event", async () => {
const { events, rootEvent } = mkThread({
room: testRoom,
client: mockClient,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(rootEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(events[1]);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
dis.dispatch<ThreadPayload>({
action: Action.ViewThread,
thread_id: rootEvent.getId()!,
});
await waitFor(() => expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId()));
Notifier.evaluateEvent(events[1]);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
});
it("should show a pop-up for an audio message", () => {
Notifier.evaluateEvent(mkAudioEvent());
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
});
it("should not show a notification for broadcast info events in any case", () => {
// Let client decide to show a notification
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {},
});
const broadcastStartedEvent = mkVoiceBroadcastInfoStateEvent(
"!other:example.org",
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ABC123",
);
Notifier.evaluateEvent(broadcastStartedEvent);
expect(Notifier.displayPopupNotification).not.toHaveBeenCalled();
});
});
describe("setPromptHidden", () => {
it("should persist by default", () => {
Notifier.setPromptHidden(true);
expect(localStorage.getItem("notifications_hidden")).toBeTruthy();
});
});
describe("onEvent", () => {
it("should not evaluate events from the thread list fake timeline sets", async () => {
mockClient.supportsThreads.mockReturnValue(true);
const fn = jest.spyOn(Notifier, "evaluateEvent");
await testRoom.createThreadsTimelineSets();
testRoom.threadsTimelineSets[0]!.addEventToTimeline(
mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: { body: "this is a thread root" },
}),
testRoom.threadsTimelineSets[0]!.getLiveTimeline(),
false,
false,
);
expect(fn).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,346 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { PostHog } from "posthog-js";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { Anonymity, getRedactedCurrentLocation, IPosthogEvent, PosthogAnalytics } from "../src/PosthogAnalytics";
import SdkConfig from "../src/SdkConfig";
import { getMockClientWithEventEmitter } from "./test-utils";
import SettingsStore from "../src/settings/SettingsStore";
import { Layout } from "../src/settings/enums/Layout";
import defaultDispatcher from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions";
import { SettingLevel } from "../src/settings/SettingLevel";
const getFakePosthog = (): PostHog =>
({
capture: jest.fn(),
init: jest.fn(),
identify: jest.fn(),
reset: jest.fn(),
register: jest.fn(),
get_distinct_id: jest.fn(),
persistence: {
get_property: jest.fn(),
},
identifyUser: jest.fn(),
}) as unknown as PostHog;
interface ITestEvent extends IPosthogEvent {
eventName: "JestTestEvents";
foo?: string;
}
describe("PosthogAnalytics", () => {
let fakePosthog: PostHog;
const shaHashes: Record<string, string> = {
"42": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049",
"some": "a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b",
"pii": "bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4",
"foo": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
};
beforeEach(() => {
fakePosthog = getFakePosthog();
Object.defineProperty(window, "crypto", {
value: {
subtle: {
digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => {
const message = new TextDecoder().decode(encodedMessage);
const hexHash = shaHashes[message];
const bytes: number[] = [];
for (let c = 0; c < hexHash.length; c += 2) {
bytes.push(parseInt(hexHash.slice(c, c + 2), 16));
}
return bytes;
},
},
},
});
});
afterEach(() => {
Object.defineProperty(window, "crypto", {
value: null,
});
SdkConfig.reset(); // we touch the config, so clean up
});
describe("Initialisation", () => {
it("Should not be enabled without config being set", () => {
// force empty/invalid state for posthog options
SdkConfig.put({ brand: "Testing" });
const analytics = new PosthogAnalytics(fakePosthog);
expect(analytics.isEnabled()).toBe(false);
});
it("Should be enabled if config is set", () => {
SdkConfig.put({
brand: "Testing",
posthog: {
project_api_key: "foo",
api_host: "bar",
},
});
const analytics = new PosthogAnalytics(fakePosthog);
analytics.setAnonymity(Anonymity.Pseudonymous);
expect(analytics.isEnabled()).toBe(true);
});
});
describe("Tracking", () => {
let analytics: PosthogAnalytics;
beforeEach(() => {
SdkConfig.put({
brand: "Testing",
posthog: {
project_api_key: "foo",
api_host: "bar",
},
});
analytics = new PosthogAnalytics(fakePosthog);
});
it("Should pass event to posthog", () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
foo: "bar",
});
expect(mocked(fakePosthog).capture.mock.calls[0][0]).toBe("JestTestEvents");
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["foo"]).toEqual("bar");
});
it("Should not track events if anonymous", async () => {
analytics.setAnonymity(Anonymity.Anonymous);
await analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
foo: "bar",
});
expect(fakePosthog.capture).not.toHaveBeenCalled();
});
it("Should not track any events if disabled", async () => {
analytics.setAnonymity(Anonymity.Disabled);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
foo: "bar",
});
expect(fakePosthog.capture).not.toHaveBeenCalled();
});
it("Should anonymise location of a known screen", async () => {
const location = getRedactedCurrentLocation("https://foo.bar", "#/register/some/pii", "/");
expect(location).toBe("https://foo.bar/#/register/<redacted>");
});
it("Should anonymise location of an unknown screen", async () => {
const location = getRedactedCurrentLocation("https://foo.bar", "#/not_a_screen_name/some/pii", "/");
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
});
it("Should handle an empty hash", async () => {
const location = getRedactedCurrentLocation("https://foo.bar", "", "/");
expect(location).toBe("https://foo.bar/");
});
it("Should identify the user to posthog if pseudonymous", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
const client = getMockClientWithEventEmitter({
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
setAccountData: jest.fn().mockResolvedValue({}),
});
await analytics.identifyUser(client, () => "analytics_id");
expect(mocked(fakePosthog).identify.mock.calls[0][0]).toBe("analytics_id");
});
it("Should not identify the user to posthog if anonymous", async () => {
analytics.setAnonymity(Anonymity.Anonymous);
const client = getMockClientWithEventEmitter({});
await analytics.identifyUser(client, () => "analytics_id");
expect(mocked(fakePosthog).identify.mock.calls.length).toBe(0);
});
it("Should identify using the server's analytics id if present", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
const client = getMockClientWithEventEmitter({
getAccountDataFromServer: jest.fn().mockResolvedValue({ id: "existing_analytics_id" }),
setAccountData: jest.fn().mockResolvedValue({}),
});
await analytics.identifyUser(client, () => "new_analytics_id");
expect(mocked(fakePosthog).identify.mock.calls[0][0]).toBe("existing_analytics_id");
});
});
describe("WebLayout", () => {
let analytics: PosthogAnalytics;
beforeEach(() => {
SdkConfig.put({
brand: "Testing",
posthog: {
project_api_key: "foo",
api_host: "bar",
},
});
analytics = new PosthogAnalytics(fakePosthog);
analytics.setAnonymity(Anonymity.Pseudonymous);
});
it("should send layout IRC correctly", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
defaultDispatcher.dispatch(
{
action: Action.SettingUpdated,
settingName: "layout",
},
true,
);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
WebLayout: "IRC",
});
});
it("should send layout Bubble correctly", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
defaultDispatcher.dispatch(
{
action: Action.SettingUpdated,
settingName: "layout",
},
true,
);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
WebLayout: "Bubble",
});
});
it("should send layout Group correctly", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
defaultDispatcher.dispatch(
{
action: Action.SettingUpdated,
settingName: "layout",
},
true,
);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
WebLayout: "Group",
});
});
it("should send layout Compact correctly", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
defaultDispatcher.dispatch(
{
action: Action.SettingUpdated,
settingName: "useCompactLayout",
},
true,
);
analytics.trackEvent<ITestEvent>({
eventName: "JestTestEvents",
});
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
WebLayout: "Compact",
});
});
});
describe("CryptoSdk", () => {
let analytics: PosthogAnalytics;
const getFakeClient = (): MatrixClient =>
({
getCrypto: jest.fn(),
setAccountData: jest.fn(),
// just fake return an `im.vector.analytics` content
getAccountDataFromServer: jest.fn().mockReturnValue({
id: "0000000",
pseudonymousAnalyticsOptIn: true,
}),
}) as unknown as MatrixClient;
beforeEach(async () => {
SdkConfig.put({
brand: "Testing",
posthog: {
project_api_key: "foo",
api_host: "bar",
},
});
analytics = new PosthogAnalytics(fakePosthog);
});
// `updateAnonymityFromSettings` is called On page load / login / account data change.
// We manually call it so we can test the behaviour.
async function simulateLogin(rustBackend: boolean, pseudonymous = true) {
// To simulate a switch we call updateAnonymityFromSettings.
// As per documentation this function is called On login.
const mockClient = getFakeClient();
mocked(mockClient.getCrypto).mockReturnValue({
getVersion: () => {
return rustBackend ? "Rust SDK 0.6.0 (9c6b550), Vodozemac 0.5.0" : "Olm 3.2.0";
},
} as unknown as CryptoApi);
await analytics.updateAnonymityFromSettings(mockClient, pseudonymous);
}
it("should send rust cryptoSDK superProperty correctly", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
await simulateLogin(false);
expect(mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Legacy");
});
it("should send Legacy cryptoSDK superProperty correctly", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
await simulateLogin(false);
// Super Properties are properties associated with events that are set once and then sent with every capture call.
// They are set using posthog.register
expect(mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Legacy");
});
it("should send cryptoSDK superProperty when enabling analytics", async () => {
analytics.setAnonymity(Anonymity.Disabled);
await simulateLogin(true, false);
// This initial call is due to the call to register platformSuperProperties
// The important thing is that the cryptoSDK superProperty is not set.
expect(mocked(fakePosthog).register.mock.lastCall![0]).toStrictEqual({});
// switching to pseudonymous should ensure that the cryptoSDK superProperty is set correctly
analytics.setAnonymity(Anonymity.Pseudonymous);
// Super Properties are properties associated with events that are set once and then sent with every capture call.
// They are set using posthog.register
expect(mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Rust");
});
});
});

View file

@ -0,0 +1,46 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { doesRoomVersionSupport, PreferredRoomVersions } from "../src/utils/PreferredRoomVersions";
describe("doesRoomVersionSupport", () => {
it("should detect unstable as unsupported", () => {
expect(doesRoomVersionSupport("org.example.unstable", "1")).toBe(false);
expect(doesRoomVersionSupport("1.2-beta", "1")).toBe(false);
});
it("should detect support properly", () => {
expect(doesRoomVersionSupport("1", "2")).toBe(false); // older
expect(doesRoomVersionSupport("2", "2")).toBe(true); // exact
expect(doesRoomVersionSupport("3", "2")).toBe(true); // newer
});
it("should handle decimal versions", () => {
expect(doesRoomVersionSupport("1.1", "2.2")).toBe(false); // older
expect(doesRoomVersionSupport("2.1", "2.2")).toBe(false); // exact-ish
expect(doesRoomVersionSupport("2.2", "2.2")).toBe(true); // exact
expect(doesRoomVersionSupport("2.3", "2.2")).toBe(true); // exact-ish
expect(doesRoomVersionSupport("3.1", "2.2")).toBe(true); // newer
});
it("should detect knock rooms in v7 and above", () => {
expect(doesRoomVersionSupport("6", PreferredRoomVersions.KnockRooms)).toBe(false);
expect(doesRoomVersionSupport("7", PreferredRoomVersions.KnockRooms)).toBe(true);
expect(doesRoomVersionSupport("8", PreferredRoomVersions.KnockRooms)).toBe(true);
expect(doesRoomVersionSupport("9", PreferredRoomVersions.KnockRooms)).toBe(true);
expect(doesRoomVersionSupport("10", PreferredRoomVersions.KnockRooms)).toBe(true);
});
it("should detect restricted rooms in v9 and v10", () => {
// Dev note: we consider it a feature that v8 rooms have to upgrade considering the bug in v8.
// https://spec.matrix.org/v1.3/rooms/v8/#redactions
expect(doesRoomVersionSupport("8", PreferredRoomVersions.RestrictedRooms)).toBe(false);
expect(doesRoomVersionSupport("9", PreferredRoomVersions.RestrictedRooms)).toBe(true);
expect(doesRoomVersionSupport("10", PreferredRoomVersions.RestrictedRooms)).toBe(true);
});
});

View file

@ -0,0 +1,195 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
IContent,
MatrixEvent,
MsgType,
M_BEACON_INFO,
LocationAssetType,
M_ASSET,
M_POLL_END,
Room,
} from "matrix-js-sdk/src/matrix";
import {
getNestedReplyText,
getParentEventId,
shouldDisplayReply,
stripHTMLReply,
stripPlainReply,
} from "../src/utils/Reply";
import { makePollStartEvent, mkEvent, stubClient } from "./test-utils";
import { RoomPermalinkCreator } from "../src/utils/permalinks/Permalinks";
function makeTestEvent(type: string, content: IContent): MatrixEvent {
return mkEvent({
event: true,
type: type,
user: "@user1:server",
room: "!room1:server",
content,
});
}
const mockPermalinkGenerator = {
forEvent(eventId: string): string {
return "$$permalink$$";
},
} as RoomPermalinkCreator;
// don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger");
describe("Reply", () => {
describe("getParentEventId", () => {
it("returns undefined if given a falsey value", async () => {
expect(getParentEventId()).toBeUndefined();
});
it("returns undefined if given a redacted event", async () => {
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.makeRedacted(event, new Room(event.getRoomId()!, stubClient(), event.getSender()!));
expect(getParentEventId(event)).toBeUndefined();
});
it("returns undefined if the given event is not a reply", async () => {
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
expect(getParentEventId(event)).toBeUndefined();
});
it("returns id of the event being replied to", async () => {
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {
"m.relates_to": {
"m.in_reply_to": {
event_id: "$event1",
},
},
},
});
expect(getParentEventId(event)).toBe("$event1");
});
});
describe("stripPlainReply", () => {
it("Removes leading quotes until the first blank line", () => {
expect(
stripPlainReply(
`
> This is part
> of the quote
But this is not
`.trim(),
),
).toBe("But this is not");
});
});
describe("stripHTMLReply", () => {
it("Removes <mx-reply> from the input", () => {
expect(
stripHTMLReply(`
<mx-reply>
This is part
of the quote
</mx-reply>
But this is not
`).trim(),
).toBe("But this is not");
});
});
describe("getNestedReplyText", () => {
it("Returns valid reply fallback text for m.text msgtypes", () => {
const event = makeTestEvent(MsgType.Text, {
body: "body",
msgtype: "m.text",
});
expect(getNestedReplyText(event, mockPermalinkGenerator)).toMatchSnapshot();
});
(
[
["m.room.message", MsgType.Location, LocationAssetType.Pin],
["m.room.message", MsgType.Location, LocationAssetType.Self],
[M_BEACON_INFO.name, undefined, LocationAssetType.Pin],
[M_BEACON_INFO.name, undefined, LocationAssetType.Self],
] as const
).forEach(([type, msgType, assetType]) => {
it(`should create the expected fallback text for ${assetType} ${type}/${msgType}`, () => {
const event = makeTestEvent(type, {
body: "body",
msgtype: msgType,
[M_ASSET.name]: { type: assetType },
});
expect(getNestedReplyText(event, mockPermalinkGenerator)).toMatchSnapshot();
});
});
it("should create the expected fallback text for poll end events", () => {
const event = makeTestEvent(M_POLL_END.name, {
body: "body",
});
expect(getNestedReplyText(event, mockPermalinkGenerator)).toMatchSnapshot();
});
it("should create the expected fallback text for poll start events", () => {
const event = makePollStartEvent("Will this test pass?", "@user:server.org");
expect(getNestedReplyText(event, mockPermalinkGenerator)).toMatchSnapshot();
});
});
describe("shouldDisplayReply", () => {
it("Returns false for redacted events", () => {
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.makeRedacted(event, new Room(event.getRoomId()!, stubClient(), event.getSender()!));
expect(shouldDisplayReply(event)).toBe(false);
});
it("Returns false for non-reply events", () => {
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
expect(shouldDisplayReply(event)).toBe(false);
});
});
});

View file

@ -0,0 +1,332 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import {
PushRuleActionName,
TweakName,
NotificationCountType,
Room,
EventStatus,
EventType,
MatrixEvent,
PendingEventOrdering,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mkEvent, mkRoom, mkRoomMember, muteRoom, stubClient, upsertRoomStateEvents } from "./test-utils";
import {
getRoomNotifsState,
RoomNotifState,
getUnreadNotificationCount,
determineUnreadState,
} from "../src/RoomNotifs";
import { NotificationLevel } from "../src/stores/notifications/NotificationLevel";
import SettingsStore from "../src/settings/SettingsStore";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
describe("RoomNotifs test", () => {
let client: jest.Mocked<MatrixClient>;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
});
it("getRoomNotifsState handles rules with no conditions", () => {
mocked(client).pushRules = {
global: {
override: [
{
rule_id: "!roomId:server",
enabled: true,
default: false,
actions: [],
},
],
},
};
expect(getRoomNotifsState(client, "!roomId:server")).toBe(null);
});
it("getRoomNotifsState handles guest users", () => {
mocked(client).isGuest.mockReturnValue(true);
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessages);
});
it("getRoomNotifsState handles mute state", () => {
const room = mkRoom(client, "!roomId:server");
muteRoom(room);
expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
});
it("getRoomNotifsState handles mute state for legacy DontNotify action", () => {
const room = mkRoom(client, "!roomId:server");
muteRoom(room);
client.pushRules!.global.override![0]!.actions = [PushRuleActionName.DontNotify];
expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
});
it("getRoomNotifsState handles mentions only", () => {
(client as any).getRoomPushRule = () => ({
rule_id: "!roomId:server",
enabled: true,
default: false,
actions: [PushRuleActionName.DontNotify],
});
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.MentionsOnly);
});
it("getRoomNotifsState handles noisy", () => {
(client as any).getRoomPushRule = () => ({
rule_id: "!roomId:server",
enabled: true,
default: false,
actions: [{ set_tweak: TweakName.Sound, value: "default" }],
});
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
});
describe("getUnreadNotificationCount", () => {
const ROOM_ID = "!roomId:example.org";
const THREAD_ID = "$threadId";
let room: Room;
beforeEach(() => {
room = new Room(ROOM_ID, client, client.getUserId()!);
});
it("counts room notification type", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(0);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(0);
});
it("counts notifications type", () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
});
describe("when there is a room predecessor", () => {
const OLD_ROOM_ID = "!oldRoomId:example.org";
const mkCreateEvent = (predecessorId?: string): MatrixEvent => {
return mkEvent({
event: true,
type: "m.room.create",
room: ROOM_ID,
user: "@zoe:localhost",
content: {
...(predecessorId ? { predecessor: { room_id: predecessorId, event_id: "$someevent" } } : {}),
creator: "@zoe:localhost",
room_version: "5",
},
ts: Date.now(),
});
};
const mkPredecessorEvent = (predecessorId: string): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomPredecessor,
room: ROOM_ID,
user: "@zoe:localhost",
skey: "",
content: {
predecessor_room_id: predecessorId,
},
ts: Date.now(),
});
};
const itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent = (): void => {
it("and there is a predecessor in the create event, it should count predecessor highlight", () => {
room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
});
};
const itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent = (): void => {
it("and there is a predecessor event, it should count predecessor highlight", () => {
client.getVisibleRooms();
room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]);
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
});
};
beforeEach(() => {
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
const oldRoom = new Room(OLD_ROOM_ID, client, client.getUserId()!);
oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10);
oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
client.getRoom.mockImplementation((roomId: string | undefined): Room | null => {
if (roomId === room.roomId) return room;
if (roomId === OLD_ROOM_ID) return oldRoom;
return null;
});
});
describe("and dynamic room predecessors are disabled", () => {
itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent();
itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent();
it("and there is only a predecessor event, it should not count predecessor highlight", () => {
room.addLiveEvents([mkCreateEvent()]);
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
});
});
describe("and dynamic room predecessors are enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent();
itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent();
it("and there is only a predecessor event, it should count predecessor highlight", () => {
room.addLiveEvents([mkCreateEvent()]);
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
});
it("and there is an unknown room in the predecessor event, it should not count predecessor highlight", () => {
room.addLiveEvents([mkCreateEvent()]);
upsertRoomStateEvents(room, [mkPredecessorEvent("!unknon:example.com")]);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
});
});
});
it("counts thread notification type", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false, THREAD_ID)).toBe(0);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false, THREAD_ID)).toBe(0);
});
it("counts thread notifications type", () => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false, THREAD_ID)).toBe(2);
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false, THREAD_ID)).toBe(1);
});
});
describe("determineUnreadState", () => {
let room: Room;
beforeEach(() => {
room = new Room("!room-id:example.com", client, "@user:example.com", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
it("shows nothing by default", async () => {
const { level, symbol, count } = determineUnreadState(room);
expect(symbol).toBe(null);
expect(level).toBe(NotificationLevel.None);
expect(count).toBe(0);
});
it("indicates if there are unsent messages", async () => {
const event = mkEvent({
event: true,
type: "m.message",
user: "@user:example.org",
content: {},
});
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, "txn");
const { level, symbol, count } = determineUnreadState(room);
expect(symbol).toBe("!");
expect(level).toBe(NotificationLevel.Unsent);
expect(count).toBeGreaterThan(0);
});
it("indicates the user has been invited to a channel", async () => {
room.updateMyMembership(KnownMembership.Invite);
const { level, symbol, count } = determineUnreadState(room);
expect(symbol).toBe("!");
expect(level).toBe(NotificationLevel.Highlight);
expect(count).toBeGreaterThan(0);
});
it("indicates the user knock has been denied", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
const roomMember = mkRoomMember(
room.roomId,
MatrixClientPeg.get()!.getSafeUserId(),
KnownMembership.Leave,
true,
{
membership: KnownMembership.Knock,
},
);
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
const { level, symbol, count } = determineUnreadState(room);
expect(symbol).toBe("!");
expect(level).toBe(NotificationLevel.Highlight);
expect(count).toBeGreaterThan(0);
});
it("shows nothing for muted channels", async () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 99);
room.setUnreadNotificationCount(NotificationCountType.Total, 99);
muteRoom(room);
const { level, count } = determineUnreadState(room);
expect(level).toBe(NotificationLevel.None);
expect(count).toBe(0);
});
it("uses the correct number of unreads", async () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 999);
const { level, count } = determineUnreadState(room);
expect(level).toBe(NotificationLevel.Notification);
expect(count).toBe(999);
});
it("uses the correct number of highlights", async () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 888);
const { level, count } = determineUnreadState(room);
expect(level).toBe(NotificationLevel.Highlight);
expect(count).toBe(888);
});
});
});

View file

@ -0,0 +1,143 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { setDMRoom } from "../src/Rooms";
import { mkEvent, stubClient } from "./test-utils";
describe("setDMRoom", () => {
const userId1 = "@user1:example.com";
const userId2 = "@user2:example.com";
const userId3 = "@user3:example.com";
const roomId1 = "!room1:example.com";
const roomId2 = "!room2:example.com";
const roomId3 = "!room3:example.com";
const roomId4 = "!room4:example.com";
let client: MatrixClient;
beforeEach(() => {
client = mocked(stubClient());
client.getAccountData = jest.fn().mockImplementation((eventType: string): MatrixEvent | undefined => {
if (eventType === EventType.Direct) {
return mkEvent({
event: true,
content: {
[userId1]: [roomId1, roomId2],
[userId2]: [roomId3],
},
type: EventType.Direct,
user: client.getSafeUserId(),
});
}
return undefined;
});
});
describe("when logged in as a guest and marking a room as DM", () => {
beforeEach(() => {
mocked(client.isGuest).mockReturnValue(true);
setDMRoom(client, roomId1, userId1);
});
it("should not update the account data", () => {
expect(client.setAccountData).not.toHaveBeenCalled();
});
});
describe("when adding a new room to an existing DM relation", () => {
beforeEach(() => {
setDMRoom(client, roomId4, userId1);
});
it("should update the account data accordingly", () => {
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
[userId1]: [roomId1, roomId2, roomId4],
[userId2]: [roomId3],
});
});
});
describe("when adding a new DM room", () => {
beforeEach(() => {
setDMRoom(client, roomId4, userId3);
});
it("should update the account data accordingly", () => {
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
[userId1]: [roomId1, roomId2],
[userId2]: [roomId3],
[userId3]: [roomId4],
});
});
});
describe("when trying to add a DM, that already exists", () => {
beforeEach(() => {
setDMRoom(client, roomId1, userId1);
});
it("should not update the account data", () => {
expect(client.setAccountData).not.toHaveBeenCalled();
});
});
describe("when removing an existing DM", () => {
beforeEach(() => {
setDMRoom(client, roomId1, null);
});
it("should update the account data accordingly", () => {
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
[userId1]: [roomId2],
[userId2]: [roomId3],
});
});
});
describe("when removing an unknown room", () => {
beforeEach(() => {
setDMRoom(client, roomId4, null);
});
it("should not update the account data", () => {
expect(client.setAccountData).not.toHaveBeenCalled();
});
});
describe("when the direct event is undefined", () => {
beforeEach(() => {
mocked(client.getAccountData).mockReturnValue(undefined);
setDMRoom(client, roomId1, userId1);
});
it("should update the account data accordingly", () => {
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
[userId1]: [roomId1],
});
});
});
describe("when the current content is undefined", () => {
beforeEach(() => {
// @ts-ignore
mocked(client.getAccountData).mockReturnValue({
getContent: jest.fn(),
});
setDMRoom(client, roomId1, userId1);
});
it("should update the account data accordingly", () => {
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
[userId1]: [roomId1],
});
});
});
});

View file

@ -0,0 +1,212 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import ScalarAuthClient from "../src/ScalarAuthClient";
import { stubClient } from "./test-utils";
import SdkConfig from "../src/SdkConfig";
import { WidgetType } from "../src/widgets/WidgetType";
describe("ScalarAuthClient", function () {
const apiUrl = "https://test.com/api";
const uiUrl = "https:/test.com/app";
const tokenObject = {
access_token: "token",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 999,
};
let client: MatrixClient;
beforeEach(function () {
jest.clearAllMocks();
client = stubClient();
});
it("should request a new token if the old one fails", async function () {
const sac = new ScalarAuthClient(apiUrl + 0, uiUrl);
fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", {
body: { message: "Invalid token" },
});
fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", {
body: { user_id: client.getUserId() },
});
client.getOpenIdToken = jest.fn().mockResolvedValue(tokenObject);
sac.exchangeForScalarToken = jest.fn((arg) => {
return Promise.resolve(arg === tokenObject ? "wokentoken" : "othertoken");
});
await sac.connect();
expect(sac.exchangeForScalarToken).toHaveBeenCalledWith(tokenObject);
expect(sac.hasCredentials).toBeTruthy();
// @ts-ignore private property
expect(sac.scalarToken).toEqual("wokentoken");
});
describe("exchangeForScalarToken", () => {
it("should return `scalar_token` from API /register", async () => {
const sac = new ScalarAuthClient(apiUrl + 1, uiUrl);
fetchMock.postOnce("https://test.com/api1/register?v=1.1", {
body: { scalar_token: "stoken" },
});
await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken");
});
it("should throw upon non-20x code", async () => {
const sac = new ScalarAuthClient(apiUrl + 2, uiUrl);
fetchMock.postOnce("https://test.com/api2/register?v=1.1", {
status: 500,
});
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Scalar request failed: 500");
});
it("should throw if scalar_token is missing in response", async () => {
const sac = new ScalarAuthClient(apiUrl + 3, uiUrl);
fetchMock.postOnce("https://test.com/api3/register?v=1.1", {
body: {},
});
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Missing scalar_token in response");
});
});
describe("registerForToken", () => {
it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => {
const sac = new ScalarAuthClient(apiUrl + 4, uiUrl);
const termsInteractionCallback = jest.fn();
sac.setTermsInteractionCallback(termsInteractionCallback);
fetchMock.get("https://test.com/api4/account?scalar_token=testtoken1&v=1.1", {
body: { errcode: "M_TERMS_NOT_SIGNED" },
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1"));
mocked(client.getTerms).mockResolvedValue({ policies: [] });
await expect(sac.registerForToken()).resolves.toBe("testtoken1");
});
it("should throw upon non-20x code", async () => {
const sac = new ScalarAuthClient(apiUrl + 5, uiUrl);
fetchMock.get("https://test.com/api5/account?scalar_token=testtoken2&v=1.1", {
body: { errcode: "SERVER_IS_SAD" },
status: 500,
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken2"));
await expect(sac.registerForToken()).rejects.toBeTruthy();
});
it("should throw if user_id is missing from response", async () => {
const sac = new ScalarAuthClient(apiUrl + 6, uiUrl);
fetchMock.get("https://test.com/api6/account?scalar_token=testtoken3&v=1.1", {
body: {},
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken3"));
await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response");
});
});
describe("getScalarPageTitle", () => {
let sac: ScalarAuthClient;
beforeEach(async () => {
SdkConfig.put({
integrations_rest_url: apiUrl + 7,
integrations_ui_url: uiUrl,
});
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api7", "wokentoken1");
fetchMock.get("https://test.com/api7/account?scalar_token=wokentoken1&v=1.1", {
body: { user_id: client.getUserId() },
});
sac = new ScalarAuthClient(apiUrl + 7, uiUrl);
await sac.connect();
});
it("should return `cached_title` from API /widgets/title_lookup", async () => {
const url = "google.com";
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
body: {
page_title_cache_item: {
cached_title: "Google",
},
},
});
await expect(sac.getScalarPageTitle(url)).resolves.toBe("Google");
});
it("should throw upon non-20x code", async () => {
const url = "yahoo.com";
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
status: 500,
});
await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500");
});
});
describe("disableWidgetAssets", () => {
let sac: ScalarAuthClient;
beforeEach(async () => {
SdkConfig.put({
integrations_rest_url: apiUrl + 8,
integrations_ui_url: uiUrl,
});
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api8", "wokentoken1");
fetchMock.get("https://test.com/api8/account?scalar_token=wokentoken1&v=1.1", {
body: { user_id: client.getUserId() },
});
sac = new ScalarAuthClient(apiUrl + 8, uiUrl);
await sac.connect();
});
it("should send state=disable to API /widgets/set_assets_state", async () => {
fetchMock.get(
"https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
"&widget_type=m.custom&widget_id=id1&state=disable",
{
body: "OK",
},
);
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id1")).resolves.toBeUndefined();
});
it("should throw upon non-20x code", async () => {
fetchMock.get(
"https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
"&widget_type=m.custom&widget_id=id2&state=disable",
{
status: 500,
},
);
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id2")).rejects.toThrow(
"Scalar request failed: 500",
);
});
});
});

View file

@ -0,0 +1,47 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig, { DEFAULTS } from "../src/SdkConfig";
describe("SdkConfig", () => {
describe("with default values", () => {
it("should return the default config", () => {
expect(SdkConfig.get()).toEqual(DEFAULTS);
});
});
describe("with custom values", () => {
beforeEach(() => {
SdkConfig.put({
voice_broadcast: {
chunk_length: 42,
max_length: 1337,
},
feedback: {
existing_issues_url: "https://existing",
} as any,
});
});
it("should return the custom config", () => {
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
customConfig.voice_broadcast.chunk_length = 42;
customConfig.voice_broadcast.max_length = 1337;
customConfig.feedback.existing_issues_url = "https://existing";
expect(SdkConfig.get()).toEqual(customConfig);
});
it("should allow overriding individual fields of sub-objects", () => {
const feedback = SdkConfig.getObject("feedback");
expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`);
expect(feedback.get("new_issue_url")).toMatchInlineSnapshot(
`"https://github.com/vector-im/element-web/issues/new/choose"`,
);
});
});
});

View file

@ -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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { accessSecretStorage } from "../src/SecurityManager";
import { filterConsole, stubClient } from "./test-utils";
describe("SecurityManager", () => {
describe("accessSecretStorage", () => {
filterConsole("Not setting dehydration key: no SSSS key found");
it("runs the function passed in", async () => {
// Given a client
const crypto = {
bootstrapCrossSigning: () => {},
bootstrapSecretStorage: () => {},
} as unknown as CryptoApi;
const client = stubClient();
client.secretStorage.hasKey = jest.fn().mockResolvedValue(true);
mocked(client.getCrypto).mockReturnValue(crypto);
// When I run accessSecretStorage
const func = jest.fn();
await accessSecretStorage(func);
// Then we call the passed-in function
expect(func).toHaveBeenCalledTimes(1);
});
describe("expecting errors", () => {
filterConsole("End-to-end encryption is disabled - unable to access secret storage");
it("throws if crypto is unavailable", async () => {
// Given a client with no crypto
const client = stubClient();
client.secretStorage.hasKey = jest.fn().mockResolvedValue(true);
mocked(client.getCrypto).mockReturnValue(undefined);
// When I run accessSecretStorage
// Then we throw an error
await expect(async () => {
await accessSecretStorage(jest.fn());
}).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage");
});
});
});
});

View file

@ -0,0 +1,412 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { Command, Commands, getCommand } from "../src/SlashCommands";
import { createTestClient } from "./test-utils";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../src/models/LocalRoom";
import SettingsStore from "../src/settings/SettingsStore";
import LegacyCallHandler from "../src/LegacyCallHandler";
import { SdkContextClass } from "../src/contexts/SDKContext";
import Modal from "../src/Modal";
import WidgetUtils from "../src/utils/WidgetUtils";
import { WidgetType } from "../src/widgets/WidgetType";
import { warnSelfDemote } from "../src/components/views/right_panel/UserInfo";
import dispatcher from "../src/dispatcher/dispatcher";
import { SettingLevel } from "../src/settings/SettingLevel";
jest.mock("../src/components/views/right_panel/UserInfo");
describe("SlashCommands", () => {
let client: MatrixClient;
const roomId = "!room:example.com";
let room: Room;
const localRoomId = LOCAL_ROOM_ID_PREFIX + "test";
let localRoom: LocalRoom;
let command: Command;
const findCommand = (cmd: string): Command | undefined => {
return Commands.find((command: Command) => command.command === cmd);
};
const setCurrentRoom = (): void => {
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === roomId) return room;
return null;
});
};
const setCurrentLocalRoom = (): void => {
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId);
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
if (rId === localRoomId) return localRoom;
return null;
});
};
beforeEach(() => {
jest.clearAllMocks();
client = createTestClient();
room = new Room(roomId, client, client.getSafeUserId());
localRoom = new LocalRoom(localRoomId, client, client.getSafeUserId());
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
});
describe("/topic", () => {
it("sets topic", async () => {
const command = getCommand("/topic pizza");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, "room-id", null, command.args);
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
});
it("should show topic modal if no args passed", async () => {
const spy = jest.spyOn(Modal, "createDialog");
const command = getCommand("/topic")!;
await command.cmd!.run(client, roomId, null);
expect(spy).toHaveBeenCalled();
});
});
describe.each([
["myroomnick"],
["roomavatar"],
["myroomavatar"],
["topic"],
["roomname"],
["invite"],
["part"],
["remove"],
["ban"],
["unban"],
["op"],
["deop"],
["addwidget"],
["discardsession"],
["whois"],
["holdcall"],
["unholdcall"],
["converttodm"],
["converttoroom"],
])("/%s", (commandName: string) => {
beforeEach(() => {
command = findCommand(commandName)!;
});
describe("isEnabled", () => {
it("should return true for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client)).toBe(true);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client)).toBe(false);
});
});
});
describe("/upgraderoom", () => {
beforeEach(() => {
command = findCommand("upgraderoom")!;
setCurrentRoom();
});
it("should be disabled by default", () => {
expect(command.isEnabled(client)).toBe(false);
});
it("should be enabled for developerMode", () => {
SettingsStore.setValue("developerMode", null, SettingLevel.DEVICE, true);
expect(command.isEnabled(client)).toBe(true);
});
});
describe("/op", () => {
beforeEach(() => {
command = findCommand("op")!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should reject with usage if given an invalid power level value", () => {
expect(command.run(client, roomId, null, "@bob:server Admin").error).toBe(command.getUsage());
});
it("should reject with usage for invalid input", () => {
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
command.run(client, roomId, null, `${client.getUserId()} 0`);
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should default to 50 if no powerlevel specified", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, "@user:server");
member.membership = KnownMembership.Join;
room.getMember = () => member;
command.run(client, roomId, null, member.userId);
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50);
});
});
describe("/deop", () => {
beforeEach(() => {
command = findCommand("deop")!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should warn about self demotion", async () => {
setCurrentRoom();
const member = new RoomMember(roomId, client.getSafeUserId());
member.membership = KnownMembership.Join;
member.powerLevel = 100;
room.getMember = () => member;
command.run(client, roomId, null, client.getSafeUserId());
expect(warnSelfDemote).toHaveBeenCalled();
});
it("should reject with usage for invalid input", () => {
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
});
});
describe("/tovirtual", () => {
beforeEach(() => {
command = findCommand("tovirtual")!;
});
describe("isEnabled", () => {
describe("when virtual rooms are supported", () => {
beforeEach(() => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(true);
});
it("should return true for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client)).toBe(true);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client)).toBe(false);
});
});
describe("when virtual rooms are not supported", () => {
beforeEach(() => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(false);
});
it("should return false for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client)).toBe(false);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client)).toBe(false);
});
});
});
});
describe("/remakeolm", () => {
beforeEach(() => {
command = findCommand("remakeolm")!;
});
describe("isEnabled", () => {
describe("when developer mode is enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "developerMode") return true;
});
});
it("should return true for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client)).toBe(true);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client)).toBe(false);
});
});
describe("when developer mode is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "developerMode") return false;
});
});
it("should return false for Room", () => {
setCurrentRoom();
expect(command.isEnabled(client)).toBe(false);
});
it("should return false for LocalRoom", () => {
setCurrentLocalRoom();
expect(command.isEnabled(client)).toBe(false);
});
});
});
});
describe("/part", () => {
it("should part room matching alias if found", async () => {
const room1 = new Room("room-id", client, client.getSafeUserId());
room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar");
const room2 = new Room("other-room", client, client.getSafeUserId());
room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar");
mocked(client.getRooms).mockReturnValue([room1, room2]);
const command = getCommand("/part #foo:bar");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, "room-id", null, command.args);
expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything());
});
it("should part room matching alt alias if found", async () => {
const room1 = new Room("room-id", client, client.getSafeUserId());
room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]);
const room2 = new Room("other-room", client, client.getSafeUserId());
room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]);
mocked(client.getRooms).mockReturnValue([room1, room2]);
const command = getCommand("/part #foo:bar");
expect(command.cmd).toBeDefined();
expect(command.args).toBeDefined();
await command.cmd!.run(client, "room-id", null, command.args!);
expect(client.leaveRoomChain).toHaveBeenCalledWith("room-id", expect.anything());
});
});
describe.each(["rainbow", "rainbowme"])("/%s", (commandName: string) => {
const command = findCommand(commandName)!;
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should make things rainbowy", () => {
return expect(
command.run(client, roomId, null, "this is a test message").promise,
).resolves.toMatchSnapshot();
});
});
describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => {
const command = findCommand(commandName)!;
it("should match snapshot with no args", () => {
return expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot();
});
it("should match snapshot with args", () => {
return expect(
command.run(client, roomId, null, "this is a test message").promise,
).resolves.toMatchSnapshot();
});
});
describe("/addwidget", () => {
it("should parse html iframe snippets", async () => {
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
const spy = jest.spyOn(WidgetUtils, "setRoomWidget");
const command = findCommand("addwidget")!;
await command.run(client, roomId, null, '<iframe src="https://element.io"></iframe>');
expect(spy).toHaveBeenCalledWith(
client,
roomId,
expect.any(String),
WidgetType.CUSTOM,
"https://element.io",
"Custom",
{},
);
});
});
describe("/join", () => {
beforeEach(() => {
jest.spyOn(dispatcher, "dispatch");
command = findCommand(KnownMembership.Join)!;
});
it("should return usage if no args", () => {
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
});
it("should handle matrix.org permalinks", () => {
command.run(client, roomId, null, "https://matrix.to/#/!roomId:server/$eventId");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!roomId:server",
event_id: "$eventId",
highlighted: true,
}),
);
});
it("should handle room aliases", () => {
command.run(client, roomId, null, "#test:server");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: "#test:server",
}),
);
});
it("should handle room aliases with no server component", () => {
command.run(client, roomId, null, "#test");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_alias: `#test:${client.getDomain()}`,
}),
);
});
it("should handle room IDs and via servers", () => {
command.run(client, roomId, null, "!foo:bar serv1.com serv2.com");
expect(dispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: "view_room",
room_id: "!foo:bar",
via_servers: ["serv1.com", "serv2.com"],
}),
);
});
});
});

View file

@ -0,0 +1,321 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMockJest from "fetch-mock-jest";
import { SlidingSyncManager } from "../src/SlidingSyncManager";
import { stubClient } from "./test-utils";
import SlidingSyncController from "../src/settings/controllers/SlidingSyncController";
import SettingsStore from "../src/settings/SettingsStore";
jest.mock("matrix-js-sdk/src/sliding-sync");
const MockSlidingSync = <jest.Mock<SlidingSync>>(<unknown>SlidingSync);
describe("SlidingSyncManager", () => {
let manager: SlidingSyncManager;
let slidingSync: SlidingSync;
let client: MatrixClient;
beforeEach(() => {
slidingSync = new MockSlidingSync();
manager = new SlidingSyncManager();
client = stubClient();
// by default the client has no rooms: stubClient magically makes rooms annoyingly.
mocked(client.getRoom).mockReturnValue(null);
manager.configure(client, "invalid");
manager.slidingSync = slidingSync;
fetchMockJest.reset();
fetchMockJest.get("https://proxy/client/server.json", {});
});
describe("setRoomVisible", () => {
it("adds a subscription for the room", async () => {
const roomId = "!room:id";
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
});
it("adds a custom subscription for a lazy-loadable room", async () => {
const roomId = "!lazy:id";
const room = new Room(roomId, client, client.getUserId()!);
room.getLiveTimeline().initialiseState([
new MatrixEvent({
type: "m.room.create",
state_key: "",
event_id: "$abc123",
sender: client.getUserId()!,
content: {
creator: client.getUserId()!,
},
}),
]);
mocked(client.getRoom).mockImplementation((r: string): Room | null => {
if (roomId === r) {
return room;
}
return null;
});
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
// we aren't prescriptive about what the sub name is.
expect(slidingSync.useCustomSubscription).toHaveBeenCalledWith(roomId, expect.anything());
});
});
describe("ensureListRegistered", () => {
it("creates a new list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue(null);
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
expect(slidingSync.setList).toHaveBeenCalledWith(
listKey,
expect.objectContaining({
sort: ["by_recency"],
}),
);
});
it("updates an existing list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
expect(slidingSync.setList).toHaveBeenCalledWith(
listKey,
expect.objectContaining({
sort: ["by_recency"],
ranges: [[0, 42]],
}),
);
});
it("updates ranges on an existing list based on the key if there's no other changes", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 52]],
});
expect(slidingSync.setList).not.toHaveBeenCalled();
expect(slidingSync.setListRanges).toHaveBeenCalledWith(listKey, [[0, 52]]);
});
it("no-ops for idential changes", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
sort: ["by_recency"],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 42]],
sort: ["by_recency"],
});
expect(slidingSync.setList).not.toHaveBeenCalled();
expect(slidingSync.setListRanges).not.toHaveBeenCalled();
});
});
describe("startSpidering", () => {
it("requests in batchSizes", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.setListRanges).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 64,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
// we expect calls for 10,19 -> 20,29 -> 30,39 -> 40,49 -> 50,59 -> 60,69
const wantWindows = [
[10, 19],
[20, 29],
[30, 39],
[40, 49],
[50, 59],
[60, 69],
];
expect(slidingSync.getListData).toHaveBeenCalledTimes(wantWindows.length);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setListRanges).toHaveBeenCalledTimes(wantWindows.length - 1);
wantWindows.forEach((range, i) => {
if (i === 0) {
// eslint-disable-next-line jest/no-conditional-expect
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
// eslint-disable-next-line jest/no-conditional-expect
expect.objectContaining({
ranges: [[0, batchSize - 1], range],
}),
);
return;
}
expect(slidingSync.setListRanges).toHaveBeenCalledWith(SlidingSyncManager.ListSearch, [
[0, batchSize - 1],
range,
]);
});
});
it("handles accounts with zero rooms", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
});
it("continues even when setList rejects", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockRejectedValue("narp");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
});
});
describe("checkSupport", () => {
beforeEach(() => {
SlidingSyncController.serverSupportsSlidingSync = false;
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
});
it("shorts out if the server has 'native' sliding sync support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(true);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(manager.getProxyFromWellKnown).not.toHaveBeenCalled(); // We return earlier
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("tries to find a sliding sync proxy url from the client well-known if there's no 'native' support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(manager.getProxyFromWellKnown).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("should query well-known on server_name not baseUrl", async () => {
fetchMockJest.get("https://matrix.org/.well-known/matrix/client", {
"m.homeserver": {
base_url: "https://matrix-client.matrix.org",
server: "matrix.org",
},
"org.matrix.msc3575.proxy": {
url: "https://proxy/",
},
});
fetchMockJest.get("https://matrix-client.matrix.org/_matrix/client/versions", { versions: ["v1.4"] });
mocked(manager.getProxyFromWellKnown).mockRestore();
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
expect(fetchMockJest).not.toHaveFetched("https://matrix-client.matrix.org/.well-known/matrix/client");
});
});
describe("nativeSlidingSyncSupport", () => {
beforeEach(() => {
SlidingSyncController.serverSupportsSlidingSync = false;
});
it("should make an OPTIONS request to avoid unintended side effects", async () => {
// See https://github.com/element-hq/element-web/issues/27426
const unstableSpy = jest
.spyOn(client, "doesServerSupportUnstableFeature")
.mockImplementation(async (feature: string) => {
expect(feature).toBe("org.matrix.msc3575");
return true;
});
const proxySpy = jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client); // first thing it does is call nativeSlidingSyncSupport
expect(proxySpy).not.toHaveBeenCalled();
expect(unstableSpy).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
});
describe("setup", () => {
beforeEach(() => {
jest.spyOn(manager, "configure");
jest.spyOn(manager, "startSpidering");
});
it("uses the baseUrl as a proxy if no proxy is set in the client well-known and the server has no native support", async () => {
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, client.baseUrl);
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the proxy declared in the client well-known", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "https://proxy/");
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the legacy `feature_sliding_sync_proxy_url` if it was set", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "feature_sliding_sync_proxy_url") return "legacy-proxy";
});
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "legacy-proxy");
expect(manager.startSpidering).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,115 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { checkBrowserSupport, LOCAL_STORAGE_KEY } from "../src/SupportedBrowser";
import ToastStore from "../src/stores/ToastStore";
import GenericToast from "../src/components/views/toasts/GenericToast";
jest.mock("matrix-js-sdk/src/logger");
describe("SupportedBrowser", () => {
beforeEach(() => {
jest.resetAllMocks();
localStorage.clear();
});
const testUserAgentFactory =
(expectedWarning?: string) =>
async (userAgent: string): Promise<void> => {
const toastSpy = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
const warnLogSpy = jest.spyOn(logger, "warn");
Object.defineProperty(window, "navigator", { value: { userAgent: userAgent }, writable: true });
checkBrowserSupport();
if (expectedWarning) {
expect(warnLogSpy).toHaveBeenCalledWith(expectedWarning, expect.any(String));
expect(toastSpy).toHaveBeenCalled();
} else {
expect(warnLogSpy).not.toHaveBeenCalled();
expect(toastSpy).not.toHaveBeenCalled();
}
};
it.each([
// Safari on iOS
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
// Firefox on iOS
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/128.0 Mobile/15E148 Safari/605.1.15",
// Opera on Samsung
"Mozilla/5.0 (Linux; Android 10; SM-G970F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.64 Mobile Safari/537.36 OPR/76.2.4027.73374",
])("should warn for mobile browsers", testUserAgentFactory("Browser unsupported, unsupported device type"));
it.each([
// Chrome on Chrome OS
"Mozilla/5.0 (X11; CrOS x86_64 15633.69.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.212 Safari/537.36",
// Opera on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0",
// Vivaldi on Linux
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Vivaldi/6.8.3381.48",
// IE11 on Windows 10
"Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko",
// Firefox 115 on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_4_5; rv:115.0) Gecko/20000101 Firefox/115.0",
])(
"should warn for unsupported desktop browsers",
testUserAgentFactory("Browser unsupported, unsupported user agent"),
);
it.each([
// Safari 17.5 on macOS Sonoma
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
// Firefox 128 on macOS Sonoma
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0",
// Edge 126 on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/126.0.2592.113",
// Edge 126 on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/126.0.2592.113",
// Firefox 128 on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
// Firefox 128 on Linux
"Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
// Chrome 127 on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
])("should not warn for supported browsers", testUserAgentFactory());
it.each([
// Element Nightly on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2024072501 Chrome/126.0.6478.127 Electron/31.2.1 Safari/537.36",
])("should not warn for Element Desktop", testUserAgentFactory());
it.each(["AppleTV11,1/11.1"])(
"should handle unknown user agent sanely",
testUserAgentFactory("Browser unsupported, unknown client"),
);
it("should not warn for unsupported browser if user accepted already", async () => {
const toastSpy = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
const warnLogSpy = jest.spyOn(logger, "warn");
const userAgent =
"Mozilla/5.0 (X11; CrOS x86_64 15633.69.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.212 Safari/537.36";
Object.defineProperty(window, "navigator", { value: { userAgent: userAgent }, writable: true });
checkBrowserSupport();
expect(warnLogSpy).toHaveBeenCalledWith("Browser unsupported, unsupported user agent", expect.any(String));
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
component: GenericToast,
title: "Element does not support this browser",
}),
);
localStorage.setItem(LOCAL_STORAGE_KEY, String(true));
toastSpy.mockClear();
warnLogSpy.mockClear();
checkBrowserSupport();
expect(warnLogSpy).toHaveBeenCalledWith("Browser unsupported, but user has previously accepted");
expect(toastSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,183 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, EventType, SERVICE_TYPES } from "matrix-js-sdk/src/matrix";
import { startTermsFlow, Service } from "../src/Terms";
import { getMockClientWithEventEmitter } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
const POLICY_ONE = {
version: "six",
en: {
name: "The first policy",
url: "http://example.com/one",
},
};
const POLICY_TWO = {
version: "IX",
en: {
name: "The second policy",
url: "http://example.com/two",
},
};
const IM_SERVICE_ONE = new Service(SERVICE_TYPES.IM, "https://imone.test", "a token token");
const IM_SERVICE_TWO = new Service(SERVICE_TYPES.IM, "https://imtwo.test", "a token token");
describe("Terms", function () {
const mockClient = getMockClientWithEventEmitter({
getAccountData: jest.fn(),
getTerms: jest.fn(),
agreeToTerms: jest.fn(),
setAccountData: jest.fn(),
});
beforeEach(function () {
jest.clearAllMocks();
mockClient.getAccountData.mockReturnValue(undefined);
mockClient.getTerms.mockResolvedValue(null);
mockClient.setAccountData.mockResolvedValue({});
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
it("should prompt for all terms & services if no account data", async function () {
mockClient.getAccountData.mockReturnValue(undefined);
mockClient.getTerms.mockResolvedValue({
policies: {
policy_the_first: POLICY_ONE,
},
});
const interactionCallback = jest.fn().mockResolvedValue([]);
await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback);
expect(interactionCallback).toHaveBeenCalledWith(
[
{
service: IM_SERVICE_ONE,
policies: {
policy_the_first: POLICY_ONE,
},
},
],
[],
);
});
it("should not prompt if all policies are signed in account data", async function () {
const directEvent = new MatrixEvent({
type: EventType.Direct,
content: {
accepted: ["http://example.com/one"],
},
});
mockClient.getAccountData.mockReturnValue(directEvent);
mockClient.getTerms.mockResolvedValue({
policies: {
policy_the_first: POLICY_ONE,
},
});
mockClient.agreeToTerms;
const interactionCallback = jest.fn();
await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback);
expect(interactionCallback).not.toHaveBeenCalled();
expect(mockClient.agreeToTerms).toHaveBeenCalledWith(SERVICE_TYPES.IM, "https://imone.test", "a token token", [
"http://example.com/one",
]);
});
it("should prompt for only terms that aren't already signed", async function () {
const directEvent = new MatrixEvent({
type: EventType.Direct,
content: {
accepted: ["http://example.com/one"],
},
});
mockClient.getAccountData.mockReturnValue(directEvent);
mockClient.getTerms.mockResolvedValue({
policies: {
policy_the_first: POLICY_ONE,
policy_the_second: POLICY_TWO,
},
});
const interactionCallback = jest.fn().mockResolvedValue(["http://example.com/one", "http://example.com/two"]);
await startTermsFlow(mockClient, [IM_SERVICE_ONE], interactionCallback);
expect(interactionCallback).toHaveBeenCalledWith(
[
{
service: IM_SERVICE_ONE,
policies: {
policy_the_second: POLICY_TWO,
},
},
],
["http://example.com/one"],
);
expect(mockClient.agreeToTerms).toHaveBeenCalledWith(SERVICE_TYPES.IM, "https://imone.test", "a token token", [
"http://example.com/one",
"http://example.com/two",
]);
});
it("should prompt for only services with un-agreed policies", async function () {
const directEvent = new MatrixEvent({
type: EventType.Direct,
content: {
accepted: ["http://example.com/one"],
},
});
mockClient.getAccountData.mockReturnValue(directEvent);
mockClient.getTerms.mockImplementation(async (_serviceTypes: SERVICE_TYPES, baseUrl: string) => {
switch (baseUrl) {
case "https://imone.test":
return {
policies: {
policy_the_first: POLICY_ONE,
},
};
case "https://imtwo.test":
return {
policies: {
policy_the_second: POLICY_TWO,
},
};
}
});
const interactionCallback = jest.fn().mockResolvedValue(["http://example.com/one", "http://example.com/two"]);
await startTermsFlow(mockClient, [IM_SERVICE_ONE, IM_SERVICE_TWO], interactionCallback);
expect(interactionCallback).toHaveBeenCalledWith(
[
{
service: IM_SERVICE_TWO,
policies: {
policy_the_second: POLICY_TWO,
},
},
],
["http://example.com/one"],
);
expect(mockClient.agreeToTerms).toHaveBeenCalledWith(SERVICE_TYPES.IM, "https://imone.test", "a token token", [
"http://example.com/one",
]);
expect(mockClient.agreeToTerms).toHaveBeenCalledWith(SERVICE_TYPES.IM, "https://imtwo.test", "a token token", [
"http://example.com/two",
]);
});
});

View file

@ -0,0 +1,46 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { SdkContextClass } from "../src/contexts/SDKContext";
import { PosthogAnalytics } from "../src/PosthogAnalytics";
import { SlidingSyncManager } from "../src/SlidingSyncManager";
import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore";
import RightPanelStore from "../src/stores/right-panel/RightPanelStore";
import { RoomViewStore } from "../src/stores/RoomViewStore";
import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore";
import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore";
import { WidgetPermissionStore } from "../src/stores/widgets/WidgetPermissionStore";
import WidgetStore from "../src/stores/WidgetStore";
import {
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPreRecordingStore,
VoiceBroadcastRecordingsStore,
} from "../src/voice-broadcast";
/**
* A class which provides the same API as SdkContextClass but adds additional unsafe setters which can
* replace individual stores. This is useful for tests which need to mock out stores.
*/
export class TestSdkContext extends SdkContextClass {
public declare _RightPanelStore?: RightPanelStore;
public declare _RoomNotificationStateStore?: RoomNotificationStateStore;
public declare _RoomViewStore?: RoomViewStore;
public declare _WidgetPermissionStore?: WidgetPermissionStore;
public declare _WidgetLayoutStore?: WidgetLayoutStore;
public declare _WidgetStore?: WidgetStore;
public declare _PosthogAnalytics?: PosthogAnalytics;
public declare _SlidingSyncManager?: SlidingSyncManager;
public declare _SpaceStore?: SpaceStoreClass;
public declare _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore;
public declare _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore;
public declare _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore;
constructor() {
super();
}
}

View file

@ -0,0 +1,616 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
EventType,
HistoryVisibility,
JoinRule,
MatrixClient,
MatrixEvent,
Room,
RoomMember,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { render } from "jest-matrix-react";
import { ReactElement } from "react";
import { Mocked, mocked } from "jest-mock";
import { textForEvent } from "../src/TextForEvent";
import SettingsStore from "../src/settings/SettingsStore";
import { createTestClient, stubClient } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import UserIdentifierCustomisations from "../src/customisations/UserIdentifier";
import { ElementCall } from "../src/models/Call";
import { getSenderName } from "../src/utils/event/getSenderName";
jest.mock("../src/settings/SettingsStore");
jest.mock("../src/customisations/UserIdentifier", () => ({
getDisplayUserIdentifier: jest.fn().mockImplementation((userId) => userId),
}));
function mockPinnedEvent(pinnedMessageIds?: string[], prevPinnedMessageIds?: string[]): MatrixEvent {
return new MatrixEvent({
type: "m.room.pinned_events",
state_key: "",
sender: "@foo:example.com",
content: {
pinned: pinnedMessageIds,
},
unsigned: {
prev_content: {
pinned: prevPinnedMessageIds,
},
},
});
}
describe("TextForEvent", () => {
const mockClient = createTestClient();
describe("getSenderName()", () => {
it("Prefers sender.name", () => {
expect(getSenderName({ sender: { name: "Alice" } } as MatrixEvent)).toBe("Alice");
});
it("Handles missing sender", () => {
expect(getSenderName({ getSender: () => "Alice" } as MatrixEvent)).toBe("Alice");
});
it("Handles missing sender and get sender", () => {
expect(getSenderName({ getSender: () => undefined } as MatrixEvent)).toBe("Someone");
});
});
describe("TextForPinnedEvent", () => {
it("mentions message when a single message was pinned, with no previously pinned messages", () => {
const event = mockPinnedEvent(["message-1"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com pinned a message to this room. See all pinned messages.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("mentions message when a single message was pinned, with multiple previously pinned messages", () => {
const event = mockPinnedEvent(["message-1", "message-2", "message-3"], ["message-1", "message-2"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com pinned a message to this room. See all pinned messages.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("mentions message when a single message was unpinned, with a single message previously pinned", () => {
const event = mockPinnedEvent([], ["message-1"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com unpinned a message from this room. See all pinned messages.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("mentions message when a single message was unpinned, with multiple previously pinned messages", () => {
const event = mockPinnedEvent(["message-2"], ["message-1", "message-2"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com unpinned a message from this room. See all pinned messages.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("shows generic text when multiple messages were pinned", () => {
const event = mockPinnedEvent(["message-1", "message-2", "message-3"], ["message-1"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com changed the pinned messages for the room.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("shows generic text when multiple messages were unpinned", () => {
const event = mockPinnedEvent(["message-3"], ["message-1", "message-2", "message-3"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com changed the pinned messages for the room.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
it("shows generic text when one message was pinned, and another unpinned", () => {
const event = mockPinnedEvent(["message-2"], ["message-1"]);
const plainText = textForEvent(event, mockClient);
const component = render(textForEvent(event, mockClient, true) as ReactElement);
const expectedText = "@foo:example.com changed the pinned messages for the room.";
expect(plainText).toBe(expectedText);
expect(component.container).toHaveTextContent(expectedText);
});
});
describe("textForPowerEvent()", () => {
let mockClient: Mocked<MatrixClient>;
const mockRoom = {
getMember: jest.fn(),
} as unknown as Mocked<Room>;
const userA = {
userId: "@a",
name: "Alice",
rawDisplayName: "Alice",
} as RoomMember;
const userB = {
userId: "@b",
name: "Bob (@b)",
rawDisplayName: "Bob",
} as RoomMember;
const userC = {
userId: "@c",
name: "Bob (@c)",
rawDisplayName: "Bob",
} as RoomMember;
interface PowerEventProps {
usersDefault?: number;
prevDefault?: number;
users: Record<string, number>;
prevUsers: Record<string, number>;
}
const mockPowerEvent = ({ usersDefault, prevDefault, users, prevUsers }: PowerEventProps): MatrixEvent => {
const mxEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
sender: userA.userId,
state_key: "",
content: {
users_default: usersDefault,
users,
},
unsigned: {
prev_content: {
users: prevUsers,
users_default: prevDefault,
},
},
});
mxEvent.sender = { name: userA.name } as RoomMember;
return mxEvent;
};
beforeAll(() => {
mockClient = createTestClient() as Mocked<MatrixClient>;
MatrixClientPeg.get = () => mockClient;
MatrixClientPeg.safeGet = () => mockClient;
mockClient.getRoom.mockClear().mockReturnValue(mockRoom);
mockRoom.getMember
.mockClear()
.mockImplementation((userId) => [userA, userB, userC].find((u) => u.userId === userId) || null);
(SettingsStore.getValue as jest.Mock).mockReturnValue(true);
});
beforeEach(() => {
(UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock)
.mockClear()
.mockImplementation((userId) => userId);
});
it("returns falsy when no users have changed power level", () => {
const event = mockPowerEvent({
users: {
[userA.userId]: 100,
},
prevUsers: {
[userA.userId]: 100,
},
});
expect(textForEvent(event, mockClient)).toBeFalsy();
});
it("returns false when users power levels have been changed by default settings", () => {
const event = mockPowerEvent({
usersDefault: 100,
prevDefault: 50,
users: {
[userA.userId]: 100,
},
prevUsers: {
[userA.userId]: 50,
},
});
expect(textForEvent(event, mockClient)).toBeFalsy();
});
it("returns correct message for a single user with changed power level", () => {
const event = mockPowerEvent({
users: {
[userB.userId]: 100,
},
prevUsers: {
[userB.userId]: 50,
},
});
const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin.";
expect(textForEvent(event, mockClient)).toEqual(expectedText);
});
it("returns correct message for a single user with power level changed to the default", () => {
const event = mockPowerEvent({
usersDefault: 20,
prevDefault: 101,
users: {
[userB.userId]: 20,
},
prevUsers: {
[userB.userId]: 50,
},
});
const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Default.";
expect(textForEvent(event, mockClient)).toEqual(expectedText);
});
it("returns correct message for a single user with power level changed to a custom level", () => {
const event = mockPowerEvent({
users: {
[userB.userId]: -1,
},
prevUsers: {
[userB.userId]: 50,
},
});
const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Custom (-1).";
expect(textForEvent(event, mockClient)).toEqual(expectedText);
});
it("returns correct message for a multiple power level changes", () => {
const event = mockPowerEvent({
users: {
[userB.userId]: 100,
[userC.userId]: 50,
},
prevUsers: {
[userB.userId]: 50,
[userC.userId]: 101,
},
});
const expectedText =
"Alice changed the power level of Bob (@b) from Moderator to Admin," +
" Bob (@c) from Custom (101) to Moderator.";
expect(textForEvent(event, mockClient)).toEqual(expectedText);
});
});
describe("textForCanonicalAliasEvent()", () => {
const userA = {
userId: "@a",
name: "Alice",
};
interface AliasEventProps {
alias?: string;
prevAlias?: string;
altAliases?: string[];
prevAltAliases?: string[];
}
const mockEvent = ({ alias, prevAlias, altAliases, prevAltAliases }: AliasEventProps): MatrixEvent =>
new MatrixEvent({
type: EventType.RoomCanonicalAlias,
sender: userA.userId,
state_key: "",
content: {
alias,
alt_aliases: altAliases,
},
unsigned: {
prev_content: {
alias: prevAlias,
alt_aliases: prevAltAliases,
},
},
});
type TestCase = [string, AliasEventProps & { result: string }];
const testCases: TestCase[] = [
[
"room alias didn't change",
{
result: "@a changed the addresses for this room.",
},
],
[
"room alias changed",
{
alias: "banana",
prevAlias: "apple",
result: "@a set the main address for this room to banana.",
},
],
[
"room alias was added",
{
alias: "banana",
result: "@a set the main address for this room to banana.",
},
],
[
"room alias was removed",
{
prevAlias: "apple",
result: "@a removed the main address for this room.",
},
],
[
"added an alt alias",
{
altAliases: ["canteloupe"],
result: "@a added alternative address canteloupe for this room.",
},
],
[
"added multiple alt aliases",
{
altAliases: ["canteloupe", "date"],
result: "@a added the alternative addresses canteloupe, date for this room.",
},
],
[
"removed an alt alias",
{
altAliases: ["canteloupe"],
prevAltAliases: ["canteloupe", "date"],
result: "@a removed alternative address date for this room.",
},
],
[
"added and removed an alt aliases",
{
altAliases: ["canteloupe", "elderberry"],
prevAltAliases: ["canteloupe", "date"],
result: "@a changed the alternative addresses for this room.",
},
],
[
"changed alias and added alt alias",
{
alias: "banana",
prevAlias: "apple",
altAliases: ["canteloupe"],
result: "@a changed the main and alternative addresses for this room.",
},
],
];
it.each(testCases)("returns correct message when %s", (_d, { result, ...eventProps }) => {
const event = mockEvent(eventProps);
expect(textForEvent(event, mockClient)).toEqual(result);
});
});
describe("textForPollStartEvent()", () => {
let pollEvent: MatrixEvent;
beforeEach(() => {
pollEvent = new MatrixEvent({
type: "org.matrix.msc3381.poll.start",
sender: "@a",
content: {
"org.matrix.msc3381.poll.start": {
answers: [{ "org.matrix.msc1767.text": "option1" }, { "org.matrix.msc1767.text": "option2" }],
question: {
"body": "Test poll name",
"msgtype": "m.text",
"org.matrix.msc1767.text": "Test poll name",
},
},
},
});
});
it("returns correct message for redacted poll start", () => {
pollEvent.makeRedacted(pollEvent, new Room(pollEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()));
expect(textForEvent(pollEvent, mockClient)).toEqual("@a: Message deleted");
});
it("returns correct message for normal poll start", () => {
expect(textForEvent(pollEvent, mockClient)).toEqual("@a has started a poll - ");
});
});
describe("textForMessageEvent()", () => {
let messageEvent: MatrixEvent;
beforeEach(() => {
messageEvent = new MatrixEvent({
type: "m.room.message",
sender: "@a",
content: {
"body": "test message",
"msgtype": "m.text",
"org.matrix.msc1767.text": "test message",
},
});
});
it("returns correct message for redacted message", () => {
messageEvent.makeRedacted(
messageEvent,
new Room(messageEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()),
);
expect(textForEvent(messageEvent, mockClient)).toEqual("@a: Message deleted");
});
it("returns correct message for normal message", () => {
expect(textForEvent(messageEvent, mockClient)).toEqual("@a: test message");
});
});
describe("textForCallEvent()", () => {
let mockClient: MatrixClient;
let callEvent: MatrixEvent;
beforeEach(() => {
stubClient();
mockClient = MatrixClientPeg.safeGet();
mocked(mockClient.getRoom).mockReturnValue({
name: "Test room",
} as unknown as Room);
callEvent = {
getRoomId: jest.fn(),
getType: jest.fn(),
isState: jest.fn().mockReturnValue(true),
} as unknown as MatrixEvent;
});
describe.each(ElementCall.CALL_EVENT_TYPE.names)("eventType=%s", (eventType: string) => {
beforeEach(() => {
mocked(callEvent).getType.mockReturnValue(eventType);
});
it("returns correct message for call event when supported", () => {
expect(textForEvent(callEvent, mockClient)).toEqual("Video call started in Test room.");
});
it("returns correct message for call event when not supported", () => {
mocked(mockClient).supportsVoip.mockReturnValue(false);
expect(textForEvent(callEvent, mockClient)).toEqual(
"Video call started in Test room. (not supported by this browser)",
);
});
});
});
describe("textForMemberEvent()", () => {
beforeEach(() => {
stubClient();
});
it("should handle both displayname and avatar changing in one event", () => {
expect(
textForEvent(
new MatrixEvent({
type: "m.room.member",
sender: "@a:foo",
content: {
membership: KnownMembership.Join,
avatar_url: "b",
displayname: "Bob",
},
unsigned: {
prev_content: {
membership: KnownMembership.Join,
avatar_url: "a",
displayname: "Andy",
},
},
state_key: "@a:foo",
}),
mockClient,
),
).toMatchInlineSnapshot(`"Andy changed their display name and profile picture"`);
});
});
describe("textForJoinRulesEvent()", () => {
type TestCase = [string, { result: string }];
const testCases: TestCase[] = [
[JoinRule.Public, { result: "@a made the room public to whoever knows the link." }],
[JoinRule.Invite, { result: "@a made the room invite only." }],
[JoinRule.Knock, { result: "@a changed the join rule to ask to join." }],
[JoinRule.Restricted, { result: "@a changed who can join this room." }],
];
it.each(testCases)("returns correct message when room join rule changed to %s", (joinRule, { result }) => {
expect(
textForEvent(
new MatrixEvent({
type: "m.room.join_rules",
sender: "@a",
content: {
join_rule: joinRule,
},
state_key: "",
}),
mockClient,
),
).toEqual(result);
});
it(`returns correct JSX message when room join rule changed to ${JoinRule.Restricted}`, () => {
expect(
textForEvent(
new MatrixEvent({
type: "m.room.join_rules",
sender: "@a",
content: {
join_rule: JoinRule.Restricted,
},
state_key: "",
}),
mockClient,
true,
),
).toMatchSnapshot();
});
it("returns correct default message", () => {
expect(
textForEvent(
new MatrixEvent({
type: "m.room.join_rules",
sender: "@a",
content: {
join_rule: "a not implemented one",
},
state_key: "",
}),
mockClient,
),
).toEqual("@a changed the join rule to a not implemented one");
});
});
describe("textForHistoryVisibilityEvent()", () => {
type TestCase = [string, { result: string }];
const testCases: TestCase[] = [
[
HistoryVisibility.Invited,
{ result: "@a made future room history visible to all room members, from the point they are invited." },
],
[
HistoryVisibility.Joined,
{ result: "@a made future room history visible to all room members, from the point they joined." },
],
[HistoryVisibility.Shared, { result: "@a made future room history visible to all room members." }],
[HistoryVisibility.WorldReadable, { result: "@a made future room history visible to anyone." }],
];
it.each(testCases)(
"returns correct message when room join rule changed to %s",
(historyVisibility, { result }) => {
expect(
textForEvent(
new MatrixEvent({
type: "m.room.history_visibility",
sender: "@a",
content: {
history_visibility: historyVisibility,
},
state_key: "",
}),
mockClient,
),
).toEqual(result);
},
);
});
});

View file

@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import * as tzh from "../src/TimezoneHandler";
describe("TimezoneHandler", () => {
it("should support setting a user timezone", async () => {
const tz = "Europe/Paris";
await tzh.setUserTimezone(tz);
expect(tzh.getUserTimezone()).toEqual(tz);
});
it("Return undefined with an empty TZ", async () => {
await tzh.setUserTimezone("");
expect(tzh.getUserTimezone()).toEqual(undefined);
});
});

View file

@ -0,0 +1,637 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixEvent, EventType, MsgType, Room, ReceiptType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { haveRendererForEvent } from "../src/events/EventTileFactory";
import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils";
import { makeThreadEvents, mkThread, populateThread } from "./test-utils/threads";
import {
doesRoomHaveUnreadMessages,
doesRoomHaveUnreadThreads,
doesRoomOrThreadHaveUnreadMessages,
eventTriggersUnreadCount,
} from "../src/Unread";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
jest.mock("../src/events/EventTileFactory", () => ({
haveRendererForEvent: jest.fn(),
}));
describe("Unread", () => {
// A different user.
const aliceId = "@alice:server.org";
stubClient();
const client = MatrixClientPeg.safeGet();
describe("eventTriggersUnreadCount()", () => {
// setup events
const alicesMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: aliceId,
content: {
msgtype: MsgType.Text,
body: "Hello from Alice",
},
});
const ourMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: client.getUserId()!,
content: {
msgtype: MsgType.Text,
body: "Hello from Bob",
},
});
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: aliceId,
});
redactedEvent.makeRedacted(redactedEvent, new Room(redactedEvent.getRoomId()!, client, aliceId));
beforeEach(() => {
jest.clearAllMocks();
mocked(haveRendererForEvent).mockClear().mockReturnValue(false);
});
it("returns false when the event was sent by the current user", () => {
expect(eventTriggersUnreadCount(client, ourMessage)).toBe(false);
// returned early before checking renderer
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
it("returns false for a redacted event", () => {
expect(eventTriggersUnreadCount(client, redactedEvent)).toBe(false);
// returned early before checking renderer
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
it("returns false for an event without a renderer", () => {
mocked(haveRendererForEvent).mockReturnValue(false);
expect(eventTriggersUnreadCount(client, alicesMessage)).toBe(false);
expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, client, false);
});
it("returns true for an event with a renderer", () => {
mocked(haveRendererForEvent).mockReturnValue(true);
expect(eventTriggersUnreadCount(client, alicesMessage)).toBe(true);
expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, client, false);
});
it("returns false for beacon locations", () => {
const beaconLocationEvent = makeBeaconEvent(aliceId);
expect(eventTriggersUnreadCount(client, beaconLocationEvent)).toBe(false);
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
const noUnreadEventTypes = [
EventType.RoomMember,
EventType.RoomThirdPartyInvite,
EventType.CallAnswer,
EventType.CallHangup,
EventType.RoomCanonicalAlias,
EventType.RoomServerAcl,
];
it.each(noUnreadEventTypes)(
"returns false without checking for renderer for events with type %s",
(eventType) => {
const event = new MatrixEvent({
type: eventType,
sender: aliceId,
});
expect(eventTriggersUnreadCount(client, event)).toBe(false);
expect(haveRendererForEvent).not.toHaveBeenCalled();
},
);
});
describe("doesRoomHaveUnreadMessages()", () => {
let room: Room;
let event: MatrixEvent;
const roomId = "!abc:server.org";
const myId = client.getSafeUserId();
beforeAll(() => {
client.supportsThreads = () => true;
});
beforeEach(() => {
room = new Room(roomId, client, myId);
jest.spyOn(logger, "warn");
});
describe("when there is an initial event in the room", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: "m.room.message",
user: aliceId,
room: roomId,
content: {},
});
room.addLiveEvents([event]);
// Don't care about the code path of hidden events.
mocked(haveRendererForEvent).mockClear().mockReturnValue(true);
});
it("returns true for a room with no receipts", () => {
expect(doesRoomHaveUnreadMessages(room, false)).toBe(true);
});
it("returns false for a room when the latest event was sent by the current user", () => {
event = mkEvent({
event: true,
type: "m.room.message",
user: myId,
room: roomId,
content: {},
});
// Only for timeline events.
room.addLiveEvents([event]);
expect(doesRoomHaveUnreadMessages(room, false)).toBe(false);
});
it("returns false for a room when the read receipt is at the latest event", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
expect(doesRoomHaveUnreadMessages(room, false)).toBe(false);
});
it("returns true for a room when the read receipt is earlier than the latest event", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
const event2 = mkEvent({
event: true,
type: "m.room.message",
user: aliceId,
room: roomId,
content: {},
});
// Only for timeline events.
room.addLiveEvents([event2]);
expect(doesRoomHaveUnreadMessages(room, false)).toBe(true);
});
it("returns true for a room with an unread message in a thread", async () => {
// Mark the main timeline as read.
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// Create a read thread, so we don't consider all threads read
// because there are no threaded read receipts.
const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] });
const receipt2 = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events[events.length - 1].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent.getId() },
},
},
},
});
room.addReceipt(receipt2);
// Create a thread as a different user.
await populateThread({ room, client, authorId: myId, participantUserIds: [aliceId] });
expect(doesRoomHaveUnreadMessages(room, true)).toBe(true);
});
it("returns false for a room when the latest thread event was sent by the current user", async () => {
// Mark the main timeline as read.
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// Create a thread as the current user.
await populateThread({ room, client, authorId: myId, participantUserIds: [myId] });
expect(doesRoomHaveUnreadMessages(room, true)).toBe(false);
});
it("returns false for a room with read thread messages", async () => {
// Mark the main timeline as read.
let receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// Create threads.
const { rootEvent, events } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read.
receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events[events.length - 1].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
expect(doesRoomHaveUnreadMessages(room, true)).toBe(false);
});
it("returns true for a room when read receipt is not on the latest thread messages", async () => {
// Mark the main timeline as read.
let receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// Create threads.
const { rootEvent, events } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read.
receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events[0].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
expect(doesRoomHaveUnreadMessages(room, true)).toBe(true);
});
it("returns true when the event for a thread receipt can't be found", async () => {
// Given a room that is read
let receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// And a thread
const { rootEvent, events } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// When we provide a receipt that points at an unknown event,
// but its timestamp is before some of the events in the thread
//
// (This could happen if we mis-filed a reaction into the main
// thread when it should actually have gone into this thread, or
// maybe the event is just not loaded for some reason.)
const receiptTs = (events.at(-1)?.getTs() ?? 0) - 100;
receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
["UNKNOWN_EVENT_ID"]: {
[ReceiptType.Read]: {
[myId]: { ts: receiptTs, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
expect(doesRoomHaveUnreadMessages(room, true)).toBe(true);
});
});
it("returns true for a room that only contains a hidden event", () => {
const redactedEvent = mkEvent({
event: true,
type: "m.room.message",
user: aliceId,
room: roomId,
content: {},
});
console.log("Event Id", redactedEvent.getId());
redactedEvent.makeRedacted(redactedEvent, room);
console.log("Event Id", redactedEvent.getId());
// Only for timeline events.
room.addLiveEvents([redactedEvent]);
expect(doesRoomHaveUnreadMessages(room, true)).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
"Falling back to unread room because of no read receipt or counting message found",
{
roomId: room.roomId,
earliestUnimportantEventId: redactedEvent.getId(),
},
);
});
it("returns false for space", () => {
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
expect(doesRoomHaveUnreadMessages(room, false)).toBe(false);
});
});
describe("doesRoomOrThreadHaveUnreadMessages()", () => {
let room: Room;
let event: MatrixEvent;
const roomId = "!abc:server.org";
const myId = client.getSafeUserId();
beforeAll(() => {
client.supportsThreads = () => true;
});
beforeEach(() => {
room = new Room(roomId, client, myId);
jest.spyOn(logger, "warn");
// Don't care about the code path of hidden events.
mocked(haveRendererForEvent).mockClear().mockReturnValue(true);
});
describe("with a single event on the main timeline", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: "m.room.message",
user: aliceId,
room: roomId,
content: {},
});
room.addLiveEvents([event]);
});
it("an unthreaded receipt for the event makes the room read", () => {
// Send unthreaded receipt into room pointing at the latest event
room.addReceipt(
new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
}),
);
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
});
it("a threaded receipt for the event makes the room read", () => {
// Send threaded receipt into room pointing at the latest event
room.addReceipt(
new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: "main" },
},
},
},
}),
);
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
});
});
describe("with an event on the main timeline and a later one in a thread", () => {
let threadEvent: MatrixEvent;
beforeEach(() => {
const { events } = makeThreadEvents({
roomId: roomId,
authorId: aliceId,
participantUserIds: ["@x:s.co"],
length: 2,
ts: 100,
currentUserId: myId,
});
room.addLiveEvents(events);
threadEvent = events[1];
});
it("an unthreaded receipt for the later threaded event makes the room read", () => {
// Send unthreaded receipt into room pointing at the latest event
room.addReceipt(
new MatrixEvent({
type: "m.receipt",
room_id: roomId,
content: {
[threadEvent.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
}),
);
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
});
});
});
describe("doesRoomHaveUnreadThreads()", () => {
let room: Room;
const roomId = "!abc:server.org";
const myId = client.getSafeUserId();
beforeAll(() => {
client.supportsThreads = () => true;
});
beforeEach(async () => {
room = new Room(roomId, client, myId);
jest.spyOn(logger, "warn");
// Don't care about the code path of hidden events.
mocked(haveRendererForEvent).mockClear().mockReturnValue(true);
});
it("returns false when no threads", () => {
expect(doesRoomHaveUnreadThreads(room)).toBe(false);
// Add event to the room
const event = mkEvent({
event: true,
type: "m.room.message",
user: aliceId,
room: roomId,
content: {},
});
room.addLiveEvents([event]);
// It still returns false
expect(doesRoomHaveUnreadThreads(room)).toBe(false);
});
it("return true when we don't have any receipt for the thread", async () => {
await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// There is no receipt for the thread, it should be unread
expect(doesRoomHaveUnreadThreads(room)).toBe(true);
});
it("return false when we have a receipt for the thread", async () => {
const { events, rootEvent } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read.
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events[events.length - 1].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
// There is a receipt for the thread, it should be read
expect(doesRoomHaveUnreadThreads(room)).toBe(false);
});
it("return true when only of the threads has a receipt", async () => {
// Create a first thread
await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Create a second thread
const { events, rootEvent } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read.
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events[events.length - 1].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
// The first thread doesn't have a receipt, it should be unread
expect(doesRoomHaveUnreadThreads(room)).toBe(true);
});
});
});

View file

@ -0,0 +1,119 @@
/*
Copyright 2019-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import UserActivity from "../src/UserActivity";
class FakeDomEventEmitter extends EventEmitter {
addEventListener(what: string, l: (...args: any[]) => void) {
this.on(what, l);
}
removeEventListener(what: string, l: (...args: any[]) => void) {
this.removeListener(what, l);
}
}
describe("UserActivity", function () {
let fakeWindow: FakeDomEventEmitter;
let fakeDocument: FakeDomEventEmitter & { hasFocus?(): boolean };
let userActivity: UserActivity;
beforeEach(function () {
fakeWindow = new FakeDomEventEmitter();
fakeDocument = new FakeDomEventEmitter();
userActivity = new UserActivity(fakeWindow as unknown as Window, fakeDocument as unknown as Document);
userActivity.start();
jest.useFakeTimers();
});
afterEach(function () {
userActivity.stop();
});
it("should return the same shared instance", function () {
expect(UserActivity.sharedInstance()).toBe(UserActivity.sharedInstance());
});
it("should consider user inactive if no activity", function () {
expect(userActivity.userActiveNow()).toBe(false);
});
it("should consider user not active recently if no activity", function () {
expect(userActivity.userActiveRecently()).toBe(false);
});
it("should not consider user active after activity if no window focus", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(false);
userActivity.onUserActivity({ type: "event" } as Event);
expect(userActivity.userActiveNow()).toBe(false);
expect(userActivity.userActiveRecently()).toBe(false);
});
it("should consider user active shortly after activity", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
expect(userActivity.userActiveNow()).toBe(true);
expect(userActivity.userActiveRecently()).toBe(true);
jest.advanceTimersByTime(200);
expect(userActivity.userActiveNow()).toBe(true);
expect(userActivity.userActiveRecently()).toBe(true);
});
it("should consider user not active after 10s of no activity", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(10000);
expect(userActivity.userActiveNow()).toBe(false);
});
it("should consider user passive after 10s of no activity", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(10000);
expect(userActivity.userActiveRecently()).toBe(true);
});
it("should not consider user passive after 10s if window un-focused", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(10000);
fakeDocument.hasFocus = jest.fn().mockReturnValue(false);
fakeWindow.emit("blur", {});
expect(userActivity.userActiveRecently()).toBe(false);
});
it("should not consider user passive after 3 mins", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(3 * 60 * 1000);
expect(userActivity.userActiveRecently()).toBe(false);
});
it("should extend timer on activity", function () {
fakeDocument.hasFocus = jest.fn().mockReturnValue(true);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(1 * 60 * 1000);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(1 * 60 * 1000);
userActivity.onUserActivity({ type: "event" } as Event);
jest.advanceTimersByTime(1 * 60 * 1000);
expect(userActivity.userActiveRecently()).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { WorkerManager } from "../src/WorkerManager";
describe("WorkerManager", () => {
it("should generate consecutive sequence numbers for each call", () => {
const postMessage = jest.fn();
const manager = new WorkerManager({ postMessage } as unknown as Worker);
manager.call({ data: "One" });
manager.call({ data: "Two" });
manager.call({ data: "Three" });
const one = postMessage.mock.calls.find((c) => c[0].data === "One")!;
const two = postMessage.mock.calls.find((c) => c[0].data === "Two")!;
const three = postMessage.mock.calls.find((c) => c[0].data === "Three")!;
expect(one[0].seq).toBe(0);
expect(two[0].seq).toBe(1);
expect(three[0].seq).toBe(2);
});
it("should support resolving out of order", async () => {
const postMessage = jest.fn();
const worker = { postMessage } as unknown as Worker;
const manager = new WorkerManager(worker);
const oneProm = manager.call({ data: "One" });
const twoProm = manager.call({ data: "Two" });
const threeProm = manager.call({ data: "Three" });
const one = postMessage.mock.calls.find((c) => c[0].data === "One")![0].seq;
const two = postMessage.mock.calls.find((c) => c[0].data === "Two")![0].seq;
const three = postMessage.mock.calls.find((c) => c[0].data === "Three")![0].seq;
worker.onmessage!({ data: { seq: one, data: 1 } } as MessageEvent);
await expect(oneProm).resolves.toEqual(expect.objectContaining({ data: 1 }));
worker.onmessage!({ data: { seq: three, data: 3 } } as MessageEvent);
await expect(threeProm).resolves.toEqual(expect.objectContaining({ data: 3 }));
worker.onmessage!({ data: { seq: two, data: 2 } } as MessageEvent);
await expect(twoProm).resolves.toEqual(expect.objectContaining({ data: 2 }));
});
});

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`bodyToHtml does not mistake characters in text presentation mode for emoji 1`] = `
<DocumentFragment>
<span
class="mx_EventTile_body translate"
dir="auto"
>
↔ ❗︎
</span>
</DocumentFragment>
`;
exports[`bodyToHtml feature_latex_maths should not mangle code blocks 1`] = `"<p>hello</p><pre><code>$\\xi$</code></pre><p>world</p>"`;
exports[`bodyToHtml feature_latex_maths should not mangle divs 1`] = `"<p>hello</p><div>world</div>"`;
exports[`bodyToHtml feature_latex_maths should render block katex 1`] = `"<p>hello</p><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span></span><p>world</p>"`;
exports[`bodyToHtml feature_latex_maths should render inline katex 1`] = `"hello <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ξ</mi></mrow><annotation encoding="application/x-tex">\\xi</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.04601em;">ξ</span></span></span></span> world"`;
exports[`bodyToHtml generates big emoji for emoji made of multiple characters 1`] = `
<DocumentFragment>
<span
class="mx_EventTile_body mx_EventTile_bigEmoji translate"
dir="auto"
>
<span
class="mx_Emoji"
title=":man-woman-girl-boy:"
>
👨‍👩‍👧‍👦
</span>
<span
class="mx_Emoji"
title=":left_right_arrow:"
>
↔️
</span>
<span
class="mx_Emoji"
title=":flag-is:"
>
🇮🇸
</span>
</span>
</DocumentFragment>
`;
exports[`bodyToHtml should generate big emoji for an emoji-only reply to a message 1`] = `
<DocumentFragment>
<span
class="mx_EventTile_body mx_EventTile_bigEmoji translate"
dir="auto"
>
<span
class="mx_Emoji"
title=":smiling_face_with_3_hearts:"
>
🥰
</span>
</span>
</DocumentFragment>
`;

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LegacyCallHandler when recording a voice broadcast and placing a call should show the info dialog 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
You cant start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.
</p>,
"hasCloseButton": true,
"title": "Cant start a call",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Reply getNestedReplyText Returns valid reply fallback text for m.text msgtypes 1`] = `
{
"body": "> <@user1:server> body
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>body</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for m.pin m.room.message/m.location 1`] = `
{
"body": "> <@user1:server> shared a location.
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>shared a location.</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for m.pin org.matrix.msc3672.beacon_info/undefined 1`] = `
{
"body": "> <@user1:server> shared a live location.
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>shared a live location.</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for m.self m.room.message/m.location 1`] = `
{
"body": "> <@user1:server> shared their location.
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>shared their location.</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for m.self org.matrix.msc3672.beacon_info/undefined 1`] = `
{
"body": "> <@user1:server> shared their live location.
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>shared their live location.</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for poll end events 1`] = `
{
"body": "> <@user1:server>Ended poll
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user1:server">@user1:server</a><br>Ended poll</blockquote></mx-reply>",
}
`;
exports[`Reply getNestedReplyText should create the expected fallback text for poll start events 1`] = `
{
"body": "> <@user:server.org> started poll: Will this test pass?
",
"html": "<mx-reply><blockquote><a href="$$permalink$$">In reply to</a> <a href="https://matrix.to/#/@user:server.org">@user:server.org</a><br>Poll: Will this test pass?</blockquote></mx-reply>",
}
`;

View file

@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SlashCommands /lenny should match snapshot with args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°) this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /lenny should match snapshot with no args 1`] = `
{
"body": "( ͡° ͜ʖ ͡°)",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /rainbow should make things rainbowy 1`] = `
{
"body": "this is a test message",
"format": "org.matrix.custom.html",
"formatted_body": "<span data-mx-color="#ff00be">t</span><span data-mx-color="#ff0080">h</span><span data-mx-color="#ff0041">i</span><span data-mx-color="#ff5f00">s</span> <span data-mx-color="#faa900">i</span><span data-mx-color="#c3bf00">s</span> <span data-mx-color="#00d800">a</span> <span data-mx-color="#00e371">t</span><span data-mx-color="#00e6b6">e</span><span data-mx-color="#00e7f8">s</span><span data-mx-color="#00e7ff">t</span> <span data-mx-color="#00deff">m</span><span data-mx-color="#00d2ff">e</span><span data-mx-color="#00c0ff">s</span><span data-mx-color="#44a4ff">s</span><span data-mx-color="#e87dff">a</span><span data-mx-color="#ff42ff">g</span><span data-mx-color="#ff00fe">e</span>",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /rainbowme should make things rainbowy 1`] = `
{
"body": "this is a test message",
"format": "org.matrix.custom.html",
"formatted_body": "<span data-mx-color="#ff00be">t</span><span data-mx-color="#ff0080">h</span><span data-mx-color="#ff0041">i</span><span data-mx-color="#ff5f00">s</span> <span data-mx-color="#faa900">i</span><span data-mx-color="#c3bf00">s</span> <span data-mx-color="#00d800">a</span> <span data-mx-color="#00e371">t</span><span data-mx-color="#00e6b6">e</span><span data-mx-color="#00e7f8">s</span><span data-mx-color="#00e7ff">t</span> <span data-mx-color="#00deff">m</span><span data-mx-color="#00d2ff">e</span><span data-mx-color="#00c0ff">s</span><span data-mx-color="#44a4ff">s</span><span data-mx-color="#e87dff">a</span><span data-mx-color="#ff42ff">g</span><span data-mx-color="#ff00fe">e</span>",
"msgtype": "m.emote",
}
`;
exports[`SlashCommands /shrug should match snapshot with args 1`] = `
{
"body": "¯\\_(ツ)_/¯ this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /shrug should match snapshot with no args 1`] = `
{
"body": "¯\\_(ツ)_/¯",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /tableflip should match snapshot with args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻ this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /tableflip should match snapshot with no args 1`] = `
{
"body": "(╯°□°)╯︵ ┻━┻",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /unflip should match snapshot with args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ) this is a test message",
"msgtype": "m.text",
}
`;
exports[`SlashCommands /unflip should match snapshot with no args 1`] = `
{
"body": "┬──┬ ( ゜-゜ノ)",
"msgtype": "m.text",
}
`;

View file

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextForEvent textForJoinRulesEvent() returns correct JSX message when room join rule changed to restricted 1`] = `
<span>
<span>
@a changed who can join this room.
<AccessibleButton
kind="link_inline"
onClick={[Function]}
role="button"
tabIndex={0}
>
View settings
</AccessibleButton>
.
</span>
</span>
`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`theme setTheme applies a custom Compound theme 1`] = `"@layer compound.custom { :root, [class*="cpd-theme-"] { --cpd-color-icon-accent-tertiary: var(--cpd-color-blue-800); --cpd-color-text-action-accent: var(--cpd-color-blue-900); } }"`;

View file

@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mockPlatformPeg, unmockPlatformPeg } from "../test-utils";
const PATH_TO_KEYBOARD_SHORTCUTS = "../../src/accessibility/KeyboardShortcuts";
const PATH_TO_KEYBOARD_SHORTCUT_UTILS = "../../src/accessibility/KeyboardShortcutUtils";
const mockKeyboardShortcuts = (override: Record<string, any>) => {
jest.doMock(PATH_TO_KEYBOARD_SHORTCUTS, () => {
const original = jest.requireActual(PATH_TO_KEYBOARD_SHORTCUTS);
return {
...original,
...override,
};
});
};
const getFile = async () => await import(PATH_TO_KEYBOARD_SHORTCUTS);
const getUtils = async () => await import(PATH_TO_KEYBOARD_SHORTCUT_UTILS);
describe("KeyboardShortcutUtils", () => {
beforeEach(() => {
unmockPlatformPeg();
jest.resetModules();
});
it("doesn't change KEYBOARD_SHORTCUTS when getting shortcuts", async () => {
mockKeyboardShortcuts({
KEYBOARD_SHORTCUTS: {
Keybind1: {},
Keybind2: {},
},
MAC_ONLY_SHORTCUTS: ["Keybind1"],
DESKTOP_SHORTCUTS: ["Keybind2"],
});
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
const utils = await getUtils();
const file = await getFile();
const copyKeyboardShortcuts = Object.assign({}, file.KEYBOARD_SHORTCUTS);
utils.getKeyboardShortcuts();
expect(file.KEYBOARD_SHORTCUTS).toEqual(copyKeyboardShortcuts);
utils.getKeyboardShortcutsForUI();
expect(file.KEYBOARD_SHORTCUTS).toEqual(copyKeyboardShortcuts);
});
describe("correctly filters shortcuts", () => {
it("when on web and not on macOS", async () => {
mockKeyboardShortcuts({
KEYBOARD_SHORTCUTS: {
Keybind1: {},
Keybind2: {},
Keybind3: { controller: { settingDisabled: true } },
Keybind4: {},
},
MAC_ONLY_SHORTCUTS: ["Keybind1"],
DESKTOP_SHORTCUTS: ["Keybind2"],
});
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
expect((await getUtils()).getKeyboardShortcuts()).toEqual({ Keybind4: {} });
});
it("when on desktop", async () => {
mockKeyboardShortcuts({
KEYBOARD_SHORTCUTS: {
Keybind1: {},
Keybind2: {},
},
MAC_ONLY_SHORTCUTS: [],
DESKTOP_SHORTCUTS: ["Keybind2"],
});
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(true) });
expect((await getUtils()).getKeyboardShortcuts()).toEqual({ Keybind1: {}, Keybind2: {} });
});
});
});

View file

@ -0,0 +1,122 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen, waitFor } from "jest-matrix-react";
import React from "react";
import { Landmark, LandmarkNavigation } from "../../src/accessibility/LandmarkNavigation";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
describe("KeyboardLandmarkUtils", () => {
it("Landmarks are cycled through correctly without an opened room", () => {
render(
<div>
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON
</div>
<div tabIndex={0} className="mx_RoomSearch" data-testid="mx_RoomSearch">
ROOM_SEARCH
</div>
<div tabIndex={0} className="mx_RoomTile" data-testid="mx_RoomTile">
ROOM_TILE
</div>
<div tabIndex={0} className="mx_HomePage" data-testid="mx_HomePage">
HOME_PAGE
</div>
</div>,
);
// ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> HOME <-> ACTIVE_SPACE_BUTTON
// ACTIVE_SPACE_BUTTON -> ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ROOM_SEARCH -> ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH);
expect(screen.getByTestId("mx_RoomTile")).toHaveFocus();
// ROOM_LIST -> HOME_PAGE
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST);
expect(screen.getByTestId("mx_HomePage")).toHaveFocus();
// HOME_PAGE -> ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
// HOME_PAGE <- ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true);
expect(screen.getByTestId("mx_HomePage")).toHaveFocus();
// ROOM_LIST <- HOME_PAGE
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true);
expect(screen.getByTestId("mx_RoomTile")).toHaveFocus();
// ROOM_SEARCH <- ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ACTIVE_SPACE_BUTTON <- ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
});
it("Landmarks are cycled through correctly with an opened room", async () => {
const callback = jest.fn();
defaultDispatcher.register(callback);
render(
<div>
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON
</div>
<div tabIndex={0} className="mx_RoomSearch" data-testid="mx_RoomSearch">
ROOM_SEARCH
</div>
<div tabIndex={0} className="mx_RoomTile_selected" data-testid="mx_RoomTile_selected">
ROOM_TILE
</div>
<div tabIndex={0} className="mx_Room" data-testid="mx_Room">
ROOM
<div tabIndex={0} className="mx_MessageComposer">
COMPOSER
</div>
</div>
</div>,
);
// ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER <-> ACTIVE_SPACE_BUTTON
// ACTIVE_SPACE_BUTTON -> ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ROOM_SEARCH -> ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH);
expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus();
// ROOM_LIST -> MESSAGE_COMPOSER
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST);
await waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
// MESSAGE_COMPOSER -> ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
// MESSAGE_COMPOSER <- ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true);
await waitFor(() => expect(callback).toHaveBeenCalledTimes(2));
// ROOM_LIST <- MESSAGE_COMPOSER
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true);
expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus();
// ROOM_SEARCH <- ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ACTIVE_SPACE_BUTTON <- ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
});
});

View file

@ -0,0 +1,414 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { HTMLAttributes } from "react";
import { render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
IState,
reducer,
RovingTabIndexProvider,
RovingTabIndexWrapper,
Type,
useRovingTabIndex,
} from "../../src/accessibility/RovingTabIndex";
const Button = (props: HTMLAttributes<HTMLButtonElement>) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]) => {
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
};
// give the buttons keys for the fibre reconciler to not treat them all as the same
const button1 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>;
const button3 = <Button key={3}>c</Button>;
const button4 = <Button key={4}>d</Button>;
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
describe("RovingTabIndex", () => {
it("RovingTabIndexProvider renders children as expected", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
<div>
<span>Test</span>
</div>
)}
</RovingTabIndexProvider>,
);
expect(container.textContent).toBe("Test");
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
});
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
const { container, rerender } = render(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
{button1}
{button2}
{button3}
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// should begin with 0th being active
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// focus on 2nd button and test it is the only active one
container.querySelectorAll("button")[2].focus();
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
// focus on 1st button and test it is the only active one
container.querySelectorAll("button")[1].focus();
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
// check that the active button does not change even on an explicit blur event
container.querySelectorAll("button")[1].blur();
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
// update the children, it should remain on the same button
rerender(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
{button1}
{button4}
{button2}
{button3}
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
// update the children, remove the active button, it should move to the next one
rerender(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
{button1}
{button4}
{button3}
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
const { container } = render(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
{button1}
{button2}
<RovingTabIndexWrapper>
{({ onFocus, isActive, ref }) => (
<button
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref as React.RefObject<HTMLButtonElement>}
>
.
</button>
)}
</RovingTabIndexWrapper>
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// should begin with 0th being active
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// focus on 2nd button and test it is the only active one
container.querySelectorAll("button")[2].focus();
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
});
describe("reducer functions as expected", () => {
it("SetFocus works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
expect(
reducer(
{
activeRef: ref1,
refs: [ref1, ref2],
},
{
type: Type.SetFocus,
payload: {
ref: ref2,
},
},
),
).toStrictEqual({
activeRef: ref2,
refs: [ref1, ref2],
});
});
it("Unregister works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
const ref3 = React.createRef<HTMLElement>();
const ref4 = React.createRef<HTMLElement>();
let state: IState = {
refs: [ref1, ref2, ref3, ref4],
};
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref2,
},
});
expect(state).toStrictEqual({
refs: [ref1, ref3, ref4],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref3,
},
});
expect(state).toStrictEqual({
refs: [ref1, ref4],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref4,
},
});
expect(state).toStrictEqual({
refs: [ref1],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref1,
},
});
expect(state).toStrictEqual({
refs: [],
});
});
it("Register works as expected", () => {
const ref1 = React.createRef<HTMLElement>();
const ref2 = React.createRef<HTMLElement>();
const ref3 = React.createRef<HTMLElement>();
const ref4 = React.createRef<HTMLElement>();
render(
<React.Fragment>
<span ref={ref1} />
<span ref={ref2} />
<span ref={ref3} />
<span ref={ref4} />
</React.Fragment>,
);
let state: IState = {
refs: [],
};
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref1,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref2,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref3,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2, ref3],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref4,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1, ref2, ref3, ref4],
});
// test that the automatic focus switch works for unmounting
state = reducer(state, {
type: Type.SetFocus,
payload: {
ref: ref2,
},
});
expect(state).toStrictEqual({
activeRef: ref2,
refs: [ref1, ref2, ref3, ref4],
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref2,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref3, ref4],
});
// test that the insert into the middle works as expected
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref2,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref2, ref3, ref4],
});
// test that insertion at the edges works
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref1,
},
});
state = reducer(state, {
type: Type.Unregister,
payload: {
ref: ref4,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref2, ref3],
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref1,
},
});
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref4,
},
});
expect(state).toStrictEqual({
activeRef: ref3,
refs: [ref1, ref2, ref3, ref4],
});
});
});
describe("handles arrow keys", () => {
it("should handle up/down arrow keys work when handleUpDown=true", async () => {
const { container } = render(
<RovingTabIndexProvider handleUpDown>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);
container.querySelectorAll("button")[0].focus();
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowDown]");
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
// Does not loop without
await userEvent.keyboard("[ArrowUp]");
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
});
it("should call scrollIntoView if specified", async () => {
const { container } = render(
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<div onKeyDown={onKeyDownHandler}>
{button1}
{button2}
{button3}
</div>
)}
</RovingTabIndexProvider>,
);
container.querySelectorAll("button")[0].focus();
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
const button = container.querySelectorAll("button")[1];
const mock = jest.spyOn(button, "scrollIntoView");
await userEvent.keyboard("[ArrowDown]");
expect(mock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,29 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { viewUserDeviceSettings } from "../../../src/actions/handlers/viewUserDeviceSettings";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
import { Action } from "../../../src/dispatcher/actions";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
describe("viewUserDeviceSettings()", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
beforeEach(() => {
dispatchSpy.mockClear();
});
it("dispatches action to view session manager", () => {
viewUserDeviceSettings();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
});
});
});

View file

@ -0,0 +1,163 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { logger } from "matrix-js-sdk/src/logger";
import { createAudioContext, decodeOgg } from "../../src/audio/compat";
import { Playback, PlaybackState } from "../../src/audio/Playback";
jest.mock("../../src/WorkerManager", () => ({
WorkerManager: jest.fn(() => ({
call: jest.fn().mockResolvedValue({ waveform: [0, 0, 1, 1] }),
})),
}));
jest.mock("../../src/audio/compat", () => ({
createAudioContext: jest.fn(),
decodeOgg: jest.fn(),
}));
describe("Playback", () => {
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
const mockAudioBuffer = {
duration: 99,
getChannelData: jest.fn(),
};
const mockChannelData = new Float32Array();
beforeEach(() => {
jest.spyOn(logger, "error").mockRestore();
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
mockAudioContext.decodeAudioData.mockReset().mockImplementation((_b, callback) => callback(mockAudioBuffer));
mockAudioContext.resume.mockClear().mockResolvedValue(undefined);
mockAudioContext.suspend.mockClear().mockResolvedValue(undefined);
mocked(decodeOgg).mockClear().mockResolvedValue(new ArrayBuffer(1));
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
});
it("initialises correctly", () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
playback.clockInfo.durationSeconds = mockAudioBuffer.duration;
expect(playback.sizeBytes).toEqual(8);
expect(playback.clockInfo).toBeTruthy();
expect(playback.liveData).toBe(playback.clockInfo.liveData);
expect(playback.timeSeconds).toBe(1337 % 99);
expect(playback.currentState).toEqual(PlaybackState.Decoding);
});
it("toggles playback on from stopped state", async () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
await playback.prepare();
// state is Stopped
await playback.toggle();
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
expect(mockAudioContext.resume).toHaveBeenCalled();
expect(playback.currentState).toEqual(PlaybackState.Playing);
});
it("toggles playback to paused from playing state", async () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
await playback.prepare();
await playback.toggle();
expect(playback.currentState).toEqual(PlaybackState.Playing);
await playback.toggle();
expect(mockAudioContext.suspend).toHaveBeenCalled();
expect(playback.currentState).toEqual(PlaybackState.Paused);
});
it("stop playbacks", async () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
await playback.prepare();
await playback.toggle();
expect(playback.currentState).toEqual(PlaybackState.Playing);
await playback.stop();
expect(mockAudioContext.suspend).toHaveBeenCalled();
expect(playback.currentState).toEqual(PlaybackState.Stopped);
});
describe("prepare()", () => {
it("decodes audio data when not greater than 5mb", async () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
await playback.prepare();
expect(mockAudioContext.decodeAudioData).toHaveBeenCalledTimes(1);
expect(mockAudioBuffer.getChannelData).toHaveBeenCalledWith(0);
// clock was updated
expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.currentState).toEqual(PlaybackState.Stopped);
});
it("tries to decode ogg when decodeAudioData fails", async () => {
// stub logger to keep console clean from expected error
jest.spyOn(logger, "error").mockReturnValue(undefined);
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const buffer = new ArrayBuffer(8);
const decodingError = new Error("test");
mockAudioContext.decodeAudioData
.mockImplementationOnce((_b, _callback, error) => error(decodingError))
.mockImplementationOnce((_b, callback) => callback(mockAudioBuffer));
const playback = new Playback(buffer);
await playback.prepare();
expect(mockAudioContext.decodeAudioData).toHaveBeenCalledTimes(2);
expect(decodeOgg).toHaveBeenCalled();
// clock was updated
expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.currentState).toEqual(PlaybackState.Stopped);
});
it("does not try to re-decode audio", async () => {
const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer);
await playback.prepare();
expect(playback.currentState).toEqual(PlaybackState.Stopped);
await playback.prepare();
// only called once in first prepare
expect(mockAudioContext.decodeAudioData).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,211 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { UploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import { EncryptedFile } from "matrix-js-sdk/src/types";
import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording";
import { uploadFile } from "../../src/ContentMessages";
import { stubClient } from "../test-utils";
import { Playback } from "../../src/audio/Playback";
jest.mock("../../src/ContentMessages", () => ({
uploadFile: jest.fn(),
}));
jest.mock("../../src/audio/Playback", () => ({
Playback: jest.fn(),
}));
describe("VoiceMessageRecording", () => {
const roomId = "!room:example.com";
const contentType = "test content type";
const durationSeconds = 23;
const testBuf = new Uint8Array([1, 2, 3]);
const testAmplitudes = [4, 5, 6];
let voiceRecording: VoiceRecording;
let voiceMessageRecording: VoiceMessageRecording;
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
voiceRecording = {
contentType,
durationSeconds,
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
isRecording: true,
isSupported: true,
liveData: jest.fn(),
amplitudes: testAmplitudes,
} as unknown as VoiceRecording;
voiceMessageRecording = new VoiceMessageRecording(client, voiceRecording);
});
it("hasRecording should return false", () => {
expect(voiceMessageRecording.hasRecording).toBe(false);
});
it("createVoiceMessageRecording should return a VoiceMessageRecording", () => {
expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording);
});
it("durationSeconds should return the VoiceRecording value", () => {
expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds);
});
it("contentType should return the VoiceRecording value", () => {
expect(voiceMessageRecording.contentType).toBe(contentType);
});
it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => {
// @ts-ignore
voiceRecording.isRecording = value;
expect(voiceMessageRecording.isRecording).toBe(value);
});
it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => {
// @ts-ignore
voiceRecording.isSupported = value;
expect(voiceMessageRecording.isSupported).toBe(value);
});
it("should return liveData from VoiceRecording", () => {
expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData);
});
it("start should forward the call to VoiceRecording.start", async () => {
await voiceMessageRecording.start();
expect(voiceRecording.start).toHaveBeenCalled();
});
it("on should forward the call to VoiceRecording", () => {
const callback = () => {};
const result = voiceMessageRecording.on("test on", callback);
expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback);
expect(result).toBe(voiceMessageRecording);
});
it("off should forward the call to VoiceRecording", () => {
const callback = () => {};
const result = voiceMessageRecording.off("test off", callback);
expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback);
expect(result).toBe(voiceMessageRecording);
});
it("emit should forward the call to VoiceRecording", () => {
voiceMessageRecording.emit("test emit", 42);
expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42);
});
it("upload should raise an error", async () => {
await expect(voiceMessageRecording.upload(roomId)).rejects.toThrow("No recording available to upload");
});
describe("when the first data has been received", () => {
const uploadUrl = "https://example.com/content123";
const encryptedFile = {} as unknown as EncryptedFile;
beforeEach(() => {
voiceRecording.onDataAvailable!(testBuf);
});
it("contentLength should return the buffer length", () => {
expect(voiceMessageRecording.contentLength).toBe(testBuf.length);
});
it("stop should return a copy of the data buffer", async () => {
const result = await voiceMessageRecording.stop();
expect(voiceRecording.stop).toHaveBeenCalled();
expect(result).toEqual(testBuf);
});
it("hasRecording should return true", () => {
expect(voiceMessageRecording.hasRecording).toBe(true);
});
describe("upload", () => {
let uploadFileClient: MatrixClient | null;
let uploadFileRoomId: string | null;
let uploadBlob: Blob | null;
beforeEach(() => {
uploadFileClient = null;
uploadFileRoomId = null;
uploadBlob = null;
mocked(uploadFile).mockImplementation(
(
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
_progressHandler?: UploadOpts["progressHandler"],
): Promise<{ url?: string; file?: EncryptedFile }> => {
uploadFileClient = matrixClient;
uploadFileRoomId = roomId;
uploadBlob = file;
// @ts-ignore
return Promise.resolve({
url: uploadUrl,
file: encryptedFile,
});
},
);
});
it("should upload the file and trigger the upload events", async () => {
const result = await voiceMessageRecording.upload(roomId);
expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading);
expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded);
expect(result.mxc).toBe(uploadUrl);
expect(result.encrypted).toBe(encryptedFile);
expect(mocked(uploadFile)).toHaveBeenCalled();
expect(uploadFileClient).toBe(client);
expect(uploadFileRoomId).toBe(roomId);
expect(uploadBlob?.type).toBe(contentType);
const blobArray = await uploadBlob!.arrayBuffer();
expect(new Uint8Array(blobArray)).toEqual(testBuf);
});
it("should reuse the result", async () => {
const result1 = await voiceMessageRecording.upload(roomId);
const result2 = await voiceMessageRecording.upload(roomId);
expect(result1).toBe(result2);
});
});
describe("getPlayback", () => {
beforeEach(() => {
mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform): any => {
expect(new Uint8Array(buf)).toEqual(testBuf);
expect(seedWaveform).toEqual(testAmplitudes);
return {} as Playback;
});
});
it("should return a Playback with the data", () => {
voiceMessageRecording.getPlayback();
expect(mocked(Playback)).toHaveBeenCalled();
});
it("should reuse the result", () => {
const playback1 = voiceMessageRecording.getPlayback();
const playback2 = voiceMessageRecording.getPlayback();
expect(playback1).toBe(playback2);
});
});
});
});

View file

@ -0,0 +1,175 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
// @ts-ignore
import Recorder from "opus-recorder/dist/recorder.min.js";
import { VoiceRecording, voiceRecorderOptions, highQualityRecorderOptions } from "../../src/audio/VoiceRecording";
import { createAudioContext } from "../..//src/audio/compat";
import MediaDeviceHandler from "../../src/MediaDeviceHandler";
import { useMockMediaDevices } from "../test-utils";
jest.mock("opus-recorder/dist/recorder.min.js");
const RecorderMock = mocked(Recorder);
jest.mock("../../src/audio/compat", () => ({
createAudioContext: jest.fn(),
}));
const createAudioContextMock = mocked(createAudioContext);
jest.mock("../../src/MediaDeviceHandler");
const MediaDeviceHandlerMock = mocked(MediaDeviceHandler);
/**
* The tests here are heavily using access to private props.
* While this is not so great, we can at lest test some behaviour easily this way.
*/
describe("VoiceRecording", () => {
let recording: VoiceRecording;
let recorderSecondsSpy: jest.SpyInstance;
const itShouldNotCallStop = () => {
it("should not call stop", () => {
expect(recording.stop).not.toHaveBeenCalled();
});
};
const simulateUpdate = (recorderSeconds: number) => {
beforeEach(() => {
recorderSecondsSpy.mockReturnValue(recorderSeconds);
// @ts-ignore
recording.processAudioUpdate(recorderSeconds);
});
};
beforeEach(() => {
useMockMediaDevices();
recording = new VoiceRecording();
// @ts-ignore
recording.observable = {
update: jest.fn(),
close: jest.fn(),
};
jest.spyOn(recording, "stop").mockImplementation();
recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get");
});
afterEach(() => {
jest.resetAllMocks();
});
describe("when starting a recording", () => {
beforeEach(() => {
const mockAudioContext = {
createMediaStreamSource: jest.fn().mockReturnValue({
connect: jest.fn(),
disconnect: jest.fn(),
}),
createScriptProcessor: jest.fn().mockReturnValue({
connect: jest.fn(),
disconnect: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}),
destination: {},
close: jest.fn(),
};
createAudioContextMock.mockReturnValue(mockAudioContext as unknown as AudioContext);
});
afterEach(async () => {
await recording.stop();
});
it("should record high-quality audio if voice processing is disabled", async () => {
MediaDeviceHandlerMock.getAudioNoiseSuppression.mockReturnValue(false);
await recording.start();
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({ noiseSuppression: { ideal: false } }),
}),
);
expect(RecorderMock).toHaveBeenCalledWith(
expect.objectContaining({
encoderBitRate: highQualityRecorderOptions.bitrate,
encoderApplication: highQualityRecorderOptions.encoderApplication,
}),
);
});
it("should record normal-quality voice if voice processing is enabled", async () => {
MediaDeviceHandlerMock.getAudioNoiseSuppression.mockReturnValue(true);
await recording.start();
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({ noiseSuppression: { ideal: true } }),
}),
);
expect(RecorderMock).toHaveBeenCalledWith(
expect.objectContaining({
encoderBitRate: voiceRecorderOptions.bitrate,
encoderApplication: voiceRecorderOptions.encoderApplication,
}),
);
});
});
describe("when recording", () => {
beforeEach(() => {
// @ts-ignore
recording.recording = true;
});
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
it("should call stop", () => {
expect(recording.stop).toHaveBeenCalled();
});
});
describe("and the max length limit has been disabled", () => {
beforeEach(() => {
recording.disableMaxLength();
});
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
itShouldNotCallStop();
});
});
});
describe("when not recording", () => {
describe("and there is an audio update and time left", () => {
simulateUpdate(42);
itShouldNotCallStop();
});
describe("and there is an audio update and time is up", () => {
// one second above the limit
simulateUpdate(901);
itShouldNotCallStop();
});
});
});

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Ryan Browne <code@commonlawfeature.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EmojiProvider from "../../src/autocomplete/EmojiProvider";
import { mkStubRoom } from "../test-utils/test-utils";
import { add } from "../../src/emojipicker/recent";
import { stubClient } from "../test-utils";
import { MatrixClientPeg } from "../../src/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: "⭕️" }];
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.title).toEqual(":heartpulse:");
expect(completionsList[1]?.component?.props.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.title).toEqual(":heart:");
expect(completionsList[1]?.component?.props.title).toEqual(":heartpulse:");
expect(completionsList[2]?.component?.props.title).toEqual(":heart_eyes:");
});
});

View file

@ -0,0 +1,165 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import QueryMatcher from "../../src/autocomplete/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.length).toBe(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.length).toBe(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.length).toBe(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.length).toBe(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.length).toBe(1);
expect(results[0].name).toBe("bob");
});
});

View file

@ -0,0 +1,127 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import RoomProvider from "../../src/autocomplete/RoomProvider";
import SettingsStore from "../../src/settings/SettingsStore";
import { mkRoom, mkSpace, stubClient } from "../test-utils";
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");
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");
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(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const room = makeRoom(client, "room:e.com");
mocked(client.getVisibleRooms).mockReturnValue([room]);
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
jest.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");
mocked(client.getVisibleRooms).mockReturnValue([room]);
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;
}

View file

@ -0,0 +1,127 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import SpaceProvider from "../../src/autocomplete/SpaceProvider";
import SettingsStore from "../../src/settings/SettingsStore";
import { mkRoom, mkSpace, stubClient } from "../test-utils";
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");
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");
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(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const space = makeSpace(client, "space:e.com");
mocked(client.getVisibleRooms).mockReturnValue([space]);
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
jest.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");
mocked(client.getVisibleRooms).mockReturnValue([space]);
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;
}

View file

@ -0,0 +1,275 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { screen, render, fireEvent, waitFor, within, act } from "jest-matrix-react";
import * as TestUtils from "../../test-utils";
import AutocompleteProvider from "../../../src/autocomplete/AutocompleteProvider";
import { ICompletion } from "../../../src/autocomplete/Autocompleter";
import { AutocompleteInput } from "../../../src/components/structures/AutocompleteInput";
describe("AutocompleteInput", () => {
const mockCompletion: ICompletion[] = [
{
type: "user",
completion: "user_1",
completionId: "@user_1:host.local",
range: { start: 1, end: 1 },
component: <div />,
},
{
type: "user",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
component: <div />,
},
];
const constructMockProvider = (data: ICompletion[]) =>
({
getCompletions: jest.fn().mockImplementation(async () => data),
}) as unknown as AutocompleteProvider;
beforeEach(() => {
TestUtils.stubClient();
});
const getEditorInput = () => {
const input = screen.getByTestId("autocomplete-input");
expect(input).toBeDefined();
return input;
};
it("should render suggestions when a query is set", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
expect(screen.getByTestId("autocomplete-matches").childNodes).toHaveLength(mockCompletion.length);
});
it("should render selected items passed in via props", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const editor = screen.getByTestId("autocomplete-editor");
const selection = within(editor).getAllByTestId("autocomplete-selection-item", { exact: false });
expect(selection).toHaveLength(mockCompletion.length);
});
it("should call onSelectionChange() when an item is removed from selection", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const editor = screen.getByTestId("autocomplete-editor");
const removeButtons = within(editor).getAllByTestId("autocomplete-selection-remove-button", { exact: false });
expect(removeButtons).toHaveLength(mockCompletion.length);
act(() => {
fireEvent.click(removeButtons[0]);
});
expect(onSelectionChangeMock).toHaveBeenCalledTimes(1);
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[1]]);
});
it("should render custom selection element when renderSelection() is defined", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const renderSelection = () => <span data-testid="custom-selection-element">custom selection element</span>;
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
renderSelection={renderSelection}
/>,
);
expect(screen.getAllByTestId("custom-selection-element")).toHaveLength(mockCompletion.length);
});
it("should render custom suggestion element when renderSuggestion() is defined", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const renderSuggestion = () => <span data-testid="custom-suggestion-element">custom suggestion element</span>;
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
renderSuggestion={renderSuggestion}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
expect(screen.getAllByTestId("custom-suggestion-element")).toHaveLength(mockCompletion.length);
});
it("should mark selected suggestions as selected", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1));
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
expect(suggestions).toHaveLength(mockCompletion.length);
suggestions.map((suggestion) => expect(suggestion).toHaveClass("mx_AutocompleteInput_suggestion--selected"));
});
it("should remove the last added selection when backspace is pressed in empty input", () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={mockCompletion}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.keyDown(input, { key: "Backspace" });
});
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]);
});
it("should toggle a selected item when a suggestion is clicked", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
act(() => {
fireEvent.mouseDown(suggestions[0]);
});
expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]);
});
it("should clear text field and suggestions when a suggestion is accepted", async () => {
const mockProvider = constructMockProvider(mockCompletion);
const onSelectionChangeMock = jest.fn();
const { container } = render(
<AutocompleteInput
provider={mockProvider}
placeholder="Search ..."
selection={[]}
onSelectionChange={onSelectionChangeMock}
/>,
);
const input = getEditorInput();
act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "user" } });
});
const suggestions = await within(container).findAllByTestId("autocomplete-suggestion-item", { exact: false });
act(() => {
fireEvent.mouseDown(suggestions[0]);
});
expect(input).toHaveValue("");
expect(within(container).queryAllByTestId("autocomplete-suggestion-item", { exact: false })).toHaveLength(0);
});
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
});

View file

@ -0,0 +1,79 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { toLeftOf, toLeftOrRightOf, toRightOf } from "../../../src/components/structures/ContextMenu";
import UIStore from "../../../src/stores/UIStore";
describe("ContextMenu", () => {
const rect = new DOMRect();
// @ts-ignore
rect.left = 23;
// @ts-ignore
rect.right = 46;
// @ts-ignore
rect.top = 42;
rect.width = 640;
rect.height = 480;
beforeEach(() => {
window.scrollX = 31;
window.scrollY = 41;
UIStore.instance.windowWidth = 1280;
});
describe("toLeftOf", () => {
it("should return the correct positioning", () => {
expect(toLeftOf(rect)).toEqual({
chevronOffset: 12,
right: 1285, // 1280 - 23 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toRightOf", () => {
it("should return the correct positioning", () => {
expect(toRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toLeftOrRightOf", () => {
describe("when there is more space to the right", () => {
// default case from test setup
it("should return a position to the right", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("when there is more space to the left", () => {
beforeEach(() => {
// @ts-ignore
rect.left = 500;
// @ts-ignore
rect.right = 1000;
});
it("should return a position to the left", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
right: 808, // 1280 - 500 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
});
});

View file

@ -0,0 +1,50 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { EventTimelineSet, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
import { screen, render, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import FilePanel from "../../../src/components/structures/FilePanel";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
TimelineWindow: jest.fn().mockReturnValue({
load: jest.fn().mockResolvedValue(null),
getEvents: jest.fn().mockReturnValue([]),
canPaginate: jest.fn().mockReturnValue(false),
}),
}));
describe("FilePanel", () => {
beforeEach(() => {
stubClient();
});
it("renders empty state", async () => {
const cli = MatrixClientPeg.safeGet();
const room = new Room("!room:server", cli, cli.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const timelineSet = new EventTimelineSet(room);
room.getOrCreateFilteredTimelineSet = jest.fn().mockReturnValue(timelineSet);
mocked(cli.getRoom).mockReturnValue(room);
const { asFragment } = render(
<FilePanel roomId={room.roomId} onClose={jest.fn()} resizeNotifier={new ResizeNotifier()} />,
);
await waitFor(() => {
expect(screen.getByText("No files visible in this room")).toBeInTheDocument();
});
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,24 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { LargeLoader } from "../../../src/components/structures/LargeLoader";
describe("LargeLoader", () => {
const text = "test loading text";
beforeEach(() => {
render(<LargeLoader text={text} />);
});
it("should render the text", () => {
screen.getByText(text);
});
});

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, RenderResult, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import LeftPanel from "../../../src/components/structures/LeftPanel";
import PageType from "../../../src/PageTypes";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../src/settings/UIFeature";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("LeftPanel", () => {
function renderComponent(): RenderResult {
return render(
<LeftPanel isMinimized={false} pageType={PageType.RoomView} resizeNotifier={new ResizeNotifier()} />,
);
}
it("does not show filter container when disabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.queryByRole("button", { name: /search/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
});
it("renders filter container when enabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Explore rooms" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,148 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { CallState } from "matrix-js-sdk/src/webrtc/call";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import LegacyCallEventGrouper from "../../../src/components/structures/LegacyCallEventGrouper";
const MY_USER_ID = "@me:here";
const THEIR_USER_ID = "@they:here";
let client: MatrixClient;
describe("LegacyCallEventGrouper", () => {
beforeEach(() => {
stubClient();
client = MatrixClientPeg.safeGet();
client.getUserId = () => {
return MY_USER_ID;
};
});
it("detects a missed call", () => {
const grouper = new LegacyCallEventGrouper();
// This assumes that the other party aborted the call by sending a hangup,
// which is the usual case. Another possible test would be for the edge
// case where there is only an expired invite event.
grouper.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
grouper.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallHangup;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
expect(grouper.state).toBe(CallState.Ended);
expect(grouper.callWasMissed).toBe(true);
});
it("detects an ended call", () => {
const grouperHangup = new LegacyCallEventGrouper();
const grouperReject = new LegacyCallEventGrouper();
grouperHangup.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: MY_USER_ID,
},
} as unknown as MatrixEvent);
grouperHangup.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallHangup;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
grouperReject.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallInvite;
},
sender: {
userId: MY_USER_ID,
},
} as unknown as MatrixEvent);
grouperReject.add({
getContent: () => {
return {
call_id: "callId",
};
},
getType: () => {
return EventType.CallReject;
},
sender: {
userId: THEIR_USER_ID,
},
} as unknown as MatrixEvent);
expect(grouperHangup.state).toBe(CallState.Ended);
expect(grouperReject.state).toBe(CallState.Ended);
});
it("detects call type", () => {
const grouper = new LegacyCallEventGrouper();
grouper.add({
getContent: () => {
return {
call_id: "callId",
offer: {
sdp: "this is definitely an SDP m=video",
},
};
},
getType: () => {
return EventType.CallInvite;
},
} as unknown as MatrixEvent);
expect(grouper.isVoice).toBe(false);
});
});

View file

@ -0,0 +1,460 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, RenderResult } from "jest-matrix-react";
import { ConditionKind, EventType, IPushRule, MatrixEvent, ClientEvent, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { logger } from "matrix-js-sdk/src/logger";
import userEvent from "@testing-library/user-event";
import LoggedInView from "../../../src/components/structures/LoggedInView";
import { SDKContext } from "../../../src/contexts/SDKContext";
import { StandardActions } from "../../../src/notifications/StandardActions";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Action } from "../../../src/dispatcher/actions";
import Modal from "../../../src/Modal";
import { SETTINGS } from "../../../src/settings/Settings";
describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getAccountData: jest.fn(),
getRoom: jest.fn(),
getSyncState: jest.fn().mockReturnValue(null),
getSyncStateData: jest.fn().mockReturnValue(null),
getMediaHandler: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSdkContext();
const defaultProps = {
matrixClient: mockClient,
onRegistered: jest.fn(),
resizeNotifier: new ResizeNotifier(),
collapseLhs: false,
hideToSRUsers: false,
config: {
brand: "Test",
element_call: {},
},
currentRoomId: "",
currentUserId: "@bob:server",
};
const getComponent = (props = {}): RenderResult =>
render(<LoggedInView {...defaultProps} {...props} />, {
wrapper: ({ children }) => <SDKContext.Provider value={mockSdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
jest.clearAllMocks();
mockClient.getMediaHandler.mockReturnValue(mediaHandler);
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
});
describe("synced push rules", () => {
const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules });
const oneToOneRule = {
conditions: [
{ kind: ConditionKind.RoomMemberCount, is: "2" },
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" },
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const oneToOneRuleDisabled = {
...oneToOneRule,
enabled: false,
};
const groupRule = {
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" }],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.message",
default: true,
enabled: true,
} as IPushRule;
const pollStartOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollEndOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.end",
},
],
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollStartGroup = {
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_start",
default: true,
enabled: true,
} as IPushRule;
beforeEach(() => {
mockClient.getAccountData.mockImplementation((eventType: string) =>
eventType === EventType.PushRules ? pushRulesEvent : undefined,
);
setPushRules([]);
// stub out error logger to avoid littering console
jest.spyOn(logger, "error")
.mockClear()
.mockImplementation(() => {});
mockClient.setPushRuleActions.mockClear();
mockClient.setPushRuleEnabled.mockClear();
});
const setPushRules = (rules: IPushRule[] = []): void => {
const pushRules = {
global: {
underride: [...rules],
},
};
mockClient.pushRules = pushRules;
};
describe("on mount", () => {
it("handles when user has no push rules event in account data", () => {
mockClient.getAccountData.mockReturnValue(undefined);
getComponent();
expect(mockClient.getAccountData).toHaveBeenCalledWith(EventType.PushRules);
expect(logger.error).not.toHaveBeenCalled();
});
it("handles when user doesnt have a push rule defined in vector definitions", () => {
// synced push rules uses VectorPushRulesDefinitions
// rules defined there may not exist in m.push_rules
// mock push rules with group rule, but missing oneToOne rule
setPushRules([pollStartOneToOne, groupRule, pollStartGroup]);
getComponent();
// just called once for one-to-one
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
// set to match primary rule (groupRule)
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules", () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
// only called for rules not in sync with their primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules when primary rule is disabled", async () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollStartOneToOne.rule_id,
false,
);
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollEndOneToOne.rule_id,
false,
);
});
it("catches and logs errors while updating a rule", async () => {
mockClient.setPushRuleActions.mockRejectedValueOnce("oups").mockResolvedValueOnce({});
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// both calls made
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
// second primary rule still updated after first rule failed
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(logger.error).toHaveBeenCalledWith(
"Failed to fully synchronise push rules for .m.rule.room_one_to_one",
"oups",
);
});
});
describe("on changes to account_data", () => {
it("ignores other account data events", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
const someOtherAccountData = new MatrixEvent({ type: "my-test-account-data " });
mockClient.emit(ClientEvent.AccountData, someOtherAccountData);
// didnt check rule sync
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
it("updates all mismatched rules from synced rules on a change to push rules account data", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules on a change to push rules account data when primary rule is disabled", async () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollEndOneToOne, // loud
]);
getComponent();
await flushPromises();
mockClient.setPushRuleEnabled.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
false,
);
});
it("stops listening to account data events on unmount", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
const { unmount } = getComponent();
mockClient.setPushRuleActions.mockClear();
unmount();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// not called
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
});
});
it("should fire FocusMessageSearch on Ctrl+F when enabled", async () => {
jest.spyOn(defaultDispatcher, "fire");
await SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.DEVICE, true);
getComponent();
await userEvent.keyboard("{Control>}f{/Control}");
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusMessageSearch);
});
it("should go home on home shortcut", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
it("should ignore home shortcut if dialogs are open", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
jest.spyOn(Modal, "hasDialogs").mockReturnValue(true);
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
describe("timezone updates", () => {
const userTimezone = "Europe/London";
const originalController = SETTINGS["userTimezonePublish"].controller;
beforeEach(async () => {
SETTINGS["userTimezonePublish"].controller = undefined;
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone);
});
afterEach(() => {
SETTINGS["userTimezonePublish"].controller = originalController;
});
it("does not update the timezone when userTimezonePublish is off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled();
});
it("should set the user timezone when userTimezonePublish is enabled", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
});
it("should set the user timezone when the timezone is changed", async () => {
const newTimezone = "Europe/Paris";
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone);
});
it("should clear the timezone when the publish feature is turned off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone);
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz");
});
});
});

View file

@ -0,0 +1,99 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, fireEvent } from "jest-matrix-react";
import MainSplit from "../../../src/components/structures/MainSplit";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics.ts";
describe("<MainSplit/>", () => {
const resizeNotifier = new ResizeNotifier();
const children = (
<div>
Child<span>Foo</span>Bar
</div>
);
const panel = <div>Right panel</div>;
beforeEach(() => {
localStorage.clear();
});
it("renders", () => {
const { asFragment, container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
analyticsRoomType="other_room"
/>,
);
expect(asFragment()).toMatchSnapshot();
// Assert it matches the default width of 320
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("320px");
});
it("respects defaultSize prop", () => {
const { asFragment, container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
defaultSize={500}
analyticsRoomType="other_room"
/>,
);
expect(asFragment()).toMatchSnapshot();
// Assert it matches the default width of 350
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("500px");
});
it("prefers size stashed in LocalStorage to the defaultSize prop", () => {
localStorage.setItem("mx_rhs_size_thread", "333");
const { container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
sizeKey="thread"
defaultSize={400}
analyticsRoomType="other_room"
/>,
);
expect(container.querySelector<HTMLElement>(".mx_RightPanel_ResizeWrapper")!.style.width).toBe("333px");
});
it("should report to analytics on resize stop", () => {
const { container } = render(
<MainSplit
resizeNotifier={resizeNotifier}
children={children}
panel={panel}
sizeKey="thread"
defaultSize={400}
analyticsRoomType="other_room"
/>,
);
const spy = jest.spyOn(PosthogAnalytics.instance, "trackEvent");
const handle = container.querySelector(".mx_ResizeHandle--horizontal")!;
fireEvent.mouseDown(handle);
fireEvent.mouseMove(handle, { clientX: 0 });
fireEvent.mouseUp(handle);
expect(spy).toHaveBeenCalledWith({
eventName: "WebPanelResize",
panel: "right",
roomType: "other_room",
size: 400,
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { act, render } from "jest-matrix-react";
import React, { useContext } from "react";
import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
import { LocalDeviceVerificationStateContext } from "../../../src/contexts/LocalDeviceVerificationStateContext";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../test-utils";
describe("MatrixClientContextProvider", () => {
it("Should expose a matrix client context", () => {
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
getCrypto: () => null,
});
let receivedClient: MatrixClient | undefined;
function ContextReceiver() {
receivedClient = useContext(MatrixClientContext);
return <></>;
}
render(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
expect(receivedClient).toBe(mockClient);
});
describe("Should expose a verification status context", () => {
/** The most recent verification status received by our `ContextReceiver` */
let receivedState: boolean | undefined;
/** The mock client for use in the tests */
let mockClient: MatrixClient;
function ContextReceiver() {
receivedState = useContext(LocalDeviceVerificationStateContext);
return <></>;
}
function getComponent(mockClient: MatrixClient) {
return render(
<MatrixClientContextProvider client={mockClient}>
<ContextReceiver />
</MatrixClientContextProvider>,
);
}
beforeEach(() => {
receivedState = undefined;
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsCrypto(),
});
});
it("returns false if device is unverified", async () => {
mockClient.getCrypto()!.getUserVerificationStatus = jest
.fn()
.mockResolvedValue(new UserVerificationStatus(false, false, false));
getComponent(mockClient);
expect(receivedState).toBe(false);
});
it("returns true if device is verified", async () => {
mockClient.getCrypto()!.getUserVerificationStatus = jest
.fn()
.mockResolvedValue(new UserVerificationStatus(true, false, false));
getComponent(mockClient);
await act(() => flushPromises());
expect(receivedState).toBe(true);
});
it("updates when the trust status updates", async () => {
const getVerificationStatus = jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false));
mockClient.getCrypto()!.getUserVerificationStatus = getVerificationStatus;
getComponent(mockClient);
// starts out false
await act(() => flushPromises());
expect(receivedState).toBe(false);
// Now the state is updated
const verifiedStatus = new UserVerificationStatus(true, false, false);
getVerificationStatus.mockResolvedValue(verifiedStatus);
act(() => {
mockClient.emit(CryptoEvent.UserTrustStatusChanged, mockClient.getSafeUserId(), verifiedStatus);
});
expect(receivedState).toBe(true);
});
});
});

View file

@ -0,0 +1,835 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 , 2022 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { EventEmitter } from "events";
import { MatrixEvent, Room, RoomMember, Thread, ReceiptType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { render } from "jest-matrix-react";
import MessagePanel, { shouldFormContinuation } from "../../../src/components/structures/MessagePanel";
import SettingsStore from "../../../src/settings/SettingsStore";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import * as TestUtilsMatrix from "../../test-utils";
import {
createTestClient,
getMockClientWithEventEmitter,
makeBeaconInfoEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { IRoomState } from "../../../src/components/structures/RoomView";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
jest.mock("../../../src/utils/beacon", () => ({
useBeacon: jest.fn(),
}));
const roomId = "!roomId:server_name";
describe("MessagePanel", function () {
const events = mkEvents();
const userId = "@me:here";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getAccountData: jest.fn(),
isUserIgnored: jest.fn().mockReturnValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getRoom: jest.fn(),
getClientWellKnown: jest.fn().mockReturnValue({}),
supportsThreads: jest.fn().mockReturnValue(true),
});
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
const room = new Room(roomId, client, userId);
const bobMember = new RoomMember(roomId, "@bob:id");
bobMember.name = "Bob";
jest.spyOn(bobMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(bobMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const alice = "@alice:example.org";
const aliceMember = new RoomMember(roomId, alice);
aliceMember.name = "Alice";
jest.spyOn(aliceMember, "getAvatarUrl").mockReturnValue("avatar.jpeg");
jest.spyOn(aliceMember, "getMxcAvatarUrl").mockReturnValue("mxc://avatar.url/image.png");
const defaultProps = {
resizeNotifier: new EventEmitter() as unknown as ResizeNotifier,
callEventGroupers: new Map(),
room,
className: "cls",
events: [] as MatrixEvent[],
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
room,
roomId: room.roomId,
canReact: true,
canSendMessages: true,
showReadReceipts: true,
showRedactions: false,
showJoinLeaves: false,
showAvatarChanges: false,
showDisplaynameChanges: true,
showHiddenEvents: false,
} as unknown as IRoomState;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) => (
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
<MessagePanel {...defaultProps} {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(function () {
jest.clearAllMocks();
// HACK: We assume all settings want to be disabled
jest.spyOn(SettingsStore, "getValue").mockImplementation((arg) => {
return arg === "showDisplaynameChanges";
});
DMRoomMap.makeShared(client);
});
function mkEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// Just to avoid breaking Dateseparator tests that might run at 00hrs
function mkOneDayEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.parse("09 May 2004 00:12:00 GMT");
for (let i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
return events;
}
// make a collection of events with some member events that should be collapsed with an EventListSummary
function mkMelsEvents() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
let i = 0;
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
);
}
events.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + ++i * 1000,
}),
);
return events;
}
// A list of membership events only with nothing else
function mkMelsEventsOnly() {
const events: MatrixEvent[] = [];
const ts0 = Date.now();
let i = 0;
for (i = 0; i < 10; i++) {
events.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: ts0 + i * 1000,
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
);
}
return events;
}
// A list of room creation, encryption, and invite events.
function mkCreationEvents() {
const mkEvent = TestUtilsMatrix.mkEvent;
const mkMembership = TestUtilsMatrix.mkMembership;
const roomId = "!someroom";
const ts0 = Date.now();
return [
mkEvent({
event: true,
type: "m.room.create",
room: roomId,
user: alice,
content: {
creator: alice,
room_version: "5",
predecessor: {
room_id: "!prevroom",
event_id: "$someevent",
},
},
ts: ts0,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
target: aliceMember,
ts: ts0 + 1,
mship: KnownMembership.Join,
name: "Alice",
}),
mkEvent({
event: true,
type: "m.room.join_rules",
room: roomId,
user: alice,
content: {
join_rule: "invite",
},
ts: ts0 + 2,
}),
mkEvent({
event: true,
type: "m.room.history_visibility",
room: roomId,
user: alice,
content: {
history_visibility: "invited",
},
ts: ts0 + 3,
}),
mkEvent({
event: true,
type: "m.room.encryption",
room: roomId,
user: alice,
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
ts: ts0 + 4,
}),
mkMembership({
event: true,
room: roomId,
user: alice,
skey: "@bob:example.org",
target: bobMember,
ts: ts0 + 5,
mship: KnownMembership.Invite,
name: "Bob",
}),
];
}
function mkMixedHiddenAndShownEvents() {
const roomId = "!room:id";
const userId = "@alice:example.org";
const ts0 = Date.now();
return [
TestUtilsMatrix.mkMessage({
event: true,
room: roomId,
user: userId,
ts: ts0,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "org.example.a_hidden_event",
room: roomId,
user: userId,
content: {},
ts: ts0 + 1,
}),
];
}
function isReadMarkerVisible(rmContainer?: Element) {
return !!rmContainer?.children.length;
}
it("should show the events", function () {
const { container } = render(getComponent({ events }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(10);
});
it("should collapse adjacent member events", function () {
const { container } = render(getComponent({ events: mkMelsEvents() }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
const summaryTiles = container.getElementsByClassName("mx_GenericEventListSummary");
expect(summaryTiles.length).toEqual(1);
});
it("should insert the read-marker in the right place", function () {
const { container } = render(
getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
}),
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
// it should follow the <li> which wraps the event tile for event 4
const eventContainer = tiles[4];
expect(rm.previousSibling).toEqual(eventContainer);
});
it("should show the read-marker that fall in summarised events after the summary", function () {
const melsEvents = mkMelsEvents();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[4].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(summary);
// read marker should be visible given props and not at the last event
expect(isReadMarkerVisible(rm)).toBeTruthy();
});
it("should hide the read-marker at the end of summarised events", function () {
const melsEvents = mkMelsEventsOnly();
const { container } = render(
getComponent({
events: melsEvents,
readMarkerEventId: melsEvents[9].getId(),
readMarkerVisible: true,
}),
);
const [summary] = container.getElementsByClassName("mx_GenericEventListSummary");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(summary);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("shows a ghost read-marker when the read-marker moves", function () {
// fake the clock so that we can test the velocity animation.
jest.useFakeTimers();
const { container, rerender } = render(
<div>
{getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
})}
</div>,
);
const tiles = container.getElementsByClassName("mx_EventTile");
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(rm.previousSibling).toEqual(tiles[4]);
rerender(
<div>
{getComponent({
events,
readMarkerEventId: events[6].getId(),
readMarkerVisible: true,
})}
</div>,
);
// now there should be two RM containers
const readMarkers = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
expect(readMarkers.length).toEqual(2);
// the first should be the ghost
expect(readMarkers[0].previousSibling).toEqual(tiles[4]);
const hr: HTMLElement = readMarkers[0].children[0] as HTMLElement;
// the second should be the real thing
expect(readMarkers[1].previousSibling).toEqual(tiles[6]);
// advance the clock, and then let the browser run an animation frame to let the animation start
jest.advanceTimersByTime(1500);
expect(hr.style.opacity).toEqual("0");
});
it("should collapse creation events", function () {
const events = mkCreationEvents();
const createEvent = events.find((event) => event.getType() === "m.room.create")!;
const encryptionEvent = events.find((event) => event.getType() === "m.room.encryption")!;
client.getRoom.mockImplementation((id) => (id === createEvent!.getRoomId() ? room : null));
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(getComponent({ events }));
// we expect that
// - the room creation event, the room encryption event, and Alice inviting Bob,
// should be outside of the room creation summary
// - all other events should be inside the room creation summary
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles[0].getAttribute("data-event-id")).toEqual(createEvent.getId());
expect(tiles[1].getAttribute("data-event-id")).toEqual(encryptionEvent.getId());
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
const summaryEventTiles = summaryTile.getElementsByClassName("mx_EventTile");
// every event except for the room creation, room encryption, and Bob's
// invite event should be in the event summary
expect(summaryEventTiles.length).toEqual(tiles.length - 3);
});
it("should not collapse beacons as part of creation events", function () {
const events = mkCreationEvents();
const creationEvent = events.find((event) => event.getType() === "m.room.create")!;
const beaconInfoEvent = makeBeaconInfoEvent(creationEvent.getSender()!, creationEvent.getRoomId()!, {
isLive: true,
});
const combinedEvents = [...events, beaconInfoEvent];
TestUtilsMatrix.upsertRoomStateEvents(room, combinedEvents);
const { container } = render(getComponent({ events: combinedEvents }));
const [summaryTile] = container.getElementsByClassName("mx_GenericEventListSummary");
// beacon body is not in the summary
expect(summaryTile.getElementsByClassName("mx_MBeaconBody").length).toBe(0);
// beacon tile is rendered
expect(container.getElementsByClassName("mx_MBeaconBody").length).toBe(1);
});
it("should hide read-marker at the end of creation event summary", function () {
const events = mkCreationEvents();
const createEvent = events.find((event) => event.getType() === "m.room.create");
client.getRoom.mockImplementation((id) => (id === createEvent!.getRoomId() ? room : null));
TestUtilsMatrix.upsertRoomStateEvents(room, events);
const { container } = render(
getComponent({
events,
readMarkerEventId: events[5].getId(),
readMarkerVisible: true,
}),
);
// find the <li> which wraps the read marker
const [rm] = container.getElementsByClassName("mx_MessagePanel_myReadMarker");
const [messageList] = container.getElementsByClassName("mx_RoomView_MessageList");
const rows = messageList.children;
expect(rows.length).toEqual(7); // 6 events + the NewRoomIntro
expect(rm.previousSibling).toEqual(rows[5]);
// read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy();
});
it("should render Date separators for the events", function () {
const events = mkOneDayEvents();
const { queryAllByRole } = render(getComponent({ events }));
const dates = queryAllByRole("separator");
expect(dates.length).toEqual(1);
});
it("appends events into summaries during forward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
...events,
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(11);
});
it("prepends events into summaries during backward pagination without changing key", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: bobMember,
ts: Date.now(),
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
...events,
];
rerender(getComponent({ events: updatedEvents }));
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual("eventlistsummary-" + events[0].getId());
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(11);
});
it("assigns different keys to summaries that get split up", () => {
const events = mkMelsEvents().slice(1, 11);
const { container, rerender } = render(getComponent({ events }));
let els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(10);
const updatedEvents = [
...events.slice(0, 5),
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Hello!",
}),
...events.slice(5, 10),
];
rerender(getComponent({ events: updatedEvents }));
// summaries split becuase room messages are not summarised
els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(2);
expect(els[0].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[0].getId()}`);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(5);
expect(els[1].getAttribute("data-testid")).toEqual(`eventlistsummary-${events[5].getId()}`);
expect(els[1].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(5);
});
// We test this because setting lookups can be *slow*, and we don't want
// them to happen in this code path
it("doesn't lookup showHiddenEventsInTimeline while rendering", () => {
// We're only interested in the setting lookups that happen on every render,
// rather than those happening on first mount, so let's get those out of the way
const { rerender } = render(getComponent({ events: [] }));
// Set up our spy and re-render with new events
const settingsSpy = jest.spyOn(SettingsStore, "getValue").mockClear();
rerender(getComponent({ events: mkMixedHiddenAndShownEvents() }));
expect(settingsSpy).not.toHaveBeenCalledWith("showHiddenEventsInTimeline");
settingsSpy.mockRestore();
});
it("should group hidden event reactions into an event list summary", () => {
const events = [
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 1,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 2,
}),
TestUtilsMatrix.mkEvent({
event: true,
type: "m.reaction",
room: "!room:id",
user: "@user:id",
content: {},
ts: 3,
}),
];
const { container } = render(getComponent({ events }, { showHiddenEvents: true }));
const els = container.getElementsByClassName("mx_GenericEventListSummary");
expect(els.length).toEqual(1);
expect(els[0].getAttribute("data-scroll-tokens")?.split(",")).toHaveLength(3);
});
it("should handle large numbers of hidden events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events: MatrixEvent[] = [];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkEvent({
event: true,
type: "unknown.event.type",
content: { key: "value" },
room: "!room:id",
user: "@user:id",
ts: 1000000 + i,
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: false }));
expect(asFragment()).toMatchSnapshot();
});
it("should handle lots of room creation events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events = [TestUtilsMatrix.mkRoomCreateEvent("@user:id", "!room:id")];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkMembership({
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
room: "!room:id",
user: "@user:id",
event: true,
skey: "123",
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: false }));
expect(asFragment()).toMatchSnapshot();
});
it("should handle lots of membership events quickly", () => {
// Increase the length of the loop here to test performance issues with
// rendering
const events: MatrixEvent[] = [];
for (let i = 0; i < 100; i++) {
events.push(
TestUtilsMatrix.mkMembership({
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
room: "!room:id",
user: "@user:id",
event: true,
skey: "123",
}),
);
}
const { asFragment } = render(getComponent({ events }, { showHiddenEvents: true }));
const cpt = asFragment();
// Ignore properties that change every time
cpt.querySelectorAll("li").forEach((li) => {
li.setAttribute("data-scroll-tokens", "__scroll_tokens__");
li.setAttribute("data-testid", "__testid__");
});
expect(cpt).toMatchSnapshot();
});
it("should set lastSuccessful=true on non-last event if last event is not eligible for special receipt", () => {
client.getRoom.mockImplementation((id) => (id === room.roomId ? room : null));
const events = [
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
}),
TestUtilsMatrix.mkEvent({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
type: "m.room.topic",
skey: "",
content: { topic: "TOPIC" },
}),
];
const { container } = render(getComponent({ events, showReadReceipts: true }));
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].querySelector(".mx_EventTile_receiptSent")).toBeTruthy();
expect(tiles[1].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
});
it("should set lastSuccessful=false on non-last event if last event has a receipt from someone else", () => {
client.getRoom.mockImplementation((id) => (id === room.roomId ? room : null));
const events = [
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
}),
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: "@other:user",
ts: 1001,
}),
];
room.addReceiptToStructure(
events[1].getId()!,
ReceiptType.Read,
"@other:user",
{
ts: 1001,
},
true,
);
const { container } = render(getComponent({ events, showReadReceipts: true }));
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
expect(tiles[1].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
});
});
describe("shouldFormContinuation", () => {
it("does not form continuations from thread roots which have summaries", () => {
const message1 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a message in the main timeline",
});
const message2 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline",
});
const threadRoot = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "Here is a thread",
});
jest.spyOn(threadRoot, "isThreadRoot", "get").mockReturnValue(true);
const message3 = TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
msg: "And here's another message in the main timeline after the thread root",
});
const client = createTestClient();
expect(shouldFormContinuation(message1, message2, client, false)).toEqual(true);
expect(shouldFormContinuation(message2, threadRoot, client, false)).toEqual(true);
expect(shouldFormContinuation(threadRoot, message3, client, false)).toEqual(true);
const thread = {
length: 1,
replyToEvent: {},
} as unknown as Thread;
jest.spyOn(threadRoot, "getThread").mockReturnValue(thread);
expect(shouldFormContinuation(message2, threadRoot, client, false)).toEqual(false);
expect(shouldFormContinuation(threadRoot, message3, client, false)).toEqual(false);
});
});

View file

@ -0,0 +1,113 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { MouseEventHandler } from "react";
import { screen, render, RenderResult } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import PictureInPictureDragger, { CreatePipChildren } from "../../../src/components/structures/PictureInPictureDragger";
describe("PictureInPictureDragger", () => {
let renderResult: RenderResult;
const mkContent1: Array<CreatePipChildren> = [
() => {
return <div>content 1</div>;
},
];
const mkContent2: Array<CreatePipChildren> = [
() => {
return (
<div>
content 2<br />
content 2.2
</div>
);
},
];
describe("when rendering the dragger with PiP content 1", () => {
beforeEach(() => {
renderResult = render(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
});
it("should render the PiP content", () => {
expect(renderResult.container).toMatchSnapshot("pip-content-1");
});
describe("and rerendering PiP content 1", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent1}</PictureInPictureDragger>);
});
it("should not change the PiP content", () => {
expect(renderResult.container).toMatchSnapshot("pip-content-1");
});
});
describe("and rendering PiP content 2", () => {
beforeEach(() => {
renderResult.rerender(<PictureInPictureDragger draggable={true}>{mkContent2}</PictureInPictureDragger>);
});
it("should update the PiP content", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
});
describe("when rendering the dragger with PiP content 1 and 2", () => {
beforeEach(() => {
renderResult = render(
<PictureInPictureDragger draggable={true}>{[...mkContent1, ...mkContent2]}</PictureInPictureDragger>,
);
});
it("should render both contents", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
describe("when rendering the dragger", () => {
let clickSpy: jest.Mocked<MouseEventHandler>;
let target: HTMLElement;
beforeEach(() => {
clickSpy = jest.fn();
render(
<PictureInPictureDragger draggable={true}>
{[
({ onStartMoving }) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onMouseDown={onStartMoving} onClick={clickSpy}>
Hello
</div>
),
]}
</PictureInPictureDragger>,
);
target = screen.getByText("Hello");
});
it("and clicking without a drag motion, it should pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { keys: "[/MouseLeft]" }]);
expect(clickSpy).toHaveBeenCalled();
});
it("and clicking with a drag motion above the threshold of 5px, it should not pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 60, y: 2 } }, "[/MouseLeft]"]);
expect(clickSpy).not.toHaveBeenCalled();
});
it("and clickign with a drag motion below the threshold of 5px, it should pass the click to the children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 4, y: 4 } }, "[/MouseLeft]"]);
expect(clickSpy).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,476 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked, Mocked } from "jest-mock";
import { screen, render, act, cleanup } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, PendingEventOrdering, Room, MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { Widget, ClientWidgetApi } from "matrix-widget-api";
import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup";
import type { RoomMember } from "matrix-js-sdk/src/matrix";
import {
useMockedCalls,
MockedCall,
mkRoomMember,
stubClient,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
wrapInSdkContext,
mkRoomCreateEvent,
mockPlatformPeg,
flushPromises,
useMockMediaDevices,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { CallStore } from "../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
import { PipContainer as UnwrappedPipContainer } from "../../../src/components/structures/PipContainer";
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { TestSdkContext } from "../../TestSdkContext";
import {
VoiceBroadcastInfoState,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPreRecording,
VoiceBroadcastPreRecordingStore,
VoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { IRoomStateEventsActionPayload } from "../../../src/actions/MatrixActionCreators";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetType } from "../../../src/widgets/WidgetType";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
jest.mock("../../../src/stores/OwnProfileStore", () => ({
OwnProfileStore: {
instance: {
isProfileInfoFetched: true,
removeListener: jest.fn(),
getHttpAvatarUrl: jest.fn().mockReturnValue("http://avatar_url"),
},
},
}));
describe("PipContainer", () => {
useMockedCalls();
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let user: UserEvent;
let sdkContext: TestSdkContext;
let client: Mocked<MatrixClient>;
let room: Room;
let room2: Room;
let alice: RoomMember;
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore;
let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore;
const actFlushPromises = async () => {
await act(async () => {
await flushPromises();
});
};
beforeEach(async () => {
useMockMediaDevices();
user = userEvent.setup();
stubClient();
client = mocked(MatrixClientPeg.safeGet());
client.getUserId.mockReturnValue("@alice:example.org");
client.getSafeUserId.mockReturnValue("@alice:example.org");
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
room2 = new Room("!2:example.com", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId: string) => {
if (roomId === room.roomId) return room;
if (roomId === room2.roomId) return room2;
return null;
});
client.getRooms.mockReturnValue([room, room2]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
room.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room.roomId)]);
jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === alice.userId ? alice : null));
room2.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room2.roomId)]);
await Promise.all(
[CallStore.instance, WidgetMessagingStore.instance].map((store) =>
setupAsyncStoreWithClient(store, client),
),
);
sdkContext = new TestSdkContext();
// @ts-ignore PipContainer uses SDKContext in the constructor
SdkContextClass.instance = sdkContext;
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore();
voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore);
sdkContext.client = client;
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore;
sdkContext._VoiceBroadcastPlaybacksStore = voiceBroadcastPlaybacksStore;
});
afterEach(async () => {
cleanup();
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.clearAllMocks();
});
const renderPip = () => {
const PipContainer = wrapInMatrixClientContext(wrapInSdkContext(UnwrappedPipContainer, sdkContext));
render(<PipContainer />);
};
const viewRoom = (roomId: string) => {
defaultDispatcher.dispatch<ViewRoomPayload>(
{
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: undefined,
},
true,
);
};
const withCall = async (fn: (call: MockedCall) => Promise<void>): Promise<void> => {
MockedCall.create(room, "1");
const call = CallStore.instance.getCall(room.roomId);
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetStore.instance.addVirtualWidget(call.widget, room.roomId);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
await act(async () => {
await call.start();
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
});
await fn(call);
cleanup();
call.destroy();
ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId);
WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId);
};
const withWidget = async (fn: () => Promise<void>): Promise<void> => {
act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true));
await fn();
cleanup();
ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId);
};
const makeVoiceBroadcastInfoStateEvent = (): MatrixEvent => {
return mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Started,
alice.userId,
client.getDeviceId() || "",
);
};
const setUpVoiceBroadcastRecording = () => {
const infoEvent = makeVoiceBroadcastInfoStateEvent();
const voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client);
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
};
const setUpVoiceBroadcastPreRecording = () => {
const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording(
room,
alice,
client,
voiceBroadcastPlaybacksStore,
voiceBroadcastRecordingsStore,
);
voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording);
};
const setUpRoomViewStore = () => {
sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext);
};
const mkVoiceBroadcast = (room: Room): MatrixEvent => {
const infoEvent = makeVoiceBroadcastInfoStateEvent();
room.currentState.setStateEvents([infoEvent]);
defaultDispatcher.dispatch<IRoomStateEventsActionPayload>(
{
action: "MatrixActions.RoomState.events",
event: infoEvent,
state: room.currentState,
lastStateEvent: null,
},
true,
);
return infoEvent;
};
it("hides if there's no content", () => {
renderPip();
expect(screen.queryByRole("complementary")).toBeNull();
});
it("shows an active call with back and leave buttons", async () => {
renderPip();
await withCall(async (call) => {
screen.getByRole("complementary");
// The return button should jump to the call
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should disconnect from the call
const disconnectSpy = jest.spyOn(call, "disconnect");
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(disconnectSpy).toHaveBeenCalled();
});
});
it("shows a persistent widget with back button when viewing the room", async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
const widget = WidgetStore.instance.addVirtualWidget(
{
id: "1",
creatorUserId: "@alice:example.org",
type: WidgetType.CUSTOM.preferred,
url: "https://example.org",
name: "Example widget",
},
room.roomId,
);
renderPip();
await withWidget(async () => {
screen.getByRole("complementary");
// The return button should maximize the widget
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
expect(screen.queryByRole("button", { name: "Leave" })).toBeNull();
});
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
it("shows a persistent Jitsi widget with back and leave buttons when not viewing the room", async () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
setUpRoomViewStore();
viewRoom(room2.roomId);
const widget = WidgetStore.instance.addVirtualWidget(
{
id: "1",
creatorUserId: "@alice:example.org",
type: WidgetType.JITSI.preferred,
url: "https://meet.example.org",
name: "Jitsi example",
},
room.roomId,
);
renderPip();
await withWidget(async () => {
screen.getByRole("complementary");
// The return button should view the room
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should hangup the call
const sendSpy = jest
.fn<
ReturnType<ClientWidgetApi["transport"]["send"]>,
Parameters<ClientWidgetApi["transport"]["send"]>
>()
.mockResolvedValue({});
const mockMessaging = { transport: { send: sendSpy }, stop: () => {} } as unknown as ClientWidgetApi;
WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging);
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {});
});
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
describe("when there is a voice broadcast recording and pre-recording", () => {
beforeEach(async () => {
setUpVoiceBroadcastPreRecording();
setUpVoiceBroadcastRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast recording PiP", () => {
// check for the „Live“ badge to be present
expect(screen.queryByText("Live")).toBeInTheDocument();
});
it("and a call it should show both, the call and the recording", async () => {
await withCall(async () => {
// Broadcast: Check for the „Live“ badge to be present
expect(screen.queryByText("Live")).toBeInTheDocument();
// Call: Check for the „Leave“ button to be present
screen.getByRole("button", { name: "Leave" });
});
});
});
describe("when there is a voice broadcast playback and pre-recording", () => {
beforeEach(async () => {
mkVoiceBroadcast(room);
setUpVoiceBroadcastPreRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast pre-recording PiP", () => {
// check for the „Go live“ button
expect(screen.queryByText("Go live")).toBeInTheDocument();
});
});
describe("when there is a voice broadcast pre-recording", () => {
beforeEach(async () => {
setUpVoiceBroadcastPreRecording();
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast pre-recording PiP", () => {
// check for the „Go live“ button
expect(screen.queryByText("Go live")).toBeInTheDocument();
});
});
describe("when listening to a voice broadcast in a room and then switching to another room", () => {
beforeEach(async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
mkVoiceBroadcast(room);
await actFlushPromises();
expect(voiceBroadcastPlaybacksStore.getCurrent()).toBeTruthy();
await voiceBroadcastPlaybacksStore.getCurrent()?.start();
viewRoom(room2.roomId);
renderPip();
});
it("should render the small voice broadcast playback PiP", () => {
// check for the „pause voice broadcast“ button
expect(screen.getByLabelText("pause voice broadcast")).toBeInTheDocument();
// check for the absence of the „30s forward“ button
expect(screen.queryByLabelText("30s forward")).not.toBeInTheDocument();
});
});
describe("when viewing a room with a live voice broadcast", () => {
let startEvent!: MatrixEvent;
beforeEach(async () => {
setUpRoomViewStore();
viewRoom(room.roomId);
startEvent = mkVoiceBroadcast(room);
renderPip();
await actFlushPromises();
});
it("should render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).toBeInTheDocument();
});
describe("and the broadcast stops", () => {
beforeEach(async () => {
const stopEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
alice.userId,
client.getDeviceId() || "",
startEvent,
);
await act(async () => {
room.currentState.setStateEvents([stopEvent]);
defaultDispatcher.dispatch<IRoomStateEventsActionPayload>(
{
action: "MatrixActions.RoomState.events",
event: stopEvent,
state: room.currentState,
lastStateEvent: stopEvent,
},
true,
);
await flushPromises();
});
});
it("should not render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument();
});
});
describe("and leaving the room", () => {
beforeEach(async () => {
await act(async () => {
viewRoom(room2.roomId);
await flushPromises();
});
});
it("should not render the voice broadcast playback pip", () => {
// check for the „resume voice broadcast“ button
expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,66 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render, screen, waitFor } from "jest-matrix-react";
import { ReleaseAnnouncement } from "../../../src/components/structures/ReleaseAnnouncement";
import Modal, { ModalManagerEvent } from "../../../src/Modal";
import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore";
describe("ReleaseAnnouncement", () => {
beforeEach(async () => {
// Reset the singleton instance of the ReleaseAnnouncementStore
// @ts-ignore
ReleaseAnnouncementStore.internalInstance = new ReleaseAnnouncementStore();
});
function renderReleaseAnnouncement() {
return render(
<ReleaseAnnouncement
feature="threadsActivityCentre"
header="header"
description="description"
closeLabel="close"
>
<div>content</div>
</ReleaseAnnouncement>,
);
}
test("render the release announcement and close it", async () => {
renderReleaseAnnouncement();
// The release announcement is displayed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
// Click on the close button in the release announcement
screen.getByRole("button", { name: "close" }).click();
// The release announcement should be hidden after the close button is clicked
await waitFor(() => expect(screen.queryByRole("dialog", { name: "header" })).toBeNull());
});
test("when a dialog is opened, the release announcement should not be displayed", async () => {
renderReleaseAnnouncement();
// The release announcement is displayed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
// Open a dialog
act(() => {
Modal.emit(ModalManagerEvent.Opened);
});
// The release announcement should be hidden after the dialog is opened
expect(screen.queryByRole("dialog", { name: "header" })).toBeNull();
// Close the dialog
act(() => {
Modal.emit(ModalManagerEvent.Closed);
});
// The release announcement should be displayed after the dialog is closed
expect(screen.queryByRole("dialog", { name: "header" })).toBeVisible();
});
});

View file

@ -0,0 +1,153 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import { jest } from "@jest/globals";
import { mocked, MockedObject } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import _RightPanel from "../../../src/components/structures/RightPanel";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient, wrapInMatrixClientContext, mkRoom, wrapInSdkContext } from "../../test-utils";
import { Action } from "../../../src/dispatcher/actions";
import dis from "../../../src/dispatcher/dispatcher";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import SettingsStore from "../../../src/settings/SettingsStore";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
const RightPanelBase = wrapInMatrixClientContext(_RightPanel);
describe("RightPanel", () => {
const resizeNotifier = new ResizeNotifier();
let cli: MockedObject<MatrixClient>;
let context: SdkContextClass;
let RightPanel: React.ComponentType<React.ComponentProps<typeof RightPanelBase>>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(cli);
context = new SdkContextClass();
context.client = cli;
RightPanel = wrapInSdkContext(RightPanelBase, context);
});
afterEach(async () => {
const roomChanged = new Promise<void>((resolve) => {
const ref = dis.register((payload) => {
if (payload.action === Action.ActiveRoomChanged) {
dis.unregister(ref);
resolve();
}
});
});
dis.fire(Action.ViewHomePage); // Stop viewing any rooms
await roomChanged;
dis.fire(Action.OnLoggedOut, true); // Shut down the stores
jest.restoreAllMocks();
});
const spinUpStores = async () => {
// Selectively spin up the stores we need
WidgetLayoutStore.instance.useUnitTestClient(cli);
// @ts-ignore
// This is private but it's the only way to selectively enable stores
await WidgetLayoutStore.instance.onReady();
// Make sure we start with a clean store
RightPanelStore.instance.reset();
RightPanelStore.instance.useUnitTestClient(cli);
// @ts-ignore
await RightPanelStore.instance.onReady();
};
const waitForRpsUpdate = () => new Promise<void>((resolve) => RightPanelStore.instance.once(UPDATE_EVENT, resolve));
it("renders info from only one room during room changes", async () => {
const r1 = mkRoom(cli, "r1");
const r2 = mkRoom(cli, "r2");
cli.getRoom.mockImplementation((roomId) => {
if (roomId === "r1") return r1;
if (roomId === "r2") return r2;
return null;
});
// Set up right panel state
const realGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => {
if (name !== "RightPanel.phases") return realGetValue(name, roomId);
if (roomId === "r1") {
return {
history: [{ phase: RightPanelPhases.RoomMemberList }],
isOpen: true,
};
}
if (roomId === "r2") {
return {
history: [{ phase: RightPanelPhases.RoomSummary }],
isOpen: true,
};
}
return null;
});
await spinUpStores();
// Run initial render with room 1, and also running lifecycle methods
const { container, rerender } = render(
<RightPanel
room={r1}
resizeNotifier={resizeNotifier}
permalinkCreator={new RoomPermalinkCreator(r1, r1.roomId)}
/>,
);
// Wait for RPS room 1 updates to fire
const rpsUpdated = waitForRpsUpdate();
dis.dispatch({
action: Action.ViewRoom,
room_id: "r1",
});
await rpsUpdated;
await waitFor(() => expect(screen.queryByTestId("spinner")).not.toBeInTheDocument());
// room one will be in the RoomMemberList phase - confirm this is rendered
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(1);
// wait for RPS room 2 updates to fire, then rerender
const _rpsUpdated = waitForRpsUpdate();
dis.dispatch({
action: Action.ViewRoom,
room_id: "r2",
});
await _rpsUpdated;
rerender(
<RightPanel
room={r2}
resizeNotifier={resizeNotifier}
permalinkCreator={new RoomPermalinkCreator(r2, r2.roomId)}
/>,
);
// After all that setup, now to the interesting part...
// We want to verify that as we change to room 2, we should always have
// the correct right panel state for whichever room we are showing, so we
// confirm we do not have the MemberList class on the page and that we have
// the expected room title
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(0);
expect(screen.getByRole("heading", { name: "r2" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,576 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import {
Room,
MatrixClient,
IEvent,
MatrixEvent,
EventType,
SearchResult,
ISearchResults,
} from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import { RoomSearchView } from "../../../src/components/structures/RoomSearchView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { stubClient } from "../../test-utils";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { searchPagination, SearchScope } from "../../../src/Searching";
jest.mock("../../../src/Searching", () => ({
searchPagination: jest.fn(),
SearchScope: jest.requireActual("../../../src/Searching").SearchScope,
}));
describe("<RoomSearchView/>", () => {
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const resizeNotifier = new ResizeNotifier();
let client: MatrixClient;
let room: Room;
beforeEach(async () => {
stubClient();
client = MatrixClientPeg.safeGet();
client.supportsThreads = jest.fn().mockReturnValue(true);
room = new Room("!room:server", client, client.getSafeUserId());
mocked(client.getRoom).mockReturnValue(room);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(100);
});
afterEach(async () => {
jest.restoreAllMocks();
});
it("should show a spinner before the promise resolves", async () => {
const deferred = defer<ISearchResults>();
render(
<RoomSearchView
inProgress={true}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>,
);
await screen.findByTestId("messagePanelSearchSpinner");
});
it("should render results when the promise resolves", async () => {
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$1",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Before", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "After", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
],
highlights: [],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
await screen.findByText("Before");
await screen.findByText("Foo Test Bar");
await screen.findByText("After");
});
it("should highlight words correctly", async () => {
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.Room}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: ["test"],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const text = await screen.findByText("Test");
expect(text).toHaveClass("mx_EventTile_searchHighlight");
});
it("should show spinner above results when backpaginating", async () => {
const searchResults: ISearchResults = {
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: ["test"],
next_batch: "next_batch",
count: 2,
};
mocked(searchPagination).mockResolvedValue({
...searchResults,
results: [
...searchResults.results,
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$4",
sender: client.getSafeUserId(),
origin_server_ts: 4,
content: { body: "Potato", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
next_batch: undefined,
});
const onUpdate = jest.fn();
const { rerender } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={true}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={onUpdate}
/>
</MatrixClientContext.Provider>,
);
await screen.findByRole("progressbar");
await screen.findByText("Potato");
expect(onUpdate).toHaveBeenCalledWith(false, expect.objectContaining({}));
rerender(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
expect(screen.queryByRole("progressbar")).toBeFalsy();
});
it("should handle resolutions after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
unmount();
deferred.resolve({
results: [],
highlights: [],
});
});
it("should handle rejections after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
unmount();
deferred.reject({
results: [],
highlights: [],
});
});
it("should show modal if error is encountered", async () => {
const deferred = defer<ISearchResults>();
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
deferred.reject(new Error("Some error"));
await screen.findByText("Search failed");
await screen.findByText("Some error");
});
it("should combine search results when the query is present in multiple sucessive messages", async () => {
const searchResults: ISearchResults = {
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$4",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Foo2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Between", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$5",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "After", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Foo", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [
{
room_id: room.roomId,
event_id: "$1",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Before", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
events_after: [
{
room_id: room.roomId,
event_id: "$3",
sender: client.getUserId() ?? "",
origin_server_ts: 1,
content: { body: "Between", msgtype: "m.text" },
type: EventType.RoomMessage,
},
],
},
},
eventMapper,
),
],
highlights: [],
next_batch: "",
count: 1,
};
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const beforeNode = await screen.findByText("Before");
const fooNode = await screen.findByText("Foo");
const betweenNode = await screen.findByText("Between");
const foo2Node = await screen.findByText("Foo2");
const afterNode = await screen.findByText("After");
expect((await screen.findAllByText("Between")).length).toBe(1);
expect(beforeNode.compareDocumentPosition(fooNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(fooNode.compareDocumentPosition(betweenNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(betweenNode.compareDocumentPosition(foo2Node) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(foo2Node.compareDocumentPosition(afterNode) == Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("should pass appropriate permalink creator for all rooms search", async () => {
const room2 = new Room("!room2:server", client, client.getSafeUserId());
const room3 = new Room("!room3:server", client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation(
(roomId) => [room, room2, room3].find((r) => r.roomId === roomId) ?? null,
);
render(
<MatrixClientContext.Provider value={client}>
<RoomSearchView
inProgress={false}
term="search term"
scope={SearchScope.All}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 1", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 2,
result: {
room_id: room2.roomId,
event_id: "$22",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 2,
result: {
room_id: room2.roomId,
event_id: "$23",
sender: client.getSafeUserId(),
origin_server_ts: 2,
content: { body: "Room 2 message 2", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
SearchResult.fromJson(
{
rank: 3,
result: {
room_id: room3.roomId,
event_id: "$32",
sender: client.getSafeUserId(),
origin_server_ts: 1,
content: { body: "Room 3", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
},
eventMapper,
),
],
highlights: [],
count: 1,
})}
resizeNotifier={resizeNotifier}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>,
);
const event1 = await screen.findByText("Room 1");
expect(event1.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room.roomId}/$2`,
);
const event2 = await screen.findByText("Room 2");
expect(event2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room2.roomId}/$22`,
);
const event2Message2 = await screen.findByText("Room 2 message 2");
expect(event2Message2.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room2.roomId}/$23`,
);
const event3 = await screen.findByText("Room 3");
expect(event3.closest(".mx_EventTile_line")!.querySelector("a")).toHaveAttribute(
"href",
`https://matrix.to/#/${room3.roomId}/$32`,
);
});
});

View file

@ -0,0 +1,150 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import {
MatrixClient,
PendingEventOrdering,
EventStatus,
MatrixEvent,
Room,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import RoomStatusBar, { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkEvent, stubClient } from "../../test-utils/test-utils";
import { mkThread } from "../../test-utils/threads";
describe("RoomStatusBar", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
client.getSyncStateData = jest.fn().mockReturnValue({});
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
event = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
event.status = EventStatus.NOT_SENT;
});
const getComponent = () =>
render(<RoomStatusBar room={room} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
});
describe("getUnsentMessages", () => {
it("returns no unsent messages", () => {
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("checks the event status", () => {
room.addPendingEvent(event, "123");
expect(getUnsentMessages(room)).toHaveLength(1);
event.status = EventStatus.SENT;
expect(getUnsentMessages(room)).toHaveLength(0);
});
it("only returns events related to a thread", () => {
room.addPendingEvent(event, "123");
const { rootEvent, events } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
});
rootEvent.status = EventStatus.NOT_SENT;
room.addPendingEvent(rootEvent, rootEvent.getId()!);
for (const event of events) {
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, Date.now() + Math.random() + "");
}
const pendingEvents = getUnsentMessages(room, rootEvent.getId());
expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId());
expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId());
// Filters out the non thread events
expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true);
});
});
describe("<RoomStatusBar />", () => {
it("should render nothing when room has no error or unsent messages", () => {
const { container } = getComponent();
expect(container.firstChild).toBe(null);
});
describe("unsent messages", () => {
it("should render warning when messages are unsent due to consent", () => {
const unsentMessage = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
unsentMessage.status = EventStatus.NOT_SENT;
unsentMessage.error = new MatrixError({
errcode: "M_CONSENT_NOT_GIVEN",
data: { consent_uri: "terms.com" },
});
room.addPendingEvent(unsentMessage, "123");
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("should render warning when messages are unsent due to resource limit", () => {
const unsentMessage = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!room1:server",
content: {},
});
unsentMessage.status = EventStatus.NOT_SENT;
unsentMessage.error = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: { limit_type: "monthly_active_user" },
});
room.addPendingEvent(unsentMessage, "123");
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,39 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { RoomStatusBarUnsentMessages } from "../../../src/components/structures/RoomStatusBarUnsentMessages";
import { StaticNotificationState } from "../../../src/stores/notifications/StaticNotificationState";
describe("RoomStatusBarUnsentMessages", () => {
const title = "test title";
const description = "test description";
const buttonsText = "test buttons";
const buttons = <div>{buttonsText}</div>;
beforeEach(() => {
render(
<RoomStatusBarUnsentMessages
title={title}
description={description}
buttons={buttons}
notificationState={StaticNotificationState.RED_EXCLAMATION}
/>,
);
});
it("should render the values passed as props", () => {
screen.getByText(title);
screen.getByText(description);
screen.getByText(buttonsText);
// notification state
screen.getByText("!");
});
});

View file

@ -0,0 +1,686 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, RefObject } from "react";
import { mocked, MockedObject } from "jest-mock";
import {
ClientEvent,
MatrixClient,
Room,
RoomEvent,
EventType,
JoinRule,
MatrixError,
RoomStateEvent,
MatrixEvent,
SearchResult,
IEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
stubClient,
mockPlatformPeg,
unmockPlatformPeg,
wrapInMatrixClientContext,
flushPromises,
mkEvent,
setupAsyncStoreWithClient,
filterConsole,
mkRoomMemberJoinEvent,
mkThirdPartyInviteEvent,
emitPromise,
createTestClient,
untilDispatch,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { Action } from "../../../src/dispatcher/actions";
import dis, { defaultDispatcher } from "../../../src/dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { RoomView as _RoomView } from "../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { NotificationState } from "../../../src/stores/notifications/NotificationState";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
import { DirectoryMember } from "../../../src/utils/direct-messages";
import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { SDKContext, SdkContextClass } from "../../../src/contexts/SDKContext";
import VoipUserMapper from "../../../src/VoipUserMapper";
import WidgetUtils from "../../../src/utils/WidgetUtils";
import { WidgetType } from "../../../src/widgets/WidgetType";
import WidgetStore from "../../../src/stores/WidgetStore";
import { ViewRoomErrorPayload } from "../../../src/dispatcher/payloads/ViewRoomErrorPayload";
import { SearchScope } from "../../../src/Searching";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../src/utils/crypto";
const RoomView = wrapInMatrixClientContext(_RoomView);
describe("RoomView", () => {
let cli: MockedObject<MatrixClient>;
let room: Room;
let rooms: Map<string, Room>;
let roomCount = 0;
let stores: SdkContextClass;
// mute some noise
filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability");
beforeEach(() => {
mockPlatformPeg({ reload: () => {} });
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org");
jest.spyOn(room, "findPredecessor");
room.getPendingEvents = () => [];
rooms = new Map();
rooms.set(room.roomId, room);
cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null);
// Re-emit certain events on the mocked client
room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args));
room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args));
DMRoomMap.makeShared(cli);
stores = new SdkContextClass();
stores.client = cli;
stores.rightPanelStore.useUnitTestClient(cli);
jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined);
});
afterEach(() => {
unmockPlatformPeg();
jest.clearAllMocks();
});
const mountRoomView = async (ref?: RefObject<_RoomView>): Promise<RenderResult> => {
if (stores.roomViewStore.getRoomId() !== room.roomId) {
const switchedRoom = new Promise<void>((resolve) => {
const subFn = () => {
if (stores.roomViewStore.getRoomId()) {
stores.roomViewStore.off(UPDATE_EVENT, subFn);
resolve();
}
};
stores.roomViewStore.on(UPDATE_EVENT, subFn);
});
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
await switchedRoom;
}
const roomView = render(
<SDKContext.Provider value={stores}>
<RoomView
// threepidInvite should be optional on RoomView props
// it is treated as optional in RoomView
threepidInvite={undefined as any}
resizeNotifier={new ResizeNotifier()}
forceTimeline={false}
wrappedRef={ref as any}
/>
</SDKContext.Provider>,
);
await flushPromises();
return roomView;
};
const renderRoomView = async (switchRoom = true): Promise<ReturnType<typeof render>> => {
if (switchRoom && stores.roomViewStore.getRoomId() !== room.roomId) {
const switchedRoom = new Promise<void>((resolve) => {
const subFn = () => {
if (stores.roomViewStore.getRoomId()) {
stores.roomViewStore.off(UPDATE_EVENT, subFn);
resolve();
}
};
stores.roomViewStore.on(UPDATE_EVENT, subFn);
});
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
await switchedRoom;
}
const roomView = render(
<SDKContext.Provider value={stores}>
<RoomView
// threepidInvite should be optional on RoomView props
// it is treated as optional in RoomView
threepidInvite={undefined as any}
resizeNotifier={new ResizeNotifier()}
forceTimeline={false}
onRegistered={jest.fn()}
/>
</SDKContext.Provider>,
);
await flushPromises();
return roomView;
};
const getRoomViewInstance = async (): Promise<_RoomView> => {
const ref = createRef<_RoomView>();
await mountRoomView(ref);
return ref.current!;
};
it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => {
const instance = await getRoomViewInstance();
expect(instance.getHiddenHighlightCount()).toBe(0);
});
describe("when there is an old room", () => {
let instance: _RoomView;
let oldRoom: Room;
beforeEach(async () => {
instance = await getRoomViewInstance();
oldRoom = new Room("!old:example.com", cli, cli.getSafeUserId());
rooms.set(oldRoom.roomId, oldRoom);
jest.spyOn(room, "findPredecessor").mockReturnValue({ roomId: oldRoom.roomId });
});
it("and it has 0 unreads, getHiddenHighlightCount should return 0", async () => {
jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(0);
expect(instance.getHiddenHighlightCount()).toBe(0);
// assert that msc3946ProcessDynamicPredecessor is false by default
expect(room.findPredecessor).toHaveBeenCalledWith(false);
});
it("and it has 23 unreads, getHiddenHighlightCount should return 23", async () => {
jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(23);
expect(instance.getHiddenHighlightCount()).toBe(23);
});
describe("and feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
instance.setState({ msc3946ProcessDynamicPredecessor: true });
});
afterEach(() => {
instance.setState({ msc3946ProcessDynamicPredecessor: false });
});
it("should pass the setting to findPredecessor", async () => {
expect(instance.getHiddenHighlightCount()).toBe(0);
expect(room.findPredecessor).toHaveBeenCalledWith(true);
});
});
});
it("updates url preview visibility on encryption state change", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
// we should be starting unencrypted
expect(cli.isCryptoEnabled()).toEqual(false);
expect(cli.isRoomEncrypted(room.roomId)).toEqual(false);
const roomViewInstance = await getRoomViewInstance();
// in a default (non-encrypted room, it should start out with url previews enabled)
// This is a white-box test in that we're asserting things about the state, which
// is not ideal, but asserting that a URL preview just isn't there could risk the
// test being invalid because the previews just hasn't rendered yet. This feels
// like the safest way I think?
// This also relies on the default settings being URL previews on normally and
// off for e2e rooms because 1) it's probably useful to assert this and
// 2) SettingsStore is a static class and so very hard to mock out.
expect(roomViewInstance.state.showUrlPreview).toBe(true);
// now enable encryption
cli.isCryptoEnabled.mockReturnValue(true);
cli.isRoomEncrypted.mockReturnValue(true);
// and fake an encryption event into the room to prompt it to re-check
room.addLiveEvents([
new MatrixEvent({
type: "m.room.encryption",
sender: cli.getUserId()!,
content: {},
event_id: "someid",
room_id: room.roomId,
}),
]);
// URL previews should now be disabled
expect(roomViewInstance.state.showUrlPreview).toBe(false);
});
it("updates live timeline when a timeline reset happens", async () => {
const roomViewInstance = await getRoomViewInstance();
const oldTimeline = roomViewInstance.state.liveTimeline;
room.getUnfilteredTimelineSet().resetLiveTimeline();
expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline);
});
describe("with virtual rooms", () => {
it("checks for a virtual room on initial load", async () => {
const { container } = await renderRoomView();
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId);
// quick check that rendered without error
expect(container.querySelector(".mx_ErrorBoundary")).toBeFalsy();
});
it("checks for a virtual room on room event", async () => {
await renderRoomView();
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId);
cli.emit(ClientEvent.Room, room);
// called again after room event
expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledTimes(2);
});
});
describe("video rooms", () => {
beforeEach(async () => {
// Make it a video room
room.isElementVideoRoom = () => true;
await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true);
});
it("normally doesn't open the chat panel", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false);
await mountRoomView();
expect(stores.rightPanelStore.isOpen).toEqual(false);
});
it("opens the chat panel if there are unread messages", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true);
await mountRoomView();
expect(stores.rightPanelStore.isOpen).toEqual(true);
expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline);
});
it("should render joined video room view", async () => {
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
const { asFragment } = await mountRoomView();
expect(asFragment()).toMatchSnapshot();
});
});
describe("for a local room", () => {
let localRoom: LocalRoom;
beforeEach(async () => {
localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]);
rooms.set(localRoom.roomId, localRoom);
cli.store.storeRoom(room);
});
it("should remove the room from the store on unmount", async () => {
const { unmount } = await renderRoomView();
unmount();
expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId);
});
describe("in state NEW", () => {
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
describe("that is encrypted", () => {
beforeEach(() => {
mocked(cli.isRoomEncrypted).mockReturnValue(true);
localRoom.encrypted = true;
localRoom.currentState.setStateEvents([
new MatrixEvent({
event_id: `~${localRoom.roomId}:${cli.makeTxnId()}`,
type: EventType.RoomEncryption,
content: {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
},
sender: cli.getUserId()!,
state_key: "",
room_id: localRoom.roomId,
origin_server_ts: Date.now(),
}),
]);
});
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
});
});
it("in state CREATING should match the snapshot", async () => {
localRoom.state = LocalRoomState.CREATING;
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
describe("in state ERROR", () => {
beforeEach(async () => {
localRoom.state = LocalRoomState.ERROR;
});
it("should match the snapshot", async () => {
const { container } = await renderRoomView();
expect(container).toMatchSnapshot();
});
it("clicking retry should set the room state to new dispatch a local room event", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
const { getByText } = await renderRoomView();
fireEvent.click(getByText("Retry"));
expect(localRoom.state).toBe(LocalRoomState.NEW);
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "local_room_event",
roomId: room.roomId,
});
});
});
});
describe("when rendering a DM room with a single third-party invite", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId),
mkThirdPartyInviteEvent(cli.getSafeUserId(), "user@example.com", room.roomId),
]);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId());
jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId]));
mocked(cli).isRoomEncrypted.mockReturnValue(true);
await renderRoomView();
});
it("should render the »waiting for third-party« view", () => {
expect(screen.getByText("Waiting for users to join Element")).toBeInTheDocument();
expect(
screen.getByText(
"Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted",
),
).toBeInTheDocument();
// no message composer
expect(screen.queryByText("Send an encrypted message…")).not.toBeInTheDocument();
expect(screen.queryByText("Send a message…")).not.toBeInTheDocument();
});
});
describe("when there is a RoomView", () => {
const widget1Id = "widget1";
const widget2Id = "widget2";
const otherUserId = "@other:example.com";
const addJitsiWidget = async (id: string, user: string, ts?: number): Promise<void> => {
const widgetEvent = mkEvent({
event: true,
room: room.roomId,
user,
type: "im.vector.modular.widgets",
content: {
id,
name: "Jitsi",
type: WidgetType.JITSI.preferred,
url: "https://example.com",
},
skey: id,
ts,
});
room.addLiveEvents([widgetEvent]);
room.currentState.setStateEvents([widgetEvent]);
cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null);
await flushPromises();
};
beforeEach(async () => {
jest.spyOn(WidgetUtils, "setRoomWidget");
const widgetStore = WidgetStore.instance;
await setupAsyncStoreWithClient(widgetStore, cli);
getRoomViewInstance();
});
const itShouldNotRemoveTheLastWidget = (): void => {
it("should not remove the last widget", (): void => {
expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id);
});
};
describe("and there is a Jitsi widget from another user", () => {
beforeEach(async () => {
await addJitsiWidget(widget1Id, otherUserId, 10_000);
});
describe("and the current user adds a Jitsi widget after 10s", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000);
});
it("the last Jitsi widget should be removed", () => {
expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id);
});
});
describe("and the current user adds a Jitsi widget after two minutes", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000);
});
itShouldNotRemoveTheLastWidget();
});
describe("and the current user adds a Jitsi widget without timestamp", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId());
});
itShouldNotRemoveTheLastWidget();
});
});
describe("and there is a Jitsi widget from another user without timestamp", () => {
beforeEach(async () => {
await addJitsiWidget(widget1Id, otherUserId);
});
describe("and the current user adds a Jitsi widget", () => {
beforeEach(async () => {
await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000);
});
itShouldNotRemoveTheLastWidget();
});
});
});
it("should show error view if failed to look up room alias", async () => {
const { asFragment, findByText } = await renderRoomView(false);
defaultDispatcher.dispatch<ViewRoomErrorPayload>({
action: Action.ViewRoomError,
room_alias: "#addy:server",
room_id: null,
err: new MatrixError({ errcode: "M_NOT_FOUND" }),
});
await emitPromise(stores.roomViewStore, UPDATE_EVENT);
await findByText("Are you sure you're at the right place?");
expect(asFragment()).toMatchSnapshot();
});
describe("knock rooms", () => {
const client = createTestClient();
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join");
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
jest.spyOn(dis, "dispatch");
});
it("allows to request to join", async () => {
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId });
await mountRoomView();
fireEvent.click(screen.getByRole("button", { name: "Request access" }));
await untilDispatch(Action.SubmitAskToJoin, dis);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "submit_ask_to_join",
roomId: room.roomId,
opts: { reason: undefined },
});
});
it("allows to cancel a join request", async () => {
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client);
jest.spyOn(client, "leave").mockResolvedValue({});
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
await mountRoomView();
fireEvent.click(screen.getByRole("button", { name: "Cancel request" }));
await untilDispatch(Action.CancelAskToJoin, dis);
expect(dis.dispatch).toHaveBeenCalledWith({ action: "cancel_ask_to_join", roomId: room.roomId });
});
});
it("should close search results when edit is clicked", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const roomViewRef = createRef<_RoomView>();
const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef);
// @ts-ignore - triggering a search organically is a lot of work
roomViewRef.current!.setState({
search: {
searchId: 1,
roomId: room.roomId,
term: "search term",
scope: SearchScope.Room,
promise: Promise.resolve({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
content: {
body: "search term",
msgtype: "m.text",
},
type: "m.room.message",
event_id: "$eventId",
sender: cli.getSafeUserId(),
origin_server_ts: 123456789,
room_id: room.roomId,
},
context: {
events_before: [],
events_after: [],
profile_info: {},
},
},
eventMapper,
),
],
highlights: [],
count: 1,
}),
inProgress: false,
count: 1,
},
});
await waitFor(() => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel"));
await userEvent.hover(getByText("search term"));
await userEvent.click(await findByLabelText("Edit"));
await prom;
});
it("should switch rooms when edit is clicked on a search result for a different room", async () => {
const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org");
rooms.set(room2.roomId, room2);
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const roomViewRef = createRef<_RoomView>();
const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef);
// @ts-ignore - triggering a search organically is a lot of work
roomViewRef.current!.setState({
search: {
searchId: 1,
roomId: room.roomId,
term: "search term",
scope: SearchScope.All,
promise: Promise.resolve({
results: [
SearchResult.fromJson(
{
rank: 1,
result: {
content: {
body: "search term",
msgtype: "m.text",
},
type: "m.room.message",
event_id: "$eventId",
sender: cli.getSafeUserId(),
origin_server_ts: 123456789,
room_id: room2.roomId,
},
context: {
events_before: [],
events_after: [],
profile_info: {},
},
},
eventMapper,
),
],
highlights: [],
count: 1,
}),
inProgress: false,
count: 1,
},
});
await waitFor(() => {
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible();
});
const prom = untilDispatch(Action.ViewRoom, dis);
await userEvent.hover(getByText("search term"));
await userEvent.click(await findByLabelText("Edit"));
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
});
it("fires Action.RoomLoaded", async () => {
jest.spyOn(dis, "dispatch");
await mountRoomView();
expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded });
});
});

View file

@ -0,0 +1,328 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked } from "jest-mock";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { HierarchyRoom, JoinRule, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { mkStubRoom, stubClient } from "../../test-utils";
import dispatcher from "../../../src/dispatcher/dispatcher";
import SpaceHierarchy, { showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy";
import { Action } from "../../../src/dispatcher/actions";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import SettingsStore from "../../../src/settings/SettingsStore";
describe("SpaceHierarchy", () => {
describe("showRoom", () => {
let client: MatrixClient;
let hierarchy: RoomHierarchy;
let room: Room;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.safeGet();
room = new Room("room-id", client, "@alice:example.com");
hierarchy = new RoomHierarchy(room);
jest.spyOn(client, "isGuest").mockReturnValue(false);
jest.spyOn(hierarchy.roomMap, "get").mockReturnValue({
children_state: [],
room_id: "room-id2",
canonical_alias: "canonical-alias",
aliases: ["uncanonical-alias", "canonical-alias"],
world_readable: true,
guest_can_join: false,
num_joined_members: 35,
});
jest.spyOn(dispatcher, "dispatch");
});
it("shows room", () => {
showRoom(client, hierarchy, "room-id2");
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
should_peek: true,
room_alias: "canonical-alias",
room_id: "room-id2",
via_servers: [],
oob_data: {
avatarUrl: undefined,
name: "canonical-alias",
},
roomType: undefined,
metricsTrigger: "RoomDirectory",
});
});
});
describe("toLocalRoom", () => {
stubClient();
const client = MatrixClientPeg.safeGet();
const roomV1 = mkStubRoom("room-id-1", "Room V1", client);
const roomV2 = mkStubRoom("room-id-2", "Room V2", client);
const roomV3 = mkStubRoom("room-id-3", "Room V3", client);
jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]);
it("grabs last room that is in hierarchy when latest version is in hierarchy", () => {
const hierarchy = {
roomMap: new Map([
[roomV1.roomId, { room_id: roomV1.roomId } as HierarchyRoom],
[roomV2.roomId, { room_id: roomV2.roomId } as HierarchyRoom],
[roomV3.roomId, { room_id: roomV3.roomId } as HierarchyRoom],
]),
} as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV3.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV3.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV3.roomId);
});
it("grabs last room that is in hierarchy when latest version is *not* in hierarchy", () => {
const hierarchy = {
roomMap: new Map([
[roomV1.roomId, { room_id: roomV1.roomId } as HierarchyRoom],
[roomV2.roomId, { room_id: roomV2.roomId } as HierarchyRoom],
]),
} as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV2.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV2.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV2.roomId);
});
it("returns specified room when none of the versions is in hierarchy", () => {
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV1.room_id).toEqual(roomV1.roomId);
const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV2.room_id).toEqual(roomV2.roomId);
const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as HierarchyRoom, hierarchy);
expect(localRoomV3.room_id).toEqual(roomV3.roomId);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
mocked(client.getRoomUpgradeHistory).mockClear();
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(roomV1.roomId, true, false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
mocked(client.getRoomUpgradeHistory).mockClear();
const hierarchy = { roomMap: new Map([]) } as RoomHierarchy;
toLocalRoom(client, { room_id: roomV1.roomId } as HierarchyRoom, hierarchy);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(roomV1.roomId, true, true);
});
});
});
describe("<SpaceHierarchy />", () => {
beforeEach(() => {
// IntersectionObserver isn't available in test environment
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
} as ResizeObserver);
window.IntersectionObserver = mockIntersectionObserver;
});
stubClient();
const client = MatrixClientPeg.safeGet();
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
const root = mkStubRoom("space-id-1", "Space 1", client);
const room1 = mkStubRoom("room-id-2", "Room 1", client);
const room2 = mkStubRoom("room-id-3", "Room 2", client);
const space1 = mkStubRoom("space-id-4", "Space 2", client);
const room3 = mkStubRoom("room-id-5", "Room 3", client);
mocked(client.getRooms).mockReturnValue([root]);
mocked(client.getRoom).mockImplementation(
(roomId) => client.getRooms().find((room) => room.roomId === roomId) ?? null,
);
[room1, room2, space1, room3].forEach((r) => mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave));
const hierarchyRoot: HierarchyRoom = {
room_id: root.roomId,
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: room1.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: room2.roomId,
content: { order: "2" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: space1.roomId,
content: { order: "3" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
{
state_key: "!knock1:server",
content: { order: "4" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom1: HierarchyRoom = {
room_id: room1.roomId,
num_joined_members: 2,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom2: HierarchyRoom = {
room_id: room2.roomId,
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchyRoom3: HierarchyRoom = {
name: "Nested room",
room_id: room3.roomId,
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
};
const hierarchySpace1: HierarchyRoom = {
room_id: space1.roomId,
name: "Nested space",
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: room3.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
const hierarchyKnockRoom1: HierarchyRoom = {
room_id: "!knock1:server",
name: "Knock room",
num_joined_members: 3,
children_state: [],
world_readable: true,
guest_can_join: true,
join_rule: JoinRule.Knock,
};
mocked(client.getRoomHierarchy).mockResolvedValue({
rooms: [
hierarchyRoot,
hierarchyRoom1,
hierarchyRoom2,
hierarchySpace1,
hierarchyRoom3,
hierarchyKnockRoom1,
],
});
const defaultProps = {
space: root,
showRoom: jest.fn(),
};
const getComponent = (props = {}): React.ReactElement => (
<MatrixClientContext.Provider value={client}>
<SpaceHierarchy {...defaultProps} {...props} />
</MatrixClientContext.Provider>
);
it("renders", async () => {
const { asFragment } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
expect(asFragment()).toMatchSnapshot();
});
it("should join subspace when joining nested room", async () => {
mocked(client.joinRoom).mockResolvedValue({} as Room);
const { getByText } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
const button = getByText("Nested room")!.closest("li")!.querySelector(".mx_AccessibleButton_kind_primary")!;
fireEvent.click(button);
await waitFor(() => {
expect(client.joinRoom).toHaveBeenCalledTimes(2);
});
// Joins subspace
expect(client.joinRoom).toHaveBeenCalledWith(space1.roomId, expect.any(Object));
expect(client.joinRoom).toHaveBeenCalledWith(room3.roomId, expect.any(Object));
});
it("should take user to view room for unjoined knockable rooms", async () => {
jest.spyOn(dispatcher, "dispatch");
const { getByText } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
const button = getByText("Knock room")!
.closest("li")!
.querySelector(".mx_AccessibleButton_kind_primary_outline")!;
fireEvent.click(button);
expect(defaultProps.showRoom).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
hierarchyKnockRoom1.room_id,
undefined,
);
});
});
});

View file

@ -0,0 +1,74 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, fireEvent, render } from "jest-matrix-react";
import TabbedView, { Tab, TabLocation } from "../../../src/components/structures/TabbedView";
import { NonEmptyArray } from "../../../src/@types/common";
import { _t } from "../../../src/languageHandler";
describe("<TabbedView />", () => {
const generalTab = new Tab("GENERAL", "common|general", "general", <div>general</div>);
const labsTab = new Tab("LABS", "common|labs", "labs", <div>labs</div>);
const securityTab = new Tab("SECURITY", "common|security", "security", <div>security</div>);
const defaultProps = {
tabLocation: TabLocation.LEFT,
tabs: [generalTab, labsTab, securityTab] as NonEmptyArray<Tab<any>>,
onChange: () => {},
};
const getComponent = (
props: {
activeTabId: "GENERAL" | "LABS" | "SECURITY";
onChange?: () => any;
tabs?: NonEmptyArray<Tab<any>>;
} = {
activeTabId: "GENERAL",
},
): React.ReactElement => <TabbedView {...defaultProps} {...props} />;
const getTabTestId = (tab: Tab<string>): string => `settings-tab-${tab.id}`;
const getActiveTab = (container: HTMLElement): Element | undefined =>
container.getElementsByClassName("mx_TabbedView_tabLabel_active")[0];
const getActiveTabBody = (container: HTMLElement): Element | undefined =>
container.getElementsByClassName("mx_TabbedView_tabPanel")[0];
it("renders tabs", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("renders activeTabId tab as active when valid", () => {
const { container } = render(getComponent({ activeTabId: securityTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(securityTab.label));
expect(getActiveTabBody(container)?.textContent).toEqual("security");
});
it("calls onchange on on tab click", () => {
const onChange = jest.fn();
const { getByTestId } = render(getComponent({ activeTabId: "GENERAL", onChange }));
act(() => {
fireEvent.click(getByTestId(getTabTestId(securityTab)));
});
expect(onChange).toHaveBeenCalledWith(securityTab.id);
});
it("keeps same tab active when order of tabs changes", () => {
// start with middle tab active
const { container, rerender } = render(getComponent({ activeTabId: labsTab.id }));
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));
rerender(getComponent({ tabs: [labsTab, generalTab, securityTab], activeTabId: labsTab.id }));
// labs tab still active
expect(getActiveTab(container)?.textContent).toEqual(_t(labsTab.label));
});
});

View file

@ -0,0 +1,288 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, fireEvent, waitFor, getByRole } from "jest-matrix-react";
import { mocked } from "jest-mock";
import {
MatrixClient,
MatrixEvent,
PendingEventOrdering,
Room,
FeatureSupport,
Thread,
} from "matrix-js-sdk/src/matrix";
import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from "../../../src/components/structures/ThreadPanel";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { _t } from "../../../src/languageHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils";
import { mkThread } from "../../test-utils/threads";
import { IRoomState } from "../../../src/components/structures/RoomView";
jest.mock("../../../src/utils/Feedback");
describe("ThreadPanel", () => {
describe("Header", () => {
it("expect that All filter for ThreadPanelHeader properly renders Show: All threads", () => {
const { asFragment } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("expect that My filter for ThreadPanelHeader properly renders Show: My threads", () => {
const { asFragment } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.My} setFilterOption={() => undefined} />,
);
expect(asFragment()).toMatchSnapshot();
});
it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => {
const { container } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
const found = container.querySelector(".mx_ThreadPanel_dropdown");
expect(found).toBeTruthy();
expect(screen.queryByRole("menu")).toBeFalsy();
fireEvent.click(found!);
expect(screen.queryByRole("menu")).toBeTruthy();
});
it("expect that ThreadPanelHeader has the correct option selected in the context menu", () => {
const { container } = render(
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />,
);
fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!);
const found = screen.queryAllByRole("menuitemradio");
expect(found).toHaveLength(2);
const foundButton = screen.queryByRole("menuitemradio", { checked: true });
expect(foundButton?.textContent).toEqual(
`${_t("threads|all_threads")}${_t("threads|all_threads_description")}`,
);
expect(foundButton).toMatchSnapshot();
});
it("sends an unthreaded read receipt when the Mark All Threads Read button is clicked", async () => {
const mockClient = createTestClient();
const mockEvent = {} as MatrixEvent;
const mockRoom = mkRoom(mockClient, "!roomId:example.org");
mockRoom.getLastLiveEvent.mockReturnValue(mockEvent);
const roomContextObject = {
room: mockRoom,
} as unknown as IRoomState;
const { container } = render(
<RoomContext.Provider value={roomContextObject}>
<MatrixClientContext.Provider value={mockClient}>
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />
</MatrixClientContext.Provider>
</RoomContext.Provider>,
);
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
await waitFor(() =>
expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(mockEvent, expect.anything(), true),
);
});
it("doesn't send a receipt if no room is in context", async () => {
const mockClient = createTestClient();
const { container } = render(
<MatrixClientContext.Provider value={mockClient}>
<ThreadPanelHeader filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />
</MatrixClientContext.Provider>,
);
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled());
});
});
describe("Filtering", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let mockClient: MatrixClient;
let room: Room;
const TestThreadPanel = () => (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider
value={getRoomContext(room, {
canSendMessages: true,
})}
>
<ThreadPanel
roomId={ROOM_ID}
onClose={jest.fn()}
resizeNotifier={new ResizeNotifier()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
beforeEach(async () => {
jest.clearAllMocks();
stubClient();
mockPlatformPeg();
mockClient = mocked(MatrixClientPeg.safeGet());
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(room, "fetchRoomThreads").mockReturnValue(Promise.resolve());
jest.spyOn(mockClient, "getRoom").mockReturnValue(room);
await room.createThreadsTimelineSets();
const [allThreads, myThreads] = room.threadsTimelineSets;
jest.spyOn(room, "createThreadsTimelineSets").mockReturnValue(Promise.resolve([allThreads!, myThreads!]));
});
function toggleThreadFilter(container: HTMLElement, newFilter: ThreadFilterType) {
fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")!);
const found = screen.queryAllByRole("menuitemradio");
expect(found).toHaveLength(2);
const allThreadsContent = `${_t("threads|all_threads")}${_t("threads|all_threads_description")}`;
const myThreadsContent = `${_t("threads|my_threads")}${_t("threads|my_threads_description")}`;
const allThreadsOption = found.find((it) => it.textContent === allThreadsContent);
const myThreadsOption = found.find((it) => it.textContent === myThreadsContent);
expect(allThreadsOption).toBeTruthy();
expect(myThreadsOption).toBeTruthy();
const toSelect = newFilter === ThreadFilterType.My ? myThreadsOption : allThreadsOption;
fireEvent.click(toSelect!);
}
type EventData = { sender: string | null; content: string | null };
function findEvents(container: HTMLElement): EventData[] {
return Array.from(container.querySelectorAll(".mx_EventTile")).map((el) => {
const sender = el.querySelector(".mx_DisambiguatedProfile_displayName")?.textContent ?? null;
const content = el.querySelector(".mx_EventTile_body")?.textContent ?? null;
return { sender, content };
});
}
function toEventData(event: MatrixEvent): EventData {
return { sender: event.event.sender ?? null, content: event.event.content?.body ?? null };
}
it("correctly filters Thread List with multiple threads", async () => {
const otherThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [mockClient.getUserId()!],
});
const mixedThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [SENDER, mockClient.getUserId()!],
});
const ownThread = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
const threadRoots = [otherThread.rootEvent, mixedThread.rootEvent, ownThread.rootEvent];
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => {
const event = threadRoots.find((it) => it.getId() === eventId)?.event;
return event ? Promise.resolve(event) : Promise.reject();
});
const [allThreads, myThreads] = room.threadsTimelineSets;
allThreads!.addLiveEvent(otherThread.rootEvent);
allThreads!.addLiveEvent(mixedThread.rootEvent);
allThreads!.addLiveEvent(ownThread.rootEvent);
myThreads!.addLiveEvent(mixedThread.rootEvent);
myThreads!.addLiveEvent(ownThread.rootEvent);
let events: EventData[] = [];
const renderResult = render(<TestThreadPanel />);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(3);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
expect(events[1]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[2]).toEqual(toEventData(ownThread.rootEvent));
await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy());
toggleThreadFilter(renderResult.container, ThreadFilterType.My);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(2);
});
expect(events[0]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[1]).toEqual(toEventData(ownThread.rootEvent));
toggleThreadFilter(renderResult.container, ThreadFilterType.All);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(3);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
expect(events[1]).toEqual(toEventData(mixedThread.rootEvent));
expect(events[2]).toEqual(toEventData(ownThread.rootEvent));
});
it("correctly filters Thread List with a single, unparticipated thread", async () => {
const otherThread = mkThread({
room,
client: mockClient,
authorId: SENDER,
participantUserIds: [mockClient.getUserId()!],
});
const threadRoots = [otherThread.rootEvent];
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation((_, eventId) => {
const event = threadRoots.find((it) => it.getId() === eventId)?.event;
return event ? Promise.resolve(event) : Promise.reject();
});
const [allThreads] = room.threadsTimelineSets;
allThreads!.addLiveEvent(otherThread.rootEvent);
let events: EventData[] = [];
const renderResult = render(<TestThreadPanel />);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(1);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy());
toggleThreadFilter(renderResult.container, ThreadFilterType.My);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(0);
});
toggleThreadFilter(renderResult.container, ThreadFilterType.All);
await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy());
await waitFor(() => {
events = findEvents(renderResult.container);
expect(findEvents(renderResult.container)).toHaveLength(1);
});
expect(events[0]).toEqual(toEventData(otherThread.rootEvent));
});
});
});

View file

@ -0,0 +1,212 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { act, getByTestId, render, RenderResult, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import {
MsgType,
RelationType,
EventStatus,
MatrixEvent,
Room,
MatrixClient,
PendingEventOrdering,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import React, { useState } from "react";
import ThreadView from "../../../src/components/structures/ThreadView";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { Action } from "../../../src/dispatcher/actions";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { mockPlatformPeg } from "../../test-utils/platform";
import { getRoomContext } from "../../test-utils/room";
import { mkMessage, stubClient } from "../../test-utils/test-utils";
import { mkThread } from "../../test-utils/threads";
describe("ThreadView", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let mockClient: MatrixClient;
let room: Room;
let rootEvent: MatrixEvent;
let changeEvent: (event: MatrixEvent) => void;
function TestThreadView({ initialEvent }: { initialEvent?: MatrixEvent }) {
const [event, setEvent] = useState(rootEvent);
changeEvent = setEvent;
return (
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider
value={getRoomContext(room, {
canSendMessages: true,
})}
>
<ThreadView
room={room}
onClose={jest.fn()}
mxEvent={event}
initialEvent={initialEvent}
resizeNotifier={new ResizeNotifier()}
/>
</RoomContext.Provider>
,
</MatrixClientContext.Provider>
);
}
async function getComponent(initialEvent?: MatrixEvent): Promise<RenderResult> {
const renderResult = render(<TestThreadView initialEvent={initialEvent} />);
await waitFor(() => {
expect(() => getByTestId(renderResult.container, "spinner")).toThrow();
});
return renderResult;
}
async function sendMessage(container: HTMLElement, text: string): Promise<void> {
const composer = getByTestId(container, "basicmessagecomposer");
await userEvent.click(composer);
await userEvent.keyboard(text);
const sendMessageBtn = getByTestId(container, "sendmessagebtn");
await userEvent.click(sendMessageBtn);
}
function expectedMessageBody(rootEvent: MatrixEvent, message: string) {
return {
"body": message,
"m.relates_to": {
"event_id": rootEvent.getId(),
"is_falling_back": true,
"m.in_reply_to": {
event_id: rootEvent
.getThread()!
.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name);
})!
.getId(),
},
"rel_type": RelationType.Thread,
},
"msgtype": MsgType.Text,
"m.mentions": {},
};
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
mockPlatformPeg();
mockClient = mocked(MatrixClientPeg.safeGet());
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const res = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
rootEvent = res.rootEvent;
DMRoomMap.makeShared(mockClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(SENDER);
});
it("does not include pending root event in the timeline twice", async () => {
rootEvent = mkMessage({
user: mockClient.getUserId()!,
event: true,
room: room.roomId,
msg: "root event message " + Math.random(),
});
rootEvent.status = EventStatus.SENDING;
rootEvent.setTxnId("1234");
room.addPendingEvent(rootEvent, "1234");
room.updatePendingEvent(rootEvent, EventStatus.SENT, rootEvent.getId());
const { container } = await getComponent();
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(1);
});
it("sends a message with the correct fallback", async () => {
const { container } = await getComponent();
await sendMessage(container, "Hello world!");
expect(mockClient.sendMessage).toHaveBeenCalledWith(
ROOM_ID,
rootEvent.getId(),
expectedMessageBody(rootEvent, "Hello world!"),
);
});
it("sends a thread message with the correct fallback", async () => {
const { container } = await getComponent();
const { rootEvent: rootEvent2 } = mkThread({
room,
client: mockClient,
authorId: mockClient.getUserId()!,
participantUserIds: [mockClient.getUserId()!],
});
act(() => {
changeEvent(rootEvent2);
});
await sendMessage(container, "yolo");
expect(mockClient.sendMessage).toHaveBeenCalledWith(
ROOM_ID,
rootEvent2.getId(),
expectedMessageBody(rootEvent2, "yolo"),
);
});
it("sets the correct thread in the room view store", async () => {
// expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull();
const { unmount } = await getComponent();
waitFor(() => {
expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId());
});
unmount();
await waitFor(() => expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull());
});
it("clears highlight message in the room view store", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(room.roomId);
const mock = jest.spyOn(dispatcher, "dispatch");
const { unmount } = await getComponent(rootEvent);
mock.mockClear();
unmount();
expect(mock).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { jest } from "@jest/globals";
import { Room } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../test-utils";
import ContentMessages from "../../../src/ContentMessages";
import { RoomUpload } from "../../../src/models/RoomUpload";
import UploadBar from "../../../src/components/structures/UploadBar";
describe("UploadBar", () => {
const client = stubClient();
const room = new Room("!room;server", client, client.getSafeUserId());
it("should pluralise 5 files correctly", () => {
jest.spyOn(ContentMessages.sharedInstance(), "getCurrentUploads").mockReturnValue([
new RoomUpload(room.roomId, "file1.jpg", undefined, 420),
new RoomUpload(room.roomId, "file2"),
new RoomUpload(room.roomId, "file3"),
new RoomUpload(room.roomId, "file4"),
new RoomUpload(room.roomId, "file5"),
]);
const { getByText } = render(<UploadBar room={room} />);
expect(getByText("Uploading file1.jpg and 4 others (420 B)")).toBeInTheDocument();
});
it("should render a single upload correctly", () => {
jest.spyOn(ContentMessages.sharedInstance(), "getCurrentUploads").mockReturnValue([
new RoomUpload(room.roomId, "file1.zip", undefined, 420000000),
]);
const { getByText } = render(<UploadBar room={room} />);
expect(getByText("Uploading file1.zip (400.54 MB)")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,224 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render, RenderResult, screen, waitFor } from "jest-matrix-react";
import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import UnwrappedUserMenu from "../../../src/components/structures/UserMenu";
import { stubClient, wrapInSdkContext } from "../../test-utils";
import {
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog";
import Modal from "../../../src/Modal";
import SettingsStore from "../../../src/settings/SettingsStore";
import { Features } from "../../../src/settings/Settings";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
import { Action } from "../../../src/dispatcher/actions";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
describe("<UserMenu>", () => {
let client: MatrixClient;
let renderResult: RenderResult;
let sdkContext: TestSdkContext;
beforeEach(() => {
sdkContext = new TestSdkContext();
});
describe("<UserMenu> when video broadcast", () => {
let voiceBroadcastInfoEvent: MatrixEvent;
let voiceBroadcastRecording: VoiceBroadcastRecording;
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
beforeAll(() => {
client = stubClient();
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
client.getUserId() || "",
client.getDeviceId() || "",
);
});
beforeEach(() => {
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client);
});
describe("when rendered", () => {
beforeEach(() => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
});
it("should render as expected", () => {
expect(renderResult.container).toMatchSnapshot();
});
describe("and a live voice broadcast starts", () => {
beforeEach(() => {
act(() => {
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
});
});
it("should render the live voice broadcast avatar addon", () => {
expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument();
});
describe("and the broadcast ends", () => {
beforeEach(() => {
act(() => {
voiceBroadcastRecordingsStore.clearCurrent();
});
});
it("should not render the live voice broadcast avatar addon", () => {
expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument();
});
});
});
});
});
describe("<UserMenu> logout", () => {
beforeEach(() => {
client = stubClient();
});
it("should logout directly if no crypto", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
jest.spyOn(client, "getCrypto").mockReturnValue(undefined);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({ action: "logout" });
});
});
it("should logout directly if no encrypted rooms", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({ action: "logout" });
});
});
it("should show dialog if some encrypted rooms", async () => {
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
renderResult = render(<UserMenu isPanelCollapsed={true} />);
mocked(client.getRooms).mockReturnValue([
{
roomId: "!room0",
} as unknown as Room,
{
roomId: "!room1",
} as unknown as Room,
]);
const crypto = client.getCrypto()!;
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockImplementation(async (roomId: string) => {
return roomId === "!room0";
});
const spy = jest.spyOn(Modal, "createDialog");
screen.getByRole("button", { name: /User menu/i }).click();
(await screen.findByRole("menuitem", { name: /Sign out/i })).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(LogoutDialog);
});
});
});
it("should render 'Link new device' button in OIDC native mode", async () => {
sdkContext.client = stubClient();
mocked(sdkContext.client.getAuthIssuer).mockResolvedValue({ issuer: "https://issuer/" });
const openIdMetadata = mockOpenIdConfiguration("https://issuer/");
openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE);
fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata);
fetchMock.get("https://issuer/jwks", {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
mocked(sdkContext.client.getVersions).mockResolvedValue({
versions: [],
unstable_features: {
"org.matrix.msc4108": true,
},
});
mocked(sdkContext.client.waitForClientWellKnown).mockResolvedValue({});
mocked(sdkContext.client.getCrypto).mockReturnValue({
isCrossSigningReady: jest.fn().mockResolvedValue(true),
exportSecretsBundle: jest.fn().mockResolvedValue({}),
} as unknown as CryptoApi);
await SettingsStore.setValue(Features.OidcNativeFlow, null, SettingLevel.DEVICE, true);
const spy = jest.spyOn(defaultDispatcher, "dispatch");
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
render(<UserMenu isPanelCollapsed={true} />);
screen.getByRole("button", { name: /User menu/i }).click();
await expect(screen.findByText("Link new device")).resolves.toBeInTheDocument();
// Assert the QR code is shown directly
screen.getByRole("menuitem", { name: "Link new device" }).click();
await waitFor(() => {
expect(spy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
props: { showMsc4108QrCode: true },
});
});
});
});

View file

@ -0,0 +1,63 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import React from "react";
import ViewSource from "../../../src/components/structures/ViewSource";
import { mkEvent, stubClient, mkMessage } from "../../test-utils/test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
describe("ViewSource", () => {
const ROOM_ID = "!roomId:example.org";
const SENDER = "@alice:example.org";
let redactedMessageEvent: MatrixEvent;
const redactionEvent = mkEvent({
user: SENDER,
event: true,
type: EventType.RoomRedaction,
content: {},
});
beforeEach(() => {
redactedMessageEvent = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
room_id: ROOM_ID,
sender: SENDER,
content: {},
state_key: undefined,
});
redactedMessageEvent.makeRedacted(redactionEvent, new Room(ROOM_ID, stubClient(), SENDER));
});
beforeEach(stubClient);
// See https://github.com/vector-im/element-web/issues/24165
it("doesn't error when viewing redacted encrypted messages", () => {
// Sanity checks
expect(redactedMessageEvent.isEncrypted()).toBeTruthy();
// @ts-ignore clearEvent is private, but it's being used directly <ViewSource />
expect(redactedMessageEvent.clearEvent).toBe(undefined);
expect(() => render(<ViewSource mxEvent={redactedMessageEvent} onFinished={() => {}} />)).not.toThrow();
});
it("should show edit button if we are the sender and can post an edit", () => {
const event = mkMessage({
msg: "Test",
user: MatrixClientPeg.get()!.getSafeUserId(),
room: ROOM_ID,
event: true,
});
const { getByRole } = render(<ViewSource mxEvent={event} onFinished={() => {}} />);
expect(getByRole("button", { name: "Edit" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilePanel renders empty state 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_FilePanel"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<p
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 mx_BaseCard_header_title_heading"
role="heading"
>
Files
</p>
</div>
<button
aria-labelledby="floating-ui-1"
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_RoomView_messagePanel mx_RoomView_messageListWrapper"
>
<div
class="mx_RoomView_empty"
>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
>
<svg
fill="currentColor"
height="32px"
viewBox="0 0 24 24"
width="32px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22c-.55 0-1.02-.196-1.412-.587A1.926 1.926 0 0 1 4 20V4c0-.55.196-1.02.588-1.413A1.926 1.926 0 0 1 6 2h7.175a1.975 1.975 0 0 1 1.4.575l4.85 4.85a1.975 1.975 0 0 1 .575 1.4V20c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 18 22H6Zm7-14V4H6v16h12V9h-4a.968.968 0 0 1-.713-.287A.967.967 0 0 1 13 8Z"
/>
</svg>
<p
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
>
No files visible in this room
</p>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
Attach files from chat or just drag and drop them anywhere in a room.
</p>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MainSplit/> renders 1`] = `
<DocumentFragment>
<div
class="mx_MainSplit"
>
<div>
Child
<span>
Foo
</span>
Bar
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 320px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel
</div>
<div>
<div
class="mx_ResizeHandle--horizontal"
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; left: -5px; cursor: col-resize;"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<MainSplit/> respects defaultSize prop 1`] = `
<DocumentFragment>
<div
class="mx_MainSplit"
>
<div>
Child
<span>
Foo
</span>
Bar
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 500px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel
</div>
<div>
<div
class="mx_ResizeHandle--horizontal"
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; left: -5px; cursor: col-resize;"
/>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,430 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens after a session is restored 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens during crypto init 1`] = `
<body
style="--emoji-font-family: Twemoji;"
>
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
</body>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we are checking the sync store 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we were waiting for the lock ourselves 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Test is connected in another tab
</h1>
<h2>
Switch to the other tab to connect to Test. This tab can now be closed.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 1`] = `
<div>
<div
class="mx_ConfirmSessionLockTheftView"
>
<div
class="mx_ConfirmSessionLockTheftView_body"
>
<p>
Test is open in another window. Click "Continue" to use Test here and disconnect the other window.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Continue
</div>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 2`] = `
<div>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 3`] = `
<div>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
>
<div
class="mx_Welcome"
data-testid="mx_welcome_screen"
>
<div
class="mx_WelcomePage mx_WelcomePage_loggedIn"
>
<div
class="mx_WelcomePage_body"
>
<h1>
Hello
</h1>
</div>
</div>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
powered by Matrix
</a>
</footer>
</div>
</div>
`;
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
<div>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> with a soft-logged-out session should show the soft-logout page 1`] = `
<div>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
>
<div
class="mx_AuthHeader"
>
<aside
class="mx_AuthHeaderLogo"
>
Matrix
</aside>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
<main
class="mx_AuthBody"
>
<h1>
You're signed out
</h1>
<h2>
Sign in
</h2>
<div>
<form>
<p>
Enter your password to sign in and regain access to your account.
</p>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_Field_1"
label="Password"
placeholder="Password"
type="password"
value=""
/>
<label
for="mx_Field_1"
>
Password
</label>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
type="submit"
>
Sign in
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Forgotten your password?
</div>
</form>
</div>
<h2>
Clear personal data
</h2>
<p>
Warning: your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.
</p>
<div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
role="button"
tabindex="0"
>
Clear all data
</div>
</div>
</main>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
powered by Matrix
</a>
</footer>
</div>
</div>
`;
exports[`<MatrixChat /> with an existing session onAction() room actions leave_room for a room should launch a confirmation modal 1`] = `
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Leave room
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<span>
Are you sure you want to leave the room '!room:server.org'?
</span>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Leave
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;
exports[`<MatrixChat /> with an existing session onAction() room actions leave_room for a space should launch a confirmation modal 1`] = `
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Leave space
</h1>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<span>
Are you sure you want to leave the space '!spaceRoom:server.org'?
</span>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Leave
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
`;

View file

@ -0,0 +1,141 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MessagePanel should handle large numbers of hidden events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
/>
</div>
</div>
</DocumentFragment>
`;
exports[`MessagePanel should handle lots of membership events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
>
<li
data-scroll-tokens="__scroll_tokens__"
data-testid="__testid__"
>
<div
aria-label="Thu, Jan 1, 1970"
class="mx_TimelineSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Thu, Jan 1, 1970
</h2>
</div>
<hr
role="none"
/>
</div>
</li>
<div
class="mx_EventTileBubble mx_HistoryTile"
>
<div
class="mx_EventTileBubble_title"
>
You can't see earlier messages
</div>
</div>
<li
class="mx_GenericEventListSummary"
data-expanded="false"
data-layout="group"
data-scroll-tokens="__scroll_tokens__"
data-testid="__testid__"
>
<div
aria-expanded="false"
class="mx_AccessibleButton mx_GenericEventListSummary_toggle mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Expand
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_EventTile_info"
>
<span
class="mx_GenericEventListSummary_avatars"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="1"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@user:id"
>
u
</span>
</span>
<span
class="mx_TextualEvent mx_GenericEventListSummary_summary"
>
<span>
@user:id made no changes 100 times
</span>
</span>
</div>
</div>
</li>
</ol>
</div>
</div>
</DocumentFragment>
`;
exports[`MessagePanel should handle lots of room creation events quickly 1`] = `
<DocumentFragment>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel cls"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
style="height: 400px;"
/>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
<div>
content 2
<br />
content 2.2
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 2
<br />
content 2.2
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
</aside>
</div>
`;
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
>
<div>
content 1
</div>
</aside>
</div>
`;

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to consent 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
<span>
You can't send any messages until you review and agree to
<a
class="mx_ExternalLink"
rel="noreferrer noopener"
target="_blank"
>
our terms and conditions
<i
class="mx_ExternalLink_icon"
/>
</a>
.
</span>
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentCancelAllBtn"
role="button"
tabindex="0"
>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
Retry all
</div>
</div>
</div>
</div>
</div>
`;
exports[`RoomStatusBar <RoomStatusBar /> unsent messages should render warning when messages are unsent due to resource limit 1`] = `
<div>
<div
class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"
>
<div
role="alert"
>
<div
class="mx_RoomStatusBar_unsentBadge"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_2char cpd-theme-light"
>
<span
class="mx_NotificationBadge_count"
>
!
</span>
</div>
</div>
<div>
<div
class="mx_RoomStatusBar_unsentTitle"
>
Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.
</div>
<div
class="mx_RoomStatusBar_unsentDescription"
>
You can select all or individual messages to retry or delete
</div>
</div>
<div
class="mx_RoomStatusBar_unsentButtonBar"
>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentCancelAllBtn"
role="button"
tabindex="0"
>
Delete all
</div>
<div
class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry"
role="button"
tabindex="0"
>
Retry all
</div>
</div>
</div>
</div>
</div>
`;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,426 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<DocumentFragment>
<div
class="mx_SearchBox mx_textinput"
>
<input
autocomplete="off"
class="mx_textinput_icon mx_textinput_search mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
data-testid="searchbox-input"
placeholder="Search names and descriptions"
type="text"
value=""
/>
<div
class="mx_AccessibleButton mx_SearchBox_closeButton"
role="button"
tabindex="-1"
/>
</div>
<div
class="mx_SpaceHierarchy_listHeader"
>
<h4
class="mx_SpaceHierarchy_listHeader_header"
>
Rooms and spaces
</h4>
<div
class="mx_SpaceHierarchy_listHeader_buttons"
>
<div
aria-disabled="true"
aria-label="Remove"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Remove
</div>
<div
aria-disabled="true"
aria-label="Mark as not suggested"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Mark as not suggested
</div>
</div>
</div>
<ul
aria-label="Space"
class="mx_SpaceHierarchy_list"
role="tree"
>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="0"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="5"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Unnamed Room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
2 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_vY7Q4uEh9K"
tabindex="0"
type="checkbox"
/>
<label
for="checkbox_vY7Q4uEh9K"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Unnamed Room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_38QgU2Pomx"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_38QgU2Pomx"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Knock room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="-1"
>
View
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_wKpa6hpi3Y"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_wKpa6hpi3Y"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
</div>
</li>
<li
aria-expanded="true"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile mx_SpaceHierarchy_subspace"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Nested space
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
1 member · 1 room
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
id="checkbox_EetmBG4yVC"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_EetmBG4yVC"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<div
class="mx_SpaceHierarchy_subspace_toggle mx_SpaceHierarchy_subspace_toggle_shown"
/>
</div>
<div
class="mx_SpaceHierarchy_subspace_children"
role="group"
/>
</li>
<li
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
Nested room
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="floating-ui-9"
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
disabled=""
id="checkbox_eEefiPqpMR"
tabindex="-1"
type="checkbox"
/>
<label
for="checkbox_eEefiPqpMR"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</span>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;

View file

@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TabbedView /> renders tabs 1`] = `
<div>
<div
class="mx_TabbedView mx_TabbedView_tabsOnLeft"
>
<ul
aria-orientation="vertical"
class="mx_TabbedView_tabLabels"
role="tablist"
>
<li
aria-controls="mx_tabpanel_GENERAL"
aria-selected="true"
class="mx_AccessibleButton mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active"
data-testid="settings-tab-GENERAL"
role="tab"
tabindex="0"
>
<span
class="mx_TabbedView_maskedIcon general"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_GENERAL_label"
>
General
</span>
</li>
<li
aria-controls="mx_tabpanel_LABS"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-LABS"
role="tab"
tabindex="-1"
>
<span
class="mx_TabbedView_maskedIcon labs"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_LABS_label"
>
Labs
</span>
</li>
<li
aria-controls="mx_tabpanel_SECURITY"
aria-selected="false"
class="mx_AccessibleButton mx_TabbedView_tabLabel"
data-testid="settings-tab-SECURITY"
role="tab"
tabindex="-1"
>
<span
class="mx_TabbedView_maskedIcon security"
/>
<span
class="mx_TabbedView_tabLabel_text"
id="mx_tabpanel_SECURITY_label"
>
Security
</span>
</li>
</ul>
<div
aria-labelledby="mx_tabpanel_GENERAL_label"
class="mx_TabbedView_tabPanel"
id="mx_tabpanel_GENERAL"
>
<div
class="mx_AutoHideScrollbar mx_TabbedView_tabPanelContent"
tabindex="-1"
>
<div>
general
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard_header_title"
>
<button
aria-labelledby="floating-ui-1"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton mx_ThreadPanel_dropdown"
role="button"
tabindex="0"
>
Show: All threads
</div>
</div>
</DocumentFragment>
`;
exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly renders Show: My threads 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard_header_title"
>
<button
aria-labelledby="floating-ui-6"
class="_icon-button_bh2qc_17"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton mx_ThreadPanel_dropdown"
role="button"
tabindex="0"
>
Show: My threads
</div>
</div>
</DocumentFragment>
`;
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
<div
aria-checked="true"
class="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
role="menuitemradio"
tabindex="0"
>
<span>
All threads
</span>
<span>
Shows all threads from current room
</span>
</div>
`;

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UserMenu> <UserMenu> when video broadcast when rendered should render as expected 1`] = `
<div>
<div
class="mx_UserMenu"
>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="User menu"
class="mx_AccessibleButton mx_UserMenu_contextMenuButton"
role="button"
tabindex="0"
>
<div
class="mx_UserMenu_userAvatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
u
</span>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import EventEmitter from "events";
import CompleteSecurity from "../../../../src/components/structures/auth/CompleteSecurity";
import { stubClient } from "../../../test-utils";
import { Phase, SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore";
import SdkConfig from "../../../../src/SdkConfig";
class MockSetupEncryptionStore extends EventEmitter {
public phase: Phase = Phase.Intro;
public lostKeys(): boolean {
return false;
}
public start: () => void = jest.fn();
public stop: () => void = jest.fn();
}
describe("CompleteSecurity", () => {
beforeEach(() => {
const client = stubClient();
const deviceIdToDevice = new Map();
deviceIdToDevice.set("DEVICE_ID", {
deviceId: "DEVICE_ID",
userId: "USER_ID",
});
const userIdToDevices = new Map();
userIdToDevices.set("USER_ID", deviceIdToDevice);
mocked(client.getCrypto()!.getUserDeviceInfo).mockResolvedValue(userIdToDevices);
const mockSetupEncryptionStore = new MockSetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(
mockSetupEncryptionStore as SetupEncryptionStore,
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("Renders with a cancel button by default", () => {
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders with a cancel button if forceVerification false", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "forceVerification") {
return false;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders without a cancel button if forceVerification true", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "force_verification") {
return true;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,427 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked } from "jest-mock";
import { act, render, RenderResult, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
import {
clearAllModals,
filterConsole,
flushPromisesWithFakeTimers,
stubClient,
waitEnoughCyclesForModal,
} from "../../../test-utils";
import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
describe("<ForgotPassword>", () => {
const testEmail = "user@example.com";
const testSid = "sid42";
const testPassword = "cRaZyP4ssw0rd!";
let client: MatrixClient;
let serverConfig: ValidatedServerConfig;
let onComplete: () => void;
let onLoginClick: () => void;
let renderResult: RenderResult;
const typeIntoField = async (label: string, value: string): Promise<void> => {
await act(async () => {
await userEvent.type(screen.getByLabelText(label), value, { delay: null });
// the message is shown after some time
jest.advanceTimersByTime(500);
});
};
const click = async (element: Element): Promise<void> => {
await act(async () => {
await userEvent.click(element, { delay: null });
});
};
const itShouldCloseTheDialogAndShowThePasswordInput = (): void => {
it("should close the dialog and show the password input", () => {
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
expect(screen.getByText("Reset your password")).toBeInTheDocument();
});
};
filterConsole(
// not implemented by js-dom https://github.com/jsdom/jsdom/issues/1937
"Not implemented: HTMLFormElement.prototype.requestSubmit",
);
beforeEach(() => {
client = stubClient();
mocked(createClient).mockReturnValue(client);
serverConfig = { hsName: "example.com" } as ValidatedServerConfig;
onComplete = jest.fn();
onLoginClick = jest.fn();
jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig);
jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError");
});
afterEach(async () => {
// clean up modals
await clearAllModals();
});
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
describe("when starting a password reset flow", () => {
beforeEach(() => {
renderResult = render(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
it("should show the email input and mention the homeserver", () => {
expect(screen.queryByLabelText("Email address")).toBeInTheDocument();
expect(screen.queryByText("example.com")).toBeInTheDocument();
});
describe("and updating the server config", () => {
beforeEach(() => {
serverConfig.hsName = "example2.com";
renderResult.rerender(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
it("should show the new homeserver server name", () => {
expect(screen.queryByText("example2.com")).toBeInTheDocument();
});
});
describe("and clicking »Sign in instead«", () => {
beforeEach(async () => {
await click(screen.getByText("Sign in instead"));
});
it("should call onLoginClick()", () => {
expect(onLoginClick).toHaveBeenCalled();
});
});
describe("and entering a non-email value", () => {
beforeEach(async () => {
await typeIntoField("Email address", "not en email");
});
it("should show a message about the wrong format", () => {
expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument();
});
});
describe("and submitting an unknown email", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockRejectedValue({
errcode: "M_THREEPID_NOT_FOUND",
});
await click(screen.getByText("Send email"));
});
it("should show an email not found message", () => {
expect(screen.getByText("This email address was not found")).toBeInTheDocument();
});
});
describe("and a connection error occurs", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockRejectedValue({
name: "ConnectionError",
});
await click(screen.getByText("Send email"));
});
it("should show an info about that", () => {
expect(
screen.getByText(
"Cannot reach homeserver: " +
"Ensure you have a stable internet connection, or get in touch with the server admin",
),
).toBeInTheDocument();
});
});
describe("and the server liveness check fails", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({});
mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({
serverErrorIsFatal: true,
serverIsAlive: false,
serverDeadError: "server down",
});
await click(screen.getByText("Send email"));
});
it("should show the server error", () => {
expect(screen.queryByText("server down")).toBeInTheDocument();
});
});
describe("and submitting an known email", () => {
beforeEach(async () => {
await typeIntoField("Email address", testEmail);
mocked(client).requestPasswordEmailToken.mockResolvedValue({
sid: testSid,
});
await click(screen.getByText("Send email"));
});
it("should send the mail and show the check email view", () => {
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
testEmail,
expect.any(String),
1, // second send attempt
);
expect(screen.getByText("Check your email to continue")).toBeInTheDocument();
expect(screen.getByText(testEmail)).toBeInTheDocument();
});
describe("and clicking »Re-enter email address«", () => {
beforeEach(async () => {
await click(screen.getByText("Re-enter email address"));
});
it("go back to the email input", () => {
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
});
});
describe("and clicking »Resend«", () => {
beforeEach(async () => {
await click(screen.getByText("Resend"));
// the message is shown after some time
jest.advanceTimersByTime(500);
});
it("should should resend the mail and show the tooltip", () => {
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
testEmail,
expect.any(String),
2, // second send attempt
);
expect(
screen.getByRole("tooltip", { name: "Verification link email resent!" }),
).toBeInTheDocument();
});
});
describe("and clicking »Next«", () => {
beforeEach(async () => {
await click(screen.getByText("Next"));
});
it("should show the password input view", () => {
expect(screen.getByText("Reset your password")).toBeInTheDocument();
});
describe("and entering different passwords", () => {
beforeEach(async () => {
await typeIntoField("New Password", testPassword);
await typeIntoField("Confirm new password", testPassword + "asd");
});
it("should show an info about that", () => {
expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument();
});
});
describe("and entering a new password", () => {
beforeEach(async () => {
mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 });
await typeIntoField("New Password", testPassword);
await typeIntoField("Confirm new password", testPassword);
});
describe("and submitting it running into rate limiting", () => {
beforeEach(async () => {
mocked(client.setPassword).mockRejectedValue({
message: "rate limit reached",
httpStatus: 429,
data: {
retry_after_ms: (13 * 60 + 37) * 1000,
},
});
await click(screen.getByText("Reset password"));
});
it("should show the rate limit error message", () => {
expect(
screen.getByText("Too many attempts in a short time. Retry after 13:37."),
).toBeInTheDocument();
});
});
describe("and confirm the email link and submitting the new password", () => {
beforeEach(async () => {
// fake link confirmed by resolving client.setPassword instead of raising an error
mocked(client.setPassword).mockResolvedValue({});
await click(screen.getByText("Reset password"));
});
it("should send the new password (once)", () => {
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
false,
);
// be sure that the next attempt to set the password would have been sent
jest.advanceTimersByTime(3000);
// it should not retry to set the password
expect(client.setPassword).toHaveBeenCalledTimes(1);
});
});
describe("and submitting it", () => {
beforeEach(async () => {
await click(screen.getByText("Reset password"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should send the new password and show the click validation link dialog", () => {
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
false,
);
expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
expect(screen.getByText(testEmail)).toBeInTheDocument();
});
describe("and dismissing the dialog by clicking the background", () => {
beforeEach(async () => {
await act(async () => {
await userEvent.click(screen.getByTestId("dialog-background"), { delay: null });
});
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and dismissing the dialog", () => {
beforeEach(async () => {
await click(screen.getByLabelText("Close dialog"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and clicking »Re-enter email address«", () => {
beforeEach(async () => {
await click(screen.getByText("Re-enter email address"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should close the dialog and go back to the email input", () => {
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
});
});
describe("and validating the link from the mail", () => {
beforeEach(async () => {
mocked(client.setPassword).mockResolvedValue({});
// be sure the next set password attempt was sent
jest.advanceTimersByTime(3000);
// quad flush promises for the modal to disappear
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
});
it("should display the confirm reset view and now show the dialog", () => {
expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument();
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
});
});
});
describe("and clicking »Sign out of all devices« and »Reset password«", () => {
beforeEach(async () => {
await click(screen.getByText("Sign out of all devices"));
await click(screen.getByText("Reset password"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should show the sign out warning dialog", async () => {
expect(
screen.getByText(
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
),
).toBeInTheDocument();
// confirm dialog
await click(screen.getByText("Continue"));
// expect setPassword with logoutDevices = true
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
true,
);
});
});
});
});
});
});
});

View file

@ -0,0 +1,473 @@
/*
Copyright 2019-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import SdkConfig from "../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import Login from "../../../../src/components/structures/auth/Login";
import BasePlatform from "../../../../src/BasePlatform";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Features } from "../../../../src/settings/Settings";
import * as registerClientUtils from "../../../../src/utils/oidc/registerClient";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
jest.useRealTimers();
const oidcStaticClientsConfig = {
"https://staticallyregisteredissuer.org/": {
client_id: "static-clientId-123",
},
};
describe("Login", function () {
let platform: MockedObject<BasePlatform>;
const mockClient = mocked({
login: jest.fn().mockResolvedValue({}),
loginFlows: jest.fn(),
} as unknown as Matrix.MatrixClient);
beforeEach(function () {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: true,
oidc_static_clients: oidcStaticClientsConfig,
});
mockClient.login.mockClear().mockResolvedValue({
access_token: "TOKEN",
device_id: "IAMADEVICE",
user_id: "@user:server",
});
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
jest.spyOn(Matrix, "createClient").mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.resetBehavior();
fetchMock.resetHistory();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
platform = mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: OidcClientConfig,
) {
return (
<Login
serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)}
onLoggedIn={() => {}}
onRegisterClick={() => {}}
onServerConfigChange={() => {}}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: OidcClientConfig) {
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
}
it("should show form with change server link", async () => {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: false,
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
});
it("should show form without change server link when custom URLs disabled", async () => {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelectorAll(".mx_ServerPicker_change")).toHaveLength(0);
});
it("should show SSO button if that flow is available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show both SSO button and username+password if both are available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }, { type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show multiple SSO buttons if multiple identity_providers are available", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
{
id: "a",
name: "Provider 1",
},
{
id: "b",
name: "Provider 2",
},
{
id: "c",
name: "Provider 3",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(3);
});
it("should show single SSO button if identity_providers is null", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[0][0].baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
});
it("should handle updating to a server with no supported flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// update the mock for the new server with no supported flows
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
// render with a new server
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
// no sso button because server2 doesnt support sso
expect(container.querySelector(".mx_SSOButton")).not.toBeInTheDocument();
});
it("should show single Continue button if OIDC MSC3824 compatibility is given by server", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
it("should show branded SSO buttons", async () => {
const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({
id: brand,
brand,
name: `Provider ${brand}`,
}));
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
...idpsWithIcons,
{
id: "foo",
name: "Provider foo",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
for (const idp of idpsWithIcons) {
const ssoButton = container.querySelector(`.mx_SSOButton.mx_SSOButton_brand_${idp.brand}`);
expect(ssoButton).toBeTruthy();
expect(ssoButton?.querySelector(`img[alt="${idp.brand}"]`)).toBeTruthy();
}
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(idpsWithIcons.length + 1);
});
it("should display an error when homeserver doesn't offer any supported login flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
});
it("should display a connection error when getting login flows fails", async () => {
mockClient.loginFlows.mockRejectedValue("oups");
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("There was a problem communicating with the homeserver, please try again later."),
).toBeInTheDocument();
});
it("should display an error when homeserver fails liveliness check", async () => {
fetchMock.resetBehavior();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 0,
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
});
it("should reset liveliness error when server config changes", async () => {
fetchMock.resetBehavior();
// matrix.org is not alive
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 400,
});
// but server2 is
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
const { rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error cleared
expect(screen.queryByText("Cannot reach homeserver")).not.toBeInTheDocument();
});
describe("OIDC native flow", () => {
const hsUrl = "https://matrix.org";
const isUrl = "https://vector.im";
const issuer = "https://test.com/";
const delegatedAuth = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
jest.spyOn(logger, "error");
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === Features.OidcNativeFlow,
);
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should not attempt registration when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
it("should attempt to register oidc client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
// called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
});
it("should fallback to normal login when client registration fails", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(new Error(OidcError.DynamicRegistrationFailed));
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// did not continue with matrix login
expect(mockClient.loginFlows).not.toHaveBeenCalled();
expect(screen.getByText("Continue")).toBeInTheDocument();
});
/**
* Oidc-aware flows still work while the oidc-native feature flag is disabled
*/
it("should show oidc-aware flow for oidc-enabled homeserver when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// oidc-aware 'continue' button displayed
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
});
});

View file

@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { act, render, RenderResult } from "jest-matrix-react";
import React, { ComponentProps } from "react";
import EventEmitter from "events";
import { CryptoEvent } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { LoginSplashView } from "../../../../src/components/structures/auth/LoginSplashView";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
describe("<LoginSplashView />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = new EventEmitter() as unknown as MatrixClient;
});
function getComponent(props: Partial<ComponentProps<typeof LoginSplashView>> = {}): RenderResult {
const defaultProps = {
matrixClient,
onLogoutClick: () => {},
syncError: null,
};
return render(<LoginSplashView {...defaultProps} {...props} />);
}
it("Renders a spinner", () => {
const rendered = getComponent();
expect(rendered.getByTestId("spinner")).toBeInTheDocument();
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Renders an error message", () => {
const rendered = getComponent({ syncError: new Error("boohoo") });
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Calls onLogoutClick", () => {
const onLogoutClick = jest.fn();
const rendered = getComponent({ onLogoutClick });
expect(onLogoutClick).not.toHaveBeenCalled();
rendered.getByRole("button", { name: "Logout" }).click();
expect(onLogoutClick).toHaveBeenCalled();
});
it("Shows migration progress", async () => {
const rendered = getComponent();
act(() => {
matrixClient.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, 5, 10);
});
rendered.getByText("Hang tight.", { exact: false });
// Wait for the animation to update
await act(() => sleep(500));
const progress = rendered.getByRole("progressbar");
expect(progress.getAttribute("value")).toEqual("5");
expect(progress.getAttribute("max")).toEqual("10");
});
});

View file

@ -0,0 +1,249 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { createClient, MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import SdkConfig, { DEFAULTS } from "../../../../src/SdkConfig";
import { getMockClientWithEventEmitter, mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
import Registration from "../../../../src/components/structures/auth/Registration";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { Features } from "../../../../src/settings/Settings";
import { startOidcLogin } from "../../../../src/utils/oidc/authorize";
jest.mock("../../../../src/utils/oidc/authorize", () => ({
startOidcLogin: jest.fn(),
}));
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
describe("Registration", function () {
let mockClient!: MockedObject<MatrixClient>;
beforeEach(function () {
SdkConfig.put({
...DEFAULTS,
disable_custom_urls: true,
});
mockClient = getMockClientWithEventEmitter({
registerRequest: jest.fn(),
loginFlows: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }),
});
mockClient.registerRequest.mockRejectedValueOnce(
new MatrixError(
{
flows: [{ stages: [] }],
},
401,
),
);
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
mocked(createClient).mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.catch(404);
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
jest.restoreAllMocks();
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
const defaultProps = {
defaultDeviceDisplayName: "test-device-display-name",
onLoggedIn: jest.fn(),
onLoginClick: jest.fn(),
onServerConfigChange: jest.fn(),
};
const defaultHsUrl = "https://matrix.org";
const defaultIsUrl = "https://vector.im";
function getRawComponent(
hsUrl = defaultHsUrl,
isUrl = defaultIsUrl,
authConfig?: OidcClientConfig,
mobileRegister?: boolean,
) {
return (
<Registration
{...defaultProps}
serverConfig={mkServerConfig(hsUrl, isUrl, authConfig)}
mobileRegister={mobileRegister}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig, mobileRegister?: boolean) {
return render(getRawComponent(hsUrl, isUrl, authConfig, mobileRegister));
}
it("should show server picker", async function () {
const { container } = getComponent();
expect(container.querySelector(".mx_ServerPicker")).toBeTruthy();
});
it("should show form when custom URLs disabled", async function () {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
});
it("should show SSO options if those are available", async () => {
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://server2");
});
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const clientId = "test-client-id";
// @ts-ignore
authConfig.metadata["prompt_values_supported"] = ["create"];
beforeEach(() => {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.metadata.issuer]: {
client_id: clientId,
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.metadata.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata);
fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] });
});
describe("when oidc native flow is not enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("should display user/pass registration form", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(mockClient.loginFlows).toHaveBeenCalled();
expect(mockClient.registerRequest).toHaveBeenCalled();
});
});
describe("when oidc native flow is enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key) => key === Features.OidcNativeFlow);
});
it("should display oidc-native continue button", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// no form
expect(container.querySelector("form")).toBeFalsy();
expect(await screen.findByText("Continue")).toBeTruthy();
});
it("should start OIDC login flow as registration on button click", async () => {
getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(await screen.findByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
authConfig,
clientId,
defaultHsUrl,
defaultIsUrl,
// isRegistration
true,
);
});
});
describe("when is mobile registeration", () => {
it("should not show server picker", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
expect(container.querySelector(".mx_ServerPicker")).toBeFalsy();
});
it("should show username field with autocaps disabled", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() =>
expect(container.querySelector("#mx_RegistrationForm_username")).toHaveAttribute(
"autocapitalize",
"none",
),
);
});
it("should show password and confirm password fields in separate rows", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() => expect(container.querySelector("#mx_RegistrationForm_username")).toBeTruthy());
// when password and confirm password fields are in separate rows there should be 4 rather than 3
expect(container.querySelectorAll(".mx_AuthBody_fieldRow")).toHaveLength(4);
});
});
});
});

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginSplashView /> Renders a spinner 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<LoginSplashView /> Renders an error message 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_LoginSplashView_syncError"
>
<div>
Unable to connect to Homeserver. Retrying…
</div>
</div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;

View 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
Please see LICENSE files in the repository root for full details.
*/
import withValidation from "../../../src/components/views/elements/Validation";
describe("Validation", () => {
it("should handle 0 rules", () => {
const handler = withValidation({
rules: [],
});
return expect(
handler({
value: "value",
focused: true,
}),
).resolves.toEqual(
expect.objectContaining({
valid: true,
}),
);
});
});

View file

@ -0,0 +1,23 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { EmojiMapping } from "matrix-js-sdk/src/crypto-api";
import { tEmoji } from "../../../src/components/views/verification/VerificationShowSas";
describe("tEmoji", () => {
it.each([
["en-GB", "Dog"],
["en", "Dog"],
["de-DE", "Hund"],
["pt", "Cachorro"],
])("should handle locale %s", (locale, expectation) => {
const emoji: EmojiMapping = ["🐶", "Dog"];
expect(tEmoji(emoji, locale)).toEqual(expectation);
});
});

View file

@ -0,0 +1,154 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked } from "jest-mock";
import { logger } from "matrix-js-sdk/src/logger";
import { fireEvent, render, RenderResult } from "jest-matrix-react";
import RecordingPlayback, { PlaybackLayout } from "../../../../src/components/views/audio_messages/RecordingPlayback";
import { Playback } from "../../../../src/audio/Playback";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { createAudioContext } from "../../../../src/audio/compat";
import { flushPromises } from "../../../test-utils";
import { IRoomState } from "../../../../src/components/structures/RoomView";
jest.mock("../../../../src/WorkerManager", () => ({
WorkerManager: jest.fn(() => ({
call: jest.fn().mockResolvedValue({ waveform: [0, 0, 1, 1] }),
})),
}));
jest.mock("../../../../src/audio/compat", () => ({
createAudioContext: jest.fn(),
decodeOgg: jest.fn().mockResolvedValue({}),
}));
describe("<RecordingPlayback />", () => {
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
currentTime: 0,
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
};
const mockAudioBuffer = {
duration: 99,
getChannelData: jest.fn(),
};
const mockChannelData = new Float32Array();
const defaultRoom = { roomId: "!room:server.org", timelineRenderingType: TimelineRenderingType.File } as IRoomState;
const getComponent = (props: React.ComponentProps<typeof RecordingPlayback>, room = defaultRoom) =>
render(
<RoomContext.Provider value={room}>
<RecordingPlayback {...props} />
</RoomContext.Provider>,
);
beforeEach(() => {
jest.spyOn(logger, "error").mockRestore();
mockAudioBuffer.getChannelData.mockClear().mockReturnValue(mockChannelData);
mockAudioContext.decodeAudioData.mockReset().mockImplementation((_b, callback) => callback(mockAudioBuffer));
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
});
const getPlayButton = (component: RenderResult) => component.getByTestId("play-pause-button");
it("renders recording playback", () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback });
expect(component).toBeTruthy();
});
it("disables play button while playback is decoding", async () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback });
expect(getPlayButton(component)).toHaveAttribute("disabled");
expect(getPlayButton(component)).toHaveAttribute("aria-disabled", "true");
});
it("enables play button when playback is finished decoding", async () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback });
await flushPromises();
expect(getPlayButton(component)).not.toHaveAttribute("disabled");
expect(getPlayButton(component)).not.toHaveAttribute("aria-disabled", "true");
});
it("displays error when playback decoding fails", async () => {
// stub logger to keep console clean from expected error
jest.spyOn(logger, "error").mockReturnValue(undefined);
jest.spyOn(logger, "warn").mockReturnValue(undefined);
mockAudioContext.decodeAudioData.mockImplementation((_b, _cb, error) => error(new Error("oh no")));
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback });
await flushPromises();
expect(component.container.querySelector(".text-warning")).toBeDefined();
});
it("displays pre-prepared playback with correct playback phase", async () => {
const playback = new Playback(new ArrayBuffer(8));
await playback.prepare();
const component = getComponent({ playback });
// playback already decoded, button is not disabled
expect(getPlayButton(component)).not.toHaveAttribute("disabled");
expect(getPlayButton(component)).not.toHaveAttribute("aria-disabled", "true");
expect(component.container.querySelector(".text-warning")).toBeFalsy();
});
it("toggles playback on play pause button click", async () => {
const playback = new Playback(new ArrayBuffer(8));
jest.spyOn(playback, "toggle").mockResolvedValue(undefined);
await playback.prepare();
const component = getComponent({ playback });
fireEvent.click(getPlayButton(component));
expect(playback.toggle).toHaveBeenCalled();
});
describe("Composer Layout", () => {
it("should have a waveform, no seek bar, and clock", () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback, layout: PlaybackLayout.Composer });
expect(component.container.querySelector(".mx_Clock")).toBeDefined();
expect(component.container.querySelector(".mx_Waveform")).toBeDefined();
expect(component.container.querySelector(".mx_SeekBar")).toBeFalsy();
});
});
describe("Timeline Layout", () => {
it("should have a waveform, a seek bar, and clock", () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback, layout: PlaybackLayout.Timeline });
expect(component.container.querySelector(".mx_Clock")).toBeDefined();
expect(component.container.querySelector(".mx_Waveform")).toBeDefined();
expect(component.container.querySelector(".mx_SeekBar")).toBeDefined();
});
it("should be the default", () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback }); // no layout set for test
expect(component.container.querySelector(".mx_Clock")).toBeDefined();
expect(component.container.querySelector(".mx_Waveform")).toBeDefined();
expect(component.container.querySelector(".mx_SeekBar")).toBeDefined();
});
});
});

View file

@ -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
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, RefObject } from "react";
import { mocked } from "jest-mock";
import { act, fireEvent, render, RenderResult } from "jest-matrix-react";
import { Playback } from "../../../../src/audio/Playback";
import { createTestPlayback } from "../../../test-utils/audio";
import SeekBar from "../../../../src/components/views/audio_messages/SeekBar";
describe("SeekBar", () => {
let playback: Playback;
let renderResult: RenderResult;
let frameRequestCallback: FrameRequestCallback;
let seekBarRef: RefObject<SeekBar>;
beforeEach(() => {
seekBarRef = createRef();
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
frameRequestCallback = callback;
return 0;
});
});
afterEach(() => {
mocked(window.requestAnimationFrame).mockRestore();
});
describe("when rendering a SeekBar for an empty playback", () => {
beforeEach(() => {
playback = createTestPlayback({
durationSeconds: 0,
timeSeconds: 0,
});
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
});
it("should render correctly", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
describe("when rendering a SeekBar", () => {
beforeEach(() => {
playback = createTestPlayback();
renderResult = render(<SeekBar ref={seekBarRef} playback={playback} />);
});
it("should render the initial position", () => {
// expected value 3141 / 31415 ~ 0.099984084
expect(renderResult.container).toMatchSnapshot();
});
describe("and the playback proceeds", () => {
beforeEach(async () => {
// @ts-ignore
playback.timeSeconds = 6969;
act(() => {
playback.liveData.update([playback.timeSeconds, playback.durationSeconds]);
frameRequestCallback(0);
});
});
it("should render as expected", () => {
// expected value 6969 / 31415 ~ 0.221836702
expect(renderResult.container).toMatchSnapshot();
});
});
describe("and seeking position with the slider", () => {
beforeEach(() => {
const rangeInput = renderResult.container.querySelector("[type='range']");
act(() => {
fireEvent.change(rangeInput!, { target: { value: 0.5 } });
});
});
it("should update the playback", () => {
expect(playback.skipTo).toHaveBeenCalledWith(0.5 * playback.durationSeconds);
});
describe("and seeking left", () => {
beforeEach(() => {
mocked(playback.skipTo).mockClear();
act(() => {
seekBarRef.current!.left();
});
});
it("should skip to minus 5 seconds", () => {
expect(playback.skipTo).toHaveBeenCalledWith(playback.timeSeconds - 5);
});
});
describe("and seeking right", () => {
beforeEach(() => {
mocked(playback.skipTo).mockClear();
act(() => {
seekBarRef.current!.right();
});
});
it("should skip to plus 5 seconds", () => {
expect(playback.skipTo).toHaveBeenCalledWith(playback.timeSeconds + 5);
});
});
});
});
describe("when rendering a disabled SeekBar", () => {
beforeEach(async () => {
renderResult = render(<SeekBar disabled={true} playback={playback} />);
});
it("should render as expected", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SeekBar when rendering a SeekBar and the playback proceeds should render as expected 1`] = `
<div>
<input
aria-label="Audio seek bar"
class="mx_SeekBar"
max="1"
min="0"
step="0.001"
style="--fillTo: 0.22183670221231896;"
tabindex="0"
type="range"
value="0.22183670221231896"
/>
</div>
`;
exports[`SeekBar when rendering a SeekBar for an empty playback should render correctly 1`] = `
<div>
<input
aria-label="Audio seek bar"
class="mx_SeekBar"
max="1"
min="0"
step="0.001"
style="--fillTo: 0;"
tabindex="0"
type="range"
value="0"
/>
</div>
`;
exports[`SeekBar when rendering a SeekBar should render the initial position 1`] = `
<div>
<input
aria-label="Audio seek bar"
class="mx_SeekBar"
max="1"
min="0"
step="0.001"
style="--fillTo: 0.0999840840362884;"
tabindex="0"
type="range"
value="0.0999840840362884"
/>
</div>
`;
exports[`SeekBar when rendering a disabled SeekBar should render as expected 1`] = `
<div>
<input
aria-label="Audio seek bar"
class="mx_SeekBar"
disabled=""
max="1"
min="0"
step="0.001"
style="--fillTo: 0.0999840840362884;"
tabindex="0"
type="range"
value="0.0999840840362884"
/>
</div>
`;

View file

@ -0,0 +1,75 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import CountryDropdown from "../../../../src/components/views/auth/CountryDropdown";
import SdkConfig from "../../../../src/SdkConfig";
describe("CountryDropdown", () => {
describe("default_country_code", () => {
afterEach(() => {
SdkConfig.reset();
});
it.each([
["GB", 44],
["IE", 353],
["ES", 34],
["FR", 33],
["PL", 48],
["DE", 49],
])("should respect configured default country code for %s", (config, defaultCountryCode) => {
SdkConfig.add({
default_country_code: config,
});
const fn = jest.fn();
render(<CountryDropdown onOptionChange={fn} isSmall={false} showPrefix={false} />);
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ prefix: defaultCountryCode.toString() }));
});
});
describe("defaultCountry", () => {
it.each([
["en-GB", 44],
["en-ie", 353],
["es-ES", 34],
["fr", 33],
["pl", 48],
["de-DE", 49],
])("should pick appropriate default country for %s", (language, defaultCountryCode) => {
Object.defineProperty(navigator, "language", {
configurable: true,
get() {
return language;
},
});
const fn = jest.fn();
render(<CountryDropdown onOptionChange={fn} isSmall={false} showPrefix={false} />);
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ prefix: defaultCountryCode.toString() }));
});
});
it("should allow filtering", async () => {
const fn = jest.fn();
const { getByRole, findByText } = render(
<CountryDropdown onOptionChange={fn} isSmall={false} showPrefix={false} />,
);
const dropdown = getByRole("button");
fireEvent.click(dropdown);
await userEvent.keyboard("Al");
await expect(findByText("Albania (+355)")).resolves.toBeInTheDocument();
});
});

View file

@ -0,0 +1,101 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor, act, fireEvent } from "jest-matrix-react";
import { AuthType } from "matrix-js-sdk/src/interactive-auth";
import userEvent from "@testing-library/user-event";
import {
EmailIdentityAuthEntry,
MasUnlockCrossSigningAuthEntry,
} from "../../../../src/components/views/auth/InteractiveAuthEntryComponents";
import { createTestClient } from "../../../test-utils";
describe("<EmailIdentityAuthEntry/>", () => {
const renderIdentityAuth = () => {
const matrixClient = createTestClient();
return render(
<EmailIdentityAuthEntry
matrixClient={matrixClient}
loginType={AuthType.Email}
onPhaseChange={jest.fn()}
submitAuthDict={jest.fn()}
fail={jest.fn()}
clientSecret="my secret"
showContinue={true}
inputs={{ emailAddress: "alice@example.xyz" }}
/>,
);
};
test("should render", () => {
const { container } = renderIdentityAuth();
expect(container).toMatchSnapshot();
});
test("should clear the requested state when the button tooltip is hidden", async () => {
renderIdentityAuth();
// After a click on the resend button, the button should display the resent label
screen.getByRole("button", { name: "Resend" }).click();
await waitFor(() => expect(screen.queryByRole("button", { name: "Resent!" })).toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Resend" })).toBeNull();
const resentButton = screen.getByRole("button", { name: "Resent!" });
// Hover briefly the button and wait for the tooltip to be displayed
await userEvent.hover(resentButton);
await waitFor(() => expect(screen.getByRole("tooltip", { name: "Resent!" })).toBeInTheDocument());
// On unhover, it should display again the resend button
await act(() => userEvent.unhover(resentButton));
await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument());
});
});
describe("<MasUnlockCrossSigningAuthEntry/>", () => {
const renderAuth = (props = {}) => {
const matrixClient = createTestClient();
return render(
<MasUnlockCrossSigningAuthEntry
matrixClient={matrixClient}
loginType={AuthType.Email}
onPhaseChange={jest.fn()}
submitAuthDict={jest.fn()}
fail={jest.fn()}
clientSecret="my secret"
showContinue={true}
stageParams={{ url: "https://example.com" }}
{...props}
/>,
);
};
test("should render", () => {
const { container } = renderAuth();
expect(container).toMatchSnapshot();
});
test("should open idp in new tab on click", async () => {
const spy = jest.spyOn(global.window, "open");
renderAuth();
fireEvent.click(screen.getByRole("button", { name: "Go to your account" }));
expect(spy).toHaveBeenCalledWith("https://example.com", "_blank");
});
test("should retry uia request on click", async () => {
const submitAuthDict = jest.fn();
renderAuth({ submitAuthDict });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(submitAuthDict).toHaveBeenCalledWith({});
});
});

Some files were not shown because too many files have changed in this diff Show more