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:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
11
test/@types/common.ts
Normal file
11
test/@types/common.ts
Normal 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];
|
||||
};
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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
11
test/globalSetup.ts
Normal 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
28
test/setup/mocks.ts
Normal 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
13
test/setup/setupConfig.ts
Normal 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);
|
|
@ -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]);
|
||||
|
|
87
test/setup/setupManualMocks.ts
Normal file
87
test/setup/setupManualMocks.ts
Normal 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
40
test/setupTests.ts
Normal 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
9
test/slowReporter.cjs
Normal 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
74
test/test-utils/audio.ts
Normal 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
209
test/test-utils/beacon.ts
Normal 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
107
test/test-utils/call.ts
Normal 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
175
test/test-utils/client.ts
Normal 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(),
|
||||
});
|
31
test/test-utils/composer.ts
Normal file
31
test/test-utils/composer.ts
Normal 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);
|
||||
});
|
||||
};
|
46
test/test-utils/console.ts
Normal file
46
test/test-utils/console.ts
Normal 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
27
test/test-utils/date.ts
Normal 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
34
test/test-utils/events.ts
Normal 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;
|
||||
}
|
|
@ -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> {
|
42
test/test-utils/jest-matrix-react.tsx
Normal file
42
test/test-utils/jest-matrix-react.tsx
Normal 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 };
|
47
test/test-utils/location.ts
Normal file
47
test/test-utils/location.ts
Normal 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
46
test/test-utils/oidc.ts
Normal 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",
|
||||
});
|
38
test/test-utils/platform.ts
Normal file
38
test/test-utils/platform.ts
Normal 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
145
test/test-utils/poll.ts
Normal 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;
|
||||
};
|
28
test/test-utils/predictableRandom.ts
Normal file
28
test/test-utils/predictableRandom.ts
Normal 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;
|
||||
}
|
||||
}
|
359
test/test-utils/pushRules.ts
Normal file
359
test/test-utils/pushRules.ts
Normal 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,
|
||||
});
|
25
test/test-utils/relations.ts
Normal file
25
test/test-utils/relations.ts
Normal 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
100
test/test-utils/room.ts
Normal 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);
|
||||
};
|
838
test/test-utils/test-utils.ts
Normal file
838
test/test-utils/test-utils.ts
Normal 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
162
test/test-utils/threads.ts
Normal 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;
|
||||
};
|
288
test/test-utils/utilities.ts
Normal file
288
test/test-utils/utilities.ts
Normal 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();
|
||||
});
|
||||
}
|
64
test/test-utils/wrappers.tsx
Normal file
64
test/test-utils/wrappers.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
}
|
96
test/unit-tests/Avatar-test.ts
Normal file
96
test/unit-tests/Avatar-test.ts
Normal 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");
|
||||
});
|
||||
});
|
418
test/unit-tests/ContentMessages-test.ts
Normal file
418
test/unit-tests/ContentMessages-test.ts
Normal 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);
|
||||
});
|
||||
});
|
734
test/unit-tests/DecryptionFailureTracker-test.ts
Normal file
734
test/unit-tests/DecryptionFailureTracker-test.ts
Normal 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;
|
||||
}
|
913
test/unit-tests/DeviceListener-test.ts
Normal file
913
test/unit-tests/DeviceListener-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
200
test/unit-tests/HtmlUtils-test.tsx
Normal file
200
test/unit-tests/HtmlUtils-test.tsx
Normal 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("<b>pizza</b>");
|
||||
});
|
||||
|
||||
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 <b>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}:`);
|
||||
}
|
||||
});
|
||||
});
|
66
test/unit-tests/Image-test.ts
Normal file
66
test/unit-tests/Image-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
165
test/unit-tests/KeyBindingsManager-test.ts
Normal file
165
test/unit-tests/KeyBindingsManager-test.ts
Normal 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);
|
||||
});
|
||||
});
|
706
test/unit-tests/LegacyCallHandler-test.ts
Normal file
706
test/unit-tests/LegacyCallHandler-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
960
test/unit-tests/Lifecycle-test.ts
Normal file
960
test/unit-tests/Lifecycle-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
184
test/unit-tests/Markdown-test.ts
Normal file
184
test/unit-tests/Markdown-test.ts
Normal 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 /><#_foonetic_xkcd:matrix.org><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 /><#_foonetic_xkcd:matrix.org></p>',
|
||||
'<p>Test1A:<br /><#_foonetic_xkcd:matrix.org><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 /><#_foonetic_xkcd:matrix.org></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);
|
||||
});
|
||||
});
|
||||
});
|
109
test/unit-tests/MatrixClientPeg-test.ts
Normal file
109
test/unit-tests/MatrixClientPeg-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
55
test/unit-tests/MediaDeviceHandler-test.ts
Normal file
55
test/unit-tests/MediaDeviceHandler-test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
49
test/unit-tests/Modal-test.ts
Normal file
49
test/unit-tests/Modal-test.ts
Normal 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();
|
||||
});
|
||||
});
|
634
test/unit-tests/Notifier-test.ts
Normal file
634
test/unit-tests/Notifier-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
346
test/unit-tests/PosthogAnalytics-test.ts
Normal file
346
test/unit-tests/PosthogAnalytics-test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
46
test/unit-tests/PreferredRoomVersions-test.ts
Normal file
46
test/unit-tests/PreferredRoomVersions-test.ts
Normal 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);
|
||||
});
|
||||
});
|
195
test/unit-tests/Reply-test.ts
Normal file
195
test/unit-tests/Reply-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
332
test/unit-tests/RoomNotifs-test.ts
Normal file
332
test/unit-tests/RoomNotifs-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
143
test/unit-tests/Rooms-test.ts
Normal file
143
test/unit-tests/Rooms-test.ts
Normal 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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
212
test/unit-tests/ScalarAuthClient-test.ts
Normal file
212
test/unit-tests/ScalarAuthClient-test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
47
test/unit-tests/SdkConfig-test.ts
Normal file
47
test/unit-tests/SdkConfig-test.ts
Normal 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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
54
test/unit-tests/SecurityManager-test.ts
Normal file
54
test/unit-tests/SecurityManager-test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
412
test/unit-tests/SlashCommands-test.tsx
Normal file
412
test/unit-tests/SlashCommands-test.tsx
Normal 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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
321
test/unit-tests/SlidingSyncManager-test.ts
Normal file
321
test/unit-tests/SlidingSyncManager-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
115
test/unit-tests/SupportedBrowser-test.ts
Normal file
115
test/unit-tests/SupportedBrowser-test.ts
Normal 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();
|
||||
});
|
||||
});
|
183
test/unit-tests/Terms-test.tsx
Normal file
183
test/unit-tests/Terms-test.tsx
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
46
test/unit-tests/TestSdkContext.ts
Normal file
46
test/unit-tests/TestSdkContext.ts
Normal 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();
|
||||
}
|
||||
}
|
616
test/unit-tests/TextForEvent-test.ts
Normal file
616
test/unit-tests/TextForEvent-test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
23
test/unit-tests/TimezoneHandler-test.ts
Normal file
23
test/unit-tests/TimezoneHandler-test.ts
Normal 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);
|
||||
});
|
||||
});
|
637
test/unit-tests/Unread-test.ts
Normal file
637
test/unit-tests/Unread-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
119
test/unit-tests/UserActivity-test.ts
Normal file
119
test/unit-tests/UserActivity-test.ts
Normal 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);
|
||||
});
|
||||
});
|
51
test/unit-tests/WorkerManager-test.ts
Normal file
51
test/unit-tests/WorkerManager-test.ts
Normal 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 }));
|
||||
});
|
||||
});
|
66
test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap
Normal file
66
test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap
Normal 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>
|
||||
`;
|
24
test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap
Normal file
24
test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap
Normal 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 can’t 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": "Can’t start a call",
|
||||
},
|
||||
],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
64
test/unit-tests/__snapshots__/Reply-test.ts.snap
Normal file
64
test/unit-tests/__snapshots__/Reply-test.ts.snap
Normal 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>",
|
||||
}
|
||||
`;
|
75
test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap
Normal file
75
test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap
Normal 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",
|
||||
}
|
||||
`;
|
18
test/unit-tests/__snapshots__/TextForEvent-test.ts.snap
Normal file
18
test/unit-tests/__snapshots__/TextForEvent-test.ts.snap
Normal 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>
|
||||
`;
|
3
test/unit-tests/__snapshots__/theme-test.ts.snap
Normal file
3
test/unit-tests/__snapshots__/theme-test.ts.snap
Normal 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); } }"`;
|
82
test/unit-tests/accessibility/KeyboardShortcutUtils-test.ts
Normal file
82
test/unit-tests/accessibility/KeyboardShortcutUtils-test.ts
Normal 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: {} });
|
||||
});
|
||||
});
|
||||
});
|
122
test/unit-tests/accessibility/LandmarkNavigation-test.tsx
Normal file
122
test/unit-tests/accessibility/LandmarkNavigation-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
414
test/unit-tests/accessibility/RovingTabIndex-test.tsx
Normal file
414
test/unit-tests/accessibility/RovingTabIndex-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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";
|
||||
|
||||
|
|
163
test/unit-tests/audio/Playback-test.ts
Normal file
163
test/unit-tests/audio/Playback-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
211
test/unit-tests/audio/VoiceMessageRecording-test.ts
Normal file
211
test/unit-tests/audio/VoiceMessageRecording-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
175
test/unit-tests/audio/VoiceRecording-test.ts
Normal file
175
test/unit-tests/audio/VoiceRecording-test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
90
test/unit-tests/autocomplete/EmojiProvider-test.ts
Normal file
90
test/unit-tests/autocomplete/EmojiProvider-test.ts
Normal 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:");
|
||||
});
|
||||
});
|
165
test/unit-tests/autocomplete/QueryMatcher-test.ts
Normal file
165
test/unit-tests/autocomplete/QueryMatcher-test.ts
Normal 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");
|
||||
});
|
||||
});
|
127
test/unit-tests/autocomplete/RoomProvider-test.ts
Normal file
127
test/unit-tests/autocomplete/RoomProvider-test.ts
Normal 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;
|
||||
}
|
127
test/unit-tests/autocomplete/SpaceProvider-test.ts
Normal file
127
test/unit-tests/autocomplete/SpaceProvider-test.ts
Normal 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;
|
||||
}
|
275
test/unit-tests/components/structures/AutocompleteInput-test.tsx
Normal file
275
test/unit-tests/components/structures/AutocompleteInput-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
79
test/unit-tests/components/structures/ContextMenu-test.ts
Normal file
79
test/unit-tests/components/structures/ContextMenu-test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
50
test/unit-tests/components/structures/FilePanel-test.tsx
Normal file
50
test/unit-tests/components/structures/FilePanel-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
24
test/unit-tests/components/structures/LargeLoader-test.tsx
Normal file
24
test/unit-tests/components/structures/LargeLoader-test.tsx
Normal 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);
|
||||
});
|
||||
});
|
46
test/unit-tests/components/structures/LeftPanel-test.tsx
Normal file
46
test/unit-tests/components/structures/LeftPanel-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
460
test/unit-tests/components/structures/LoggedInView-test.tsx
Normal file
460
test/unit-tests/components/structures/LoggedInView-test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
99
test/unit-tests/components/structures/MainSplit-test.tsx
Normal file
99
test/unit-tests/components/structures/MainSplit-test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
1520
test/unit-tests/components/structures/MatrixChat-test.tsx
Normal file
1520
test/unit-tests/components/structures/MatrixChat-test.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
835
test/unit-tests/components/structures/MessagePanel-test.tsx
Normal file
835
test/unit-tests/components/structures/MessagePanel-test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
476
test/unit-tests/components/structures/PipContainer-test.tsx
Normal file
476
test/unit-tests/components/structures/PipContainer-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
153
test/unit-tests/components/structures/RightPanel-test.tsx
Normal file
153
test/unit-tests/components/structures/RightPanel-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
576
test/unit-tests/components/structures/RoomSearchView-test.tsx
Normal file
576
test/unit-tests/components/structures/RoomSearchView-test.tsx
Normal 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`,
|
||||
);
|
||||
});
|
||||
});
|
150
test/unit-tests/components/structures/RoomStatusBar-test.tsx
Normal file
150
test/unit-tests/components/structures/RoomStatusBar-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue