Migrate more tests to vitest (#34349)

This commit is contained in:
Michael Telatynski
2026-07-20 13:07:35 +00:00
committed by GitHub
parent 6c2c962588
commit b719f531dd
20 changed files with 310 additions and 271 deletions
+99
View File
@@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { HTTPError, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { createTestClient } from "test-utils";
import { createCrossSigning } from "./CreateCrossSigning";
import Modal from "./Modal";
vi.mock("./Modal");
describe("CreateCrossSigning", () => {
let client: MatrixClient;
beforeEach(() => {
client = createTestClient();
});
it("should call bootstrapCrossSigning with an authUploadDeviceSigningKeys function", async () => {
await createCrossSigning(client);
expect(client.getCrypto()?.bootstrapCrossSigning).toHaveBeenCalledWith({
authUploadDeviceSigningKeys: expect.any(Function),
});
});
it("should upload", async () => {
client.uploadDeviceSigningKeys = vi.fn().mockRejectedValueOnce(
new MatrixError({
flows: [
{
stages: ["m.login.password"],
},
],
}),
);
await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = vi.mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const makeRequest = vi.fn();
await authUploadDeviceSigningKeys!(makeRequest);
expect(makeRequest).toHaveBeenCalledWith(null);
});
it("should prompt user if upload failed with UIA", async () => {
const createDialog = vi.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true]),
close: vi.fn(),
});
client.uploadDeviceSigningKeys = vi.fn().mockRejectedValueOnce(
new MatrixError({
flows: [
{
stages: ["dummy.mystery_flow_nobody_knows"],
},
],
}),
);
await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = vi.mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const makeRequest = vi.fn().mockRejectedValue(
new MatrixError({
flows: [
{
stages: ["dummy.mystery_flow_nobody_knows"],
},
],
}),
);
await authUploadDeviceSigningKeys!(makeRequest);
expect(makeRequest).not.toHaveBeenCalledWith();
expect(createDialog).toHaveBeenCalled();
});
it("should throw error if server fails with something other than UIA", async () => {
await createCrossSigning(client);
const { authUploadDeviceSigningKeys } = vi.mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0];
const error = new HTTPError("Internal Server Error", 500);
const makeRequest = vi.fn().mockRejectedValue(error);
await expect(authUploadDeviceSigningKeys!(makeRequest)).rejects.toThrow(error);
expect(makeRequest).not.toHaveBeenCalledWith();
});
});
@@ -0,0 +1,745 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, afterEach, type Mocked, type MockedObject } from "vitest";
import { HttpApiEvent, type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { decryptExistingEvent, mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
import {
type CryptoApi,
DecryptionFailureCode,
UserVerificationStatus,
CryptoEvent,
} from "matrix-js-sdk/src/crypto-api";
import { sleep } from "matrix-js-sdk/src/utils";
import { stubClient } from "test-utils";
import { DecryptionFailureTracker, type ErrorProperties } from "./DecryptionFailureTracker";
import * as Lifecycle from "./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_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
// 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.SENDER_IDENTITY_PREVIOUSLY_VERIFIED);
await createAndTrackEventWithError(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE);
await createAndTrackEventWithError(DecryptionFailureCode.UNKNOWN_ERROR);
// Pretend "now" is Infinity
tracker.checkFailures(Infinity);
expect(errorCodes).toEqual([
"OlmKeysNotSentError",
"OlmIndexError",
"HistoricalMessage",
"HistoricalMessage",
"HistoricalMessage",
"ExpectedDueToMembership",
"OlmKeysNotSentError",
"RoomKeysWithheldForUnverifiedDevice",
"ExpectedVerificationViolation",
"ExpectedSentByInsecureDevice",
"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_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
// 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.
vi.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);
vi.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_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
// 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);
vi.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");
vi.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 = vi.mocked(stubClient());
const mockCrypto = {
getVersion: vi.fn().mockReturnValue("Rust SDK 0.7.0 (61b175b), Vodozemac 0.5.1"),
getUserVerificationStatus: vi.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)),
} as unknown as Mocked<CryptoApi>;
client.getCrypto.mockReturnValue(mockCrypto);
// @ts-ignore
client.stopClient = vi.fn(() => {});
// @ts-ignore
client.removeAllListeners = vi.fn(() => {});
client.store = { destroy: vi.fn(() => {}) } as any;
return client;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { vi, describe, it, expect, beforeEach } from "vitest";
import React from "react";
import { formatList, formatCount, formatCountLong } from "./FormattingUtils";
import SettingsStore from "../settings/SettingsStore";
vi.mock("../dispatcher/dispatcher");
describe("FormattingUtils", () => {
describe("formatCount", () => {
it.each([
{ count: 999, expectedCount: "999" },
{ count: 9999, expectedCount: "10K" },
{ count: 99999, expectedCount: "100K" },
{ count: 999999, expectedCount: "1M" },
{ count: 9999999, expectedCount: "10M" },
{ count: 99999999, expectedCount: "100M" },
{ count: 999999999, expectedCount: "1B" },
{ count: 9999999999, expectedCount: "10B" },
])("formats $count as $expectedCount", ({ count, expectedCount }) => {
expect(formatCount(count)).toBe(expectedCount);
});
});
describe("formatCountLong", () => {
it("formats numbers according to the locale", () => {
expect(formatCountLong(1000)).toBe("1,000");
});
});
describe("formatList", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(SettingsStore, "getValue").mockReturnValue("en-GB");
});
it("should return empty string when given empty list", () => {
expect(formatList([])).toEqual("");
});
it("should return only item when given list of length 1", () => {
expect(formatList(["abc"])).toEqual("abc");
});
it("should return expected sentence in English without item limit", () => {
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def and ghi");
});
it("should return expected sentence in German without item limit", () => {
vi.spyOn(SettingsStore, "getValue").mockReturnValue("de");
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def und ghi");
});
it("should return expected sentence in English with item limit", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 2)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 3)).toEqual("abc, def, ghi and one other");
});
it("should return expected sentence in English with item limit and includeCount", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 3, true)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 4, true)).toEqual("abc, def, ghi and jkl");
});
it("should return expected sentence in ReactNode when given 2 React children", () => {
expect(formatList([<span key="a">a</span>, <span key="b">b</span>])).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when given more React children", () => {
expect(
formatList([
<span key="a">a</span>,
<span key="b">b</span>,
<span key="c">c</span>,
<span key="d">d</span>,
]),
).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when using itemLimit", () => {
expect(
formatList(
[<span key="a">a</span>, <span key="b">b</span>, <span key="c">c</span>, <span key="d">d</span>],
2,
),
).toMatchSnapshot();
});
});
});
@@ -0,0 +1,86 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { render } from "test-utils-rtl";
import type { IContent } from "matrix-js-sdk/src/matrix";
import type React from "react";
import { editBodyDiffToHtml } from "./MessageDiffUtils";
describe("editBodyDiffToHtml", () => {
function buildContent(message: string): IContent {
return {
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
};
}
function renderDiff(before: string, after: string) {
const node = editBodyDiffToHtml(buildContent(before), buildContent(after));
return render(node as React.ReactElement);
}
it.each([
["simple word changes", "hello", "world"],
["central word changes", "beginning middle end", "beginning :smile: end"],
["text deletions", "<b>hello</b> world", "<b>hello</b>"],
["text additions", "<b>hello</b>", "<b>hello</b> world"],
["block element additions", "hello", "hello <p>world</p>"],
["inline element additions", "hello", "hello <q>world</q>"],
["block element deletions", `hi <blockquote>there</blockquote>`, "hi"],
["inline element deletions", `hi <em>there</em>`, "hi"],
["element replacements", `hi <i>there</i>`, "hi <em>there</em>"],
["attribute modifications", `<a href="#hi">hi</a>`, `<a href="#bye">hi</a>`],
["attribute deletions", `<a href="#hi">hi</a>`, `<a>hi</a>`],
["attribute additions", `<a>hi</a>`, `<a href="#/room/!123">hi</a>`],
["handles empty tags", `<a>hi</a>`, `<a><h1></h1></a> hi`],
])("renders %s", (_label, before, after) => {
const { container } = renderDiff(before, after);
expect(container).toMatchSnapshot();
});
// see https://github.com/fiduswriter/diffDOM/issues/90
// fixed in diff-dom in 4.2.2+
it("deduplicates diff steps", () => {
const { container } = renderDiff("<div><em>foo</em> bar baz</div>", "<div><em>foo</em> bar bay</div>");
expect(container).toMatchSnapshot();
});
it("handles non-html input", () => {
const before: IContent = {
body: "who knows what's going on <strong>here</strong>",
format: "org.exotic.encoding",
formatted_body: "who knows what's going on <strong>here</strong>",
msgtype: "m.text",
};
const after: IContent = {
...before,
body: "who knows what's going on <strong>there</strong>",
formatted_body: "who knows what's going on <strong>there</strong>",
};
const { container } = render(editBodyDiffToHtml(before, after) as React.ReactElement);
expect(container).toMatchSnapshot();
});
// see https://github.com/vector-im/element-web/issues/23665
it("handles complex transformations", () => {
const { container } = renderDiff(
'<span data-mx-maths="{☃️}^\\infty"><code>{☃️}^\\infty</code></span>',
'<span data-mx-maths="{😃}^\\infty"><code>{😃}^\\infty</code></span>',
);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,72 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when given 2 React children 1`] = `
<React.Fragment>
<React.Fragment>
<span>
a
</span>
</React.Fragment>
<React.Fragment>
and
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
</React.Fragment>
`;
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when given more React children 1`] = `
<React.Fragment>
<React.Fragment>
<span>
a
</span>
</React.Fragment>
<React.Fragment>
,
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
<React.Fragment>
,
</React.Fragment>
<React.Fragment>
<span>
c
</span>
</React.Fragment>
<React.Fragment>
and
</React.Fragment>
<React.Fragment>
<span>
d
</span>
</React.Fragment>
</React.Fragment>
`;
exports[`FormattingUtils > formatList > should return expected sentence in ReactNode when using itemLimit 1`] = `
<span>
<React.Fragment>
<React.Fragment>
<span>
a
</span>
,
</React.Fragment>
<React.Fragment>
<span>
b
</span>
</React.Fragment>
</React.Fragment>
and 2 others
</span>
`;
@@ -0,0 +1,500 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`editBodyDiffToHtml > deduplicates diff steps 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<div>
<em>
foo
</em>
<span>
bar ba
<span
class="mx_EditHistoryMessage_deletion"
>
z
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
y
</span>
</span>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > handles complex transformations 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':snowman:'>☃️</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':smiley:'>😃</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > handles non-html input 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
who knows what's going on &lt;strong&gt;
<span
class="mx_EditHistoryMessage_insertion"
>
t
</span>
here&lt;/strong&gt;
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
target="undefined"
>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders attribute modifications 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="#bye"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders block element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<p>
world
</p>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders block element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_deletion"
>
<blockquote>
there
</blockquote>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders central word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
beginning
<span
class="mx_EditHistoryMessage_insertion"
>
:s
</span>
mi
<span
class="mx_EditHistoryMessage_deletion"
>
dd
</span>
le
<span
class="mx_EditHistoryMessage_insertion"
>
:
</span>
end
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders element replacements 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
hi
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<i>
there
</i>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<em>
there
</em>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders handles empty tags 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<a
rel="noreferrer noopener"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hi
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<h1 />
</div>
</span>
</a>
<span
class="mx_EditHistoryMessage_insertion"
>
hi
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders inline element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders inline element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<span
class="mx_EditHistoryMessage_deletion"
>
<em>
there
</em>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders simple word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hello
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders text additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml > renders text deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_deletion"
>
world
</span>
</span>
</div>
`;
@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`createVoiceMessageContent > should create a voice message content 1`] = `
{
"body": "Voice message",
"file": {},
"info": {
"duration": 23000,
"mimetype": "ogg/opus",
"size": 42000,
},
"msgtype": "m.audio",
"org.matrix.msc1767.audio": {
"duration": 23000,
"waveform": [
1,
2,
3,
],
},
"org.matrix.msc1767.file": {
"file": {},
"mimetype": "ogg/opus",
"name": "Voice message.ogg",
"size": 42000,
"url": "mxc://example.com/file",
},
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc3245.voice": {},
"url": "mxc://example.com/file",
}
`;
@@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { describe, it, expect } from "vitest";
import { type EncryptedFile } from "matrix-js-sdk/src/types";
import { createVoiceMessageContent } from "./createVoiceMessageContent";
describe("createVoiceMessageContent", () => {
it("should create a voice message content", () => {
expect(
createVoiceMessageContent(
"mxc://example.com/file",
"ogg/opus",
23000,
42000,
{} as unknown as EncryptedFile,
[1, 2, 3],
),
).toMatchSnapshot();
});
});
@@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { describe, it, expect } from "vitest";
import { PermalinkParts } from "./PermalinkConstructor";
import MatrixSchemePermalinkConstructor from "./MatrixSchemePermalinkConstructor";
describe("MatrixSchemePermalinkConstructor", () => {
const peramlinkConstructor = new MatrixSchemePermalinkConstructor();
describe("parsePermalink", () => {
it("should strip ?action=chat from user links", () => {
expect(peramlinkConstructor.parsePermalink("matrix:u/user:example.com?action=chat")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});
@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { describe, it, expect } from "vitest";
import MatrixToPermalinkConstructor from "./MatrixToPermalinkConstructor";
import { PermalinkParts } from "./PermalinkConstructor";
describe("MatrixToPermalinkConstructor", () => {
const peramlinkConstructor = new MatrixToPermalinkConstructor();
describe("parsePermalink", () => {
it.each([
["empty URL", ""],
["something that is not an URL", "hello"],
["should raise an error for a non-matrix.to URL", "https://example.com/#/@user:example.com"],
])("should raise an error for %s", (name: string, url: string) => {
expect(() => peramlinkConstructor.parsePermalink(url)).toThrow(
new Error("Does not appear to be a permalink"),
);
});
it.each([
["(https)", "https://matrix.to/#/@user:example.com"],
["(http)", "http://matrix.to/#/@user:example.com"],
["without protocol", "matrix.to/#/@user:example.com"],
])("should parse an MXID %s", (name: string, url: string) => {
expect(peramlinkConstructor.parsePermalink(url)).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
describe("forRoom", () => {
it("constructs a link given a room ID and via servers", () => {
expect(peramlinkConstructor.forRoom("!myroom:example.com", ["one.example.com", "two.example.com"])).toEqual(
"https://matrix.to/#/!myroom:example.com?via=one.example.com&via=two.example.com",
);
});
});
describe("forEvent", () => {
it("constructs a link given an event ID, room ID and via servers", () => {
expect(
peramlinkConstructor.forEvent("!myroom:example.com", "$event4", ["one.example.com", "two.example.com"]),
).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com");
});
});
});
@@ -0,0 +1,459 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import { getMockClientWithEventEmitter } from "test-utils/client";
import { type EventEmitter } from "events";
import { Room, RoomMember, EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { PermalinkParts } from "./PermalinkConstructor";
import { makeRoomPermalink, makeUserPermalink, parsePermalink, RoomPermalinkCreator } from "./Permalinks";
import { type IConfigOptions } from "../../IConfigOptions";
import SdkConfig from "../../SdkConfig";
describe("Permalinks", function () {
const userId = "@test:example.com";
const mockClient = getMockClientWithEventEmitter({
getUserId: vi.fn().mockReturnValue(userId),
getRoom: vi.fn(),
});
mockClient.credentials = { userId };
const makeMemberWithPL = (roomId: Room["roomId"], userId: string, powerLevel: number): RoomMember => {
const member = new RoomMember(roomId, userId);
member.powerLevel = powerLevel;
return member;
};
function mockRoom(
roomId: Room["roomId"],
members: RoomMember[],
serverACLContent?: { deny?: string[]; allow?: string[] },
): Room {
members.forEach((m) => (m.membership = KnownMembership.Join));
const powerLevelsUsers = members.reduce<Record<string, number>>((pl, member) => {
if (Number.isFinite(member.powerLevel)) {
pl[member.userId] = member.powerLevel;
}
return pl;
}, {});
const room = new Room(roomId, mockClient, userId);
const powerLevels = new MatrixEvent({
type: EventType.RoomPowerLevels,
room_id: roomId,
state_key: "",
content: {
users: powerLevelsUsers,
users_default: 0,
},
});
const serverACL = serverACLContent
? new MatrixEvent({
type: EventType.RoomServerAcl,
room_id: roomId,
state_key: "",
content: serverACLContent,
})
: undefined;
const stateEvents = serverACL ? [powerLevels, serverACL] : [powerLevels];
room.currentState.setStateEvents(stateEvents);
vi.spyOn(room, "getCanonicalAlias").mockReturnValue(null);
vi.spyOn(room, "getJoinedMembers").mockReturnValue(members);
vi.spyOn(room, "getMember").mockImplementation((userId) => members.find((m) => m.userId === userId) || null);
return room;
}
beforeEach(function () {
vi.clearAllMocks();
});
afterAll(() => {
vi.spyOn(MatrixClientPeg, "get").mockRestore();
});
it("should not clean up listeners even if start was called multiple times", () => {
const room = mockRoom("!fake:example.org", []);
const getListenerCount = (emitter: EventEmitter) =>
emitter
.eventNames()
.map((e) => emitter.listenerCount(e))
.reduce((a, b) => a + b, 0);
const listenerCountBefore = getListenerCount(room.currentState);
const creator = new RoomPermalinkCreator(room);
creator.start();
creator.start();
creator.start();
creator.start();
expect(getListenerCount(room.currentState)).toBeGreaterThan(listenerCountBefore);
creator.stop();
expect(getListenerCount(room.currentState)).toBe(listenerCountBefore);
});
it("should pick no candidate servers when the room has no members", function () {
const room = mockRoom("!fake:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should gracefully handle invalid MXIDs", () => {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl-50:org", 50);
const room = mockRoom(roomId, [alice50]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
});
it("should pick a candidate server for the highest power level user in the room", function () {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50", 50);
const alice75 = makeMemberWithPL(roomId, "@alice:pl_75", 75);
const alice95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom("!fake:example.org", [alice50, alice75, alice95]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("pl_95");
// we don't check the 2nd and 3rd servers because that is done by the next test
});
it("should change candidate server when highest power level user leaves the room", function () {
const roomId = "!fake:example.org";
const member95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:pl_50", 50),
makeMemberWithPL(roomId, "@alice:pl_75", 75),
member95,
]);
const creator = new RoomPermalinkCreator(room, null);
creator.load();
expect(creator.serverCandidates![0]).toBe("pl_95");
member95.membership = KnownMembership.Leave;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_75");
member95.membership = KnownMembership.Join;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_95");
});
it("should pick candidate servers based on user population", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 0),
makeMemberWithPL(roomId, "@bob:first", 0),
makeMemberWithPL(roomId, "@charlie:first", 0),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick prefer candidate servers with higher power levels", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick a maximum of 3 candidate servers", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:alpha", 100),
makeMemberWithPL(roomId, "@alice:bravo", 0),
makeMemberWithPL(roomId, "@alice:charlie", 0),
makeMemberWithPL(roomId, "@alice:delta", 0),
makeMemberWithPL(roomId, "@alice:echo", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
});
it("should not consider IPv4 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv4 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should work with hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:example.org:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toBe("example.org:8448");
});
it("should not consider servers explicitly denied by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["evilcorp.com", "*.evilcorp.com"],
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider servers not allowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: [], // implies "ban everyone"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should consider servers not explicitly banned by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["*.evilcorp.com"], // evilcorp.com is still good though
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should consider servers not disallowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
"!fake:example.org",
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: ["evilcorp.com"], // implies "ban everyone else"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should handle when ACL allow is not an array", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
deny: ["*.evilcorp.com"],
allow: "not-an-array" as any, // Test malformed data
});
const creator = new RoomPermalinkCreator(room);
creator.load();
// Should fall back to default behavior (no allowed servers list = allow none)
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should handle when ACL deny is not an array", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
deny: "not-an-array" as any, // Test malformed data
allow: ["*"],
});
const creator = new RoomPermalinkCreator(room);
creator.load();
// Should not crash and still allow the server
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("goodcorp.com");
});
it("should generate an event permalink for room IDs with no candidate servers", function () {
const room = mockRoom("!somewhere:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com");
});
it("should generate an event permalink for room IDs with some candidate servers", function () {
const roomId = "!somewhere:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@bob:second", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com?via=first&via=second");
});
it("should generate a room permalink for room IDs with some candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId?: string) => {
return mockRoom(roomId!, [
makeMemberWithPL(roomId!, "@alice:first", 100),
makeMemberWithPL(roomId!, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "!somewhere:example.org");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org?via=first&via=second");
});
it("should generate a room permalink for room aliases with no candidate servers", function () {
mockClient.getRoom.mockReturnValue(null);
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a room permalink for room aliases without candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId?: string) => {
return mockRoom(roomId!, [
makeMemberWithPL(roomId!, "@alice:first", 100),
makeMemberWithPL(roomId!, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a user permalink", function () {
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://matrix.to/#/@someone:example.org");
});
it("should use permalink_prefix for permalinks", function () {
const sdkConfigGet = SdkConfig.get;
vi.spyOn(SdkConfig, "get").mockImplementation((key: keyof IConfigOptions, altCaseName?: string) => {
if (key === "permalink_prefix") {
return "https://element.fs.tld";
} else return sdkConfigGet(key, altCaseName);
});
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://element.fs.tld/#/user/@someone:example.org");
});
describe("parsePermalink", () => {
it("should correctly parse room permalinks with a via argument", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=some.org");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["some.org"]);
});
it("should correctly parse room permalink via arguments", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=foo.bar&via=bar.foo");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["foo.bar", "bar.foo"]);
});
it("should correctly parse event permalink via arguments", () => {
const result = parsePermalink(
"https://matrix.to/#/!room_id:server/$event_id/some_thing_here/foobar" + "?via=m1.org&via=m2.org",
);
expect(result?.eventId).toBe("$event_id/some_thing_here/foobar");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["m1.org", "m2.org"]);
});
it("should correctly parse permalinks with http protocol", () => {
expect(parsePermalink("http://matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
it("should correctly parse permalinks without protocol", () => {
expect(parsePermalink("matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach, type Mocked } from "vitest";
import { type IIdentityServerProvider, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils";
import { DirectoryMember, ThreepidMember } from "./direct-messages";
import { lookupThreePids, resolveThreePids } from "./threepids";
describe("threepids", () => {
let client: Mocked<MatrixClient>;
const accessToken = "s3cr3t";
let identityServer: Mocked<IIdentityServerProvider>;
beforeEach(() => {
client = stubClient() as Mocked<MatrixClient>;
identityServer = {
getAccessToken: vi.fn().mockResolvedValue(accessToken),
} as unknown as Mocked<IIdentityServerProvider>;
});
describe("resolveThreePids", () => {
const userId = "@user1:example.com";
const directoryMember = new DirectoryMember({
user_id: userId,
});
const threePid1Id = "three1@example.com";
const threePid1MXID = "@three1:example.com";
const threePid1Member = new ThreepidMember(threePid1Id);
const threePid1Displayname = "Three Pid 1";
const threePid2Id = "three2@example.com";
const threePid2MXID = "@three2:example.com";
const threePid2Member = new ThreepidMember(threePid2Id);
const threePid3Id = "three3@example.com";
const threePid3Member = new ThreepidMember(threePid3Id);
const threePidPhoneId = "8801500121121";
const threePidPhoneMember = new ThreepidMember(threePidPhoneId);
it("should return an empty list for an empty input", async () => {
expect(await resolveThreePids([], client)).toEqual([]);
});
it("should return the same list for non-3rd-party members", async () => {
expect(await resolveThreePids([directoryMember], client)).toEqual([directoryMember]);
});
it("should return the same list for if no identity server is configured", async () => {
expect(await resolveThreePids([directoryMember, threePid1Member], client)).toEqual([
directoryMember,
threePid1Member,
]);
});
describe("when an identity server is configured", () => {
beforeEach(() => {
client.identityServer = identityServer;
});
it("should return the same list if the lookup doesn't return any results", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePidPhoneMember],
client,
),
).toEqual([directoryMember, threePid1Member, threePid2Member, threePidPhoneMember]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["msisdn", threePidPhoneId],
],
accessToken,
);
});
describe("and some 3-rd party members can be resolved", () => {
beforeEach(() => {
client.bulkLookupThreePids.mockResolvedValue({
threepids: [
["email", threePid1Id, threePid1MXID],
["email", threePid2Id, threePid2MXID],
],
});
});
it("should return the resolved members", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["email", threePid3Id],
],
accessToken,
);
});
describe("and some 3rd-party members have a profile", () => {
beforeEach(() => {
client.getProfileInfo.mockImplementation((matrixId: string) => {
if (matrixId === threePid1MXID)
return Promise.resolve({ displayname: threePid1Displayname });
throw new Error("Profile not found");
});
});
it("should resolve the profiles", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID, display_name: threePid1Displayname }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
});
});
});
});
});
describe("lookupThreePids", () => {
it("should return an empty list for an empty list", async () => {
client.identityServer = identityServer;
expect(await lookupThreePids([], client)).toEqual([]);
});
});
});
+158
View File
@@ -0,0 +1,158 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils";
import {
clearUserStatus,
fetchUserStatus,
setUserStatus,
userStatusFromProfile,
userStatusTextWithinMaxLength,
} from "./userStatus";
describe("userStatus utils", () => {
describe("userStatusFromProfile", () => {
it("returns the user status if it is valid", () => {
expect(userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, undefined)).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the user status is invalid and there is no call status", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, undefined)).toBeUndefined();
});
it("returns the call status if the user status is invalid but the call status is valid", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, { call_joined_ts: 12345 })).toEqual({
emoji: "📞",
text: "On a call",
});
});
it("prefers the user status over the call status if both are valid", () => {
expect(
userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, { call_joined_ts: 12345 }),
).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the call status has a non-positive call_joined_ts", () => {
expect(userStatusFromProfile(undefined, { call_joined_ts: 0 })).toBeUndefined();
});
it("returns undefined if neither status is valid", () => {
expect(userStatusFromProfile(undefined, undefined)).toBeUndefined();
});
});
describe("userStatusTextWithinMaxLength", () => {
it("returns true for text within the max length", () => {
const text = "a".repeat(256);
expect(userStatusTextWithinMaxLength(text)).toBe(true);
});
it("returns false for text exceeding the max length", () => {
const text = "a".repeat(257);
expect(userStatusTextWithinMaxLength(text)).toBe(false);
});
});
describe("setUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("sets the user status with valid input", async () => {
setUserStatus(client, { emoji: "🐳", text: "Feeling a little blue" });
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: "🐳",
text: "Feeling a little blue",
});
});
});
describe("fetchUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
client.doesServerSupportExtendedProfiles = vi.fn();
});
it("returns undefined if the server does not support extended profiles", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(false);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
expect(client.getExtendedProfileProperty).not.toHaveBeenCalled();
});
it("returns the validated status if the server supports extended profiles and has a status set", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({
emoji: "🐳",
text: "Feeling a little blue",
});
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(
"@alice:example.com",
"org.matrix.msc4426.status",
);
});
it("returns undefined if the status is invalid", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ text: "Feeling a little blue" });
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
it("returns undefined if the user has no status set", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
vi.mocked(client.getExtendedProfileProperty).mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
it("returns undefined and logs a warning if fetching the status fails unexpectedly", async () => {
vi.mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
const error = new Error("network error");
vi.mocked(client.getExtendedProfileProperty).mockRejectedValue(error);
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
});
});
describe("clearUserStatus", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("clears the user status", async () => {
clearUserStatus(client);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
});
});
});
@@ -0,0 +1,67 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach, afterEach, type Mocked } from "vitest";
import fetchMock from "@fetch-mock/vitest";
import type { ConsoleLogger } from "../rageshake/rageshake";
import SdkConfig from "../SdkConfig";
import "./rageshakesetup";
import { BugReportEndpointURLLocal } from "../IConfigOptions";
const RAGESHAKE_URL = "https://logs.example.org/logtome";
describe("mxSendRageshake", () => {
let prevLogger: ConsoleLogger;
beforeEach(() => {
fetchMock.mockGlobal();
SdkConfig.put({ bug_report_endpoint_url: RAGESHAKE_URL });
fetchMock.postOnce(RAGESHAKE_URL, { status: 200, body: {} });
const mockConsoleLogger = {
flush: vi.fn(),
consume: vi.fn(),
warn: vi.fn(),
} as unknown as Mocked<ConsoleLogger>;
prevLogger = global.mx_rage_logger;
mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
global.mx_rage_logger = mockConsoleLogger;
});
afterEach(() => {
global.mx_rage_logger = prevLogger;
vi.restoreAllMocks();
fetchMock.unmockGlobal();
SdkConfig.reset();
});
it("Does not send a rageshake if the URL is not configured", async () => {
SdkConfig.put({ bug_report_endpoint_url: undefined });
await window.mxSendRageshake("test");
expect(fetchMock).not.toHaveFetched();
});
it.each(["", " ", undefined, null])("Does not send a rageshake if text is '%s'", async (text) => {
await window.mxSendRageshake(text as string);
expect(fetchMock).not.toHaveFetched();
});
it("Sends a rageshake via URL", async () => {
await window.mxSendRageshake("Hello world");
expect(fetchMock).toHaveFetched(RAGESHAKE_URL);
});
it("Provides a rageshake locally", async () => {
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
const urlSpy = vi.spyOn(URL, "createObjectURL");
await window.mxSendRageshake("Hello world");
expect(fetchMock).not.toHaveFetched(RAGESHAKE_URL);
expect(urlSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,356 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import React from "react";
import { ConnectionError, Direction } from "matrix-js-sdk/src/matrix";
import { flushPromisesWithFakeTimers } from "test-utils/utilities";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { formatFullDateNoTime } from "../../../DateUtils";
import Modal from "../../../Modal";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { SDKContextClass } from "../../../contexts/SDKContextClass";
import { DateSeparatorViewModel } from "./DateSeparatorViewModel";
vi.mock("../../../Modal");
vi.mock("../../../settings/SettingsStore");
vi.mock("../../../contexts/SDKContextClass", () => ({
SDKContextClass: {
instance: {
roomViewStore: {
getRoomId: vi.fn(),
},
},
},
}));
describe("DateSeparatorViewModel", () => {
const HOUR_MS = 3600000;
const DAY_MS = HOUR_MS * 24;
// Friday Dec 17 2021, 9:09am
const nowDate = new Date("2021-12-17T08:09:00.000Z");
const roomId = "!room:example.org";
const defaultProps = {
roomId,
ts: nowDate.getTime(),
};
type TestCase = [string, number, string];
const testCases: TestCase[] = [
["the exact same moment", nowDate.getTime(), "today"],
["same day as current day", nowDate.getTime() - HOUR_MS, "today"],
["day before the current day", nowDate.getTime() - HOUR_MS * 12, "yesterday"],
["2 days ago", nowDate.getTime() - DAY_MS * 2, "Wednesday"],
["144 hours ago", nowDate.getTime() - HOUR_MS * 144, "Sat, Dec 11, 2021"],
[
"6 days ago, but less than 144h",
new Date("Saturday Dec 11 2021 23:59:00 GMT+0100 (Central European Standard Time)").getTime(),
"Saturday",
],
];
const watchCallbacks = new Map<string, (...args: any[]) => void>();
const mockTimestampToEvent = vi.fn();
const hasTestId = (node: React.ReactNode, testId: string): boolean => {
if (!React.isValidElement<{ children?: React.ReactNode }>(node)) return false;
const props = node.props as { "children"?: React.ReactNode; "data-testid"?: string };
if (props["data-testid"] === testId) return true;
const children = React.Children.toArray(props.children);
return children.some((child) => hasTestId(child, testId));
};
const createViewModel = (
props: Partial<typeof defaultProps> & { forExport?: boolean } = {},
): DateSeparatorViewModel => {
return new DateSeparatorViewModel({
...defaultProps,
...props,
roomViewStore: SDKContextClass.instance.roomViewStore,
});
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(nowDate.getTime());
watchCallbacks.clear();
vi.mocked(SettingsStore).getValue.mockImplementation((key): any => {
if (String(key) === UIFeature.TimelineEnableRelativeDates) return true;
if (key === "feature_jump_to_date") return false;
return undefined;
});
vi.mocked(SettingsStore).watchSetting.mockImplementation((settingName, _roomId, cb): any => {
watchCallbacks.set(String(settingName), cb);
return `${String(settingName)}-watch-ref`;
});
vi.mocked(SettingsStore).unwatchSetting.mockImplementation(() => {});
mockTimestampToEvent.mockReset();
vi.spyOn(MatrixClientPeg, "safeGet").mockReturnValue({
timestampToEvent: mockTimestampToEvent,
} as any);
vi.spyOn(dispatcher, "dispatch").mockImplementation(() => {});
vi.spyOn(Modal, "createDialog").mockImplementation(() => ({ close: vi.fn() }) as any);
vi.mocked(SDKContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.useRealTimers();
});
it("computes relative label for today", () => {
const vm = createViewModel();
expect(vm.getSnapshot().label).toBe("today");
});
it("uses full date when exporting", () => {
const vm = createViewModel({ forExport: true });
expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate));
});
it("updates label when relative dates setting changes at runtime", () => {
const vm = createViewModel();
expect(vm.getSnapshot().label).toBe("today");
const callback = watchCallbacks.get(UIFeature.TimelineEnableRelativeDates);
expect(callback).toBeDefined();
callback?.(UIFeature.TimelineEnableRelativeDates, null, null, null, false);
expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate));
});
it("exposes jumpToDateMenu when feature is enabled", () => {
vi.mocked(SettingsStore).getValue.mockImplementation((key): any => {
if (String(key) === UIFeature.TimelineEnableRelativeDates) return true;
if (key === "feature_jump_to_date") return true;
return undefined;
});
const vm = createViewModel();
expect(vm.getSnapshot().jumpToEnabled).toBeTruthy();
});
it("exposes jumpFromDate in snapshot", () => {
const vm = createViewModel();
expect(vm.getSnapshot().jumpFromDate).toBe("2021-12-17");
});
it("does not expose jumpToDateMenu when exporting", () => {
vi.mocked(SettingsStore).getValue.mockImplementation((key): any => {
if (String(key) === UIFeature.TimelineEnableRelativeDates) return true;
if (key === "feature_jump_to_date") return true;
return undefined;
});
const vm = createViewModel({ forExport: true });
expect(vm.getSnapshot().jumpToEnabled).toBeFalsy();
});
it("updates jumpToEnabled when feature_jump_to_date changes at runtime", () => {
const vm = createViewModel();
expect(vm.getSnapshot().jumpToEnabled).toBeFalsy();
const callback = watchCallbacks.get("feature_jump_to_date");
expect(callback).toBeDefined();
callback?.("feature_jump_to_date", null, null, null, true);
expect(vm.getSnapshot().jumpToEnabled).toBeTruthy();
});
it("dispatches ViewRoom when pickDate resolves in active room", async () => {
const eventId = "$event";
const unixTimestamp = nowDate.getTime() - DAY_MS;
mockTimestampToEvent.mockResolvedValue({
event_id: eventId,
origin_server_ts: unixTimestamp,
});
const vm = createViewModel();
await vm.pickDate(unixTimestamp);
expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, unixTimestamp, Direction.Forward);
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
});
});
it("does not dispatch ViewRoom when room changed before pickDate resolves", async () => {
mockTimestampToEvent.mockResolvedValue({
event_id: "$event",
origin_server_ts: nowDate.getTime(),
});
vi.mocked(SDKContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!other:example.org");
const vm = createViewModel();
await vm.pickDate(nowDate.getTime() - HOUR_MS);
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
it("shows submit debug logs option for generic errors", async () => {
mockTimestampToEvent.mockRejectedValue(new Error("Boom"));
const vm = createViewModel();
await vm.pickDate(nowDate.getTime() - HOUR_MS);
expect(Modal.createDialog).toHaveBeenCalled();
const [, params] = vi.mocked(Modal.createDialog).mock.calls.at(-1)!;
expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true);
});
it("does not show submit debug logs option for connection errors", async () => {
mockTimestampToEvent.mockRejectedValue(new ConnectionError("offline"));
const vm = createViewModel();
await vm.pickDate(nowDate.getTime() - HOUR_MS);
expect(Modal.createDialog).toHaveBeenCalled();
const [, params] = vi.mocked(Modal.createDialog).mock.calls.at(-1)!;
expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false);
});
describe("snapshot labels", () => {
it.each(testCases)("formats date correctly when current time is %s", (_d, ts, result) => {
expect(createViewModel({ ts }).getSnapshot().label).toContain(result);
});
describe("when forExport is true", () => {
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
expect(createViewModel({ ts, forExport: true }).getSnapshot().label).toContain(
formatFullDateNoTime(new Date(ts)),
);
});
});
describe("when TimelineEnableRelativeDates is false", () => {
beforeEach(() => {
vi.mocked(SettingsStore).getValue.mockImplementation((key): any => {
if (String(key) === UIFeature.TimelineEnableRelativeDates) return false;
if (key === "feature_jump_to_date") return false;
return undefined;
});
});
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
expect(createViewModel({ ts }).getSnapshot().label).toContain(formatFullDateNoTime(new Date(ts)));
});
});
});
describe("jump actions", () => {
beforeEach(() => {
vi.mocked(SettingsStore).getValue.mockImplementation((key): any => {
if (String(key) === UIFeature.TimelineEnableRelativeDates) return true;
if (key === "feature_jump_to_date") return true;
return undefined;
});
});
[
{
timeDescriptor: "last week",
run: (vm: DateSeparatorViewModel): Promise<void> => vm.onLastWeekPicked(),
},
{
timeDescriptor: "last month",
run: (vm: DateSeparatorViewModel): Promise<void> => vm.onLastMonthPicked(),
},
{
timeDescriptor: "the beginning",
run: (vm: DateSeparatorViewModel): Promise<void> => vm.onBeginningPicked(),
},
].forEach((testCase) => {
it(`can jump to ${testCase.timeDescriptor}`, async () => {
const returnedDate = new Date();
returnedDate.setDate(nowDate.getDate() - 100);
const returnedEventId = "$abc";
mockTimestampToEvent.mockResolvedValue({
event_id: returnedEventId,
origin_server_ts: returnedDate.getTime(),
});
const vm = createViewModel();
await testCase.run(vm);
await flushPromisesWithFakeTimers();
expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, expect.any(Number), Direction.Forward);
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: returnedEventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
});
});
});
it("does not jump when room changed before request resolves", async () => {
vi.mocked(SDKContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room");
mockTimestampToEvent.mockResolvedValue({
event_id: "$abc",
origin_server_ts: 0,
});
const vm = createViewModel();
await vm.onLastWeekPicked();
await flushPromisesWithFakeTimers();
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
it("does not show jump to date error if user switched room", async () => {
vi.mocked(SDKContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room");
mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test"));
const vm = createViewModel();
await vm.onLastWeekPicked();
await flushPromisesWithFakeTimers();
expect(Modal.createDialog).not.toHaveBeenCalled();
});
it("shows error dialog with submit debug logs option when non-networking error occurs", async () => {
mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test"));
const vm = createViewModel();
await vm.onLastWeekPicked();
await flushPromisesWithFakeTimers();
expect(Modal.createDialog).toHaveBeenCalled();
const [, params] = vi.mocked(Modal.createDialog).mock.calls.at(-1)!;
expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true);
});
it("shows error dialog without submit debug logs option when networking error occurs", async () => {
mockTimestampToEvent.mockRejectedValue(new ConnectionError("Fake connection error in test"));
const vm = createViewModel();
await vm.onLastWeekPicked();
await flushPromisesWithFakeTimers();
expect(Modal.createDialog).toHaveBeenCalled();
const [, params] = vi.mocked(Modal.createDialog).mock.calls.at(-1)!;
expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false);
});
});
});
@@ -0,0 +1,162 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { vi, describe, it, expect } from "vitest";
import { DisambiguatedProfileViewModel } from "./DisambiguatedProfileViewModel";
describe("DisambiguatedProfileViewModel", () => {
const member = {
userId: "@alice:example.org",
roomId: "!room:example.org",
rawDisplayName: "Alice",
disambiguate: true,
};
const nonDisambiguatedMember = {
...member,
disambiguate: false,
};
it("should return the snapshot from props", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
colored: true,
emphasizeDisplayName: true,
withTooltip: true,
});
expect(vm.getSnapshot()).toEqual({
displayName: "Alice",
colorClass: "mx_Username_color3",
displayIdentifier: "@alice:example.org",
title: "Alice (@alice:example.org)",
emphasizeDisplayName: true,
});
});
it("should default member fields when member is null", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
expect(vm.getSnapshot()).toMatchObject({
displayName: "Fallback",
colorClass: undefined,
displayIdentifier: undefined,
title: undefined,
emphasizeDisplayName: undefined,
});
});
it("should delegate onClick without emitting a snapshot update", () => {
const onClick = vi.fn();
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
onClick,
});
const prevSnapshot = vm.getSnapshot();
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.onClick?.({} as never);
expect(onClick).toHaveBeenCalledTimes(1);
expect(subscriber).not.toHaveBeenCalled();
expect(vm.getSnapshot()).toBe(prevSnapshot);
});
it("should keep onClick bound when extracted as a callback", () => {
const onClick = vi.fn();
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
onClick,
});
const clickHandler = vm.onClick;
expect(() => clickHandler?.({} as never)).not.toThrow();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should emit snapshot update when fallbackName changes", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.setMember("Updated");
expect(subscriber).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().displayName).toBe("Updated");
});
it("should emit snapshot update when setMember is called even if fallbackName is unchanged", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback");
expect(subscriber).toHaveBeenCalledTimes(1);
});
it("should compute tooltip title from constructor props when withTooltip is true", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
withTooltip: true,
});
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
});
it("should compute tooltip title even when disambiguation is not needed", () => {
const vm = new DisambiguatedProfileViewModel({
member: nonDisambiguatedMember,
fallbackName: "Fallback",
withTooltip: true,
});
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
});
it("should emit snapshot update when member changes via setMember", () => {
const vm = new DisambiguatedProfileViewModel({
member: null,
fallbackName: "Fallback",
});
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback", member);
expect(subscriber).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().displayName).toBe("Alice");
});
it("should emit snapshot update when setMember is called with unchanged member", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
});
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.setMember("Fallback", member);
expect(subscriber).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { waitFor } from "@testing-library/dom";
import { type Playback, PlaybackState } from "../../../../../audio/Playback";
import { AudioPlayerViewModel } from "./AudioPlayerViewModel";
import { MockedPlayback } from "../../../../../../test/unit-tests/audio/MockedPlayback";
describe("AudioPlayerViewModel", () => {
let playback: Playback;
beforeEach(() => {
playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
});
it("should return the snapshot", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
expect(vm.getSnapshot()).toMatchObject({
mediaName: "mediaName",
sizeBytes: 8000,
playbackState: "decoding",
durationSeconds: 50,
playedSeconds: 10,
percentComplete: 20,
error: false,
});
});
it("should toggle the playback state", async () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await vm.togglePlay();
expect(playback.toggle).toHaveBeenCalled();
});
it("should move the playback on seekbar change", async () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await vm.onSeekbarChange({ target: { value: "20" } } as ChangeEvent<HTMLInputElement>);
expect(playback.skipTo).toHaveBeenCalledWith(10); // 20% of 50 seconds
});
it("should has error=true when playback.prepare fails", async () => {
vi.spyOn(playback, "prepare").mockRejectedValue(new Error("Failed to prepare playback"));
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
await waitFor(() => expect(vm.getSnapshot().error).toBe(true));
});
it("should handle key down events", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
let event = new KeyboardEvent("keydown", { key: " " }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.toggle).toHaveBeenCalled();
event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back
event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
vm.onKeyDown(event);
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
});
it("does not stop propagation for unhandled key down events", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
const event = new KeyboardEvent("keydown", { key: "a" });
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
vm.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLDivElement>);
expect(stopPropagationSpy).not.toHaveBeenCalled();
expect(playback.toggle).not.toHaveBeenCalled();
expect(playback.skipTo).not.toHaveBeenCalled();
});
it("should update snapshot when setProps is called with new mediaName", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
expect(vm.getSnapshot().mediaName).toBe("oldName");
vm.setProps({ mediaName: "newName" });
expect(vm.getSnapshot().mediaName).toBe("newName");
});
});
@@ -0,0 +1,144 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, afterEach } from "vitest";
import { waitFor } from "test-utils-rtl";
import { type PanelImperativeHandle } from "@element-hq/web-shared-components";
import { ResizerViewModel } from "./ResizerViewModel";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
vi.mock("what-input");
describe("LeftPanelResizerViewModel", () => {
afterEach(() => {
localStorage.clear();
SettingsStore.reset();
});
describe("Initial state is correct", () => {
it("should have correct initial state when panel was previously collapsed", () => {
SettingsStore.setValue("RoomList.isPanelCollapsed", null, SettingLevel.DEVICE, true);
const vm = new ResizerViewModel();
expect(vm.getSnapshot()).toStrictEqual({
isCollapsed: true,
initialSize: 0,
});
});
it("should have correct initial state when panel was previously resized", () => {
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
const vm = new ResizerViewModel();
expect(vm.getSnapshot()).toStrictEqual({
isCollapsed: false,
initialSize: 34,
});
});
it("should have correct initial state when panel was neither resized nor collapsed", () => {
const vm = new ResizerViewModel();
expect(vm.getSnapshot()).toStrictEqual({
isCollapsed: false,
initialSize: undefined,
});
});
});
it("should update isCollapsed on onLeftPanelResized()", async () => {
const vm = new ResizerViewModel();
vm.onLeftPanelResize({ inPixels: 100, asPercentage: 6 });
await waitFor(() => {
expect(vm.getSnapshot().isCollapsed).toStrictEqual(false);
});
vm.onLeftPanelResize({ inPixels: 0, asPercentage: 6 });
await waitFor(() => {
expect(vm.getSnapshot().isCollapsed).toStrictEqual(true);
});
});
it("should noop on click when handle is not yet set", () => {
const vm = new ResizerViewModel();
expect(() => {
// Click
vm.onPointerDown();
vm.onPointerUp();
}).not.toThrow();
});
it("should noop on mouse drag", () => {
const vm = new ResizerViewModel();
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
const mockHandle = {
resize: vi.fn(),
isCollapsed: vi.fn().mockReturnValue(true),
} as unknown as PanelImperativeHandle;
vm.setPanelHandle(mockHandle);
// Simulate drag
vm.onPointerDown();
vm.onPointerMove();
vm.onPointerUp();
expect(mockHandle.resize).not.toHaveBeenCalledWith("34%");
});
describe("should expand panel on double click when panel is collapsed", () => {
it("to last non-zero width that the user set", () => {
const vm = new ResizerViewModel();
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
const mockHandle = {
resize: vi.fn(),
isCollapsed: vi.fn().mockReturnValue(true),
} as unknown as PanelImperativeHandle;
vm.setPanelHandle(mockHandle);
// Simulate click
vm.onPointerDown();
vm.onPointerUp();
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
});
it("to maximum size of the panel", () => {
const vm = new ResizerViewModel();
const mockHandle = {
resize: vi.fn(),
isCollapsed: vi.fn().mockReturnValue(true),
} as unknown as PanelImperativeHandle;
vm.setPanelHandle(mockHandle);
// Simulate click
vm.onPointerDown();
vm.onPointerUp();
expect(mockHandle.resize).toHaveBeenCalledWith("100%");
});
});
it("should collapse panel on click when panel is expanded", () => {
const vm = new ResizerViewModel();
const mockHandle = {
collapse: vi.fn(),
isCollapsed: vi.fn().mockReturnValue(false),
} as unknown as PanelImperativeHandle;
vm.setPanelHandle(mockHandle);
vm.onDoubleClick();
expect(mockHandle.collapse).toHaveBeenCalled();
});
it("should resize to nearest whole number", () => {
const vm = new ResizerViewModel();
const mockHandle = {
resize: vi.fn(),
} as unknown as PanelImperativeHandle;
vm.setPanelHandle(mockHandle);
vm.onLeftPanelResized(25.515);
expect(mockHandle.resize).toHaveBeenCalledWith("26%");
});
});