diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index ed124ba7a..d7968caa2 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -70,20 +70,41 @@ export class MockAudioContext { } export class MockRTCPeerConnection { + private static instances: MockRTCPeerConnection[] = []; + + private negotiationNeededListener: () => void; + private needsNegotiation = false; localDescription: RTCSessionDescription; + public static triggerAllNegotiations() { + for (const inst of this.instances) { + inst.doNegotiation(); + } + } + + public static resetInstances() { + this.instances = []; + } + constructor() { this.localDescription = { sdp: DUMMY_SDP, type: 'offer', toJSON: function() { }, }; + + MockRTCPeerConnection.instances.push(this); } - addEventListener() { } + addEventListener(type: string, listener: () => void) { + if (type === 'negotiationneeded') this.negotiationNeededListener = listener; + } createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; } createOffer() { - return Promise.resolve({}); + return Promise.resolve({ + type: 'offer', + sdp: DUMMY_SDP, + }); } setRemoteDescription() { return Promise.resolve(); @@ -93,7 +114,17 @@ export class MockRTCPeerConnection { } close() { } getStats() { return []; } - addTrack(track: MockMediaStreamTrack) { return new MockRTCRtpSender(track); } + addTrack(track: MockMediaStreamTrack) { + this.needsNegotiation = true; + return new MockRTCRtpSender(track); + } + + doNegotiation() { + if (this.needsNegotiation && this.negotiationNeededListener) { + this.needsNegotiation = false; + this.negotiationNeededListener(); + } + } } export class MockRTCRtpSender { @@ -140,6 +171,10 @@ export class MockMediaStream { this.dispatchEvent("addtrack"); } removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); } + + clone() { + return new MockMediaStream(this.id, this.tracks); + } } export class MockMediaDeviceInfo { @@ -149,6 +184,9 @@ export class MockMediaDeviceInfo { } export class MockMediaHandler { + public userMediaStreams: MediaStream[] = []; + public screensharingStreams: MediaStream[] = []; + getUserMediaStream(audio: boolean, video: boolean) { const tracks = []; if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio")); @@ -160,3 +198,32 @@ export class MockMediaHandler { hasAudioDevice() { return true; } stopAllStreams() {} } + +export function installWebRTCMocks() { + global.navigator = { + mediaDevices: { + // @ts-ignore Mock + getUserMedia: () => new MockMediaStream("local_stream"), + // @ts-ignore Mock + enumerateDevices: async () => [new MockMediaDeviceInfo("audio"), new MockMediaDeviceInfo("video")], + }, + }; + + global.window = { + // @ts-ignore Mock + RTCPeerConnection: MockRTCPeerConnection, + // @ts-ignore Mock + RTCSessionDescription: {}, + // @ts-ignore Mock + RTCIceCandidate: {}, + getUserMedia: () => new MockMediaStream("local_stream"), + }; + // @ts-ignore Mock + global.document = {}; + + // @ts-ignore Mock + global.AudioContext = MockAudioContext; + + // @ts-ignore Mock + global.RTCRtpReceiver = {}; +} diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 9fa33e3ec..4f0e177d7 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -22,9 +22,7 @@ import { MockMediaHandler, MockMediaStream, MockMediaStreamTrack, - MockMediaDeviceInfo, - MockRTCPeerConnection, - MockAudioContext, + installWebRTCMocks, } from "../../test-utils/webrtc"; import { CallFeed } from "../../../src/webrtc/callFeed"; @@ -48,29 +46,7 @@ describe('Call', function() { prevDocument = global.document; prevWindow = global.window; - global.navigator = { - mediaDevices: { - // @ts-ignore Mock - getUserMedia: () => new MockMediaStream("local_stream"), - // @ts-ignore Mock - enumerateDevices: async () => [new MockMediaDeviceInfo("audio"), new MockMediaDeviceInfo("video")], - }, - }; - - global.window = { - // @ts-ignore Mock - RTCPeerConnection: MockRTCPeerConnection, - // @ts-ignore Mock - RTCSessionDescription: {}, - // @ts-ignore Mock - RTCIceCandidate: {}, - getUserMedia: () => new MockMediaStream("local_stream"), - }; - // @ts-ignore Mock - global.document = {}; - - // @ts-ignore Mock - global.AudioContext = MockAudioContext; + installWebRTCMocks(); client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); // We just stub out sendEvent: we're not interested in testing the client's diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 36669ea15..4bba4a91d 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -14,85 +14,308 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, GroupCallIntent, GroupCallType, Room, RoomMember } from '../../../src'; +import { EventType, GroupCallIntent, GroupCallType, MatrixEvent, Room, RoomMember } from '../../../src'; import { GroupCall } from "../../../src/webrtc/groupCall"; import { MatrixClient } from "../../../src/client"; -import { MockAudioContext, MockMediaHandler } from '../../test-utils/webrtc'; +import { installWebRTCMocks, MockMediaHandler, MockRTCPeerConnection } from '../../test-utils/webrtc'; +import { ReEmitter } from '../../../src/ReEmitter'; +import { TypedEventEmitter } from '../../../src/models/typed-event-emitter'; +import { MediaHandler } from '../../../src/webrtc/mediaHandler'; -const FAKE_SELF_USER_ID = "@me:test.dummy"; -const FAKE_SELF_DEVICE_ID = "AAAAAA"; -const FAKE_SELF_SESSION_ID = "1"; 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"; + +class MockCallMatrixClient { + public mediaHandler: MediaHandler = new MockMediaHandler() as unknown as MediaHandler; + + constructor(public userId: string, public deviceId: string, public sessionId: string) { + } + + groupCallEventHandler = { + groupCalls: new Map(), + }; + + callEventHandler = { + calls: new Map(), + }; + + sendStateEvent = jest.fn(); + + getMediaHandler() { return this.mediaHandler; } + + getUserId() { return this.userId; } + + getDeviceId() { return this.deviceId; } + getSessionId() { return this.sessionId; } + + emit = jest.fn(); + on = jest.fn(); + removeListener = jest.fn(); + getTurnServers = () => []; + isFallbackICEServerAllowed = () => false; + reEmitter = new ReEmitter(new TypedEventEmitter()); + getUseE2eForGroupCall = () => false; + checkTurnServers = () => null; +} describe('Group Call', function() { beforeEach(function() { - // @ts-ignore Mock - global.AudioContext = MockAudioContext; + installWebRTCMocks(); }); - it("sends state event to room when creating", async () => { - const mockSendState = jest.fn(); + describe('Basic functionality', function() { + let mockSendState: jest.Mock; + let mockClient: MatrixClient; + let room: Room; + let groupCall: GroupCall; - const mockClient = { - sendStateEvent: mockSendState, - groupCallEventHandler: { - groupCalls: new Map(), - }, - } as unknown as MatrixClient; + beforeEach(function() { + const typedMockClient = new MockCallMatrixClient( + FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, + ); + mockSendState = typedMockClient.sendStateEvent; - const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_SELF_USER_ID); - const groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt); + mockClient = typedMockClient as unknown as MatrixClient; - await groupCall.create(); + room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1); + groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt); + }); - expect(mockSendState.mock.calls[0][0]).toEqual(FAKE_ROOM_ID); - expect(mockSendState.mock.calls[0][1]).toEqual(EventType.GroupCallPrefix); - expect(mockSendState.mock.calls[0][2]["m.type"]).toEqual(GroupCallType.Video); - expect(mockSendState.mock.calls[0][2]["m.intent"]).toEqual(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("starts with mic muted in PTT calls", async () => { + try { + // replace groupcall with a PTT one for this test + // we will probably want a dedicated test suite for PTT calls, so when we do, + // this can go in there instead. + groupCall = new GroupCall(mockClient, room, GroupCallType.Video, true, GroupCallIntent.Prompt); + + await groupCall.create(); + + await groupCall.initLocalCallFeed(); + + expect(groupCall.isMicrophoneMuted()).toEqual(true); + } 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("sends member state event to room on enter", async () => { - const mockSendState = jest.fn(); - const mockMediaHandler = new MockMediaHandler(); + describe('Placing calls', function() { + let groupCall1: GroupCall; + let groupCall2: GroupCall; + let client1: MatrixClient; + let client2: MatrixClient; - const mockClient = { - sendStateEvent: mockSendState, - groupCallEventHandler: { - groupCalls: new Map(), - }, - callEventHandler: { - calls: new Map(), - }, - mediaHandler: mockMediaHandler, - getMediaHandler: () => mockMediaHandler, - getUserId: () => FAKE_SELF_USER_ID, - getDeviceId: () => FAKE_SELF_DEVICE_ID, - getSessionId: () => FAKE_SELF_SESSION_ID, - emit: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), - } as unknown as MatrixClient; + beforeEach(function() { + MockRTCPeerConnection.resetInstances(); - const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_SELF_USER_ID); - const groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt); + client1 = new MockCallMatrixClient( + FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1, + ) as unknown as MatrixClient; - room.currentState.members[FAKE_SELF_USER_ID] = { - userId: FAKE_SELF_USER_ID, - } as unknown as RoomMember; + client2 = new MockCallMatrixClient( + FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2, + ) as unknown as MatrixClient; - await groupCall.create(); + client1.sendStateEvent = client2.sendStateEvent = (roomId, eventType, content, statekey) => { + if (eventType === EventType.GroupCallMemberPrefix) { + const fakeEvent = { + getContent: () => content, + getRoomId: () => FAKE_ROOM_ID, + getStateKey: () => statekey, + } as unknown as MatrixEvent; - try { - await groupCall.enter(); + let subMap = client1Room.currentState.events.get(eventType); + if (!subMap) { + subMap = new Map(); + 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); - expect(mockSendState.mock.lastCall[0]).toEqual(FAKE_ROOM_ID); - expect(mockSendState.mock.lastCall[1]).toEqual(EventType.GroupCallMemberPrefix); - expect(mockSendState.mock.lastCall[2]['m.calls'].length).toEqual(1); - expect(mockSendState.mock.lastCall[2]['m.calls'][0]["m.call_id"]).toEqual(groupCall.groupCallId); - expect(mockSendState.mock.lastCall[2]['m.calls'][0]['m.devices'].length).toEqual(1); - expect(mockSendState.mock.lastCall[2]['m.calls'][0]['m.devices'][0].device_id).toEqual(FAKE_SELF_DEVICE_ID); - } finally { - groupCall.leave(); - } + groupCall1.onMemberStateChanged(fakeEvent); + groupCall2.onMemberStateChanged(fakeEvent); + } + return Promise.resolve(null); + }; + + const client1Room = new Room(FAKE_ROOM_ID, client1, FAKE_USER_ID_1); + + const client2Room = new Room(FAKE_ROOM_ID, client2, FAKE_USER_ID_2); + + groupCall1 = new GroupCall( + client1, client1Room, GroupCallType.Video, false, GroupCallIntent.Prompt, FAKE_CONF_ID, + ); + + groupCall2 = new GroupCall( + client2, 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() { + MockRTCPeerConnection.resetInstances(); + }); + + it("Places a call to a peer", async function() { + await groupCall1.create(); + + try { + // keep this as its own variable so we have it typed as a mock + // rather than its type in the client object + const mockSendToDevice = jest.fn, [ + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record } }, + txnId?: string, + ]>(); + + const toDeviceProm = new Promise(resolve => { + mockSendToDevice.mockImplementation(() => { + resolve(); + return Promise.resolve({}); + }); + }); + + client1.sendToDevice = mockSendToDevice; + + await Promise.all([groupCall1.enter(), groupCall2.enter()]); + + MockRTCPeerConnection.triggerAllNegotiations(); + + await toDeviceProm; + + expect(mockSendToDevice.mock.calls[0][0]).toBe("m.call.invite"); + + const toDeviceCallContent = mockSendToDevice.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()]); + } + }); }); });