Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

11
test/@types/common.ts Normal file
View file

@ -0,0 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export type PublicInterface<T> = {
[P in keyof T]: T[P];
};

View file

@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig from "matrix-react-sdk/src/SdkConfig";
import PlatformPeg from "matrix-react-sdk/src/PlatformPeg";
import fetchMock from "fetch-mock-jest";
import SdkConfig from "../../src/SdkConfig";
import PlatformPeg from "../../src/PlatformPeg";
import { loadApp } from "../../src/vector/app";
import WebPlatform from "../../src/vector/platform/WebPlatform";

View file

@ -8,14 +8,14 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SdkConfig from "matrix-react-sdk/src/SdkConfig";
import PlatformPeg from "matrix-react-sdk/src/PlatformPeg";
import fetchMock from "fetch-mock-jest";
import { render, RenderResult, screen } from "@testing-library/react";
import { ModuleRunner } from "matrix-react-sdk/src/modules/ModuleRunner";
import { render, RenderResult, screen } from "jest-matrix-react";
import { WrapperLifecycle, WrapperOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WrapperLifecycle";
import MatrixChat from "matrix-react-sdk/src/components/structures/MatrixChat";
import SdkConfig from "../../src/SdkConfig";
import PlatformPeg from "../../src/PlatformPeg";
import { ModuleRunner } from "../../src/modules/ModuleRunner";
import MatrixChat from "../../src/components/structures/MatrixChat";
import WebPlatform from "../../src/vector/platform/WebPlatform";
import { loadApp } from "../../src/vector/app";
import { waitForLoadingSpinner, waitForWelcomeComponent } from "../test-utils";

11
test/globalSetup.ts Normal file
View file

@ -0,0 +1,11 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
module.exports = async () => {
process.env.TZ = "UTC";
};

28
test/setup/mocks.ts Normal file
View file

@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export const mocks = {
AudioBufferSourceNode: {
connect: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
} as unknown as AudioBufferSourceNode,
AudioContext: {
close: jest.fn(),
createMediaElementSource: jest.fn(),
createMediaStreamDestination: jest.fn(),
createMediaStreamSource: jest.fn(),
createStreamTrackSource: jest.fn(),
createBufferSource: jest.fn((): AudioBufferSourceNode => ({ ...mocks.AudioBufferSourceNode })),
getOutputTimestamp: jest.fn(),
resume: jest.fn(),
setSinkId: jest.fn(),
suspend: jest.fn(),
decodeAudioData: jest.fn(),
},
};

13
test/setup/setupConfig.ts Normal file
View file

@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig, { DEFAULTS } from "../../src/SdkConfig";
// uninitialised SdkConfig causes lots of warnings in console
// init with defaults
SdkConfig.put(DEFAULTS);

View file

@ -1,22 +1,45 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "fetch-mock-jest";
import _ from "lodash";
import { setupLanguageMock as reactSetupLanguageMock } from "matrix-react-sdk/test/setup/setupLanguage";
import * as languageHandler from "../../src/languageHandler";
import en from "../../src/i18n/strings/en_EN.json";
import reactEn from "../../src/i18n/strings/en_EN.json";
import de from "../../src/i18n/strings/de_DE.json";
fetchMock.config.overwriteRoutes = false;
const lv = {
Save: "Saglabāt",
room: {
upload: {
uploading_multiple_file: {
one: "Качване на %(filename)s и %(count)s друг",
},
},
},
};
// Fake languages.json containing references to en_EN, de_DE and lv
// en_EN.json
// de_DE.json
// lv.json - mock version with few translations, used to test fallback translation
export function setupLanguageMock() {
reactSetupLanguageMock();
fetchMock.get("end:en_EN.json", _.merge({}, en, reactEn), { overwriteRoutes: true });
fetchMock
.get("/i18n/languages.json", {
en: "en_EN.json",
de: "de_DE.json",
lv: "lv.json",
})
.get("end:en_EN.json", en)
.get("end:de_DE.json", de)
.get("end:lv.json", lv);
}
setupLanguageMock();
languageHandler.setLanguage("en");
languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);

View file

@ -0,0 +1,87 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "fetch-mock-jest";
import { TextDecoder, TextEncoder } from "util";
import { Response } from "node-fetch";
import { mocks } from "./mocks";
// Stub ResizeObserver
// @ts-ignore - we know it's a duplicate (that's why we're stubbing it)
class ResizeObserver {
observe() {} // do nothing
unobserve() {} // do nothing
disconnect() {} // do nothing
}
window.ResizeObserver = ResizeObserver;
// Stub DOMRect
class DOMRect {
x = 0;
y = 0;
top = 0;
bottom = 0;
left = 0;
right = 0;
height = 0;
width = 0;
static fromRect() {
return new DOMRect();
}
toJSON() {}
}
window.DOMRect = DOMRect;
// Work around missing ClipboardEvent type
class MyClipboardEvent extends Event {}
window.ClipboardEvent = MyClipboardEvent as any;
// matchMedia is not included in jsdom
// TODO: Extract this to a function and have tests that need it opt into it.
const mockMatchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
});
global.matchMedia = mockMatchMedia;
// maplibre requires a createObjectURL mock
global.URL.createObjectURL = jest.fn();
global.URL.revokeObjectURL = jest.fn();
// polyfilling TextEncoder as it is not available on JSDOM
// view https://github.com/facebook/jest/issues/9983
global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;
// prevent errors whenever a component tries to manually scroll.
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLAudioElement.prototype.canPlayType = jest.fn((format) => (format === "audio/mpeg" ? "probably" : ""));
// set up fetch API mock
fetchMock.config.overwriteRoutes = false;
fetchMock.catch("");
fetchMock.get("/image-file-stub", "image file stub");
fetchMock.get("/_matrix/client/versions", {});
// @ts-ignore
window.fetch = fetchMock.sandbox();
// @ts-ignore
window.Response = Response;
// set up AudioContext API mock
global.AudioContext = jest.fn().mockImplementation(() => ({ ...mocks.AudioContext }));

40
test/setupTests.ts Normal file
View file

@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import "@testing-library/jest-dom";
import "blob-polyfill";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { mocked } from "jest-mock";
import { PredictableRandom } from "./test-utils/predictableRandom"; // https://github.com/jsdom/jsdom/issues/2555
// Fake random strings to give a predictable snapshot for IDs
jest.mock("matrix-js-sdk/src/randomstring");
beforeEach(() => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const mockRandom = new PredictableRandom();
mocked(randomString).mockImplementation((len) => {
let ret = "";
for (let i = 0; i < len; ++i) {
const v = mockRandom.get() * chars.length;
const m = ((v % chars.length) + chars.length) % chars.length; // account for negative modulo
ret += chars.charAt(Math.floor(m));
}
return ret;
});
});
// Very carefully enable the mocks for everything else in
// a specific order. We use this order to ensure we properly
// establish an application state that actually works.
//
// These are also require() calls to make sure they get called
// synchronously.
require("./setup/setupManualMocks"); // must be first
require("./setup/setupLanguage");
require("./setup/setupConfig");

9
test/slowReporter.cjs Normal file
View file

@ -0,0 +1,9 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
module.exports = require("matrix-js-sdk/spec/slowReporter.cjs");

74
test/test-utils/audio.ts Normal file
View file

@ -0,0 +1,74 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { SimpleObservable } from "matrix-widget-api";
import { Playback, PlaybackState } from "../../src/audio/Playback";
import { PlaybackClock } from "../../src/audio/PlaybackClock";
import { UPDATE_EVENT } from "../../src/stores/AsyncStore";
import { PublicInterface } from "../@types/common";
export const createTestPlayback = (overrides: Partial<Playback> = {}): Playback => {
const eventEmitter = new EventEmitter();
return {
thumbnailWaveform: [1, 2, 3],
sizeBytes: 23,
waveform: [4, 5, 6],
waveformData: new SimpleObservable<number[]>(),
destroy: jest.fn(),
play: jest.fn(),
prepare: jest.fn(),
pause: jest.fn(),
stop: jest.fn(),
toggle: jest.fn(),
skipTo: jest.fn(),
isPlaying: false,
clockInfo: createTestPlaybackClock(),
currentState: PlaybackState.Stopped,
emit: (event: PlaybackState, ...args: any[]): boolean => {
eventEmitter.emit(event, ...args);
eventEmitter.emit(UPDATE_EVENT, event, ...args);
return true;
},
// EventEmitter
on: eventEmitter.on.bind(eventEmitter) as Playback["on"],
once: eventEmitter.once.bind(eventEmitter) as Playback["once"],
off: eventEmitter.off.bind(eventEmitter) as Playback["off"],
addListener: eventEmitter.addListener.bind(eventEmitter) as Playback["addListener"],
removeListener: eventEmitter.removeListener.bind(eventEmitter) as Playback["removeListener"],
removeAllListeners: eventEmitter.removeAllListeners.bind(eventEmitter) as Playback["removeAllListeners"],
getMaxListeners: eventEmitter.getMaxListeners.bind(eventEmitter) as Playback["getMaxListeners"],
setMaxListeners: eventEmitter.setMaxListeners.bind(eventEmitter) as Playback["setMaxListeners"],
listeners: eventEmitter.listeners.bind(eventEmitter) as Playback["listeners"],
rawListeners: eventEmitter.rawListeners.bind(eventEmitter) as Playback["rawListeners"],
listenerCount: eventEmitter.listenerCount.bind(eventEmitter) as Playback["listenerCount"],
eventNames: eventEmitter.eventNames.bind(eventEmitter) as Playback["eventNames"],
prependListener: eventEmitter.prependListener.bind(eventEmitter) as Playback["prependListener"],
prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter) as Playback["prependOnceListener"],
liveData: new SimpleObservable<number[]>(),
durationSeconds: 31415,
timeSeconds: 3141,
...overrides,
} as PublicInterface<Playback> as Playback;
};
export const createTestPlaybackClock = (): PlaybackClock => {
return {
durationSeconds: 31,
timeSeconds: 41,
liveData: new SimpleObservable<number[]>(),
populatePlaceholdersFrom: jest.fn(),
flagLoadTime: jest.fn(),
flagStart: jest.fn(),
flagStop: jest.fn(),
syncTo: jest.fn(),
destroy: jest.fn(),
} as PublicInterface<PlaybackClock> as PlaybackClock;
};

209
test/test-utils/beacon.ts Normal file
View file

