mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-06-08 15:21:53 +03:00
* Bump eslint-plugin-matrix-org to enable @typescript-eslint/consistent-type-imports rule * Re-lint after merge
1830 lines
74 KiB
TypeScript
1830 lines
74 KiB
TypeScript
/*
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import { mocked } from "jest-mock";
|
|
|
|
import {
|
|
EventType,
|
|
GroupCallIntent,
|
|
GroupCallType,
|
|
type MatrixCall,
|
|
MatrixEvent,
|
|
Room,
|
|
type RoomMember,
|
|
} from "../../../src";
|
|
import { RoomStateEvent } from "../../../src/models/room-state";
|
|
import { GroupCall, GroupCallEvent, GroupCallState, GroupCallStatsReportEvent } from "../../../src/webrtc/groupCall";
|
|
import { type IMyDevice, MatrixClient } from "../../../src/client";
|
|
import {
|
|
FAKE_CONF_ID,
|
|
FAKE_DEVICE_ID_1,
|
|
FAKE_DEVICE_ID_2,
|
|
FAKE_ROOM_ID,
|
|
FAKE_SESSION_ID_1,
|
|
FAKE_SESSION_ID_2,
|
|
FAKE_USER_ID_1,
|
|
FAKE_USER_ID_2,
|
|
FAKE_USER_ID_3,
|
|
installWebRTCMocks,
|
|
MockCallFeed,
|
|
MockCallMatrixClient,
|
|
MockMatrixCall,
|
|
MockMediaStream,
|
|
MockMediaStreamTrack,
|
|
MockRTCPeerConnection,
|
|
} from "../../test-utils/webrtc";
|
|
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
|
|
import { sleep } from "../../../src/utils";
|
|
import { CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
|
|
import { CallFeed } from "../../../src/webrtc/callFeed";
|
|
import { CallEvent, CallState } from "../../../src/webrtc/call";
|
|
import { flushPromises } from "../../test-utils/flushPromises";
|
|
import { type CallFeedReport } from "../../../src/webrtc/stats/statsReport";
|
|
import { CallFeedStatsReporter } from "../../../src/webrtc/stats/callFeedStatsReporter";
|
|
import { type StatsReportEmitter } from "../../../src/webrtc/stats/statsReportEmitter";
|
|
import { KnownMembership } from "../../../src/@types/membership";
|
|
|
|
const FAKE_STATE_EVENTS = [
|
|
{
|
|
getContent: () => ({
|
|
"m.calls": [],
|
|
}),
|
|
getStateKey: () => FAKE_USER_ID_1,
|
|
getRoomId: () => FAKE_ROOM_ID,
|
|
getTs: () => 0,
|
|
},
|
|
{
|
|
getContent: () => ({
|
|
"m.calls": [
|
|
{
|
|
"m.call_id": FAKE_CONF_ID,
|
|
"m.devices": [
|
|
{
|
|
device_id: FAKE_DEVICE_ID_2,
|
|
session_id: FAKE_SESSION_ID_2,
|
|
expires_ts: Date.now() + ONE_HOUR,
|
|
feeds: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
getStateKey: () => FAKE_USER_ID_2,
|
|
getRoomId: () => FAKE_ROOM_ID,
|
|
getTs: () => 0,
|
|
},
|
|
{
|
|
getContent: () => ({
|
|
"m.expires_ts": Date.now() + ONE_HOUR,
|
|
"m.calls": [
|
|
{
|
|
"m.call_id": FAKE_CONF_ID,
|
|
"m.devices": [
|
|
{
|
|
device_id: "user3_device",
|
|
session_id: "user3_session",
|
|
expires_ts: Date.now() + ONE_HOUR,
|
|
feeds: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
getStateKey: () => "user3",
|
|
getRoomId: () => FAKE_ROOM_ID,
|
|
getTs: () => 0,
|
|
},
|
|
];
|
|
|
|
const mockGetStateEvents =
|
|
(events: MatrixEvent[] = FAKE_STATE_EVENTS as MatrixEvent[]) =>
|
|
(type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => {
|
|
if (type === EventType.GroupCallMemberPrefix) {
|
|
return userId === undefined ? events : (events.find((e) => e.getStateKey() === userId) ?? null);
|
|
} else {
|
|
const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent;
|
|
return userId === undefined ? [fakeEvent] : fakeEvent;
|
|
}
|
|
};
|
|
|
|
const ONE_HOUR = 1000 * 60 * 60;
|
|
|
|
const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise<GroupCall> => {
|
|
const groupCall = new GroupCall(cli, room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID);
|
|
|
|
await groupCall.create();
|
|
await groupCall.enter();
|
|
|
|
return groupCall;
|
|
};
|
|
|
|
describe("Group Call", function () {
|
|
beforeEach(function () {
|
|
installWebRTCMocks();
|
|
});
|
|
|
|
describe("Basic functionality", function () {
|
|
let mockSendState: jest.Mock;
|
|
let mockClient: MatrixClient;
|
|
let room: Room;
|
|
let groupCall: GroupCall;
|
|
|
|
beforeEach(function () {
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
mockSendState = typedMockClient.sendStateEvent;
|
|
|
|
mockClient = typedMockClient as unknown as MatrixClient;
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
|
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
});
|
|
|
|
afterEach(() => {
|
|
groupCall.leave();
|
|
});
|
|
|
|
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
|
|
"throws when initializing local call feed in %s state",
|
|
async (state: GroupCallState) => {
|
|
// @ts-ignore
|
|
groupCall.state = state;
|
|
await expect(groupCall.initLocalCallFeed()).rejects.toThrow();
|
|
},
|
|
);
|
|
|
|
it.each([0, 3, 5, 10, 5000])("sets correct creation timestamp when creating a call", async (time: number) => {
|
|
jest.spyOn(Date, "now").mockReturnValue(time);
|
|
await groupCall.create();
|
|
|
|
expect(groupCall.creationTs).toBe(time);
|
|
});
|
|
|
|
it("does not initialize local call feed, if it already is", async () => {
|
|
await groupCall.initLocalCallFeed();
|
|
jest.spyOn(groupCall, "initLocalCallFeed");
|
|
await groupCall.enter();
|
|
|
|
expect(groupCall.initLocalCallFeed).not.toHaveBeenCalled();
|
|
|
|
groupCall.leave();
|
|
});
|
|
|
|
it("does not start initializing local call feed twice", () => {
|
|
const promise1 = groupCall.initLocalCallFeed();
|
|
// @ts-ignore Mock
|
|
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
|
|
const promise2 = groupCall.initLocalCallFeed();
|
|
|
|
expect(promise1).toEqual(promise2);
|
|
});
|
|
|
|
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValue("Error");
|
|
|
|
await expect(groupCall.initLocalCallFeed()).rejects.toBeTruthy();
|
|
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
|
|
});
|
|
|
|
it("stops initializing local call feed when leaving", async () => {
|
|
const initPromise = groupCall.initLocalCallFeed();
|
|
groupCall.leave();
|
|
await expect(initPromise).rejects.toBeDefined();
|
|
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
|
|
});
|
|
|
|
it("sends state event to room when creating", async () => {
|
|
await groupCall.create();
|
|
|
|
expect(mockSendState).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.GroupCallPrefix,
|
|
expect.objectContaining({
|
|
"m.type": GroupCallType.Video,
|
|
"m.intent": GroupCallIntent.Prompt,
|
|
}),
|
|
groupCall.groupCallId,
|
|
);
|
|
});
|
|
|
|
it("sends member state event to room on enter", async () => {
|
|
await groupCall.create();
|
|
|
|
try {
|
|
await groupCall.enter();
|
|
|
|
expect(mockSendState).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.GroupCallMemberPrefix,
|
|
expect.objectContaining({
|
|
"m.calls": [
|
|
expect.objectContaining({
|
|
"m.call_id": groupCall.groupCallId,
|
|
"m.devices": [
|
|
expect.objectContaining({
|
|
device_id: FAKE_DEVICE_ID_1,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
}),
|
|
FAKE_USER_ID_1,
|
|
{ keepAlive: false },
|
|
);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("sends member state event to room on leave", async () => {
|
|
await groupCall.create();
|
|
await groupCall.enter();
|
|
mockSendState.mockClear();
|
|
|
|
groupCall.leave();
|
|
expect(mockSendState).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.GroupCallMemberPrefix,
|
|
expect.objectContaining({ "m.calls": [] }),
|
|
FAKE_USER_ID_1,
|
|
{ keepAlive: true }, // Request should outlive the window
|
|
);
|
|
});
|
|
|
|
it("includes local device in participants when entered via another session", async () => {
|
|
const hasLocalParticipant = () =>
|
|
groupCall.participants.get(room.getMember(mockClient.getUserId()!)!)?.has(mockClient.getDeviceId()!) ??
|
|
false;
|
|
|
|
expect(groupCall.enteredViaAnotherSession).toBe(false);
|
|
expect(hasLocalParticipant()).toBe(false);
|
|
|
|
groupCall.enteredViaAnotherSession = true;
|
|
expect(hasLocalParticipant()).toBe(true);
|
|
});
|
|
|
|
it("starts with mic unmuted in regular calls", async () => {
|
|
try {
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("disables audio stream when audio is set to muted", async () => {
|
|
try {
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
|
|
await groupCall.setMicrophoneMuted(true);
|
|
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("starts with video unmuted in regular calls", async () => {
|
|
try {
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
|
|
expect(groupCall.isLocalVideoMuted()).toEqual(false);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("disables video stream when video is set to muted", async () => {
|
|
try {
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
|
|
await groupCall.setLocalVideoMuted(true);
|
|
|
|
expect(groupCall.isLocalVideoMuted()).toEqual(true);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("retains state of local user media stream when updated", async () => {
|
|
try {
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
|
|
const oldStream = groupCall.localCallFeed?.stream as unknown as MockMediaStream;
|
|
|
|
// arbitrary values, important part is that they're the same afterwards
|
|
await groupCall.setLocalVideoMuted(true);
|
|
await groupCall.setMicrophoneMuted(false);
|
|
|
|
const newStream = await mockClient.getMediaHandler().getUserMediaStream(true, true);
|
|
|
|
groupCall.updateLocalUsermediaStream(newStream);
|
|
|
|
expect(groupCall.localCallFeed?.stream).toBe(newStream);
|
|
|
|
expect(groupCall.isLocalVideoMuted()).toEqual(true);
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
|
|
expect(oldStream.isStopped).toEqual(true);
|
|
} finally {
|
|
groupCall.leave();
|
|
}
|
|
});
|
|
|
|
it("does not throw when calling updateLocalUsermediaStream() without local usermedia stream", () => {
|
|
expect(async () => await groupCall.updateLocalUsermediaStream({} as MediaStream)).not.toThrow();
|
|
});
|
|
|
|
it.each([GroupCallState.Ended, GroupCallState.Entered, GroupCallState.InitializingLocalCallFeed])(
|
|
"throws when entering call in the wrong state",
|
|
async (state: GroupCallState) => {
|
|
// @ts-ignore Mock
|
|
groupCall.state = state;
|
|
|
|
await expect(groupCall.enter()).rejects.toThrow();
|
|
},
|
|
);
|
|
|
|
describe("hasLocalParticipant()", () => {
|
|
it("should return false, if we don't have a local participant", () => {
|
|
expect(groupCall.hasLocalParticipant()).toBeFalsy();
|
|
});
|
|
|
|
it("should return true, if we do have local participant", async () => {
|
|
await groupCall.enter();
|
|
expect(groupCall.hasLocalParticipant()).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("call feeds changing", () => {
|
|
let call: MockMatrixCall;
|
|
const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current"));
|
|
const newFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("new"));
|
|
|
|
beforeEach(async () => {
|
|
jest.spyOn(currentFeed, "dispose");
|
|
jest.spyOn(newFeed, "measureVolumeActivity");
|
|
|
|
jest.spyOn(groupCall, "emit");
|
|
|
|
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
|
|
await groupCall.create();
|
|
|
|
const deviceCallMap = new Map<string, MatrixCall>();
|
|
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
|
|
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
|
|
});
|
|
|
|
it("ignores changes, if we can't get user id of opponent", async () => {
|
|
const call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
jest.spyOn(call, "getOpponentMember").mockReturnValue({ userId: undefined });
|
|
|
|
// @ts-ignore Mock
|
|
expect(() => groupCall.onCallFeedsChanged(call)).toThrow();
|
|
});
|
|
|
|
describe("usermedia feeds", () => {
|
|
it("adds new usermedia feed", async () => {
|
|
call.remoteUsermediaFeed = newFeed.typed();
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.userMediaFeeds).toStrictEqual([newFeed]);
|
|
});
|
|
|
|
it("replaces usermedia feed", async () => {
|
|
groupCall.userMediaFeeds.push(currentFeed.typed());
|
|
|
|
call.remoteUsermediaFeed = newFeed.typed();
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.userMediaFeeds).toStrictEqual([newFeed]);
|
|
});
|
|
|
|
it("removes usermedia feed", async () => {
|
|
groupCall.userMediaFeeds.push(currentFeed.typed());
|
|
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.userMediaFeeds).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("screenshare feeds", () => {
|
|
it("adds new screenshare feed", async () => {
|
|
call.remoteScreensharingFeed = newFeed.typed();
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.screenshareFeeds).toStrictEqual([newFeed]);
|
|
});
|
|
|
|
it("replaces screenshare feed", async () => {
|
|
groupCall.screenshareFeeds.push(currentFeed.typed());
|
|
|
|
call.remoteScreensharingFeed = newFeed.typed();
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.screenshareFeeds).toStrictEqual([newFeed]);
|
|
});
|
|
|
|
it("removes screenshare feed", async () => {
|
|
groupCall.screenshareFeeds.push(currentFeed.typed());
|
|
|
|
// @ts-ignore Mock
|
|
groupCall.onCallFeedsChanged(call);
|
|
|
|
expect(groupCall.screenshareFeeds).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("feed replacing", () => {
|
|
it("replaces usermedia feed", async () => {
|
|
groupCall.userMediaFeeds.push(currentFeed.typed());
|
|
|
|
// @ts-ignore Mock
|
|
groupCall.replaceUserMediaFeed(currentFeed, newFeed);
|
|
|
|
const newFeeds = [newFeed];
|
|
|
|
expect(groupCall.userMediaFeeds).toStrictEqual(newFeeds);
|
|
expect(currentFeed.dispose).toHaveBeenCalled();
|
|
expect(newFeed.measureVolumeActivity).toHaveBeenCalledWith(true);
|
|
expect(groupCall.emit).toHaveBeenCalledWith(GroupCallEvent.UserMediaFeedsChanged, newFeeds);
|
|
});
|
|
|
|
it("replaces screenshare feed", async () => {
|
|
groupCall.screenshareFeeds.push(currentFeed.typed());
|
|
|
|
// @ts-ignore Mock
|
|
groupCall.replaceScreenshareFeed(currentFeed, newFeed);
|
|
|
|
const newFeeds = [newFeed];
|
|
|
|
expect(groupCall.screenshareFeeds).toStrictEqual(newFeeds);
|
|
expect(currentFeed.dispose).toHaveBeenCalled();
|
|
expect(newFeed.measureVolumeActivity).toHaveBeenCalledWith(true);
|
|
expect(groupCall.emit).toHaveBeenCalledWith(GroupCallEvent.ScreenshareFeedsChanged, newFeeds);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PTT calls", () => {
|
|
beforeEach(async () => {
|
|
// replace groupcall with a PTT one
|
|
groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt);
|
|
|
|
await groupCall.create();
|
|
|
|
await groupCall.initLocalCallFeed();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
|
|
groupCall.leave();
|
|
});
|
|
|
|
it("starts with mic muted in PTT calls", async () => {
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
});
|
|
|
|
it("re-mutes microphone after transmit timeout in PTT mode", async () => {
|
|
jest.useFakeTimers();
|
|
|
|
await groupCall.setMicrophoneMuted(false);
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
|
|
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
});
|
|
|
|
it("timer is cleared when mic muted again in PTT mode", async () => {
|
|
jest.useFakeTimers();
|
|
|
|
await groupCall.setMicrophoneMuted(false);
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
|
|
// 'talk' for half the allowed time
|
|
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime / 2);
|
|
|
|
await groupCall.setMicrophoneMuted(true);
|
|
await groupCall.setMicrophoneMuted(false);
|
|
|
|
// we should still be unmuted after almost the full timeout duration
|
|
// if not, the timer for the original talking session must have fired
|
|
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime - 100);
|
|
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
});
|
|
|
|
it("sends metadata updates before unmuting in PTT mode", async () => {
|
|
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
|
|
// @ts-ignore
|
|
groupCall.calls.set(
|
|
mockCall.getOpponentMember().userId!,
|
|
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
|
|
);
|
|
|
|
let metadataUpdateResolve: () => void;
|
|
const metadataUpdatePromise = new Promise<void>((resolve) => {
|
|
metadataUpdateResolve = resolve;
|
|
});
|
|
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
|
|
|
|
const mutePromise = groupCall.setMicrophoneMuted(false);
|
|
// we should still be muted at this point because the metadata update hasn't sent
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).not.toHaveBeenCalled();
|
|
metadataUpdateResolve!();
|
|
|
|
await mutePromise;
|
|
|
|
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
|
});
|
|
|
|
it("sends metadata updates after muting in PTT mode", async () => {
|
|
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
|
|
// @ts-ignore
|
|
groupCall.calls.set(
|
|
mockCall.getOpponentMember().userId!,
|
|
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
|
|
);
|
|
|
|
// the call starts muted, so unmute to get in the right state to test
|
|
await groupCall.setMicrophoneMuted(false);
|
|
mocked(mockCall.localUsermediaFeed.setAudioVideoMuted).mockReset();
|
|
|
|
let metadataUpdateResolve: () => void;
|
|
const metadataUpdatePromise = new Promise<void>((resolve) => {
|
|
metadataUpdateResolve = resolve;
|
|
});
|
|
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
|
|
|
|
const getUserMediaStreamFlush = Promise.resolve("stream");
|
|
// @ts-ignore
|
|
mockCall.cleint = {
|
|
getMediaHandler: {
|
|
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
|
|
},
|
|
};
|
|
const mutePromise = groupCall.setMicrophoneMuted(true);
|
|
await getUserMediaStreamFlush;
|
|
// we should be muted at this point, before the metadata update has been sent
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
|
|
metadataUpdateResolve!();
|
|
|
|
await mutePromise;
|
|
|
|
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Placing calls", function () {
|
|
let groupCall1: GroupCall;
|
|
let groupCall2: GroupCall;
|
|
let client1: MockCallMatrixClient;
|
|
let client2: MockCallMatrixClient;
|
|
|
|
beforeEach(function () {
|
|
MockRTCPeerConnection.resetInstances();
|
|
|
|
client1 = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
|
|
client2 = new MockCallMatrixClient(FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2);
|
|
|
|
// Inject the state events directly into each client when sent
|
|
const fakeSendStateEvents = (roomId: string, eventType: EventType, content: any, statekey: string) => {
|
|
if (eventType === EventType.GroupCallMemberPrefix) {
|
|
const fakeEvent = {
|
|
getContent: () => content,
|
|
getRoomId: () => roomId,
|
|
getStateKey: () => statekey,
|
|
} as unknown as MatrixEvent;
|
|
|
|
let subMap = client1Room.currentState.events.get(eventType);
|
|
if (!subMap) {
|
|
subMap = new Map<string, MatrixEvent>();
|
|
client1Room.currentState.events.set(eventType, subMap);
|
|
client2Room.currentState.events.set(eventType, subMap);
|
|
}
|
|
// since we cheat & use the same maps for each, we can
|
|
// just add it once.
|
|
subMap.set(statekey, fakeEvent);
|
|
|
|
client1Room.currentState.emit(RoomStateEvent.Update, client1Room.currentState);
|
|
client2Room.currentState.emit(RoomStateEvent.Update, client2Room.currentState);
|
|
}
|
|
return Promise.resolve({ event_id: "foo" });
|
|
};
|
|
|
|
client1.sendStateEvent.mockImplementation(fakeSendStateEvents);
|
|
client2.sendStateEvent.mockImplementation(fakeSendStateEvents);
|
|
|
|
const client1Room = new Room(FAKE_ROOM_ID, client1.typed(), FAKE_USER_ID_1);
|
|
const client2Room = new Room(FAKE_ROOM_ID, client2.typed(), FAKE_USER_ID_2);
|
|
|
|
client1Room.currentState.members[FAKE_USER_ID_1] = client2Room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
client1Room.currentState.members[FAKE_USER_ID_2] = client2Room.currentState.members[FAKE_USER_ID_2] = {
|
|
userId: FAKE_USER_ID_2,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
|
|
groupCall1 = new GroupCall(
|
|
client1.typed(),
|
|
client1Room,
|
|
GroupCallType.Video,
|
|
false,
|
|
GroupCallIntent.Prompt,
|
|
FAKE_CONF_ID,
|
|
);
|
|
|
|
groupCall2 = new GroupCall(
|
|
client2.typed(),
|
|
client2Room,
|
|
GroupCallType.Video,
|
|
false,
|
|
GroupCallIntent.Prompt,
|
|
FAKE_CONF_ID,
|
|
);
|
|
});
|
|
|
|
afterEach(function () {
|
|
groupCall1.leave();
|
|
groupCall2.leave();
|
|
jest.useRealTimers();
|
|
|
|
MockRTCPeerConnection.resetInstances();
|
|
});
|
|
|
|
it("Places a call to a peer", async function () {
|
|
await groupCall1.create();
|
|
|
|
try {
|
|
const toDeviceProm = new Promise<void>((resolve) => {
|
|
client1.sendToDevice.mockImplementation(() => {
|
|
resolve();
|
|
return Promise.resolve({});
|
|
});
|
|
});
|
|
|
|
await Promise.all([groupCall1.enter(), groupCall2.enter()]);
|
|
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
|
|
await toDeviceProm;
|
|
|
|
expect(client1.sendToDevice.mock.calls[0][0]).toBe("m.call.invite");
|
|
|
|
const toDeviceCallContent = client1.sendToDevice.mock.calls[0][1];
|
|
expect(toDeviceCallContent.size).toBe(1);
|
|
expect(toDeviceCallContent.has(FAKE_USER_ID_2)).toBe(true);
|
|
|
|
const toDeviceBobDevices = toDeviceCallContent.get(FAKE_USER_ID_2);
|
|
expect(toDeviceBobDevices?.size).toBe(1);
|
|
expect(toDeviceBobDevices?.has(FAKE_DEVICE_ID_2)).toBe(true);
|
|
|
|
const bobDeviceMessage = toDeviceBobDevices?.get(FAKE_DEVICE_ID_2);
|
|
expect(bobDeviceMessage?.conf_id).toBe(FAKE_CONF_ID);
|
|
} finally {
|
|
await Promise.all([groupCall1.leave(), groupCall2.leave()]);
|
|
}
|
|
});
|
|
|
|
it("Retries calls", async function () {
|
|
jest.useFakeTimers();
|
|
await groupCall1.create();
|
|
|
|
try {
|
|
const toDeviceProm = new Promise<void>((resolve) => {
|
|
client1.sendToDevice.mockImplementation(() => {
|
|
resolve();
|
|
return Promise.resolve({});
|
|
});
|
|
});
|
|
|
|
await Promise.all([groupCall1.enter(), groupCall2.enter()]);
|
|
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
|
|
await toDeviceProm;
|
|
|
|
expect(client1.sendToDevice).toHaveBeenCalled();
|
|
|
|
// @ts-ignore
|
|
const oldCall = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
|
|
oldCall.emit(CallEvent.Hangup, oldCall!);
|
|
|
|
client1.sendToDevice.mockClear();
|
|
|
|
const toDeviceProm2 = new Promise<void>((resolve) => {
|
|
client1.sendToDevice.mockImplementation(() => {
|
|
resolve();
|
|
return Promise.resolve({});
|
|
});
|
|
});
|
|
|
|
jest.advanceTimersByTime(groupCall1.retryCallInterval + 500);
|
|
|
|
// when we placed the call, we could await on enter which waited for the call to
|
|
// be made. We don't have that luxury now, so first have to wait for the call
|
|
// to even be created...
|
|
let newCall: MatrixCall | undefined;
|
|
while (
|
|
// @ts-ignore
|
|
(newCall = groupCall1.calls.get(client2.userId)?.get(client2.deviceId)) === undefined ||
|
|
newCall.peerConn === undefined ||
|
|
newCall.callId == oldCall.callId
|
|
) {
|
|
await flushPromises();
|
|
}
|
|
const mockPc = newCall.peerConn as unknown as MockRTCPeerConnection;
|
|
|
|
// ...then wait for it to be ready to negotiate
|
|
await mockPc.readyToNegotiate;
|
|
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
|
|
// ...and then finally we can wait for the invite to be sent
|
|
await toDeviceProm2;
|
|
|
|
expect(client1.sendToDevice).toHaveBeenCalledWith(EventType.CallInvite, expect.objectContaining({}));
|
|
} finally {
|
|
await Promise.all([groupCall1.leave(), groupCall2.leave()]);
|
|
}
|
|
});
|
|
|
|
it("Updates call mute status correctly on call state change", async function () {
|
|
await groupCall1.create();
|
|
|
|
try {
|
|
const toDeviceProm = new Promise<void>((resolve) => {
|
|
client1.sendToDevice.mockImplementation(() => {
|
|
resolve();
|
|
return Promise.resolve({});
|
|
});
|
|
});
|
|
|
|
await Promise.all([groupCall1.enter(), groupCall2.enter()]);
|
|
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
|
|
await toDeviceProm;
|
|
|
|
groupCall1.setMicrophoneMuted(false);
|
|
groupCall1.setLocalVideoMuted(false);
|
|
|
|
// @ts-ignore
|
|
const call = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
|
|
call.isMicrophoneMuted = jest.fn().mockReturnValue(true);
|
|
call.setMicrophoneMuted = jest.fn();
|
|
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
|
|
call.setLocalVideoMuted = jest.fn();
|
|
|
|
call.emit(CallEvent.State, CallState.Connected, CallState.InviteSent, call);
|
|
|
|
expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false);
|
|
expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false);
|
|
} finally {
|
|
await Promise.all([groupCall1.leave(), groupCall2.leave()]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("muting", () => {
|
|
let mockClient: MatrixClient;
|
|
let room: Room;
|
|
|
|
beforeEach(() => {
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
mockClient = typedMockClient as unknown as MatrixClient;
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
|
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.members[FAKE_USER_ID_2] = {
|
|
userId: FAKE_USER_ID_2,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
});
|
|
|
|
describe("local muting", () => {
|
|
it("should mute local audio when calling setMicrophoneMuted()", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
|
|
groupCall.localCallFeed!.setAudioVideoMuted = jest.fn();
|
|
const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = [];
|
|
const tracksArray: MediaStreamTrack[] = [];
|
|
const sendMetadataUpdateArray: (() => Promise<void>)[] = [];
|
|
groupCall.forEachCall((call) => {
|
|
setAVMutedArray.push((call.localUsermediaFeed!.setAudioVideoMuted = jest.fn()));
|
|
tracksArray.push(...call.localUsermediaStream!.getAudioTracks());
|
|
sendMetadataUpdateArray.push((call.sendMetadataUpdate = jest.fn()));
|
|
});
|
|
|
|
await groupCall.setMicrophoneMuted(true);
|
|
|
|
groupCall.localCallFeed!.stream.getAudioTracks().forEach((track) => expect(track.enabled).toBe(false));
|
|
expect(groupCall.localCallFeed!.setAudioVideoMuted).toHaveBeenCalledWith(true, null);
|
|
setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(true, null));
|
|
tracksArray.forEach((track) => expect(track.enabled).toBe(false));
|
|
sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled());
|
|
|
|
groupCall.terminate();
|
|
});
|
|
|
|
it("should mute local video when calling setLocalVideoMuted()", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
|
|
jest.spyOn(groupCall, "updateLocalUsermediaStream");
|
|
jest.spyOn(groupCall.localCallFeed!, "setAudioVideoMuted");
|
|
|
|
const setAVMutedArray: ((audioMuted: boolean | null, videoMuted: boolean | null) => void)[] = [];
|
|
const tracksArray: MediaStreamTrack[] = [];
|
|
const sendMetadataUpdateArray: (() => Promise<void>)[] = [];
|
|
groupCall.forEachCall((call) => {
|
|
call.localUsermediaFeed!.isVideoMuted = jest.fn().mockReturnValue(true);
|
|
setAVMutedArray.push((call.localUsermediaFeed!.setAudioVideoMuted = jest.fn()));
|
|
tracksArray.push(...call.localUsermediaStream!.getVideoTracks());
|
|
sendMetadataUpdateArray.push((call.sendMetadataUpdate = jest.fn()));
|
|
});
|
|
|
|
await groupCall.setLocalVideoMuted(true);
|
|
|
|
groupCall.localCallFeed!.stream.getVideoTracks().forEach((track) => expect(track.enabled).toBe(false));
|
|
expect(mockClient.getMediaHandler().getUserMediaStream).toHaveBeenCalledWith(true, false);
|
|
expect(groupCall.updateLocalUsermediaStream).toHaveBeenCalled();
|
|
setAVMutedArray.forEach((f) => expect(f).toHaveBeenCalledWith(null, true));
|
|
tracksArray.forEach((track) => expect(track.enabled).toBe(false));
|
|
sendMetadataUpdateArray.forEach((f) => expect(f).toHaveBeenCalled());
|
|
|
|
groupCall.terminate();
|
|
});
|
|
|
|
it("returns false when unmuting audio with no audio device", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
jest.spyOn(mockClient.getMediaHandler(), "hasAudioDevice").mockResolvedValue(false);
|
|
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
|
});
|
|
|
|
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
// @ts-ignore
|
|
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
|
|
new Error("No Permission"),
|
|
);
|
|
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
|
});
|
|
|
|
it("returns false when user media stream null", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
// @ts-ignore
|
|
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
|
// @ts-ignore
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
|
|
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
|
});
|
|
|
|
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
// @ts-ignore
|
|
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
|
|
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
|
|
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
|
|
});
|
|
|
|
it("returns false when unmuting video with no video device", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
|
|
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
|
|
});
|
|
|
|
it("returns false when no permission for video stream", async () => {
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
|
|
new Error("No Permission"),
|
|
);
|
|
expect(await groupCall.setLocalVideoMuted(false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("remote muting", () => {
|
|
const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent =>
|
|
({
|
|
getContent: () => ({
|
|
[SDPStreamMetadataKey]: {
|
|
stream: {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: audio,
|
|
video_muted: video,
|
|
},
|
|
},
|
|
}),
|
|
}) as MatrixEvent;
|
|
|
|
it("should mute remote feed's audio after receiving metadata with video audio", async () => {
|
|
const metadataEvent = getMetadataEvent(true, false);
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
|
|
// It takes a bit of time for the calls to get created
|
|
await sleep(10);
|
|
|
|
// @ts-ignore
|
|
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
|
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
|
// @ts-ignore Mock
|
|
call.pushRemoteFeed(
|
|
// @ts-ignore Mock
|
|
new MockMediaStream("stream", [
|
|
new MockMediaStreamTrack("audio_track", "audio"),
|
|
new MockMediaStreamTrack("video_track", "video"),
|
|
]),
|
|
);
|
|
call.onSDPStreamMetadataChangedReceived(metadataEvent);
|
|
|
|
const feed = groupCall.getUserMediaFeed(call.invitee!, call.getOpponentDeviceId()!);
|
|
expect(feed!.isAudioMuted()).toBe(true);
|
|
expect(feed!.isVideoMuted()).toBe(false);
|
|
|
|
groupCall.terminate();
|
|
});
|
|
|
|
it("should mute remote feed's video after receiving metadata with video muted", async () => {
|
|
const metadataEvent = getMetadataEvent(false, true);
|
|
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
|
|
// It takes a bit of time for the calls to get created
|
|
await sleep(10);
|
|
|
|
// @ts-ignore
|
|
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
|
|
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
|
// @ts-ignore Mock
|
|
call.pushRemoteFeed(
|
|
// @ts-ignore Mock
|
|
new MockMediaStream("stream", [
|
|
new MockMediaStreamTrack("audio_track", "audio"),
|
|
new MockMediaStreamTrack("video_track", "video"),
|
|
]),
|
|
);
|
|
call.onSDPStreamMetadataChangedReceived(metadataEvent);
|
|
|
|
const feed = groupCall.getUserMediaFeed(call.invitee!, call.getOpponentDeviceId()!);
|
|
expect(feed!.isAudioMuted()).toBe(false);
|
|
expect(feed!.isVideoMuted()).toBe(true);
|
|
|
|
groupCall.terminate();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("incoming calls", () => {
|
|
let mockClient: MatrixClient;
|
|
let room: Room;
|
|
let groupCall: GroupCall;
|
|
|
|
beforeEach(async () => {
|
|
// we are bob here because we're testing incoming calls, and since alice's user id
|
|
// is lexicographically before Bob's, the spec requires that she calls Bob.
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2);
|
|
mockClient = typedMockClient as unknown as MatrixClient;
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.members[FAKE_USER_ID_2] = {
|
|
userId: FAKE_USER_ID_2,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
|
|
groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
});
|
|
|
|
afterEach(() => {
|
|
groupCall.leave();
|
|
});
|
|
|
|
it("ignores incoming calls for other rooms", async () => {
|
|
const mockCall = new MockMatrixCall("!someotherroom.fake.dummy", groupCall.groupCallId);
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
expect(mockCall.reject).not.toHaveBeenCalled();
|
|
expect(mockCall.answerWithCallFeeds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects incoming calls for the wrong group call", async () => {
|
|
const mockCall = new MockMatrixCall(room.roomId, "not " + groupCall.groupCallId);
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
expect(mockCall.reject).toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores incoming calls not in the ringing state", async () => {
|
|
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
mockCall.state = CallState.Connected;
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
expect(mockCall.reject).not.toHaveBeenCalled();
|
|
expect(mockCall.answerWithCallFeeds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("answers calls for the right room & group call ID", async () => {
|
|
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
expect(mockCall.reject).not.toHaveBeenCalled();
|
|
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]));
|
|
});
|
|
|
|
it("replaces calls if it already has one with the same user", async () => {
|
|
const oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
const newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
|
|
newMockCall.callId = "not " + oldMockCall.callId;
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall as unknown as MatrixCall);
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, newMockCall as unknown as MatrixCall);
|
|
|
|
expect(oldMockCall.hangup).toHaveBeenCalled();
|
|
expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled();
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]));
|
|
});
|
|
|
|
it("starts to process incoming calls when we've entered", async () => {
|
|
// First we leave the call since we have already entered
|
|
groupCall.leave();
|
|
|
|
const call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
mockClient.callEventHandler!.calls = new Map<string, MatrixCall>([[call.callId, call.typed()]]);
|
|
await groupCall.enter();
|
|
|
|
expect(call.answerWithCallFeeds).toHaveBeenCalled();
|
|
});
|
|
|
|
const aliceEnters = () => {
|
|
room.currentState.getStateEvents = jest.fn().mockImplementation(
|
|
mockGetStateEvents([
|
|
{
|
|
getContent: () => ({
|
|
"m.calls": [
|
|
{
|
|
"m.call_id": groupCall.groupCallId,
|
|
"m.devices": [
|
|
{
|
|
device_id: FAKE_DEVICE_ID_1,
|
|
session_id: FAKE_SESSION_ID_1,
|
|
expires_ts: Date.now() + ONE_HOUR,
|
|
feeds: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
getStateKey: () => FAKE_USER_ID_1,
|
|
getRoomId: () => FAKE_ROOM_ID,
|
|
getTs: () => 0,
|
|
},
|
|
] as unknown as MatrixEvent[]),
|
|
);
|
|
room.currentState.emit(RoomStateEvent.Update, room.currentState);
|
|
};
|
|
|
|
const aliceLeaves = () => {
|
|
room.currentState.getStateEvents = jest
|
|
.fn()
|
|
.mockImplementation(mockGetStateEvents([] as unknown as MatrixEvent[]));
|
|
room.currentState.emit(RoomStateEvent.Update, room.currentState);
|
|
};
|
|
|
|
it("enables tracks on expected calls, then disables them when the participant leaves", async () => {
|
|
aliceEnters();
|
|
|
|
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
// Tracks should be enabled
|
|
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
|
|
|
|
aliceLeaves();
|
|
|
|
// Tracks should be disabled
|
|
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
|
|
});
|
|
|
|
it("disables tracks on unexpected calls, then enables them when the participant joins", async () => {
|
|
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
|
|
|
|
// Tracks should be disabled
|
|
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
|
|
|
|
aliceEnters();
|
|
|
|
// Tracks should be enabled
|
|
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
|
|
});
|
|
|
|
describe("handles call being replaced", () => {
|
|
let callChangedListener: jest.Mock;
|
|
let oldMockCall: MockMatrixCall;
|
|
let newMockCall: MockMatrixCall;
|
|
let newCallsMap: Map<string, Map<string, MatrixCall>>;
|
|
|
|
beforeEach(() => {
|
|
callChangedListener = jest.fn();
|
|
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
|
|
|
|
oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
newCallsMap = new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall.typed()]])]]);
|
|
|
|
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
|
|
newMockCall.callId = "not " + oldMockCall.callId;
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall.typed());
|
|
});
|
|
|
|
it("handles regular case", () => {
|
|
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
|
|
|
|
expect(oldMockCall.hangup).toHaveBeenCalled();
|
|
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(newCallsMap);
|
|
});
|
|
|
|
it("handles case where call is missing from the calls map", () => {
|
|
// @ts-ignore
|
|
groupCall.calls = new Map();
|
|
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
|
|
|
|
expect(oldMockCall.hangup).toHaveBeenCalled();
|
|
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(newCallsMap);
|
|
});
|
|
});
|
|
|
|
describe("handles call being hangup", () => {
|
|
let callChangedListener: jest.Mock;
|
|
let mockCall: MockMatrixCall;
|
|
|
|
beforeEach(() => {
|
|
callChangedListener = jest.fn();
|
|
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
|
|
mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
});
|
|
|
|
it("doesn't throw when calls map is empty", () => {
|
|
// @ts-ignore
|
|
expect(() => groupCall.onCallHangup(mockCall)).not.toThrow();
|
|
});
|
|
|
|
it("clears map completely when we're the last users device left", () => {
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
|
|
mockCall.emit(CallEvent.Hangup, mockCall.typed());
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(new Map());
|
|
});
|
|
|
|
it("doesn't remove another call of the same user", () => {
|
|
const anotherCallOfTheSameUser = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
|
anotherCallOfTheSameUser.callId = "another call id";
|
|
anotherCallOfTheSameUser.getOpponentDeviceId = () => FAKE_DEVICE_ID_2;
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, anotherCallOfTheSameUser.typed());
|
|
|
|
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
|
|
mockCall.emit(CallEvent.Hangup, mockCall.typed());
|
|
// @ts-ignore
|
|
expect(groupCall.calls).toEqual(
|
|
new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_2, anotherCallOfTheSameUser.typed()]])]]),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("screensharing", () => {
|
|
let typedMockClient: MockCallMatrixClient;
|
|
let mockClient: MatrixClient;
|
|
let room: Room;
|
|
let groupCall: GroupCall;
|
|
|
|
beforeEach(async () => {
|
|
typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
mockClient = typedMockClient.typed();
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.members[FAKE_USER_ID_2] = {
|
|
userId: FAKE_USER_ID_2,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
|
|
|
|
groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
});
|
|
|
|
it("sending screensharing stream", async () => {
|
|
const onNegotiationNeededArray: (() => Promise<void>)[] = [];
|
|
groupCall.forEachCall((call) => {
|
|
// @ts-ignore Mock
|
|
onNegotiationNeededArray.push((call.gotLocalOffer = jest.fn()));
|
|
});
|
|
|
|
let enabledResult: boolean;
|
|
enabledResult = await groupCall.setScreensharingEnabled(true);
|
|
expect(enabledResult).toEqual(true);
|
|
expect(typedMockClient.mediaHandler.getScreensharingStream).toHaveBeenCalled();
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
|
|
expect(groupCall.screenshareFeeds).toHaveLength(1);
|
|
groupCall.forEachCall((c) => {
|
|
expect(c.getLocalFeeds().find((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare)).toBeDefined();
|
|
});
|
|
onNegotiationNeededArray.forEach((f) => expect(f).toHaveBeenCalled());
|
|
|
|
// Enabling it again should do nothing
|
|
typedMockClient.mediaHandler.getScreensharingStream.mockClear();
|
|
enabledResult = await groupCall.setScreensharingEnabled(true);
|
|
expect(enabledResult).toEqual(true);
|
|
expect(typedMockClient.mediaHandler.getScreensharingStream).not.toHaveBeenCalled();
|
|
|
|
// Should now be able to disable it
|
|
enabledResult = await groupCall.setScreensharingEnabled(false);
|
|
expect(enabledResult).toEqual(false);
|
|
expect(groupCall.screenshareFeeds).toHaveLength(0);
|
|
|
|
groupCall.terminate();
|
|
});
|
|
|
|
it("receiving screensharing stream", async () => {
|
|
// It takes a bit of time for the calls to get created
|
|
await sleep(10);
|
|
|
|
// @ts-ignore
|
|
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
|
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
|
call.onNegotiateReceived({
|
|
getContent: () => ({
|
|
[SDPStreamMetadataKey]: {
|
|
screensharing_stream: {
|
|
purpose: SDPStreamMetadataPurpose.Screenshare,
|
|
},
|
|
},
|
|
description: {
|
|
type: "offer",
|
|
sdp: "...",
|
|
},
|
|
}),
|
|
} as MatrixEvent);
|
|
// @ts-ignore Mock
|
|
call.pushRemoteFeed(
|
|
// @ts-ignore Mock
|
|
new MockMediaStream("screensharing_stream", [new MockMediaStreamTrack("video_track", "video")]),
|
|
);
|
|
|
|
expect(groupCall.screenshareFeeds).toHaveLength(1);
|
|
expect(groupCall.getScreenshareFeed(call.invitee!, call.getOpponentDeviceId()!)).toBeDefined();
|
|
|
|
groupCall.terminate();
|
|
});
|
|
|
|
it("cleans up screensharing when terminating", async () => {
|
|
// @ts-ignore Mock
|
|
jest.spyOn(groupCall, "removeScreenshareFeed");
|
|
jest.spyOn(mockClient.getMediaHandler(), "stopScreensharingStream");
|
|
|
|
await groupCall.setScreensharingEnabled(true);
|
|
|
|
const screensharingFeed = groupCall.localScreenshareFeed;
|
|
|
|
groupCall.terminate();
|
|
|
|
expect(mockClient.getMediaHandler()!.stopScreensharingStream).toHaveBeenCalledWith(
|
|
screensharingFeed!.stream,
|
|
);
|
|
// @ts-ignore Mock
|
|
expect(groupCall.removeScreenshareFeed).toHaveBeenCalledWith(screensharingFeed);
|
|
expect(groupCall.localScreenshareFeed).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("active speaker events", () => {
|
|
let room: Room;
|
|
let groupCall: GroupCall;
|
|
let mediaFeed1: CallFeed;
|
|
let mediaFeed2: CallFeed;
|
|
let onActiveSpeakerEvent: jest.Mock<void, []>;
|
|
|
|
beforeEach(async () => {
|
|
jest.useFakeTimers();
|
|
|
|
const mockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient.typed(), FAKE_USER_ID_1);
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
} as unknown as RoomMember;
|
|
groupCall = await createAndEnterGroupCall(mockClient.typed(), room);
|
|
|
|
mediaFeed1 = new CallFeed({
|
|
client: mockClient.typed(),
|
|
roomId: FAKE_ROOM_ID,
|
|
userId: FAKE_USER_ID_2,
|
|
deviceId: FAKE_DEVICE_ID_1,
|
|
stream: new MockMediaStream("foo", []).typed(),
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audioMuted: false,
|
|
videoMuted: true,
|
|
});
|
|
groupCall.userMediaFeeds.push(mediaFeed1);
|
|
|
|
mediaFeed2 = new CallFeed({
|
|
client: mockClient.typed(),
|
|
roomId: FAKE_ROOM_ID,
|
|
userId: FAKE_USER_ID_3,
|
|
deviceId: FAKE_DEVICE_ID_1,
|
|
stream: new MockMediaStream("foo", []).typed(),
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audioMuted: false,
|
|
videoMuted: true,
|
|
});
|
|
groupCall.userMediaFeeds.push(mediaFeed2);
|
|
|
|
onActiveSpeakerEvent = jest.fn();
|
|
groupCall.on(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeakerEvent);
|
|
});
|
|
|
|
afterEach(() => {
|
|
groupCall.off(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeakerEvent);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it("fires active speaker events when a user is speaking", async () => {
|
|
mediaFeed1.speakingVolumeSamples = [100, 100];
|
|
mediaFeed2.speakingVolumeSamples = [0, 0];
|
|
|
|
jest.runOnlyPendingTimers();
|
|
expect(groupCall.activeSpeaker).toEqual(mediaFeed1);
|
|
expect(onActiveSpeakerEvent).toHaveBeenCalledWith(mediaFeed1);
|
|
|
|
mediaFeed1.speakingVolumeSamples = [0, 0];
|
|
mediaFeed2.speakingVolumeSamples = [100, 100];
|
|
|
|
jest.runOnlyPendingTimers();
|
|
expect(groupCall.activeSpeaker).toEqual(mediaFeed2);
|
|
expect(onActiveSpeakerEvent).toHaveBeenCalledWith(mediaFeed2);
|
|
});
|
|
});
|
|
|
|
describe("creating group calls", () => {
|
|
let client: MatrixClient;
|
|
|
|
beforeEach(() => {
|
|
client = new MatrixClient({ baseUrl: "base_url", userId: "my_user_id" });
|
|
|
|
jest.spyOn(client, "sendStateEvent").mockResolvedValue({} as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.stopClient();
|
|
});
|
|
|
|
it("throws when there already is a call", async () => {
|
|
jest.spyOn(client, "getRoom").mockReturnValue(new Room("room_id", client, "my_user_id"));
|
|
|
|
await client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt);
|
|
|
|
await expect(
|
|
client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt),
|
|
).rejects.toThrow("room_id already has an existing group call");
|
|
});
|
|
|
|
it("throws if the room doesn't exist", async () => {
|
|
await expect(
|
|
client.createGroupCall("room_id", GroupCallType.Video, false, GroupCallIntent.Prompt),
|
|
).rejects.toThrow("Cannot find room room_id");
|
|
});
|
|
|
|
describe("correctly passes parameters", () => {
|
|
beforeEach(() => {
|
|
jest.spyOn(client, "getRoom").mockReturnValue(new Room("room_id", client, "my_user_id"));
|
|
});
|
|
|
|
it("correctly passes voice ptt room call", async () => {
|
|
const groupCall = await client.createGroupCall(
|
|
"room_id",
|
|
GroupCallType.Voice,
|
|
true,
|
|
GroupCallIntent.Room,
|
|
);
|
|
|
|
expect(groupCall.type).toBe(GroupCallType.Voice);
|
|
expect(groupCall.isPtt).toBe(true);
|
|
expect(groupCall.intent).toBe(GroupCallIntent.Room);
|
|
});
|
|
|
|
it("correctly passes voice ringing call", async () => {
|
|
const groupCall = await client.createGroupCall(
|
|
"room_id",
|
|
GroupCallType.Voice,
|
|
false,
|
|
GroupCallIntent.Ring,
|
|
);
|
|
|
|
expect(groupCall.type).toBe(GroupCallType.Voice);
|
|
expect(groupCall.isPtt).toBe(false);
|
|
expect(groupCall.intent).toBe(GroupCallIntent.Ring);
|
|
});
|
|
|
|
it("correctly passes video prompt call", async () => {
|
|
const groupCall = await client.createGroupCall(
|
|
"room_id",
|
|
GroupCallType.Video,
|
|
false,
|
|
GroupCallIntent.Prompt,
|
|
);
|
|
|
|
expect(groupCall.type).toBe(GroupCallType.Video);
|
|
expect(groupCall.isPtt).toBe(false);
|
|
expect(groupCall.intent).toBe(GroupCallIntent.Prompt);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("cleaning member state", () => {
|
|
const bobWeb: IMyDevice = {
|
|
device_id: "bobweb",
|
|
last_seen_ts: 0,
|
|
};
|
|
const bobDesktop: IMyDevice = {
|
|
device_id: "bobdesktop",
|
|
last_seen_ts: 0,
|
|
};
|
|
const bobDesktopOffline: IMyDevice = {
|
|
device_id: "bobdesktopoffline",
|
|
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
|
|
};
|
|
const bobDesktopNeverOnline: IMyDevice = {
|
|
device_id: "bobdesktopneveronline",
|
|
};
|
|
|
|
const mkContent = (devices: IMyDevice[]) => ({
|
|
"m.calls": [
|
|
{
|
|
"m.call_id": groupCall.groupCallId,
|
|
"m.devices": devices.map((d) => ({
|
|
device_id: d.device_id,
|
|
session_id: "1",
|
|
feeds: [],
|
|
expires_ts: 1000 * 60 * 10,
|
|
})),
|
|
},
|
|
],
|
|
});
|
|
|
|
const expectDevices = (devices: IMyDevice[]) =>
|
|
expect(
|
|
room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)?.getContent(),
|
|
).toEqual({
|
|
"m.calls": [
|
|
{
|
|
"m.call_id": groupCall.groupCallId,
|
|
"m.devices": devices.map((d) => ({
|
|
device_id: d.device_id,
|
|
session_id: "1",
|
|
feeds: [],
|
|
expires_ts: expect.any(Number),
|
|
})),
|
|
},
|
|
],
|
|
});
|
|
|
|
let mockClient: MatrixClient;
|
|
let room: Room;
|
|
let groupCall: GroupCall;
|
|
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(0);
|
|
});
|
|
|
|
afterAll(() => jest.useRealTimers());
|
|
|
|
beforeEach(async () => {
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_2, bobWeb.device_id, FAKE_SESSION_ID_2);
|
|
jest.spyOn(typedMockClient, "sendStateEvent").mockImplementation(
|
|
async (roomId, eventType, content, stateKey) => {
|
|
const eventId = `$${Math.random()}`;
|
|
if (roomId === room.roomId) {
|
|
room.addLiveEvents(
|
|
[
|
|
new MatrixEvent({
|
|
event_id: eventId,
|
|
type: eventType,
|
|
room_id: roomId,
|
|
sender: FAKE_USER_ID_2,
|
|
content,
|
|
state_key: stateKey,
|
|
}),
|
|
],
|
|
{ addToState: true },
|
|
);
|
|
}
|
|
return { event_id: eventId };
|
|
},
|
|
);
|
|
mockClient = typedMockClient as unknown as MatrixClient;
|
|
|
|
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
|
|
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
|
|
|
|
groupCall = new GroupCall(
|
|
mockClient,
|
|
room,
|
|
GroupCallType.Video,
|
|
false,
|
|
GroupCallIntent.Prompt,
|
|
FAKE_CONF_ID,
|
|
);
|
|
await groupCall.create();
|
|
|
|
mockClient.getDevices = async () => ({
|
|
devices: [bobWeb, bobDesktop, bobDesktopOffline, bobDesktopNeverOnline],
|
|
});
|
|
});
|
|
|
|
afterEach(() => groupCall.leave());
|
|
|
|
it("doesn't clean up valid devices", async () => {
|
|
await groupCall.enter();
|
|
await mockClient.sendStateEvent(
|
|
room.roomId,
|
|
EventType.GroupCallMemberPrefix,
|
|
mkContent([bobWeb, bobDesktop]),
|
|
FAKE_USER_ID_2,
|
|
);
|
|
|
|
await groupCall.cleanMemberState();
|
|
expectDevices([bobWeb, bobDesktop]);
|
|
});
|
|
|
|
it("cleans up our own device if we're disconnected", async () => {
|
|
await mockClient.sendStateEvent(
|
|
room.roomId,
|
|
EventType.GroupCallMemberPrefix,
|
|
mkContent([bobWeb, bobDesktop]),
|
|
FAKE_USER_ID_2,
|
|
);
|
|
|
|
await groupCall.cleanMemberState();
|
|
expectDevices([bobDesktop]);
|
|
});
|
|
|
|
it("doesn't clean up the local device if entered via another session", async () => {
|
|
groupCall.enteredViaAnotherSession = true;
|
|
await mockClient.sendStateEvent(
|
|
room.roomId,
|
|
EventType.GroupCallMemberPrefix,
|
|
mkContent([bobWeb]),
|
|
FAKE_USER_ID_2,
|
|
);
|
|
|
|
await groupCall.cleanMemberState();
|
|
expectDevices([bobWeb]);
|
|
});
|
|
|
|
it("cleans up devices that have never been online", async () => {
|
|
await mockClient.sendStateEvent(
|
|
room.roomId,
|
|
EventType.GroupCallMemberPrefix,
|
|
mkContent([bobDesktop, bobDesktopNeverOnline]),
|
|
FAKE_USER_ID_2,
|
|
);
|
|
|
|
await groupCall.cleanMemberState();
|
|
expectDevices([bobDesktop]);
|
|
});
|
|
|
|
it("no-ops if there are no state events", async () => {
|
|
await groupCall.cleanMemberState();
|
|
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe("collection stats", () => {
|
|
let groupCall: GroupCall;
|
|
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(0);
|
|
});
|
|
|
|
afterAll(() => jest.useRealTimers());
|
|
|
|
beforeEach(async () => {
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
const mockClient = typedMockClient.typed();
|
|
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
|
groupCall = new GroupCall(
|
|
mockClient,
|
|
room,
|
|
GroupCallType.Video,
|
|
false,
|
|
GroupCallIntent.Prompt,
|
|
FAKE_CONF_ID,
|
|
);
|
|
});
|
|
it("should be undefined if not get stats", async () => {
|
|
// @ts-ignore
|
|
const stats = groupCall.stats;
|
|
expect(stats).toBeUndefined();
|
|
});
|
|
|
|
it("should be defined after first access", async () => {
|
|
groupCall.getGroupCallStats();
|
|
// @ts-ignore
|
|
const stats = groupCall.stats;
|
|
expect(stats).toBeDefined();
|
|
});
|
|
|
|
it("with every number should do nothing if no stats exists.", async () => {
|
|
groupCall.setGroupCallStatsInterval(0);
|
|
// @ts-ignore
|
|
let stats = groupCall.stats;
|
|
expect(stats).toBeUndefined();
|
|
|
|
groupCall.setGroupCallStatsInterval(10000);
|
|
// @ts-ignore
|
|
stats = groupCall.stats;
|
|
expect(stats).toBeUndefined();
|
|
});
|
|
|
|
it("with number should stop existing stats", async () => {
|
|
const stats = groupCall.getGroupCallStats();
|
|
// @ts-ignore
|
|
const stop = jest.spyOn(stats, "stop");
|
|
// @ts-ignore
|
|
const start = jest.spyOn(stats, "start");
|
|
groupCall.setGroupCallStatsInterval(0);
|
|
|
|
expect(stop).toHaveBeenCalled();
|
|
expect(start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("with number should restart existing stats", async () => {
|
|
const stats = groupCall.getGroupCallStats();
|
|
// @ts-ignore
|
|
const stop = jest.spyOn(stats, "stop");
|
|
// @ts-ignore
|
|
const start = jest.spyOn(stats, "start");
|
|
groupCall.setGroupCallStatsInterval(10000);
|
|
|
|
expect(stop).toHaveBeenCalled();
|
|
expect(start).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("as stats event listener and a CallFeedReport was triggered", () => {
|
|
let groupCall: GroupCall;
|
|
let reportEmitter: StatsReportEmitter;
|
|
const report: CallFeedReport = {} as CallFeedReport;
|
|
beforeEach(async () => {
|
|
CallFeedStatsReporter.expandCallFeedReport = jest.fn().mockReturnValue(report);
|
|
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
|
const mockClient = typedMockClient.typed();
|
|
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
|
room.currentState.members[FAKE_USER_ID_1] = {
|
|
userId: FAKE_USER_ID_1,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.members[FAKE_USER_ID_2] = {
|
|
userId: FAKE_USER_ID_2,
|
|
membership: KnownMembership.Join,
|
|
} as unknown as RoomMember;
|
|
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
|
|
groupCall = await createAndEnterGroupCall(mockClient, room);
|
|
reportEmitter = groupCall.getGroupCallStats().reports;
|
|
});
|
|
|
|
it("should not extends with feed stats if no call exists", async () => {
|
|
const testPromise = new Promise<void>((done) => {
|
|
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
|
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith({}, [], "from-call-feed");
|
|
done();
|
|
});
|
|
});
|
|
const report: CallFeedReport = {} as CallFeedReport;
|
|
reportEmitter.emitCallFeedReport(report);
|
|
await testPromise;
|
|
});
|
|
|
|
it("and a CallFeedReport was triggered then it should extends with local feed", async () => {
|
|
const localCallFeed = {} as CallFeed;
|
|
groupCall.localCallFeed = localCallFeed;
|
|
|
|
const testPromise = new Promise<void>((done) => {
|
|
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
|
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
|
report,
|
|
[localCallFeed],
|
|
"from-local-feed",
|
|
);
|
|
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
|
report,
|
|
[],
|
|
"from-call-feed",
|
|
);
|
|
done();
|
|
});
|
|
});
|
|
const report: CallFeedReport = {} as CallFeedReport;
|
|
reportEmitter.emitCallFeedReport(report);
|
|
await testPromise;
|
|
});
|
|
|
|
it("and a CallFeedReport was triggered then it should extends with remote feed", async () => {
|
|
const localCallFeed = {} as CallFeed;
|
|
groupCall.localCallFeed = localCallFeed;
|
|
// @ts-ignore Suppress error because access to private property
|
|
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
|
report.callId = call.callId;
|
|
const feeds = call.getFeeds();
|
|
const testPromise = new Promise<void>((done) => {
|
|
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
|
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
|
report,
|
|
[localCallFeed],
|
|
"from-local-feed",
|
|
);
|
|
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
|
report,
|
|
feeds,
|
|
"from-call-feed",
|
|
);
|
|
done();
|
|
});
|
|
});
|
|
reportEmitter.emitCallFeedReport(report);
|
|
await testPromise;
|
|
});
|
|
});
|
|
});
|