diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index fb8107aff..0d3ec6fb7 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -14,6 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { + ClientEvent, + ClientEventHandlerMap, + EventType, + GroupCall, + GroupCallIntent, + GroupCallType, + ISendEventResponse, + MatrixClient, + MatrixEvent, + Room, + RoomStateEvent, + RoomStateEventHandlerMap, +} from "../../src"; +import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; +import { ReEmitter } from "../../src/ReEmitter"; +import { SyncState } from "../../src/sync"; +import { CallEvent, CallEventHandlerMap, MatrixCall } from "../../src/webrtc/call"; +import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "../../src/webrtc/callEventHandler"; +import { GroupCallEventHandlerMap } from "../../src/webrtc/groupCall"; +import { GroupCallEventHandlerEvent } from "../../src/webrtc/groupCallEventHandler"; import { IScreensharingOpts, MediaHandler } from "../../src/webrtc/mediaHandler"; export const DUMMY_SDP = ( @@ -295,6 +316,57 @@ export class MockMediaDevices { typed(): MediaDevices { return this as unknown as MediaDevices; } } +type EmittedEvents = CallEventHandlerEvent | CallEvent | ClientEvent | RoomStateEvent | GroupCallEventHandlerEvent; +type EmittedEventMap = CallEventHandlerEventHandlerMap & + CallEventHandlerMap & + ClientEventHandlerMap & + RoomStateEventHandlerMap & + GroupCallEventHandlerMap; + +export class MockCallMatrixClient extends TypedEventEmitter { + public mediaHandler = new MockMediaHandler(); + + constructor(public userId: string, public deviceId: string, public sessionId: string) { + super(); + } + + groupCallEventHandler = { + groupCalls: new Map(), + }; + + callEventHandler = { + calls: new Map(), + }; + + sendStateEvent = jest.fn, [ + roomId: string, eventType: EventType, content: any, statekey: string, + ]>(); + sendToDevice = jest.fn, [ + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record } }, + 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; + + getSyncState = jest.fn().mockReturnValue(SyncState.Syncing); + + getRooms = jest.fn().mockReturnValue([]); + + typed(): MatrixClient { return this as unknown as MatrixClient; } +} + export function installWebRTCMocks() { global.navigator = { mediaDevices: new MockMediaDevices().typed(), @@ -318,3 +390,26 @@ export function installWebRTCMocks() { // @ts-ignore Mock global.RTCRtpReceiver = {}; } + +export function makeMockGroupCallStateEvent(roomId: string, groupCallId: string): MatrixEvent { + return { + getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix), + getRoomId: jest.fn().mockReturnValue(roomId), + getTs: jest.fn().mockReturnValue(0), + getContent: jest.fn().mockReturnValue({ + "m.type": GroupCallType.Video, + "m.intent": GroupCallIntent.Prompt, + }), + getStateKey: jest.fn().mockReturnValue(groupCallId), + } as unknown as MatrixEvent; +} + +export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: string): MatrixEvent { + return { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getRoomId: jest.fn().mockReturnValue(roomId), + getTs: jest.fn().mockReturnValue(0), + getContent: jest.fn().mockReturnValue({}), + getStateKey: jest.fn().mockReturnValue(groupCallId), + } as unknown as MatrixEvent; +} diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index dd8825e40..5ea4bb420 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -18,7 +18,6 @@ import { EventType, GroupCallIntent, GroupCallType, - ISendEventResponse, MatrixCall, MatrixEvent, Room, @@ -28,19 +27,16 @@ import { GroupCall } from "../../../src/webrtc/groupCall"; import { MatrixClient } from "../../../src/client"; import { installWebRTCMocks, - MockMediaHandler, + MockCallMatrixClient, 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 { CallEventHandlerEvent } from '../../../src/webrtc/callEventHandler'; import { CallFeed } from '../../../src/webrtc/callFeed'; -import { CallEvent, CallEventHandlerMap, CallState } from '../../../src/webrtc/call'; +import { CallEvent, CallState } from '../../../src/webrtc/call'; import { flushPromises } from '../../test-utils/flushPromises'; const FAKE_ROOM_ID = "!fake:test.dummy"; @@ -107,49 +103,6 @@ const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise { - public mediaHandler = new MockMediaHandler(); - - constructor(public userId: string, public deviceId: string, public sessionId: string) { - super(); - } - - groupCallEventHandler = { - groupCalls: new Map(), - }; - - callEventHandler = { - calls: new Map(), - }; - - sendStateEvent = jest.fn, [ - roomId: string, eventType: EventType, content: any, statekey: string, - ]>(); - sendToDevice = jest.fn, [ - eventType: string, - contentMap: { [userId: string]: { [deviceId: string]: Record } }, - 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) { } diff --git a/spec/unit/webrtc/groupCallEventHandler.spec.ts b/spec/unit/webrtc/groupCallEventHandler.spec.ts new file mode 100644 index 000000000..cc9217f8d --- /dev/null +++ b/spec/unit/webrtc/groupCallEventHandler.spec.ts @@ -0,0 +1,162 @@ +/* +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 { ClientEvent, GroupCall, Room, RoomState, RoomStateEvent } from "../../../src"; +import { SyncState } from "../../../src/sync"; +import { GroupCallEventHandler, GroupCallEventHandlerEvent } from "../../../src/webrtc/groupCallEventHandler"; +import { flushPromises } from "../../test-utils/flushPromises"; +import { + makeMockGroupCallMemberStateEvent, + makeMockGroupCallStateEvent, + MockCallMatrixClient, +} from "../../test-utils/webrtc"; + +const FAKE_USER_ID = "@alice:test.dummy"; +const FAKE_DEVICE_ID = "AAAAAAA"; +const FAKE_SESSION_ID = "session1"; +const FAKE_ROOM_ID = "!roomid:test.dummy"; +const FAKE_GROUP_CALL_ID = "fakegroupcallid"; + +describe('Group Call Event Handler', function() { + let groupCallEventHandler: GroupCallEventHandler; + let mockClient: MockCallMatrixClient; + let mockRoom: Room; + + beforeEach(() => { + mockClient = new MockCallMatrixClient( + FAKE_USER_ID, FAKE_DEVICE_ID, FAKE_SESSION_ID, + ); + groupCallEventHandler = new GroupCallEventHandler(mockClient.typed()); + + mockRoom = { + roomId: FAKE_ROOM_ID, + currentState: { + getStateEvents: jest.fn().mockReturnValue([makeMockGroupCallStateEvent( + FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, + )]), + }, + } as unknown as Room; + + (mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom); + }); + + it("waits until client starts syncing", async () => { + mockClient.getSyncState.mockReturnValue(null); + let isStarted = false; + (async () => { + await groupCallEventHandler.start(); + isStarted = true; + })(); + + const setSyncState = async (newState: SyncState) => { + const oldState = mockClient.getSyncState(); + mockClient.getSyncState.mockReturnValue(newState); + mockClient.emit(ClientEvent.Sync, newState, oldState, undefined); + await flushPromises(); + }; + + await flushPromises(); + expect(isStarted).toEqual(false); + + await setSyncState(SyncState.Prepared); + expect(isStarted).toEqual(false); + + await setSyncState(SyncState.Syncing); + expect(isStarted).toEqual(true); + }); + + it("finds existing group calls when started", async () => { + const mockClientEmit = mockClient.emit = jest.fn(); + + mockClient.getRooms.mockReturnValue([mockRoom]); + await groupCallEventHandler.start(); + + expect(mockClientEmit).toHaveBeenCalledWith( + GroupCallEventHandlerEvent.Incoming, + expect.objectContaining({ + groupCallId: FAKE_GROUP_CALL_ID, + }), + ); + + groupCallEventHandler.stop(); + }); + + it("can wait until a room is ready for group calls", async () => { + await groupCallEventHandler.start(); + + const prom = groupCallEventHandler.waitUntilRoomReadyForGroupCalls(FAKE_ROOM_ID); + let resolved = false; + + (async () => { + await prom; + resolved = true; + })(); + + expect(resolved).toEqual(false); + mockClient.emit(ClientEvent.Room, mockRoom); + + await prom; + expect(resolved).toEqual(true); + + groupCallEventHandler.stop(); + }); + + it("fires events for incoming calls", async () => { + const onIncomingGroupCall = jest.fn(); + mockClient.on(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall); + await groupCallEventHandler.start(); + + mockClient.emit( + RoomStateEvent.Events, + makeMockGroupCallStateEvent( + FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, + ), + { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState, + null, + ); + + expect(onIncomingGroupCall).toHaveBeenCalledWith(expect.objectContaining({ + groupCallId: FAKE_GROUP_CALL_ID, + })); + + mockClient.off(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall); + }); + + it("sends member events to group calls", async () => { + await groupCallEventHandler.start(); + + const mockGroupCall = { + onMemberStateChanged: jest.fn(), + }; + + groupCallEventHandler.groupCalls.set(FAKE_ROOM_ID, mockGroupCall as unknown as GroupCall); + + const mockStateEvent = makeMockGroupCallMemberStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID); + + mockClient.emit( + RoomStateEvent.Events, + mockStateEvent, + { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState, + null, + ); + + expect(mockGroupCall.onMemberStateChanged).toHaveBeenCalledWith(mockStateEvent); + }); +}); diff --git a/src/webrtc/groupCallEventHandler.ts b/src/webrtc/groupCallEventHandler.ts index eb4cb7723..ddfc806e3 100644 --- a/src/webrtc/groupCallEventHandler.ts +++ b/src/webrtc/groupCallEventHandler.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { MatrixEvent } from '../models/event'; -import { RoomStateEvent } from '../models/room-state'; import { MatrixClient, ClientEvent } from '../client'; import { GroupCall, @@ -24,7 +23,7 @@ import { IGroupCallDataChannelOptions, } from "./groupCall"; import { Room } from "../models/room"; -import { RoomState } from "../models/room-state"; +import { RoomState, RoomStateEvent } from "../models/room-state"; import { RoomMember } from "../models/room-member"; import { logger } from '../logger'; import { EventType } from "../@types/event";