@ -0,0 +1,209 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MockedObject } from "jest-mock";
import {
MatrixClient,
MatrixEvent,
Beacon,
getBeaconInfoIdentifier,
ContentHelpers,
LocationAssetType,
M_BEACON,
M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix";
import { getMockGeolocationPositionError } from "./location";
import { makeRoomWithStateEvents } from "./room";
type InfoContentProps = {
timeout: number;
isLive?: boolean;
assetType?: LocationAssetType;
description?: string;
timestamp?: number;
};
const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = {
timeout: 3600000,
};
/**
* Create an m.beacon_info event
* all required properties are mocked
* override with contentProps
*/
export const makeBeaconInfoEvent = (
sender: string,
roomId: string,
contentProps: Partial<InfoContentProps> = {},
eventId?: string,
): MatrixEvent => {
const { timeout, isLive, description, assetType, timestamp } = {
...DEFAULT_INFO_CONTENT_PROPS,
...contentProps,
};
const event = new MatrixEvent({
type: M_BEACON_INFO.name,
room_id: roomId,
state_key: sender,
sender,
content: ContentHelpers.makeBeaconInfoContent(timeout, isLive, description, assetType, timestamp),
});
event.event.origin_server_ts = Date.now();
// live beacons use the beacon_info event id
// set or default this
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`);
return event;
};
type ContentProps = {
geoUri: string;
timestamp: number;
beaconInfoId: string;
description?: string;
};
const DEFAULT_CONTENT_PROPS: ContentProps = {
geoUri: "geo:-36.24484561954707,175.46884959563613;u=10",
timestamp: 123,
beaconInfoId: "$123",
};
/**
* Create an m.beacon event
* all required properties are mocked
* override with contentProps
*/
export const makeBeaconEvent = (
sender: string,
contentProps: Partial<ContentProps> = {},
roomId?: string,
): MatrixEvent => {
const { geoUri, timestamp, beaconInfoId, description } = {
...DEFAULT_CONTENT_PROPS,
...contentProps,
};
return new MatrixEvent({
type: M_BEACON.name,
room_id: roomId,
sender,
content: ContentHelpers.makeBeaconContent(geoUri, timestamp, beaconInfoId, description),
origin_server_ts: 0,
});
};
/**
* Create a mock geolocation position
* defaults all required properties
*/
export const makeGeolocationPosition = ({
timestamp,
coords,
}: {
timestamp?: number;
coords?: Partial<GeolocationCoordinates>;
}): GeolocationPosition =>
({
timestamp: timestamp ?? 1647256791840,
coords: {
accuracy: 1,
latitude: 54.001927,
longitude: -8.253491,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
...coords,
},
}) as unknown as GeolocationPosition;
/**
* Creates a basic mock of Geolocation
* sets navigator.geolocation to the mock
* and returns mock
*/
export const mockGeolocation = (): MockedObject<Geolocation> => {
const mockGeolocation = {
clearWatch: jest.fn(),
getCurrentPosition: jest.fn().mockImplementation((callback) => callback(makeGeolocationPosition({}))),
watchPosition: jest.fn().mockImplementation((callback) => callback(makeGeolocationPosition({}))),
} as unknown as MockedObject<Geolocation>;
// jest jsdom does not provide geolocation
// @ts-ignore illegal assignment to readonly property
navigator.geolocation = mockGeolocation;
return mockGeolocation;
};
/**
* Creates a mock watchPosition implementation
* that calls success callback at the provided delays
* ```
* geolocation.watchPosition.mockImplementation([0, 1000, 5000, 50])
* ```
* will call the provided handler with a mock position at
* next tick, 1000ms, 6000ms, 6050ms
*
* to produce errors provide an array of error codes
* that will be applied to the delay with the same index
* eg:
* ```
* // return two good positions, then a permission denied error
* geolocation.watchPosition.mockImplementation(watchPositionMockImplementation(
* [0, 1000, 3000], [0, 0, 1]),
* );
* ```
* See for error codes: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
*/
export const watchPositionMockImplementation = (delays: number[], errorCodes: number[] = []) => {
return (callback: PositionCallback, error: PositionErrorCallback): number => {
const position = makeGeolocationPosition({});
let totalDelay = 0;
delays.map((delayMs, index) => {
totalDelay += delayMs;
const timeout = window.setTimeout(() => {
if (errorCodes[index]) {
error(getMockGeolocationPositionError(errorCodes[index], "error message"));
} else {
callback({ ...position, timestamp: position.timestamp + totalDelay });
}
}, totalDelay);
return timeout;
});
return totalDelay;
};
};
/**
* Creates a room with beacon events
* sets given locations on beacons
* returns beacons
*/
export const makeRoomWithBeacons = (
roomId: string,
mockClient: MockedObject<MatrixClient>,
beaconInfoEvents: MatrixEvent[],
locationEvents?: MatrixEvent[],
): Beacon[] => {
const room = makeRoomWithStateEvents(beaconInfoEvents, { roomId, mockClient });
const beacons = beaconInfoEvents.map((event) => room.currentState.beacons.get(getBeaconInfoIdentifier(event))!);
if (locationEvents) {
beacons.forEach((beacon) => {
// this filtering happens in roomState, which is bypassed here
const validLocationEvents = locationEvents?.filter((event) => event.getSender() === beacon.beaconInfoOwner);
beacon.addLocations(validLocationEvents);
});
}
return beacons;
};

107
test/test-utils/call.ts Normal file
View file

@ -0,0 +1,107 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixWidgetType } from "matrix-widget-api";
import type { GroupCall, Room, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "./test-utils";
import { Call, ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call";
import { CallStore } from "../../src/stores/CallStore";
export class MockedCall extends Call {
public static readonly EVENT_TYPE = "org.example.mocked_call";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private constructor(
room: Room,
public readonly event: MatrixEvent,
) {
super(
{
id: event.getStateKey()!,
eventId: "$1:example.org",
roomId: room.roomId,
type: MatrixWidgetType.Custom,
url: "https://example.org",
name: "Group call",
creatorUserId: "@alice:example.org",
// waitForIframeLoad = false, makes the widget API wait for the 'contentLoaded' event.
waitForIframeLoad: false,
},
room.client,
);
this.groupCall = { creationTs: this.event.getTs() } as unknown as GroupCall;
}
public static get(room: Room): MockedCall | null {
const [event] = room.currentState.getStateEvents(this.EVENT_TYPE);
return event === undefined || "m.terminated" in event.getContent() ? null : new MockedCall(room, event);
}
public static create(room: Room, id: string) {
room.addLiveEvents([
mkEvent({
event: true,
type: this.EVENT_TYPE,
room: room.roomId,
user: "@alice:example.org",
content: { "m.type": "m.video", "m.intent": "m.prompt" },
skey: id,
ts: Date.now(),
}),
]);
// @ts-ignore deliberately calling a private method
// Let CallStore know that a call might now exist
CallStore.instance.updateRoom(room);
}
public readonly groupCall: GroupCall;
public get participants(): Map<RoomMember, Set<string>> {
return super.participants;
}
public set participants(value: Map<RoomMember, Set<string>>) {
super.participants = value;
}
public setConnectionState(value: ConnectionState): void {
super.connectionState = value;
}
// No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {}
// Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}
public destroy() {
// Terminate the call for good measure
this.room.addLiveEvents([
mkEvent({
event: true,
type: MockedCall.EVENT_TYPE,
room: this.room.roomId,
user: "@alice:example.org",
content: { ...this.event.getContent(), "m.terminated": "Call ended" },
skey: this.widget.id,
ts: Date.now(),
}),
]);
super.destroy();
}
}
/**
* Sets up the call store to use mocked calls.
*/
export const useMockedCalls = () => {
Call.get = (room) => MockedCall.get(room);
JitsiCall.create = async (room) => MockedCall.create(room, "1");
ElementCall.create = async (room) => MockedCall.create(room, "1");
};

175
test/test-utils/client.ts Normal file
View file

@ -0,0 +1,175 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { MethodLikeKeys, mocked, MockedObject, PropertyLikeKeys } from "jest-mock";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { MatrixClient, Room, MatrixError, User } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
/**
* Mocked generic class with a real EventEmitter.
* Useful for mocks which need event emitters.
*/
export class MockEventEmitter<T> extends EventEmitter {
/**
* Construct a new event emitter with additional properties/functions. The event emitter functions
* like .emit and .on will be real.
* @param mockProperties An object with the mock property or function implementations. 'getters'
* are correctly cloned to this event emitter.
*/
constructor(mockProperties: Partial<Record<MethodLikeKeys<T> | PropertyLikeKeys<T>, unknown>> = {}) {
super();
// We must use defineProperties and not assign as the former clones getters correctly,
// whereas the latter invokes the getter and sets the return value permanently on the
// destination object.
Object.defineProperties(this, Object.getOwnPropertyDescriptors(mockProperties));
}
}
/**
* Mock client with real event emitter
* useful for testing code that listens
* to MatrixClient events
*/
export class MockClientWithEventEmitter extends EventEmitter {
constructor(mockProperties: Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> = {}) {
super();
Object.assign(this, mockProperties);
}
}
/**
* - make a mock client
* - cast the type to mocked(MatrixClient)
* - spy on MatrixClientPeg.get to return the mock
* eg
* ```
* const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(aliceId),
});
* ```
*
* See also {@link stubClient} which does something similar but uses a more complete mock client.
*/
export const getMockClientWithEventEmitter = (
mockProperties: Partial<Record<keyof MatrixClient, unknown>>,
): MockedObject<MatrixClient> => {
const mock = mocked(new MockClientWithEventEmitter(mockProperties) as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mock);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mock);
// @ts-ignore simplified test stub
mock.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
mock.canSupport.set(feature as Feature, ServerSupport.Stable);
});
return mock;
};
export const unmockClientPeg = () => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
jest.spyOn(MatrixClientPeg, "safeGet").mockRestore();
};
/**
* Returns basic mocked client methods related to the current user
* ```
* const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser('@mytestuser:domain'),
});
* ```
*/
export const mockClientMethodsUser = (userId = "@alice:domain") => ({
getUserId: jest.fn().mockReturnValue(userId),
getDomain: jest.fn().mockReturnValue(userId.split(":")[1]),
getSafeUserId: jest.fn().mockReturnValue(userId),
getUser: jest.fn().mockReturnValue(new User(userId)),
isGuest: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
credentials: { userId },
getThreePids: jest.fn().mockResolvedValue({ threepids: [] }),
getAccessToken: jest.fn(),
getDeviceId: jest.fn(),
getAccountData: jest.fn(),
});
/**
* Returns basic mocked client methods related to rendering events
* ```
* const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser('@mytestuser:domain'),
});
* ```
*/
export const mockClientMethodsEvents = () => ({
decryptEventIfNeeded: jest.fn(),
getPushActionsForEvent: jest.fn(),
});
/**
* Returns basic mocked client methods related to server support
*/
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockResolvedValue({}),
getClientWellKnown: jest.fn().mockReturnValue({}),
waitForClientWellKnown: jest.fn().mockResolvedValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
isVersionSupported: jest.fn().mockResolvedValue(false),
getVersions: jest.fn().mockResolvedValue({}),
isFallbackICEServerAllowed: jest.fn(),
getAuthIssuer: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN" }, 404)),
});
export const mockClientMethodsDevice = (
deviceId = "test-device-id",
): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getDeviceId: jest.fn().mockReturnValue(deviceId),
getDevices: jest.fn().mockResolvedValue({ devices: [] }),
});
export const mockClientMethodsCrypto = (): Partial<
Record<MethodLikeKeys<MatrixClient> & PropertyLikeKeys<MatrixClient>, unknown>
> => ({
isCryptoEnabled: jest.fn(),
isCrossSigningReady: jest.fn(),
isKeyBackupKeyStored: jest.fn(),
getCrossSigningCacheCallbacks: jest.fn().mockReturnValue({ getCrossSigningKeyCache: jest.fn() }),
getStoredCrossSigningForUser: jest.fn(),
getKeyBackupVersion: jest.fn().mockResolvedValue(null),
secretStorage: { hasKey: jest.fn() },
getCrypto: jest.fn().mockReturnValue({
getUserDeviceInfo: jest.fn(),
getCrossSigningStatus: jest.fn().mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
}),
isCrossSigningReady: jest.fn().mockResolvedValue(true),
isSecretStorageReady: jest.fn(),
getSessionBackupPrivateKey: jest.fn(),
getVersion: jest.fn().mockReturnValue("Version 0"),
getOwnDeviceKeys: jest.fn().mockReturnValue(new Promise(() => {})),
getCrossSigningKeyId: jest.fn(),
}),
});
export const mockClientMethodsRooms = (rooms: Room[] = []): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getRooms: jest.fn().mockReturnValue(rooms),
getRoom: jest.fn((roomId) => rooms.find((r) => r.roomId === roomId) ?? null),
isRoomEncrypted: jest.fn(),
});

View file

@ -0,0 +1,31 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { act, fireEvent, RenderResult } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
export const addTextToComposer = (container: HTMLElement, text: string) =>
act(() => {
// couldn't get input event on contenteditable to work
// paste works without illegal private method access
const pasteEvent: Partial<ClipboardEvent> = {
clipboardData: {
types: [],
files: [],
getData: (type: string) => (type === "text/plain" ? text : undefined),
} as unknown as DataTransfer,
};
fireEvent.paste(container.querySelector('[role="textbox"]')!, pasteEvent);
});
export const addTextToComposerRTL = async (renderResult: RenderResult, text: string): Promise<void> => {
await act(async () => {
await userEvent.click(renderResult.getByLabelText("Send a message…"));
await userEvent.keyboard(text);
});
};

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
type FilteredConsole = Pick<Console, "log" | "error" | "info" | "debug" | "warn">;
/**
* Allows to filter out specific messages in console.*.
* Call this from any describe block.
* Automagically restores the original function by implementing an afterAll hook.
*
* @param ignoreList Messages to be filtered
*/
export const filterConsole = (...ignoreList: string[]): void => {
const originalFunctions: FilteredConsole = {
log: console.log,
error: console.error,
info: console.info,
debug: console.debug,
warn: console.warn,
};
beforeAll(() => {
for (const [key, originalFunction] of Object.entries(originalFunctions)) {
window.console[key as keyof FilteredConsole] = (...data: any[]) => {
const message = data?.[0]?.message || data?.[0];
if (typeof message === "string" && ignoreList.some((i) => message.includes(i))) {
return;
}
originalFunction(...data);
};
}
});
afterAll(() => {
for (const [key, originalFunction] of Object.entries(originalFunctions)) {
window.console[key as keyof FilteredConsole] = originalFunction;
}
});
};

27
test/test-utils/date.ts Normal file
View file

@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export const REPEATABLE_DATE = new Date(2022, 10, 17, 16, 58, 32, 517);
// allow setting default locale and set timezone
// defaults to en-GB / Europe/London
// so tests run the same everywhere
export const mockIntlDateTimeFormat = (defaultLocale = "en-GB", defaultTimezone = "Europe/London"): void => {
// unmock so we can use real DateTimeFormat in mockImplementation
if (jest.isMockFunction(global.Intl.DateTimeFormat)) {
unmockIntlDateTimeFormat();
}
const DateTimeFormat = Intl.DateTimeFormat;
jest.spyOn(global.Intl, "DateTimeFormat").mockImplementation(
(locale, options) => new DateTimeFormat(locale || defaultLocale, { ...options, timeZone: defaultTimezone }),
);
};
export const unmockIntlDateTimeFormat = (): void => {
jest.spyOn(global.Intl, "DateTimeFormat").mockRestore();
};

34
test/test-utils/events.ts Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MsgType } from "matrix-js-sdk/src/matrix";
interface MessageContent {
msgtype: MsgType;
body: string;
format?: string;
formatted_body?: string;
}
/**
* Creates the `content` for an `m.room.message` event based on input.
* @param text The text to put in the event.
* @param html Optional HTML to put in the event.
* @returns A complete `content` object for an `m.room.message` event.
*/
export function createMessageEventContent(text: string, html?: string): MessageContent {
const content: MessageContent = {
msgtype: MsgType.Text,
body: text,
};
if (html) {
content.format = "org.matrix.custom.html";
content.formatted_body = html;
}
return content;
}

View file

@ -6,7 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { RenderResult, screen, waitFor } from "@testing-library/react";
import { RenderResult, screen, waitFor } from "jest-matrix-react";
export * from "./beacon";
export * from "./client";
export * from "./location";
export * from "./platform";
export * from "./poll";
export * from "./room";
export * from "./test-utils";
export * from "./call";
export * from "./wrappers";
export * from "./utilities";
export * from "./date";
export * from "./relations";
export * from "./console";
// wait for loading page
export async function waitForLoadingSpinner(): Promise<void> {

View file

@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactElement } from "react";
// eslint-disable-next-line no-restricted-imports
import { render, RenderOptions } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";
const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => {
return ({ children }: { children: React.ReactNode }) => {
if (Wrapper) {
return (
<Wrapper>
<TooltipProvider>{children}</TooltipProvider>
</Wrapper>
);
} else {
return <TooltipProvider>{children}</TooltipProvider>;
}
};
};
const customRender = (ui: ReactElement, options: RenderOptions = {}) => {
return render(ui, {
...options,
wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"],
}) as ReturnType<typeof render>;
};
// eslint-disable-next-line no-restricted-imports
export * from "@testing-library/react";
/**
* This custom render function wraps your component with a TooltipProvider.
* See https://testing-library.com/docs/react-testing-library/setup/#custom-render
*/
export { customRender as render };

View file

@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { LocationAssetType, M_LOCATION, MatrixEvent, EventType, ContentHelpers } from "matrix-js-sdk/src/matrix";
let id = 1;
export const makeLegacyLocationEvent = (geoUri: string): MatrixEvent => {
return new MatrixEvent({
event_id: `$${++id}`,
type: EventType.RoomMessage,
content: {
body: "Something about where I am",
msgtype: "m.location",
geo_uri: geoUri,
},
origin_server_ts: 0,
});
};
export const makeLocationEvent = (geoUri: string, assetType?: LocationAssetType): MatrixEvent => {
return new MatrixEvent({
event_id: `$${++id}`,
type: M_LOCATION.name,
content: ContentHelpers.makeLocationContent(
`Found at ${geoUri} at 2021-12-21T12:22+0000`,
geoUri,
252523,
"Human-readable label",
assetType,
),
origin_server_ts: 0,
});
};
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
export const getMockGeolocationPositionError = (code: number, message: string): GeolocationPositionError => ({
code,
message,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3,
});

46
test/test-utils/oidc.ts Normal file
View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { ValidatedIssuerMetadata } from "matrix-js-sdk/src/oidc/validate";
/**
* Makes a valid OidcClientConfig with minimum valid values
* @param issuer used as the base for all other urls
* @returns OidcClientConfig
*/
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
const metadata = mockOpenIdConfiguration(issuer);
return {
accountManagementEndpoint: issuer + "account",
registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint,
metadata,
};
};
/**
* Useful for mocking <issuer>/.well-known/openid-configuration
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
authorization_endpoint: issuer + "auth",
registration_endpoint: issuer + "registration",
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
account_management_uri: issuer + "account",
});

View file

@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MethodLikeKeys, mocked, MockedObject } from "jest-mock";
import BasePlatform from "../../src/BasePlatform";
import PlatformPeg from "../../src/PlatformPeg";
// doesn't implement abstract
// @ts-ignore
class MockPlatform extends BasePlatform {
constructor(platformMocks: Partial<Record<keyof BasePlatform, unknown>>) {
super();
Object.assign(this, platformMocks);
}
}
/**
* Mock Platform Peg
* Creates a mock BasePlatform class
* spies on PlatformPeg.get and returns mock platform
* @returns MockPlatform instance
*/
export const mockPlatformPeg = (
platformMocks: Partial<Record<MethodLikeKeys<BasePlatform>, unknown>> = {},
): MockedObject<BasePlatform> => {
const mockPlatform = new MockPlatform(platformMocks);
jest.spyOn(PlatformPeg, "get").mockReturnValue(mockPlatform);
return mocked(mockPlatform);
};
export const unmockPlatformPeg = () => {
jest.spyOn(PlatformPeg, "get").mockRestore();
};

145
test/test-utils/poll.ts Normal file
View file

@ -0,0 +1,145 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Mocked } from "jest-mock";
import {
MatrixClient,
MatrixEvent,
Room,
M_POLL_START,
PollAnswer,
M_POLL_KIND_DISCLOSED,
M_POLL_END,
M_POLL_RESPONSE,
M_TEXT,
} from "matrix-js-sdk/src/matrix";
import { uuid4 } from "@sentry/utils";
import { flushPromises } from "./utilities";
type Options = {
roomId: string;
ts: number;
id: string;
};
export const makePollStartEvent = (
question: string,
sender: string,
answers?: PollAnswer[],
{ roomId, ts, id }: Partial<Options> = {},
): MatrixEvent => {
if (!answers) {
answers = [
{ id: "socks", [M_TEXT.name]: "Socks" },
{ id: "shoes", [M_TEXT.name]: "Shoes" },
];
}
return new MatrixEvent({
event_id: id || "$mypoll",
room_id: roomId || "#myroom:example.com",
sender: sender,
type: M_POLL_START.name,
content: {
[M_POLL_START.name]: {
question: {
[M_TEXT.name]: question,
},
kind: M_POLL_KIND_DISCLOSED.name,
answers: answers,
},
[M_TEXT.name]: `${question}: answers`,
},
origin_server_ts: ts || 0,
});
};
export const makePollEndEvent = (
pollStartEventId: string,
roomId: string,
sender: string,
ts = 0,
id?: string,
): MatrixEvent => {
return new MatrixEvent({
event_id: id || uuid4(),
room_id: roomId,
origin_server_ts: ts,
type: M_POLL_END.name,
sender: sender,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollStartEventId,
},
[M_POLL_END.name]: {},
[M_TEXT.name]: "The poll has ended. Something.",
},
});
};
export const makePollResponseEvent = (
pollId: string,
answerIds: string[],
sender: string,
roomId: string,
ts = 0,
): MatrixEvent =>
new MatrixEvent({
event_id: uuid4(),
room_id: roomId,
origin_server_ts: ts,
type: M_POLL_RESPONSE.name,
sender,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollId,
},
[M_POLL_RESPONSE.name]: {
answers: answerIds,
},
},
});
/**
* Creates a room with attached poll events
* Returns room from mockClient
* mocks relations api
* @param mxEvent - poll start event
* @param relationEvents - returned by relations api
* @param endEvents - returned by relations api
* @param mockClient - client in use
* @returns
*/
export const setupRoomWithPollEvents = async (
pollStartEvents: MatrixEvent[],
relationEvents: Array<MatrixEvent>,
endEvents: Array<MatrixEvent> = [],
mockClient: Mocked<MatrixClient>,
existingRoom?: Room,
): Promise<Room> => {
const room = existingRoom || new Room(pollStartEvents[0].getRoomId()!, mockClient, mockClient.getSafeUserId());
room.processPollEvents([...pollStartEvents, ...relationEvents, ...endEvents]);
// set redaction allowed for current user only
// poll end events are validated against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation((_evt: MatrixEvent, id: string) => {
return id === mockClient.getSafeUserId();
});
// wait for events to process on room
await flushPromises();
mockClient.getRoom.mockReturnValue(room);
mockClient.relations.mockImplementation(async (_roomId: string, eventId: string) => {
return {
events: [...relationEvents, ...endEvents].filter((event) => event.getRelation()?.event_id === eventId),
};
});
return room;
};

View file

@ -0,0 +1,28 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
// Fake random strings to give a predictable snapshot for IDs
// Simple Xorshift random number generator with predictable ID
export class PredictableRandom {
private state: number;
constructor() {
this.state = 314159265;
}
get(): number {
this.state ^= this.state << 13;
this.state ^= this.state >> 17;
this.state ^= this.state << 5;
return this.state / 1073741823;
}
reset(): void {
this.state = 314159265;
}
}

View file

@ -0,0 +1,359 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { IAnnotatedPushRule, IPushRule, IPushRules, PushRuleKind, RuleId } from "matrix-js-sdk/src/matrix";
/**
* Default set of push rules for a new account
* Use to mock push rule fetching, or use `getDefaultRuleWithKind`
* to use default examples of specific push rules
*/
export const DEFAULT_PUSH_RULES: IPushRules = Object.freeze({
global: {
underride: [
{
conditions: [{ kind: "event_match", key: "type", pattern: "m.call.invite" }],
actions: ["notify", { set_tweak: "sound", value: "ring" }, { set_tweak: "highlight", value: false }],
rule_id: ".m.rule.call",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "m.room.message" },
{ kind: "room_member_count", is: "2" },
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "m.room.encrypted" },
{ kind: "room_member_count", is: "2" },
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".m.rule.encrypted_room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.encrypted" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.encrypted_room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.message" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.message.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.file" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.file.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.image" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.image.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.video" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.video.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "org.matrix.msc1767.audio" },
{ kind: "room_member_count", is: "2" },
{
kind: "org.matrix.msc3931.room_version_supports",
feature: "org.matrix.msc3932.extensible_events",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }, { set_tweak: "highlight", value: false }],
rule_id: ".org.matrix.msc3933.rule.extensible.audio.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "m.room.message" }],
actions: ["notify", { set_tweak: "highlight", value: false }],
rule_id: ".m.rule.message",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "m.room.encrypted" }],
actions: ["notify", { set_tweak: "highlight", value: false }],
rule_id: ".m.rule.encrypted",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "im.vector.modular.widgets" },
{ kind: "event_match", key: "content.type", pattern: "jitsi" },
{ kind: "event_match", key: "state_key", pattern: "*" },
],
actions: ["notify", { set_tweak: "highlight", value: false }],
rule_id: ".im.vector.jitsi",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "room_member_count", is: "2" },
{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.start" },
],
actions: ["notify", { set_tweak: "sound", value: "default" }],
rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.start" }],
actions: ["notify"],
rule_id: ".org.matrix.msc3930.rule.poll_start",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "room_member_count", is: "2" },
{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.end" },
],
actions: ["notify", { set_tweak: "sound", value: "default" }],
rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.end" }],
actions: ["notify"],
rule_id: ".org.matrix.msc3930.rule.poll_end",
default: true,
enabled: true,
},
],
sender: [],
room: [],
content: [
{
actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }],
rule_id: ".m.rule.contains_user_name",
default: true,
pattern: "alice",
enabled: true,
},
],
override: [
{ conditions: [], actions: ["dont_notify"], rule_id: ".m.rule.master", default: true, enabled: false },
{
conditions: [{ kind: "event_match", key: "content.msgtype", pattern: "m.notice" }],
actions: ["dont_notify"],
rule_id: ".m.rule.suppress_notices",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "m.room.member" },
{ kind: "event_match", key: "content.membership", pattern: "invite" },
{ kind: "event_match", key: "state_key", pattern: "@alice:example.org" },
],
actions: ["notify", { set_tweak: "highlight", value: false }, { set_tweak: "sound", value: "default" }],
rule_id: ".m.rule.invite_for_me",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "m.room.member" }],
actions: ["dont_notify"],
rule_id: ".m.rule.member_event",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_property_contains",
key: "content.m\\.mentions.user_ids",
value_type: "user_id",
},
],
actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }],
rule_id: ".m.rule.is_user_mention",
default: true,
enabled: true,
},
{
conditions: [{ kind: "contains_display_name" }],
actions: ["notify", { set_tweak: "highlight" }, { set_tweak: "sound", value: "default" }],
rule_id: ".m.rule.contains_display_name",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_property_is", key: "content.m\\.mentions.room", value: true },
{ kind: "sender_notification_permission", key: "room" },
],
actions: ["notify", { set_tweak: "highlight" }],
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "sender_notification_permission", key: "room" },
{ kind: "event_match", key: "content.body", pattern: "@room" },
],
actions: ["notify", { set_tweak: "highlight" }],
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "m.room.tombstone" },
{ kind: "event_match", key: "state_key", pattern: "" },
],
actions: ["notify", { set_tweak: "highlight" }],
rule_id: ".m.rule.tombstone",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "m.reaction" }],
actions: [],
rule_id: ".m.rule.reaction",
default: true,
enabled: true,
},
{
conditions: [
{ kind: "event_match", key: "type", pattern: "m.room.server_acl" },
{ kind: "event_match", key: "state_key", pattern: "" },
],
actions: [],
rule_id: ".m.rule.room.server_acl",
default: true,
enabled: true,
},
{
conditions: [{ kind: "event_match", key: "type", pattern: "org.matrix.msc3381.poll.response" }],
actions: [],
rule_id: ".org.matrix.msc3930.rule.poll_response",
default: true,
enabled: true,
},
],
},
} as IPushRules);
/**
* Get rule by id from default rules
* @param ruleId
* @returns {IPushRule} matching push rule
* @returns {PushRuleKind}
* @throws when no rule is found with ruleId
*/
export const getDefaultRuleWithKind = (ruleId: RuleId | string): { rule: IPushRule; kind: PushRuleKind } => {
for (const kind of Object.keys(DEFAULT_PUSH_RULES.global)) {
const rule = DEFAULT_PUSH_RULES.global[kind as PushRuleKind]!.find((r: IPushRule) => r.rule_id === ruleId);
if (rule) {
return { rule, kind: kind as PushRuleKind };
}
}
throw new Error(`Could not find default rule for id ${ruleId}`);
};
/**
* Get rule by id from default rules as an IAnnotatedPushRule
* @param ruleId
* @returns
*/
export const getDefaultAnnotatedRule = (ruleId: RuleId | string): IAnnotatedPushRule => {
const { rule, kind } = getDefaultRuleWithKind(ruleId);
return {
...rule,
kind,
};
};
/**
* Make a push rule with default values
* @param ruleId
* @param ruleOverrides
* @returns IPushRule
*/
export const makePushRule = (ruleId: RuleId | string, ruleOverrides: Partial<IPushRule> = {}): IPushRule => ({
actions: [],
enabled: true,
default: false,
...ruleOverrides,
rule_id: ruleId,
});
export const makeAnnotatedPushRule = (
kind: PushRuleKind,
ruleId: RuleId | string,
ruleOverrides: Partial<IPushRule> = {},
): IAnnotatedPushRule => ({
...makePushRule(ruleId, ruleOverrides),
kind,
});

View file

@ -0,0 +1,25 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Relations } from "matrix-js-sdk/src/matrix";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { PublicInterface } from "../@types/common";
export const mkRelations = (): Relations => {
return {} as PublicInterface<Relations> as Relations;
};
export const mkRelationsContainer = (): RelationsContainer => {
return {
aggregateChildEvent: jest.fn(),
aggregateParentEvent: jest.fn(),
getAllChildEventsForEvent: jest.fn(),
getChildEventsForEvent: jest.fn(),
} as PublicInterface<RelationsContainer> as RelationsContainer;
};

100
test/test-utils/room.ts Normal file
View file

@ -0,0 +1,100 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MockedObject } from "jest-mock";
import { EventTimeline, EventType, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { IRoomState, MainSplitContentType } from "../../src/components/structures/RoomView";
import { TimelineRenderingType } from "../../src/contexts/RoomContext";
import { Layout } from "../../src/settings/enums/Layout";
import { mkEvent } from "./test-utils";
export const makeMembershipEvent = (roomId: string, userId: string, membership = KnownMembership.Join) =>
mkEvent({
event: true,
type: EventType.RoomMember,
room: roomId,
user: userId,
skey: userId,
content: { membership },
ts: Date.now(),
});
/**
* Creates a room
* sets state events on the room
* Sets client getRoom to return room
* returns room
*/
export const makeRoomWithStateEvents = (
stateEvents: MatrixEvent[] = [],
{ roomId, mockClient }: { roomId: string; mockClient: MockedObject<MatrixClient> },
): Room => {
const room1 = new Room(roomId, mockClient, "@user:server.org");
room1.currentState.setStateEvents(stateEvents);
mockClient.getRoom.mockReturnValue(room1);
return room1;
};
export function getRoomContext(room: Room, override: Partial<IRoomState>): IRoomState {
return {
room,
roomLoading: true,
peekLoading: false,
shouldPeek: true,
membersLoaded: false,
numUnreadMessages: 0,
canPeek: false,
showApps: false,
isPeeking: false,
showRightPanel: true,
joining: false,
atEndOfLiveTimeline: true,
showTopUnreadMessagesBar: false,
statusBarVisible: false,
canReact: false,
canSendMessages: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,
userTimezone: undefined,
showTwelveHourTimestamps: false,
readMarkerInViewThresholdMs: 3000,
readMarkerOutOfViewThresholdMs: 30000,
showHiddenEvents: false,
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: false,
timelineRenderingType: TimelineRenderingType.Room,
mainSplitContentType: MainSplitContentType.Timeline,
liveTimeline: undefined,
canSelfRedact: false,
resizing: false,
narrow: false,
activeCall: null,
msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,
viewRoomOpts: { buttons: [] },
...override,
};
}
export const setupRoomWithEventsTimeline = (room: Room, events: MatrixEvent[] = []): void => {
const timelineSet = room.getUnfilteredTimelineSet();
const getTimelineForEventSpy = jest.spyOn(timelineSet, "getTimelineForEvent");
const eventTimeline = {
getEvents: jest.fn().mockReturnValue(events),
} as unknown as EventTimeline;
getTimelineForEventSpy.mockReturnValue(eventTimeline);
};

View file

@ -0,0 +1,838 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { mocked, MockedObject } from "jest-mock";
import {
MatrixEvent,
Room,
User,
IContent,
IEvent,
RoomMember,
MatrixClient,
EventTimeline,
RoomState,
EventType,
IEventRelation,
IUnsigned,
IPusher,
RoomType,
KNOWN_SAFE_ROOM_VERSION,
ConditionKind,
IPushRules,
RelationType,
JoinRule,
OidcClientConfig,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { MapperOpts } from "matrix-js-sdk/src/event-mapper";
import { MatrixRTCSessionManager, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import type { GroupCall } from "matrix-js-sdk/src/matrix";
import type { Membership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg";
import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
import { EnhancedMap } from "../../src/utils/maps";
import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient";
import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/MatrixClientBackedSettingsHandler";
/**
* Stub out the MatrixClient, and configure the MatrixClientPeg object to
* return it when get() is called.
*
* TODO: once the components are updated to get their MatrixClients from
* the react context, we can get rid of this and just inject a test client
* via the context instead.
*
* See also {@link getMockClientWithEventEmitter} which does something similar but different.
*/
export function stubClient(): MatrixClient {
const client = createTestClient();
// stub out the methods in MatrixClientPeg
//
// 'sandbox.restore()' doesn't work correctly on inherited methods,
// so we do this for each method
jest.spyOn(peg, "get");
jest.spyOn(peg, "safeGet");
jest.spyOn(peg, "unset");
jest.spyOn(peg, "replaceUsingCreds");
// MatrixClientPeg.safeGet() is called a /lot/, so implement it with our own
// fast stub function rather than a sinon stub
peg.get = () => client;
peg.safeGet = () => client;
MatrixClientBackedSettingsHandler.matrixClient = client;
return client;
}
/**
* Create a stubbed-out MatrixClient
*
* @returns {object} MatrixClient stub
*/
export function createTestClient(): MatrixClient {
const eventEmitter = new EventEmitter();
let txnId = 1;
const client = {
getHomeserverUrl: jest.fn(),
getIdentityServerUrl: jest.fn(),
getDomain: jest.fn().mockReturnValue("matrix.org"),
getUserId: jest.fn().mockReturnValue("@userId:matrix.org"),
getSafeUserId: jest.fn().mockReturnValue("@userId:matrix.org"),
getUserIdLocalpart: jest.fn().mockResolvedValue("userId"),
getUser: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn() }),
getDevice: jest.fn(),
getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"),
getStoredCrossSigningForUser: jest.fn(),
getStoredDevice: jest.fn(),
requestVerification: jest.fn(),
deviceId: "ABCDEFGHI",
getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }),
getSessionId: jest.fn().mockReturnValue("iaszphgvfku"),
credentials: { userId: "@userId:matrix.org" },
bootstrapCrossSigning: jest.fn(),
hasSecretStorageKey: jest.fn(),
getKeyBackupVersion: jest.fn(),
secretStorage: {
get: jest.fn(),
isStored: jest.fn().mockReturnValue(false),
checkKey: jest.fn().mockResolvedValue(false),
},
store: {
getPendingEvents: jest.fn().mockResolvedValue([]),
setPendingEvents: jest.fn().mockResolvedValue(undefined),
storeRoom: jest.fn(),
removeRoom: jest.fn(),
},
crypto: {
deviceList: {
downloadKeys: jest.fn(),
},
},
getCrypto: jest.fn().mockReturnValue({
getOwnDeviceKeys: jest.fn(),
getUserDeviceInfo: jest.fn(),
getUserVerificationStatus: jest.fn(),
getDeviceVerificationStatus: jest.fn(),
resetKeyBackup: jest.fn(),
isEncryptionEnabledInRoom: jest.fn(),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
setDeviceIsolationMode: jest.fn(),
prepareToEncrypt: jest.fn(),
}),
getPushActionsForEvent: jest.fn(),
getRoom: jest.fn().mockImplementation((roomId) => mkStubRoom(roomId, "My room", client)),
getRooms: jest.fn().mockReturnValue([]),
getVisibleRooms: jest.fn().mockReturnValue([]),
loginFlows: jest.fn(),
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
removeListener: eventEmitter.removeListener.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
isRoomEncrypted: jest.fn().mockReturnValue(false),
peekInRoom: jest.fn().mockResolvedValue(mkStubRoom(undefined, undefined, undefined)),
stopPeeking: jest.fn(),
paginateEventTimeline: jest.fn().mockResolvedValue(undefined),
sendReadReceipt: jest.fn().mockResolvedValue(undefined),
getRoomIdForAlias: jest.fn().mockResolvedValue(undefined),
getRoomDirectoryVisibility: jest.fn().mockResolvedValue(undefined),
getProfileInfo: jest.fn().mockResolvedValue({}),
getThirdpartyProtocols: jest.fn().mockResolvedValue({}),
getClientWellKnown: jest.fn().mockReturnValue(null),
waitForClientWellKnown: jest.fn().mockResolvedValue({}),
supportsVoip: jest.fn().mockReturnValue(true),
getTurnServers: jest.fn().mockReturnValue([]),
getTurnServersExpiry: jest.fn().mockReturnValue(2 ^ 32),
getThirdpartyUser: jest.fn().mockResolvedValue([]),
getAccountData: jest.fn().mockImplementation((type) => {
return mkEvent({
user: "@user:example.com",
room: undefined,
type,
event: true,
content: {},
});
}),
mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`),
scheduleAllGroupSessionsForBackup: jest.fn().mockResolvedValue(undefined),
setAccountData: jest.fn(),
setRoomAccountData: jest.fn(),
setRoomTopic: jest.fn(),
setRoomReadMarkers: jest.fn().mockResolvedValue({}),
sendTyping: jest.fn().mockResolvedValue({}),
sendMessage: jest.fn().mockResolvedValue({}),
sendStateEvent: jest.fn().mockResolvedValue(undefined),
getSyncState: jest.fn().mockReturnValue("SYNCING"),
generateClientSecret: () => "t35tcl1Ent5ECr3T",
isGuest: jest.fn().mockReturnValue(false),
getRoomHierarchy: jest.fn().mockReturnValue({
rooms: [],
}),
createRoom: jest.fn().mockResolvedValue({ room_id: "!1:example.org" }),
setPowerLevel: jest.fn().mockResolvedValue(undefined),
pushRules: {},
decryptEventIfNeeded: () => Promise.resolve(),
isUserIgnored: jest.fn().mockReturnValue(false),
getCapabilities: jest.fn().mockResolvedValue({}),
supportsThreads: jest.fn().mockReturnValue(false),
supportsIntentionalMentions: jest.fn().mockReturnValue(false),
getRoomUpgradeHistory: jest.fn().mockReturnValue([]),
getOpenIdToken: jest.fn().mockResolvedValue(undefined),
registerWithIdentityServer: jest.fn().mockResolvedValue({}),
getIdentityAccount: jest.fn().mockResolvedValue({}),
getTerms: jest.fn().mockResolvedValue({ policies: [] }),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(undefined),
isVersionSupported: jest.fn().mockResolvedValue(undefined),
getPushRules: jest.fn().mockResolvedValue(undefined),
getPushers: jest.fn().mockResolvedValue({ pushers: [] }),
getThreePids: jest.fn().mockResolvedValue({ threepids: [] }),
bulkLookupThreePids: jest.fn().mockResolvedValue({ threepids: [] }),
setAvatarUrl: jest.fn().mockResolvedValue(undefined),
setDisplayName: jest.fn().mockResolvedValue(undefined),
setPusher: jest.fn().mockResolvedValue(undefined),
setPushRuleEnabled: jest.fn().mockResolvedValue(undefined),
setPushRuleActions: jest.fn().mockResolvedValue(undefined),
relations: jest.fn().mockResolvedValue({
events: [],
}),
isCryptoEnabled: jest.fn().mockReturnValue(false),
hasLazyLoadMembersEnabled: jest.fn().mockReturnValue(false),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
downloadKeys: jest.fn(),
fetchRoomEvent: jest.fn().mockRejectedValue({}),
makeTxnId: jest.fn().mockImplementation(() => `t${txnId++}`),
sendToDevice: jest.fn().mockResolvedValue(undefined),
queueToDevice: jest.fn().mockResolvedValue(undefined),
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
cancelPendingEvent: jest.fn(),
getMediaHandler: jest.fn().mockReturnValue({
setVideoInput: jest.fn(),
setAudioInput: jest.fn(),
setAudioSettings: jest.fn(),
stopAllStreams: jest.fn(),
} as unknown as MediaHandler),
uploadContent: jest.fn(),
getEventMapper: (_options?: MapperOpts) => (event: Partial<IEvent>) => new MatrixEvent(event),
leaveRoomChain: jest.fn((roomId) => ({ [roomId]: null })),
requestPasswordEmailToken: jest.fn().mockRejectedValue({}),
setPassword: jest.fn().mockRejectedValue({}),
groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() },
redactEvent: jest.fn(),
createMessagesRequest: jest.fn().mockResolvedValue({
chunk: [],
}),
sendEvent: jest.fn().mockImplementation((roomId, type, content) => {
return new MatrixEvent({
type,
sender: "@me:localhost",
content,
event_id: "$9999999999999999999999999999999999999999999",
room_id: roomId,
});
}),
_unstable_sendDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }),
setDeviceVerified: jest.fn(),
joinRoom: jest.fn(),
getSyncStateData: jest.fn(),
getDehydratedDevice: jest.fn(),
exportRoomKeys: jest.fn(),
knockRoom: jest.fn(),
leave: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
requestAdd3pidEmailToken: jest.fn(),
requestAdd3pidMsisdnToken: jest.fn(),
submitMsisdnTokenOtherUrl: jest.fn(),
deleteThreePid: jest.fn().mockResolvedValue({}),
bindThreePid: jest.fn().mockResolvedValue({}),
unbindThreePid: jest.fn().mockResolvedValue({}),
requestEmailToken: jest.fn(),
addThreePidOnly: jest.fn(),
requestMsisdnToken: jest.fn(),
submitMsisdnToken: jest.fn(),
getMediaConfig: jest.fn(),
baseUrl: "https://matrix-client.matrix.org",
matrixRTC: createStubMatrixRTC(),
isFallbackICEServerAllowed: jest.fn().mockReturnValue(false),
getAuthIssuer: jest.fn(),
getOrCreateFilter: jest.fn(),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);
client.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
client.canSupport.set(feature as Feature, ServerSupport.Stable);
});
Object.defineProperty(client, "pollingTurnServers", {
configurable: true,
get: () => true,
});
return client;
}
export function createStubMatrixRTC(): MatrixRTCSessionManager {
const eventEmitterMatrixRTCSessionManager = new EventEmitter();
const mockGetRoomSession = jest.fn();
mockGetRoomSession.mockImplementation((roomId) => {
const session = new EventEmitter() as MatrixRTCSession;
session.memberships = [];
session.getOldestMembership = () => undefined;
return session;
});
return {
start: jest.fn(),
stop: jest.fn(),
getActiveRoomSession: jest.fn(),
getRoomSession: mockGetRoomSession,
on: eventEmitterMatrixRTCSessionManager.on.bind(eventEmitterMatrixRTCSessionManager),
off: eventEmitterMatrixRTCSessionManager.off.bind(eventEmitterMatrixRTCSessionManager),
removeListener: eventEmitterMatrixRTCSessionManager.removeListener.bind(eventEmitterMatrixRTCSessionManager),
emit: eventEmitterMatrixRTCSessionManager.emit.bind(eventEmitterMatrixRTCSessionManager),
} as unknown as MatrixRTCSessionManager;
}
type MakeEventPassThruProps = {
user: User["userId"];
relatesTo?: IEventRelation;
event?: boolean;
ts?: number;
skey?: string;
};
type MakeEventProps = MakeEventPassThruProps & {
/** If provided will be used as event Id. Else an Id is generated. */
id?: string;
type: string;
redacts?: string;
content: IContent;
room?: Room["roomId"]; // to-device messages are roomless
// eslint-disable-next-line camelcase
prev_content?: IContent;
unsigned?: IUnsigned;
};
export const mkRoomCreateEvent = (userId: string, roomId: string, content?: IContent): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomCreate,
content: {
creator: userId,
room_version: KNOWN_SAFE_ROOM_VERSION,
...content,
},
skey: "",
user: userId,
room: roomId,
});
};
/**
* Create an Event.
* @param {Object} opts Values for the event.
* @param {string} opts.type The event.type
* @param {string} opts.room The event.room_id
* @param {string} opts.user The event.user_id
* @param {string=} opts.skey Optional. The state key (auto inserts empty string)
* @param {number=} opts.ts Optional. Timestamp for the event
* @param {Object} opts.content The event.content
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {unsigned=} opts.unsigned
* @return {Object} a JSON object representing this event.
*/
export function mkEvent(opts: MakeEventProps): MatrixEvent {
if (!opts.type || !opts.content) {
throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
}
const event: Partial<IEvent> = {
type: opts.type,
room_id: opts.room,
sender: opts.user,
content: opts.content,
event_id: opts.id ?? "$" + Math.random() + "-" + Math.random(),
origin_server_ts: opts.ts ?? 0,
unsigned: {
...opts.unsigned,
prev_content: opts.prev_content,
},
redacts: opts.redacts,
};
if (opts.skey !== undefined) {
event.state_key = opts.skey;
} else if (
[
"m.room.name",
"m.room.topic",
"m.room.create",
"m.room.join_rules",
"m.room.power_levels",
"m.room.topic",
"m.room.history_visibility",
"m.room.encryption",
"m.room.member",
"com.example.state",
"m.room.guest_access",
"m.room.tombstone",
].indexOf(opts.type) !== -1
) {
event.state_key = "";
}
const mxEvent = opts.event ? new MatrixEvent(event) : (event as unknown as MatrixEvent);
if (!mxEvent.sender && opts.user && opts.room) {
mxEvent.sender = {
userId: opts.user,
membership: KnownMembership.Join,
name: opts.user,
rawDisplayName: opts.user,
roomId: opts.room,
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
} as unknown as RoomMember;
}
return mxEvent;
}
/**
* Create an m.room.member event.
* @param {Object} opts Values for the membership.
* @param {string} opts.room The room ID for the event.
* @param {string} opts.mship The content.membership for the event.
* @param {string} opts.prevMship The prev_content.membership for the event.
* @param {number=} opts.ts Optional. Timestamp for the event
* @param {string} opts.user The user ID for the event.
* @param {RoomMember} opts.target The target of the event.
* @param {string=} opts.skey The other user ID for the event if applicable
* e.g. for invites/bans.
* @param {string} opts.name The content.displayname for the event.
* @param {string=} opts.url The content.avatar_url for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event
*/
export function mkMembership(
opts: MakeEventPassThruProps & {
room: Room["roomId"];
mship: Membership;
prevMship?: Membership;
name?: string;
url?: string;
skey?: string;
target?: RoomMember;
},
): MatrixEvent {
const event: MakeEventProps = {
...opts,
type: "m.room.member",
content: {
membership: opts.mship,
},
};
if (!opts.skey) {
event.skey = opts.user;
}
if (!opts.mship) {
throw new Error("Missing .mship => " + JSON.stringify(opts));
}
if (opts.prevMship) {
event.prev_content = { membership: opts.prevMship };
}
if (opts.name) {
event.content.displayname = opts.name;
}
if (opts.url) {
event.content.avatar_url = opts.url;
}
const e = mkEvent(event);
if (opts.target) {
e.target = opts.target;
}
return e;
}
export function mkRoomMember(
roomId: string,
userId: string,
membership = KnownMembership.Join,
isKicked = false,
prevMemberContent: Partial<IContent> = {},
): RoomMember {
return {
userId,
membership,
name: userId,
rawDisplayName: userId,
roomId,
events: {
member: {
getSender: () => undefined,
getPrevContent: () => prevMemberContent,
},
},
isKicked: () => isKicked,
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
getDMInviter: () => {},
off: () => {},
} as unknown as RoomMember;
}
export type MessageEventProps = MakeEventPassThruProps & {
room: Room["roomId"];
relatesTo?: IEventRelation;
msg?: string;
};
/**
* Creates a "🙃" reaction for the given event.
* Uses the same room and user as for the event.
*
* @returns The reaction event
*/
export const mkReaction = (event: MatrixEvent, opts: Partial<MakeEventProps> = {}): MatrixEvent => {
return mkEvent({
event: true,
room: event.getRoomId(),
type: EventType.Reaction,
user: event.getSender()!,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: event.getId(),
key: "🙃",
},
},
...opts,
});
};
/**
* Create an m.room.message event.
* @param {Object} opts Values for the message
* @param {string} opts.room The room ID for the event.
* @param {string} opts.user The user ID for the event.
* @param {number} opts.ts The timestamp for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {string=} opts.msg Optional. The content.body for the event.
* @param {string=} opts.format Optional. The content.format for the event.
* @param {string=} opts.formattedMsg Optional. The content.formatted_body for the event.
* @return {Object|MatrixEvent} The event
*/
export function mkMessage({
msg,
format,
formattedMsg,
relatesTo,
...opts
}: MakeEventPassThruProps &
Pick<MakeEventProps, "id"> & {
room: Room["roomId"];
msg?: string;
format?: string;
formattedMsg?: string;
}): MatrixEvent {
if (!opts.room || !opts.user) {
throw new Error("Missing .room or .user from options");
}
const message = msg ?? "Random->" + Math.random();
const event: MakeEventProps = {
ts: 0,
...opts,
type: "m.room.message",
content: {
msgtype: "m.text",
body: message,
...(format && formattedMsg ? { format, formatted_body: formattedMsg } : {}),
["m.relates_to"]: relatesTo,
},
};
return mkEvent(event);
}
export function mkStubRoom(
roomId: string | null | undefined = null,
name: string | undefined,
client: MatrixClient | undefined,
): Room {
const stubTimeline = {
getEvents: (): MatrixEvent[] => [],
getState: (): RoomState | undefined => undefined,
} as unknown as EventTimeline;
return {
canInvite: jest.fn(),
client,
findThreadForEvent: jest.fn(),
createThreadsTimelineSets: jest.fn().mockReturnValue(new Promise(() => {})),
currentState: {
getStateEvents: jest.fn((_type, key) => (key === undefined ? [] : null)),
getMember: jest.fn(),
mayClientSendStateEvent: jest.fn().mockReturnValue(true),
maySendStateEvent: jest.fn().mockReturnValue(true),
maySendRedactionForEvent: jest.fn().mockReturnValue(true),
maySendEvent: jest.fn().mockReturnValue(true),
members: {},
getJoinRule: jest.fn().mockReturnValue(JoinRule.Invite),
on: jest.fn(),
off: jest.fn(),
} as unknown as RoomState,
eventShouldLiveIn: jest.fn().mockReturnValue({ shouldLiveInRoom: true, shouldLiveInThread: false }),
fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()),
findEventById: jest.fn().mockReturnValue(undefined),
findPredecessor: jest.fn().mockReturnValue({ roomId: "", eventId: null }),
getAccountData: (_: EventType | string) => undefined as MatrixEvent | undefined,
getAltAliases: jest.fn().mockReturnValue([]),
getAvatarUrl: () => "mxc://avatar.url/room.png",
getCanonicalAlias: jest.fn(),
getDMInviter: jest.fn(),
getEventReadUpTo: jest.fn(() => null),
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(1),
getJoinRule: jest.fn().mockReturnValue("invite"),
getJoinedMemberCount: jest.fn().mockReturnValue(1),
getJoinedMembers: jest.fn().mockReturnValue([]),
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
getLastLiveEvent: jest.fn().mockReturnValue(undefined),
getMember: jest.fn().mockReturnValue({
userId: "@member:domain.bla",
name: "Member",
rawDisplayName: "Member",
roomId: roomId,
getAvatarUrl: () => "mxc://avatar.url/image.png",
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
events: {},
isKicked: () => false,
}),
getMembers: jest.fn().mockReturnValue([]),
getMembersWithMembership: jest.fn().mockReturnValue([]),
getMxcAvatarUrl: () => "mxc://avatar.url/room.png",
getMyMembership: jest.fn().mockReturnValue(KnownMembership.Join),
getPendingEvents: () => [] as MatrixEvent[],
getReceiptsForEvent: jest.fn().mockReturnValue([]),
getRecommendedVersion: jest.fn().mockReturnValue(Promise.resolve("")),
getThreads: jest.fn().mockReturnValue([]),
getType: jest.fn().mockReturnValue(undefined),
getUnfilteredTimelineSet: jest.fn(),
getUnreadNotificationCount: jest.fn(() => 0),
getRoomUnreadNotificationCount: jest.fn().mockReturnValue(0),
getVersion: jest.fn().mockReturnValue("1"),
hasMembershipState: () => false,
isElementVideoRoom: jest.fn().mockReturnValue(false),
isSpaceRoom: jest.fn().mockReturnValue(false),
isCallRoom: jest.fn().mockReturnValue(false),
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
loadMembersIfNeeded: jest.fn(),
maySendMessage: jest.fn().mockReturnValue(true),
myUserId: client?.getUserId(),
name,
normalizedName: normalize(name || ""),
off: jest.fn(),
on: jest.fn(),
removeListener: jest.fn(),
roomId,
setBlacklistUnverifiedDevices: jest.fn(),
setUnreadNotificationCount: jest.fn(),
tags: {},
timeline: [],
} as unknown as Room;
}
export function mkServerConfig(
hsUrl: string,
isUrl: string,
delegatedAuthentication?: OidcClientConfig,
): ValidatedServerConfig {
return {
hsUrl,
hsName: "TEST_ENVIRONMENT",
hsNameIsDifferent: false, // yes, we lie
isUrl,
delegatedAuthentication,
} as ValidatedServerConfig;
}
// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent
// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client.
export const setupAsyncStoreWithClient = async <T extends Object = any>(
store: AsyncStoreWithClient<T>,
client: MatrixClient,
) => {
// @ts-ignore protected access
store.readyStore.useUnitTestClient(client);
// @ts-ignore protected access
await store.onReady();
};
export const resetAsyncStoreWithClient = async <T extends Object = any>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore protected access
await store.onNotReady();
};
export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach((event) => {
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey()!, event);
});
// recreate the overloading in RoomState
function getStateEvents(eventType: EventType | string): MatrixEvent[];
function getStateEvents(eventType: EventType | string, stateKey: string): MatrixEvent;
function getStateEvents(eventType: EventType | string, stateKey?: string) {
if (stateKey || stateKey === "") {
return stateMap.get(eventType)?.get(stateKey) || null;
}
return Array.from(stateMap.get(eventType)?.values() || []);
}
return getStateEvents;
};
export const mkRoom = (
client: MatrixClient,
roomId: string,
rooms?: ReturnType<typeof mkStubRoom>[],
): MockedObject<Room> => {
const room = mocked(mkStubRoom(roomId, roomId, client));
mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([]));
rooms?.push(room);
return room;
};
/**
* Upserts given events into room.currentState
* @param room
* @param events
*/
export const upsertRoomStateEvents = (room: Room, events: MatrixEvent[]): void => {
const eventsMap = events.reduce((acc, event) => {
const eventType = event.getType();
if (!acc.has(eventType)) {
acc.set(eventType, new Map());
}
acc.get(eventType)?.set(event.getStateKey()!, event);
return acc;
}, room.currentState.events || new Map<string, Map<string, MatrixEvent>>());
room.currentState.events = eventsMap;
};
export const mkSpace = (
client: MatrixClient,
spaceId: string,
rooms?: ReturnType<typeof mkStubRoom>[],
children: string[] = [],
): MockedObject<Room> => {
const space = mocked(mkRoom(client, spaceId, rooms));
space.isSpaceRoom.mockReturnValue(true);
space.getType.mockReturnValue(RoomType.Space);
mocked(space.currentState).getStateEvents.mockImplementation(
mockStateEventImplementation(
children.map((roomId) =>
mkEvent({
event: true,
type: EventType.SpaceChild,
room: spaceId,
user: "@user:server",
skey: roomId,
content: { via: [] },
ts: Date.now(),
}),
),
),
);
return space;
};
export const mkRoomMemberJoinEvent = (user: string, room: string, content?: IContent): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomMember,
content: {
membership: KnownMembership.Join,
...content,
},
skey: user,
user,
room,
});
};
export const mkRoomCanonicalAliasEvent = (userId: string, roomId: string, alias: string): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomCanonicalAlias,
content: {
alias,
},
skey: "",
user: userId,
room: roomId,
});
};
export const mkThirdPartyInviteEvent = (user: string, displayName: string, room: string): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomThirdPartyInvite,
content: {
display_name: displayName,
},
skey: "test" + Math.random(),
user,
room,
});
};
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
app_display_name: "app",
app_id: "123",
data: {},
device_display_name: "name",
kind: "http",
lang: "en",
pushkey: "pushpush",
...extra,
});
/** Add a mute rule for a room. */
export function muteRoom(room: Room): void {
const client = room.client!;
client.pushRules = client.pushRules ?? ({ global: [] } as IPushRules);
client.pushRules.global = client.pushRules.global ?? {};
client.pushRules.global.override = [
{
default: true,
enabled: true,
rule_id: "rule_id",
conditions: [
{
kind: ConditionKind.EventMatch,
key: "room_id",
pattern: room.roomId,
},
],
actions: [],
},
];
}

