1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00
Files
matrix-js-sdk/spec/unit/webrtc/groupCall.spec.ts

936 lines
36 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 {
EventType,
GroupCallIntent,
GroupCallType,
ISendEventResponse,
MatrixCall,
MatrixEvent,
Room,
RoomMember,
} from '../../../src';
import { GroupCall } from "../../../src/webrtc/groupCall";
import { MatrixClient } from "../../../src/client";
import {
installWebRTCMocks,
MockMediaHandler,
MockMediaStream,
MockMediaStreamTrack,
MockRTCPeerConnection,
} from '../../test-utils/webrtc';
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
import { sleep } from "../../../src/utils";
import { ReEmitter } from "../../../src/ReEmitter";
import { TypedEventEmitter } from '../../../src/models/typed-event-emitter';
import { MediaHandler } from '../../../src/webrtc/mediaHandler';
import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from '../../../src/webrtc/callEventHandler';
import { CallFeed } from '../../../src/webrtc/callFeed';
import { CallEvent, CallEventHandlerMap, CallState } from '../../../src/webrtc/call';
import { flushPromises } from '../../test-utils/flushPromises';
const FAKE_ROOM_ID = "!fake:test.dummy";
const FAKE_CONF_ID = "fakegroupcallid";
const FAKE_USER_ID_1 = "@alice:test.dummy";
const FAKE_DEVICE_ID_1 = "@AAAAAA";
const FAKE_SESSION_ID_1 = "alice1";
const FAKE_USER_ID_2 = "@bob:test.dummy";
const FAKE_DEVICE_ID_2 = "@BBBBBB";
const FAKE_SESSION_ID_2 = "bob1";
const FAKE_STATE_EVENTS = [
{
getContent: () => ({
["m.expires_ts"]: Date.now() + ONE_HOUR,
}),
getStateKey: () => FAKE_USER_ID_1,
getRoomId: () => FAKE_ROOM_ID,
},
{
getContent: () => ({
["m.expires_ts"]: Date.now() + ONE_HOUR,
["m.calls"]: [{
["m.call_id"]: FAKE_CONF_ID,
["m.devices"]: [{
device_id: FAKE_DEVICE_ID_2,
feeds: [],
}],
}],
}),
getStateKey: () => FAKE_USER_ID_2,
getRoomId: () => FAKE_ROOM_ID,
}, {
getContent: () => ({
["m.expires_ts"]: Date.now() + ONE_HOUR,
["m.calls"]: [{
["m.call_id"]: FAKE_CONF_ID,
["m.devices"]: [{
device_id: "user3_device",
feeds: [],
}],
}],
}),
getStateKey: () => "user3",
getRoomId: () => FAKE_ROOM_ID,
},
];
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;
};
type EmittedEvents = CallEventHandlerEvent | CallEvent;
type EmittedEventMap = CallEventHandlerEventHandlerMap & CallEventHandlerMap;
class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
public mediaHandler = new MockMediaHandler();
constructor(public userId: string, public deviceId: string, public sessionId: string) {
super();
}
groupCallEventHandler = {
groupCalls: new Map<string, GroupCall>(),
};
callEventHandler = {
calls: new Map<string, MatrixCall>(),
};
sendStateEvent = jest.fn<Promise<ISendEventResponse>, [
roomId: string, eventType: EventType, content: any, statekey: string,
]>();
sendToDevice = jest.fn<Promise<{}>, [
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
]>();
getMediaHandler(): MediaHandler { return this.mediaHandler.typed(); }
getUserId(): string { return this.userId; }
getDeviceId(): string { return this.deviceId; }
getSessionId(): string { return this.sessionId; }
getTurnServers = () => [];
isFallbackICEServerAllowed = () => false;
reEmitter = new ReEmitter(new TypedEventEmitter());
getUseE2eForGroupCall = () => false;
checkTurnServers = () => null;
typed(): MatrixClient { return this as unknown as MatrixClient; }
}
class MockCall {
constructor(public roomId: string, public groupCallId: string) {
}
public state = CallState.Ringing;
public opponentUserId = FAKE_USER_ID_1;
public callId = "1";
public localUsermediaFeed = {
setAudioVideoMuted: jest.fn<void, [boolean, boolean]>(),
stream: new MockMediaStream("stream"),
};
public reject = jest.fn<void, []>();
public answerWithCallFeeds = jest.fn<void, [CallFeed[]]>();
public hangup = jest.fn<void, []>();
public sendMetadataUpdate = jest.fn<void, []>();
on = jest.fn();
removeListener = jest.fn();
getOpponentMember() {
return {
userId: this.opponentUserId,
};
}
}
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);
});
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 () => {
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
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,
);
} finally {
groupCall.leave();
}
});
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();
}
});
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);
jest.advanceTimersByTime(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 MockCall(FAKE_ROOM_ID, groupCall.groupCallId);
groupCall.calls.push(mockCall as unknown as MatrixCall);
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 MockCall(FAKE_ROOM_ID, groupCall.groupCallId);
groupCall.calls.push(mockCall as unknown as MatrixCall);
// the call starts muted, so unmute to get in the right state to test
await groupCall.setMicrophoneMuted(false);
mockCall.localUsermediaFeed.setAudioVideoMuted.mockReset();
let metadataUpdateResolve: () => void;
const metadataUpdatePromise = new Promise<void>(resolve => {
metadataUpdateResolve = resolve;
});
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
const mutePromise = groupCall.setMicrophoneMuted(true);
// 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: () => FAKE_ROOM_ID,
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);
groupCall1.onMemberStateChanged(fakeEvent);
groupCall2.onMemberStateChanged(fakeEvent);
}
return Promise.resolve(null);
};
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);
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,
);
client1Room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
client1Room.currentState.members[FAKE_USER_ID_2] = {
userId: FAKE_USER_ID_2,
} as unknown as RoomMember;
client2Room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
} as unknown as RoomMember;
client2Room.currentState.members[FAKE_USER_ID_2] = {
userId: FAKE_USER_ID_2,
} as unknown as RoomMember;
});
afterEach(function() {
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(Object.keys(toDeviceCallContent).length).toBe(1);
expect(Object.keys(toDeviceCallContent)[0]).toBe(FAKE_USER_ID_2);
const toDeviceBobDevices = toDeviceCallContent[FAKE_USER_ID_2];
expect(Object.keys(toDeviceBobDevices).length).toBe(1);
expect(Object.keys(toDeviceBobDevices)[0]).toBe(FAKE_DEVICE_ID_2);
const bobDeviceMessage = toDeviceBobDevices[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();
const oldCall = groupCall1.getCallByUserId(client2.userId);
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;
while (
(newCall = groupCall1.getCallByUserId(client2.userId)) === 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);
const call = groupCall1.getCallByUserId(client2.userId);
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);
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((type: EventType, userId: string) => {
return type === EventType.GroupCallMemberPrefix
? FAKE_STATE_EVENTS.find(e => e.getStateKey() === userId) || FAKE_STATE_EVENTS
: { getContent: () => ([]) };
});
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
});
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 = groupCall.calls.map(call => {
call.localUsermediaFeed.setAudioVideoMuted = jest.fn();
return call.localUsermediaFeed.setAudioVideoMuted;
});
const tracksArray = groupCall.calls.reduce((acc, call) => {
acc.push(...call.localUsermediaStream.getAudioTracks());
return acc;
}, []);
const sendMetadataUpdateArray = groupCall.calls.map(call => {
call.sendMetadataUpdate = jest.fn();
return call.sendMetadataUpdate;
});
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);
groupCall.localCallFeed.setAudioVideoMuted = jest.fn();
const setAVMutedArray = groupCall.calls.map(call => {
call.localUsermediaFeed.setAudioVideoMuted = jest.fn();
return call.localUsermediaFeed.setAudioVideoMuted;
});
const tracksArray = groupCall.calls.reduce((acc, call) => {
acc.push(...call.localUsermediaStream.getVideoTracks());
return acc;
}, []);
const sendMetadataUpdateArray = groupCall.calls.map(call => {
call.sendMetadataUpdate = jest.fn();
return call.sendMetadataUpdate;
});
await groupCall.setLocalVideoMuted(true);
groupCall.localCallFeed.stream.getVideoTracks().forEach(track => expect(track.enabled).toBe(false));
expect(groupCall.localCallFeed.setAudioVideoMuted).toHaveBeenCalledWith(null, true);
setAVMutedArray.forEach(f => expect(f).toHaveBeenCalledWith(null, true));
tracksArray.forEach(track => expect(track.enabled).toBe(false));
sendMetadataUpdateArray.forEach(f => expect(f).toHaveBeenCalled());
groupCall.terminate();
});
});
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);
const call = groupCall.calls[0];
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(new MockMediaStream("stream", [
new MockMediaStreamTrack("audio_track", "audio"),
new MockMediaStreamTrack("video_track", "video"),
]));
call.onSDPStreamMetadataChangedReceived(metadataEvent);
const feed = groupCall.getUserMediaFeedByUserId(call.invitee);
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);
const call = groupCall.calls[0];
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(new MockMediaStream("stream", [
new MockMediaStreamTrack("audio_track", "audio"),
new MockMediaStreamTrack("video_track", "video"),
]));
call.onSDPStreamMetadataChangedReceived(metadataEvent);
const feed = groupCall.getUserMediaFeedByUserId(call.invitee);
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.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
groupCall = await createAndEnterGroupCall(mockClient, room);
});
afterEach(() => {
groupCall.leave();
});
it("ignores incoming calls for other rooms", async () => {
const mockCall = new MockCall("!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 MockCall(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 MockCall(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 MockCall(room.roomId, groupCall.groupCallId);
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
expect(mockCall.reject).not.toHaveBeenCalled();
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
expect(groupCall.calls).toEqual([mockCall]);
});
it("replaces calls if it already has one with the same user", async () => {
const oldMockCall = new MockCall(room.roomId, groupCall.groupCallId);
const newMockCall = new MockCall(room.roomId, groupCall.groupCallId);
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();
expect(groupCall.calls).toEqual([newMockCall]);
});
});
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.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
room.currentState.getStateEvents = jest.fn().mockImplementation((type: EventType, userId: string) => {
return type === EventType.GroupCallMemberPrefix
? FAKE_STATE_EVENTS.find(e => e.getStateKey() === userId) || FAKE_STATE_EVENTS
: { getContent: () => ([]) };
});
groupCall = await createAndEnterGroupCall(mockClient, room);
});
it("sending screensharing stream", async () => {
const onNegotiationNeededArray = groupCall.calls.map(call => {
// @ts-ignore Mock
call.gotLocalOffer = jest.fn();
// @ts-ignore Mock
return call.gotLocalOffer;
});
let enabledResult;
enabledResult = await groupCall.setScreensharingEnabled(true);
expect(enabledResult).toEqual(true);
expect(typedMockClient.mediaHandler.getScreensharingStream).toHaveBeenCalled();
MockRTCPeerConnection.triggerAllNegotiations();
expect(groupCall.screenshareFeeds).toHaveLength(1);
groupCall.calls.forEach(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);
const call = groupCall.calls[0];
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(new MockMediaStream("screensharing_stream", [
new MockMediaStreamTrack("video_track", "video"),
]));
expect(groupCall.screenshareFeeds).toHaveLength(1);
expect(groupCall.getScreenshareFeedByUserId(call.invitee)).toBeDefined();
groupCall.terminate();
});
});
});