162
test/test-utils/threads.ts Normal file
View file

@ -0,0 +1,162 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType, Room, Thread } from "matrix-js-sdk/src/matrix";
import { mkMessage, MessageEventProps } from "./test-utils";
export const makeThreadEvent = ({
rootEventId,
replyToEventId,
...props
}: MessageEventProps & {
rootEventId: string;
replyToEventId: string;
}): MatrixEvent =>
mkMessage({
...props,
relatesTo: {
event_id: rootEventId,
rel_type: "m.thread",
["m.in_reply_to"]: {
event_id: replyToEventId,
},
},
});
type MakeThreadEventsProps = {
roomId: Room["roomId"];
// root message user id
authorId: string;
// user ids of thread replies
// cycled through until thread length is fulfilled
participantUserIds: string[];
// number of messages in the thread, root message included
// optional, default 2
length?: number;
ts?: number;
// provide to set current_user_participated accurately
currentUserId?: string;
};
export const makeThreadEvents = ({
roomId,
authorId,
participantUserIds,
length = 2,
ts = 1,
currentUserId,
}: MakeThreadEventsProps): { rootEvent: MatrixEvent; events: MatrixEvent[] } => {
const rootEvent = mkMessage({
user: authorId,
event: true,
room: roomId,
msg: "root event message " + Math.random(),
ts,
});
const rootEventId = rootEvent.getId()!;
const events = [rootEvent];
for (let i = 1; i < length; i++) {
const prevEvent = events[i - 1];
const replyToEventId = prevEvent.getId()!;
const user = participantUserIds[i % participantUserIds.length];
events.push(
makeThreadEvent({
user,
room: roomId,
event: true,
msg: `reply ${i} by ${user}`,
rootEventId,
replyToEventId,
// replies are 1ms after each other
ts: ts + i,
}),
);
}
rootEvent.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
latest_event: events[events.length - 1],
count: length,
current_user_participated: [...participantUserIds, authorId].includes(currentUserId!),
},
},
});
return { rootEvent, events };
};
type MakeThreadProps = {
room: Room;
client: MatrixClient;
authorId: string;
participantUserIds: string[];
length?: number;
ts?: number;
};
/**
* Create a thread but don't actually populate it with events - see
* populateThread for what you probably want to do.
*
* Leaving this here in case it is needed by some people, but I (andyb) would
* expect us to move to use populateThread exclusively.
*/
export const mkThread = ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
participantUserIds,
length,
ts,
currentUserId: client.getUserId()!,
});
expect(rootEvent).toBeTruthy();
for (const evt of events) {
room?.reEmitter.reEmit(evt, [MatrixEventEvent.BeforeRedaction]);
}
const thread = room.createThread(rootEvent.getId()!, rootEvent, events, true);
return { thread, rootEvent, events };
};
/**
* Create a thread, and make sure the events added to the thread and the room's
* timeline as if they came in via sync.
*
* Note that mkThread doesn't actually add the events properly to the room.
*/
export const populateThread = async ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): Promise<{ thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] }> => {
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
// So that we do not have to mock the thread loading, tell the thread
// that it is already loaded, and send the events again to the room
// so they are added to the thread timeline.
ret.thread.initialEventsFetched = true;
await room.addLiveEvents(ret.events);
return ret;
};

View file

@ -0,0 +1,288 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from "events";
import { ActionPayload } from "../../src/dispatcher/payloads";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
import { DispatcherAction } from "../../src/dispatcher/actions";
import Modal from "../../src/Modal";
export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise((r) => e.once(k, r));
/**
* Waits for a certain payload to be dispatched.
* @param waitForAction The action string to wait for or the callback which is invoked for every dispatch. If this returns true, stops waiting.
* @param timeout The max time to wait before giving up and stop waiting. If 0, no timeout.
* @param dispatcher The dispatcher to listen on.
* @returns A promise which resolves when the callback returns true. Resolves with the payload that made it stop waiting.
* Rejects when the timeout is reached.
*/
export function untilDispatch(
waitForAction: DispatcherAction | ((payload: ActionPayload) => boolean),
dispatcher = defaultDispatcher,
timeout = 1000,
): Promise<ActionPayload> {
const callerLine = new Error().stack!.toString().split("\n")[2];
if (typeof waitForAction === "string") {
const action = waitForAction;
waitForAction = (payload) => {
return payload.action === action;
};
}
const callback = waitForAction as (payload: ActionPayload) => boolean;
return new Promise((resolve, reject) => {
let fulfilled = false;
let timeoutId: number;
// set a timeout handler if needed
if (timeout > 0) {
timeoutId = window.setTimeout(() => {
if (!fulfilled) {
reject(new Error(`untilDispatch: timed out at ${callerLine}`));
fulfilled = true;
}
}, timeout);
}
// listen for dispatches
const token = dispatcher.register((p: ActionPayload) => {
const finishWaiting = callback(p);
if (finishWaiting || fulfilled) {
// wait until we're told or we timeout
// if we haven't timed out, resolve now with the payload.
if (!fulfilled) {
resolve(p);
fulfilled = true;
}
// cleanup
dispatcher.unregister(token);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
});
});
}
/**
* Waits for a certain event to be emitted.
* @param emitter The EventEmitter to listen on.
* @param eventName The event string to wait for.
* @param check Optional function which is invoked when the event fires. If this returns true, stops waiting.
* @param timeout The max time to wait before giving up and stop waiting. If 0, no timeout.
* @returns A promise which resolves when the callback returns true or when the event is emitted if
* no callback is provided. Rejects when the timeout is reached.
*/
export function untilEmission(
emitter: EventEmitter,
eventName: string,
check?: (...args: any[]) => boolean,
timeout = 1000,
): Promise<void> {
const callerLine = new Error().stack!.toString().split("\n")[2];
return new Promise((resolve, reject) => {
let fulfilled = false;
let timeoutId: number;
// set a timeout handler if needed
if (timeout > 0) {
timeoutId = window.setTimeout(() => {
if (!fulfilled) {
reject(new Error(`untilEmission: timed out at ${callerLine}`));
fulfilled = true;
}
}, timeout);
}
const callback = (...args: any[]) => {
// if they supplied a check function, call it now. Bail if it returns false.
if (check) {
if (!check(...args)) {
return;
}
}
// we didn't time out, resolve. Otherwise, we already rejected so don't resolve now.
if (!fulfilled) {
resolve();
fulfilled = true;
}
// cleanup
emitter.off(eventName, callback);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
// listen for emissions
emitter.on(eventName, callback);
});
}
export const flushPromises = async () => await new Promise<void>((resolve) => window.setTimeout(resolve));
// with jest's modern fake timers process.nextTick is also mocked,
// flushing promises in the normal way then waits for some advancement
// of the fake timers
// https://gist.github.com/apieceofbart/e6dea8d884d29cf88cdb54ef14ddbcc4?permalink_comment_id=4018174#gistcomment-4018174
export const flushPromisesWithFakeTimers = async (): Promise<void> => {
const promise = new Promise((resolve) => process.nextTick(resolve));
jest.advanceTimersByTime(1);
await promise;
};
/**
* Call fn before calling componentDidUpdate on a react component instance, inst.
* @param {React.Component} inst an instance of a React component.
* @param {number} updates Number of updates to wait for. (Defaults to 1.)
* @returns {Promise} promise that resolves when componentDidUpdate is called on
* given component instance.
*/
export function waitForUpdate(inst: React.Component, updates = 1): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cdu = inst.componentDidUpdate;
console.log(`Waiting for ${updates} update(s)`);
inst.componentDidUpdate = (prevProps, prevState, snapshot) => {
updates--;
console.log(`Got update, ${updates} remaining`);
if (updates == 0) {
inst.componentDidUpdate = cdu;
resolve();
}
if (cdu) cdu(prevProps, prevState, snapshot);
};
});
}
/**
* Advance jests fake timers and Date.now mock by ms
* Useful for testing code using timeouts or intervals
* that also checks timestamps
*/
export const advanceDateAndTime = (ms: number) => {
jest.spyOn(global.Date, "now").mockReturnValue(Date.now() + ms);
jest.advanceTimersByTime(ms);
};
/**
* A horrible hack necessary to wait enough time to ensure any modal is shown after a
* `Modal.createDialog(...)` call. We have to contend with the Modal code which renders
* things asyncronhously and has weird sleeps which we should strive to remove.
*/
export const waitEnoughCyclesForModal = async ({
useFakeTimers = false,
}: {
useFakeTimers?: boolean;
} = {}): Promise<void> => {
// XXX: Maybe in the future with Jest 29.5.0+, we could use `runAllTimersAsync` instead.
const flushFunc = useFakeTimers ? flushPromisesWithFakeTimers : flushPromises;
await flushFunc();
await flushFunc();
await flushFunc();
};
/**
* A horrible hack necessary to make sure modals don't leak and pollute tests.
* `jest-matrix-react` automatic cleanup function does not pick up the async modal
* rendering and the modals don't unmount when the component unmounts. We should strive
* to fix this.
*/
export const clearAllModals = async (): Promise<void> => {
// Prevent modals from leaking and polluting other tests
let keepClosingModals = true;
while (keepClosingModals) {
keepClosingModals = Modal.closeCurrentModal();
// Then wait for the screen to update (probably React rerender and async/await).
// Important for tests using Jest fake timers to not get into an infinite loop
// of removing the same modal because the promises don't flush otherwise.
//
// XXX: Maybe in the future with Jest 29.5.0+, we could use `runAllTimersAsync` instead.
// this is called in some places where timers are not faked
// which causes a lot of noise in the console
// to make a hack even hackier check if timers are faked using a weird trick from github
// then call the appropriate promise flusher
// https://github.com/facebook/jest/issues/10555#issuecomment-1136466942
const jestTimersFaked = setTimeout.name === "setTimeout";
if (jestTimersFaked) {
await flushPromisesWithFakeTimers();
} else {
await flushPromises();
}
}
};
/** Install a stub object at `navigator.mediaDevices` */
export function useMockMediaDevices(): void {
// @ts-ignore assignment of a thing that isn't a `MediaDevices` to read-only property
navigator["mediaDevices"] = {
enumerateDevices: jest.fn().mockResolvedValue([]),
getUserMedia: jest.fn(),
};
}
/**
* Clean up the JSDOM after each test.
*
* Registers `beforeEach` and `afterEach` functions which will deregister any event listeners and timers from the
* `window` and `document` objects.
*
* Also clears out `localStorage` and `sessionStorage`.
*/
export function resetJsDomAfterEach(): void {
// list of calls to run in afterEach
const resetCalls: (() => void)[] = [];
beforeEach(() => {
// intercept `window.addEventListener` and `document.addEventListener`, and register 'removeEventListener' calls
// for `afterEach`.
for (const obj of [window, document]) {
const originalFn = obj.addEventListener;
obj.addEventListener = (...args: Parameters<Window["addEventListener"]>) => {
originalFn.apply(obj, args);
resetCalls.push(() => obj.removeEventListener(...args));
};
// also reset the intercept after the test
resetCalls.push(() => {
obj.addEventListener = originalFn;
});
}
// intercept setTimeout and setInterval, and clear them at the end.
//
// *Don't* use jest.spyOn for this because it makes the DOM testing library think we are using fake timers.
//
["setTimeout", "setInterval"].forEach((name) => {
const originalFn = window[name as keyof Window];
// @ts-ignore assignment to read-only property
window[name] = (...args) => {
const result = originalFn.apply(window, args);
resetCalls.push(() => window.clearTimeout(result));
return result;
};
resetCalls.push(() => {
// @ts-ignore assignment to read-only property
window[name] = originalFn;
});
});
});
afterEach(() => {
// clean up event listeners, timers, etc.
for (const call of resetCalls) {
call();
}
resetCalls.splice(0);
// other cleanup
localStorage.clear();
sessionStorage.clear();
});
}

View file

@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentType, Ref } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { RenderOptions } from "jest-matrix-react";
import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg";
import MatrixClientContext from "../../src/contexts/MatrixClientContext";
import { SDKContext, SdkContextClass } from "../../src/contexts/SDKContext";
type WrapperProps<T> = { wrappedRef?: Ref<ComponentType<T>> } & T;
export function wrapInMatrixClientContext<T>(WrappedComponent: ComponentType<T>): ComponentType<WrapperProps<T>> {
class Wrapper extends React.Component<WrapperProps<T>> {
_matrixClient: MatrixClient;
constructor(props: WrapperProps<T>) {
super(props);
this._matrixClient = peg.safeGet();
}
render() {
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<WrappedComponent ref={this.props.wrappedRef} {...this.props} />
</MatrixClientContext.Provider>
);
}
}
return Wrapper;
}
export function wrapInSdkContext<T>(
WrappedComponent: ComponentType<T>,
sdkContext: SdkContextClass,
): ComponentType<WrapperProps<T>> {
return class extends React.Component<WrapperProps<T>> {
render() {
return (
<SDKContext.Provider value={sdkContext}>
<WrappedComponent {...this.props} />
</SDKContext.Provider>
);
}
};
}
/**
* Test helper to generate React testing library render options for wrapping with a MatrixClientContext.Provider
* @param client the MatrixClient instance to expose via the provider
*/
export function withClientContextRenderOptions(client: MatrixClient): RenderOptions {
return {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
),
};
}

View file

@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../../src/Avatar";
import { Media, mediaFromMxc } from "../../src/customisations/Media";
import DMRoomMap from "../../src/utils/DMRoomMap";
jest.mock("../../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(),
}));
const roomId = "!room:example.com";
const avatarUrl1 = "https://example.com/avatar1";
const avatarUrl2 = "https://example.com/avatar2";
describe("avatarUrlForRoom", () => {
let getThumbnailOfSourceHttp: jest.Mock;
let room: Room;
let roomMember: RoomMember;
let dmRoomMap: DMRoomMap;
beforeEach(() => {
getThumbnailOfSourceHttp = jest.fn();
mocked(mediaFromMxc).mockImplementation((): Media => {
return {
getThumbnailOfSourceHttp,
} as unknown as Media;
});
room = {
roomId,
getMxcAvatarUrl: jest.fn(),
isSpaceRoom: jest.fn(),
getType: jest.fn(),
getAvatarFallbackMember: jest.fn(),
} as unknown as Room;
dmRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
roomMember = {
getMxcAvatarUrl: jest.fn(),
} as unknown as RoomMember;
});
it("should return null for a null room", () => {
expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
});
it("should return the HTTP source if the room provides a MXC url", () => {
mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
it("should return null for a space room", () => {
mocked(room.isSpaceRoom).mockReturnValue(true);
mocked(room.getType).mockReturnValue(RoomType.Space);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the room is not a DM", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(null);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId);
});
it("should return null if there is no other member in the room", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(undefined);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return null if the other member has no avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
it("should return the other member's avatar URL", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import fs from "fs";
import path from "path";
import { blobIsAnimated, mayBeAnimated } from "../../src/utils/Image";
describe("Image", () => {
describe("mayBeAnimated", () => {
it("image/gif", async () => {
expect(mayBeAnimated("image/gif")).toBeTruthy();
});
it("image/webp", async () => {
expect(mayBeAnimated("image/webp")).toBeTruthy();
});
it("image/png", async () => {
expect(mayBeAnimated("image/png")).toBeTruthy();
});
it("image/apng", async () => {
expect(mayBeAnimated("image/apng")).toBeTruthy();
});
it("image/jpeg", async () => {
expect(mayBeAnimated("image/jpeg")).toBeFalsy();
});
});
describe("blobIsAnimated", () => {
it("Animated GIF", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]);
expect(await blobIsAnimated("image/gif", img)).toBeTruthy();
});
it("Static GIF", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]);
expect(await blobIsAnimated("image/gif", img)).toBeFalsy();
});
it("Animated WEBP", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]);
expect(await blobIsAnimated("image/webp", img)).toBeTruthy();
});
it("Static WEBP", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]);
expect(await blobIsAnimated("image/webp", img)).toBeFalsy();
});
it("Animated PNG", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.apng"))]);
expect(await blobIsAnimated("image/png", img)).toBeTruthy();
expect(await blobIsAnimated("image/apng", img)).toBeTruthy();
});
it("Static PNG", async () => {
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.png"))]);
expect(await blobIsAnimated("image/png", img)).toBeFalsy();
expect(await blobIsAnimated("image/apng", img)).toBeFalsy();
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,55 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { SettingLevel } from "../../src/settings/SettingLevel";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { stubClient } from "../test-utils";
import MediaDeviceHandler from "../../src/MediaDeviceHandler";
import SettingsStore from "../../src/settings/SettingsStore";
jest.mock("../../src/settings/SettingsStore");
const SettingsStoreMock = mocked(SettingsStore);
describe("MediaDeviceHandler", () => {
beforeEach(() => {
stubClient();
});
afterEach(() => {
jest.clearAllMocks();
});
it("sets audio settings", async () => {
const expectedAudioSettings = new Map<string, boolean>([
["webrtc_audio_autoGainControl", false],
["webrtc_audio_echoCancellation", true],
["webrtc_audio_noiseSuppression", false],
]);
SettingsStoreMock.getValue.mockImplementation((settingName): any => {
return expectedAudioSettings.get(settingName);
});
await MediaDeviceHandler.setAudioAutoGainControl(false);
await MediaDeviceHandler.setAudioEchoCancellation(true);
await MediaDeviceHandler.setAudioNoiseSuppression(false);
expectedAudioSettings.forEach((value, key) => {
expect(SettingsStoreMock.setValue).toHaveBeenCalledWith(key, null, SettingLevel.DEVICE, value);
});
expect(MatrixClientPeg.safeGet().getMediaHandler().setAudioSettings).toHaveBeenCalledWith({
autoGainControl: false,
echoCancellation: true,
noiseSuppression: false,
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig, { DEFAULTS } from "../../src/SdkConfig";
describe("SdkConfig", () => {
describe("with default values", () => {
it("should return the default config", () => {
expect(SdkConfig.get()).toEqual(DEFAULTS);
});
});
describe("with custom values", () => {
beforeEach(() => {
SdkConfig.put({
voice_broadcast: {
chunk_length: 42,
max_length: 1337,
},
feedback: {
existing_issues_url: "https://existing",
} as any,
});
});
it("should return the custom config", () => {
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
customConfig.voice_broadcast.chunk_length = 42;
customConfig.voice_broadcast.max_length = 1337;
customConfig.feedback.existing_issues_url = "https://existing";
expect(SdkConfig.get()).toEqual(customConfig);
});
it("should allow overriding individual fields of sub-objects", () => {
const feedback = SdkConfig.getObject("feedback");
expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`);
expect(feedback.get("new_issue_url")).toMatchInlineSnapshot(
`"https://github.com/vector-im/element-web/issues/new/choose"`,
);
});
});
});

View file

@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { accessSecretStorage } from "../../src/SecurityManager";
import { filterConsole, stubClient } from "../test-utils";
describe("SecurityManager", () => {
describe("accessSecretStorage", () => {
filterConsole("Not setting dehydration key: no SSSS key found");
it("runs the function passed in", async () => {
// Given a client
const crypto = {
bootstrapCrossSigning: () => {},
bootstrapSecretStorage: () => {},
} as unknown as CryptoApi;
const client = stubClient();
client.secretStorage.hasKey = jest.fn().mockResolvedValue(true);
mocked(client.getCrypto).mockReturnValue(crypto);
// When I run accessSecretStorage
const func = jest.fn();
await accessSecretStorage(func);
// Then we call the passed-in function
expect(func).toHaveBeenCalledTimes(1);
});
describe("expecting errors", () => {
filterConsole("End-to-end encryption is disabled - unable to access secret storage");
it("throws if crypto is unavailable", async () => {
// Given a client with no crypto
const client = stubClient();
client.secretStorage.hasKey = jest.fn().mockResolvedValue(true);
mocked(client.getCrypto).mockReturnValue(undefined);
// When I run accessSecretStorage
// Then we throw an error
await expect(async () => {
await accessSecretStorage(jest.fn());
}).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage");
});
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { SdkContextClass } from "../../src/contexts/SDKContext";
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
import { SlidingSyncManager } from "../../src/SlidingSyncManager";
import { RoomNotificationStateStore } from "../../src/stores/notifications/RoomNotificationStateStore";
import RightPanelStore from "../../src/stores/right-panel/RightPanelStore";
import { RoomViewStore } from "../../src/stores/RoomViewStore";
import { SpaceStoreClass } from "../../src/stores/spaces/SpaceStore";
import { WidgetLayoutStore } from "../../src/stores/widgets/WidgetLayoutStore";
import { WidgetPermissionStore } from "../../src/stores/widgets/WidgetPermissionStore";
import WidgetStore from "../../src/stores/WidgetStore";
import {
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPreRecordingStore,
VoiceBroadcastRecordingsStore,
} from "../../src/voice-broadcast";
/**
* A class which provides the same API as SdkContextClass but adds additional unsafe setters which can
* replace individual stores. This is useful for tests which need to mock out stores.
*/
export class TestSdkContext extends SdkContextClass {
public declare _RightPanelStore?: RightPanelStore;
public declare _RoomNotificationStateStore?: RoomNotificationStateStore;
public declare _RoomViewStore?: RoomViewStore;
public declare _WidgetPermissionStore?: WidgetPermissionStore;
public declare _WidgetLayoutStore?: WidgetLayoutStore;
public declare _WidgetStore?: WidgetStore;
public declare _PosthogAnalytics?: PosthogAnalytics;
public declare _SlidingSyncManager?: SlidingSyncManager;
public declare _SpaceStore?: SpaceStoreClass;
public declare _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore;
public declare _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore;
public declare _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore;
constructor() {
super();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { WorkerManager } from "../../src/WorkerManager";
describe("WorkerManager", () => {
it("should generate consecutive sequence numbers for each call", () => {
const postMessage = jest.fn();
const manager = new WorkerManager({ postMessage } as unknown as Worker);
manager.call({ data: "One" });
manager.call({ data: "Two" });
manager.call({ data: "Three" });
const one = postMessage.mock.calls.find((c) => c[0].data === "One")!;
const two = postMessage.mock.calls.find((c) => c[0].data === "Two")!;
const three = postMessage.mock.calls.find((c) => c[0].data === "Three")!;
expect(one[0].seq).toBe(0);
expect(two[0].seq).toBe(1);
expect(three[0].seq).toBe(2);
});
it("should support resolving out of order", async () => {
const postMessage = jest.fn();
const worker = { postMessage } as unknown as Worker;
const manager = new WorkerManager(worker);
const oneProm = manager.call({ data: "One" });
const twoProm = manager.call({ data: "Two" });
const threeProm = manager.call({ data: "Three" });
const one = postMessage.mock.calls.find((c) => c[0].data === "One")![0].seq;
const two = postMessage.mock.calls.find((c) => c[0].data === "Two")![0].seq;
const three = postMessage.mock.calls.find((c) => c[0].data === "Three")![0].seq;
worker.onmessage!({ data: { seq: one, data: 1 } } as MessageEvent);
await expect(oneProm).resolves.toEqual(expect.objectContaining({ data: 1 }));
worker.onmessage!({ data: { seq: three, data: 3 } } as MessageEvent);
await expect(threeProm).resolves.toEqual(expect.objectContaining({ data: 3 }));
worker.onmessage!({ data: { seq: two, data: 2 } } as MessageEvent);
await expect(twoProm).resolves.toEqual(expect.objectContaining({ data: 2 }));
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { viewUserDeviceSettings } from "../../../../src/actions/handlers/viewUserDeviceSettings";
import { UserTab } from "../../../../src/components/views/dialogs/UserTab";
import { Action } from "../../../../src/dispatcher/actions";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
describe("viewUserDeviceSettings()", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
beforeEach(() => {
dispatchSpy.mockClear();
});
it("dispatches action to view session manager", () => {
viewUserDeviceSettings();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
});
});
});

View file

@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details.
*/
import * as React from "react";
import { render } from "@testing-library/react";
import SdkConfig from "matrix-react-sdk/src/SdkConfig";
import { render } from "jest-matrix-react";
import SdkConfig from "../../../../src/SdkConfig";
import { ErrorView, UnsupportedBrowserView } from "../../../../src/async-components/structures/ErrorView";
import { setupLanguageMock } from "../../../setup/setupLanguage";

View file

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

View file

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

View file

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

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Ryan Browne <code@commonlawfeature.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import EmojiProvider from "../../../src/autocomplete/EmojiProvider";
import { mkStubRoom } from "../../test-utils/test-utils";
import { add } from "../../../src/emojipicker/recent";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
const EMOJI_SHORTCODES = [
":+1",
":heart",
":grinning",
":hand",
":man",
":sweat",
":monkey",
":boat",
":mailbox",
":cop",
":bow",
":kiss",
":golf",
];
// Some emoji shortcodes are too short and do not actually trigger autocompletion until the ending `:`.
// This means that we cannot compare their autocompletion before and after the ending `:` and have
// to simply assert that the final completion with the colon is the exact emoji.
const TOO_SHORT_EMOJI_SHORTCODE = [{ emojiShortcode: ":o", expectedEmoji: "⭕️" }];
describe("EmojiProvider", function () {
const testRoom = mkStubRoom(undefined, undefined, undefined);
stubClient();
MatrixClientPeg.safeGet();
it.each(EMOJI_SHORTCODES)("Returns consistent results after final colon %s", async function (emojiShortcode) {
const ep = new EmojiProvider(testRoom);
const range = { beginning: true, start: 0, end: 3 };
const completionsBeforeColon = await ep.getCompletions(emojiShortcode, range);
const completionsAfterColon = await ep.getCompletions(emojiShortcode + ":", range);
const firstCompletionWithoutColon = completionsBeforeColon[0].completion;
const firstCompletionWithColon = completionsAfterColon[0].completion;
expect(firstCompletionWithoutColon).toEqual(firstCompletionWithColon);
});
it.each(TOO_SHORT_EMOJI_SHORTCODE)(
"Returns correct results after final colon $emojiShortcode",
async ({ emojiShortcode, expectedEmoji }) => {
const ep = new EmojiProvider(testRoom);
const range = { beginning: true, start: 0, end: 3 };
const completions = await ep.getCompletions(emojiShortcode + ":", range);
expect(completions[0].completion).toEqual(expectedEmoji);
},
);
it("Recently used emojis are correctly sorted", async function () {
add("😘"); //kissing_heart
add("💗"); //heartpulse
add("💗"); //heartpulse
add("😍"); //heart_eyes
const ep = new EmojiProvider(testRoom);
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
expect(completionsList[0]?.component?.props.title).toEqual(":heartpulse:");
expect(completionsList[1]?.component?.props.title).toEqual(":heart_eyes:");
});
it("Exact match in recently used takes the lead", async function () {
add("😘"); //kissing_heart
add("💗"); //heartpulse
add("💗"); //heartpulse
add("😍"); //heart_eyes
add("❤️"); //heart
const ep = new EmojiProvider(testRoom);
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
expect(completionsList[0]?.component?.props.title).toEqual(":heart:");
expect(completionsList[1]?.component?.props.title).toEqual(":heartpulse:");
expect(completionsList[2]?.component?.props.title).toEqual(":heart_eyes:");
});
});

View file

@ -0,0 +1,165 @@
/*
Copyright 2018-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import QueryMatcher from "../../../src/autocomplete/QueryMatcher";
const OBJECTS = [
{ name: "Mel B", nick: "Scary" },
{ name: "Mel C", nick: "Sporty" },
{ name: "Emma", nick: "Baby" },
{ name: "Geri", nick: "Ginger" },
{ name: "Victoria", nick: "Posh" },
];
const NONWORDOBJECTS = [{ name: "B.O.B" }, { name: "bob" }];
describe("QueryMatcher", function () {
it("Returns results by key", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Geri");
expect(results.length).toBe(1);
expect(results[0].name).toBe("Geri");
});
it("Returns results by prefix", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Ge");
expect(results.length).toBe(1);
expect(results[0].name).toBe("Geri");
});
it("Matches case-insensitive", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("geri");
expect(results.length).toBe(1);
expect(results[0].name).toBe("Geri");
});
it("Matches ignoring accents", function () {
const qm = new QueryMatcher([{ name: "Gëri", foo: 46 }], { keys: ["name"] });
const results = qm.match("geri");
expect(results.length).toBe(1);
expect(results[0].foo).toBe(46);
});
it("Returns multiple results in order of search string appearance", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name", "nick"] });
const results = qm.match("or");
expect(results.length).toBe(2);
expect(results[0].name).toBe("Mel C");
expect(results[1].name).toBe("Victoria");
qm.setObjects(OBJECTS.slice().reverse());
const reverseResults = qm.match("or");
// should still be in the same order: search string position
// takes precedence over input order
expect(reverseResults.length).toBe(2);
expect(reverseResults[0].name).toBe("Mel C");
expect(reverseResults[1].name).toBe("Victoria");
});
it("Returns results with search string in same place according to key index", function () {
const objects = [
{ name: "a", first: "hit", second: "miss", third: "miss" },
{ name: "b", first: "miss", second: "hit", third: "miss" },
{ name: "c", first: "miss", second: "miss", third: "hit" },
];
const qm = new QueryMatcher(objects, { keys: ["second", "first", "third"] });
const results = qm.match("hit");
expect(results.length).toBe(3);
expect(results[0].name).toBe("b");
expect(results[1].name).toBe("a");
expect(results[2].name).toBe("c");
qm.setObjects(objects.slice().reverse());
const reverseResults = qm.match("hit");
// should still be in the same order: key index
// takes precedence over input order
expect(reverseResults.length).toBe(3);
expect(reverseResults[0].name).toBe("b");
expect(reverseResults[1].name).toBe("a");
expect(reverseResults[2].name).toBe("c");
});
it("Returns results with search string in same place and key in same place in insertion order", function () {
const qm = new QueryMatcher(OBJECTS, { keys: ["name"] });
const results = qm.match("Mel");
expect(results.length).toBe(2);
expect(results[0].name).toBe("Mel B");
expect(results[1].name).toBe("Mel C");
qm.setObjects(OBJECTS.slice().reverse());
const reverseResults = qm.match("Mel");
expect(reverseResults.length).toBe(2);
expect(reverseResults[0].name).toBe("Mel C");
expect(reverseResults[1].name).toBe("Mel B");
});
it("Returns numeric results in correct order (input pos)", function () {
// regression test for depending on object iteration order
const qm = new QueryMatcher([{ name: "123456badger" }, { name: "123456" }], { keys: ["name"] });
const results = qm.match("123456");
expect(results.length).toBe(2);
expect(results[0].name).toBe("123456badger");
expect(results[1].name).toBe("123456");
});
it("Returns numeric results in correct order (query pos)", function () {
const qm = new QueryMatcher([{ name: "999999123456" }, { name: "123456badger" }], { keys: ["name"] });
const results = qm.match("123456");
expect(results.length).toBe(2);
expect(results[0].name).toBe("123456badger");
expect(results[1].name).toBe("999999123456");
});
it("Returns results by function", function () {
const qm = new QueryMatcher(OBJECTS, {
keys: ["name"],
funcs: [(x) => x.name.replace("Mel", "Emma")],
});
const results = qm.match("Emma");
expect(results.length).toBe(3);
expect(results[0].name).toBe("Emma");
expect(results[1].name).toBe("Mel B");
expect(results[2].name).toBe("Mel C");
});
it("Matches words only by default", function () {
const qm = new QueryMatcher(NONWORDOBJECTS, { keys: ["name"] });
const results = qm.match("bob");
expect(results.length).toBe(2);
expect(results[0].name).toBe("B.O.B");
expect(results[1].name).toBe("bob");
});
it("Matches all chars with words-only off", function () {
const qm = new QueryMatcher(NONWORDOBJECTS, {
keys: ["name"],
shouldMatchWordsOnly: false,
});
const results = qm.match("bob");
expect(results.length).toBe(1);
expect(results[0].name).toBe("bob");
});
});

View file

@ -0,0 +1,127 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import RoomProvider from "../../../src/autocomplete/RoomProvider";
import SettingsStore from "../../../src/settings/SettingsStore";
import { mkRoom, mkSpace, stubClient } from "../../test-utils";
describe("RoomProvider", () => {
it("suggests a room whose alias matches a prefix", async () => {
// Given a room
const client = stubClient();
const room = makeRoom(client, "room:e.com");
mocked(client.getVisibleRooms).mockReturnValue([room]);
// When we search for rooms starting with its prefix
const roomProvider = new RoomProvider(room);
const completions = await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
// Then we find it
expect(completions).toStrictEqual([
{
type: "room",
completion: room.getCanonicalAlias(),
completionId: room.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
it("suggests only rooms matching a prefix", async () => {
// Given some rooms with different names
const client = stubClient();
const room1 = makeRoom(client, "room1:e.com");
const room2 = makeRoom(client, "room2:e.com");
const other = makeRoom(client, "other:e.com");
const space = makeSpace(client, "room3:e.com");
mocked(client.getVisibleRooms).mockReturnValue([room1, room2, other, space]);
// When we search for rooms starting with a prefix
const roomProvider = new RoomProvider(room1);
const completions = await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
// Then we find the two rooms with that prefix, but not the other one
expect(completions).toStrictEqual([
{
type: "room",
completion: room1.getCanonicalAlias(),
completionId: room1.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room1:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
{
type: "room",
completion: room2.getCanonicalAlias(),
completionId: room2.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#room2:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const room = makeRoom(client, "room:e.com");
mocked(client.getVisibleRooms).mockReturnValue([room]);
mocked(client.getVisibleRooms).mockClear();
const roomProvider = new RoomProvider(room);
await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const room = makeRoom(client, "room:e.com");
mocked(client.getVisibleRooms).mockReturnValue([room]);
mocked(client.getVisibleRooms).mockClear();
const roomProvider = new RoomProvider(room);
await roomProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
});
function makeSpace(client: MatrixClient, name: string): Room {
const space = mkSpace(client, `!${name}`);
space.getCanonicalAlias.mockReturnValue(`#${name}`);
return space;
}
function makeRoom(client: MatrixClient, name: string): Room {
const room = mkRoom(client, `!${name}`);
room.getCanonicalAlias.mockReturnValue(`#${name}`);
return room;
}

View file

@ -0,0 +1,127 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import SpaceProvider from "../../../src/autocomplete/SpaceProvider";
import SettingsStore from "../../../src/settings/SettingsStore";
import { mkRoom, mkSpace, stubClient } from "../../test-utils";
describe("SpaceProvider", () => {
it("suggests a space whose alias matches a prefix", async () => {
// Given a space
const client = stubClient();
const space = makeSpace(client, "space:e.com");
mocked(client.getVisibleRooms).mockReturnValue([space]);
// When we search for spaces starting with its prefix
const spaceProvider = new SpaceProvider(space);
const completions = await spaceProvider.getCompletions("#sp", { beginning: true, start: 0, end: 3 });
// Then we find it
expect(completions).toStrictEqual([
{
type: "room",
completion: space.getCanonicalAlias(),
completionId: space.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
it("suggests only spaces matching a prefix", async () => {
// Given some spaces with different names
const client = stubClient();
const space1 = makeSpace(client, "space1:e.com");
const space2 = makeSpace(client, "space2:e.com");
const other = makeSpace(client, "other:e.com");
const room = makeRoom(client, "space3:e.com");
mocked(client.getVisibleRooms).mockReturnValue([space1, space2, other, room]);
// When we search for spaces starting with a prefix
const spaceProvider = new SpaceProvider(space1);
const completions = await spaceProvider.getCompletions("#sp", { beginning: true, start: 0, end: 3 });
// Then we find the two spaces with that prefix, but not the other one
expect(completions).toStrictEqual([
{
type: "room",
completion: space1.getCanonicalAlias(),
completionId: space1.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space1:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
{
type: "room",
completion: space2.getCanonicalAlias(),
completionId: space2.roomId,
component: expect.anything(),
href: "https://matrix.to/#/#space2:e.com",
range: { start: 0, end: 3 },
suffix: " ",
},
]);
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const space = makeSpace(client, "space:e.com");
mocked(client.getVisibleRooms).mockReturnValue([space]);
mocked(client.getVisibleRooms).mockClear();
const spaceProvider = new SpaceProvider(space);
await spaceProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_space_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
const client = stubClient();
const space = makeSpace(client, "space:e.com");
mocked(client.getVisibleRooms).mockReturnValue([space]);
mocked(client.getVisibleRooms).mockClear();
const spaceProvider = new SpaceProvider(space);
await spaceProvider.getCompletions("#ro", { beginning: true, start: 0, end: 3 });
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
});
function makeSpace(client: MatrixClient, name: string): Room {
const space = mkSpace(client, `!${name}`);
space.getCanonicalAlias.mockReturnValue(`#${name}`);
return space;
}
function makeRoom(client: MatrixClient, name: string): Room {
const room = mkRoom(client, `!${name}`);
room.getCanonicalAlias.mockReturnValue(`#${name}`);
return room;
}

View file

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

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { toLeftOf, toLeftOrRightOf, toRightOf } from "../../../../src/components/structures/ContextMenu";
import UIStore from "../../../../src/stores/UIStore";
describe("ContextMenu", () => {
const rect = new DOMRect();
// @ts-ignore
rect.left = 23;
// @ts-ignore
rect.right = 46;
// @ts-ignore
rect.top = 42;
rect.width = 640;
rect.height = 480;
beforeEach(() => {
window.scrollX = 31;
window.scrollY = 41;
UIStore.instance.windowWidth = 1280;
});
describe("toLeftOf", () => {
it("should return the correct positioning", () => {
expect(toLeftOf(rect)).toEqual({
chevronOffset: 12,
right: 1285, // 1280 - 23 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toRightOf", () => {
it("should return the correct positioning", () => {
expect(toRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toLeftOrRightOf", () => {
describe("when there is more space to the right", () => {
// default case from test setup
it("should return a position to the right", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("when there is more space to the left", () => {
beforeEach(() => {
// @ts-ignore
rect.left = 500;
// @ts-ignore
rect.right = 1000;
});
it("should return a position to the left", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
right: 808, // 1280 - 500 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
});
});

View file

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

View file

@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { LargeLoader } from "../../../../src/components/structures/LargeLoader";
describe("LargeLoader", () => {
const text = "test loading text";
beforeEach(() => {
render(<LargeLoader text={text} />);
});
it("should render the text", () => {
screen.getByText(text);
});
});

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { RoomStatusBarUnsentMessages } from "../../../../src/components/structures/RoomStatusBarUnsentMessages";
import { StaticNotificationState } from "../../../../src/stores/notifications/StaticNotificationState";
describe("RoomStatusBarUnsentMessages", () => {
const title = "test title";
const description = "test description";
const buttonsText = "test buttons";
const buttons = <div>{buttonsText}</div>;
beforeEach(() => {
render(
<RoomStatusBarUnsentMessages
title={title}
description={description}
buttons={buttons}
notificationState={StaticNotificationState.RED_EXCLAMATION}
/>,
);
});
it("should render the values passed as props", () => {
screen.getByText(title);
screen.getByText(description);
screen.getByText(buttonsText);
// notification state
screen.getByText("!");
});
});

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