1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

Test placing a call in a group call (#2593)

* Test placing a call in a group call

Refactors a bit of the call testing stuff

Fixes https://github.com/vector-im/element-call/issues/521

* Unused imports

* Use expect.toHaveBeenCalledWith()

* Types

* More types

* Add comment on mock typing

* Use toHaveBeenCalledWith()

* Initialise groupcall & room in beforeEach

* Initialise mockMediahandler sensibly

* Add type params to mock

* Rename mute tests

* Move comment

* Join / leave in parallel

* Remove leftover expect
This commit is contained in:
David Baker
2022-08-16 18:22:36 +01:00
committed by GitHub
parent 020743141b
commit e8f682f452
3 changed files with 354 additions and 88 deletions

View File

@@ -70,20 +70,41 @@ export class MockAudioContext {
} }
export class MockRTCPeerConnection { export class MockRTCPeerConnection {
private static instances: MockRTCPeerConnection[] = [];
private negotiationNeededListener: () => void;
private needsNegotiation = false;
localDescription: RTCSessionDescription; localDescription: RTCSessionDescription;
public static triggerAllNegotiations() {
for (const inst of this.instances) {
inst.doNegotiation();
}
}
public static resetInstances() {
this.instances = [];
}
constructor() { constructor() {
this.localDescription = { this.localDescription = {
sdp: DUMMY_SDP, sdp: DUMMY_SDP,
type: 'offer', type: 'offer',
toJSON: function() { }, 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 }; } createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
createOffer() { createOffer() {
return Promise.resolve({}); return Promise.resolve({
type: 'offer',
sdp: DUMMY_SDP,
});
} }
setRemoteDescription() { setRemoteDescription() {
return Promise.resolve(); return Promise.resolve();
@@ -93,7 +114,17 @@ export class MockRTCPeerConnection {
} }
close() { } close() { }
getStats() { return []; } 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 { export class MockRTCRtpSender {
@@ -140,6 +171,10 @@ export class MockMediaStream {
this.dispatchEvent("addtrack"); this.dispatchEvent("addtrack");
} }
removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); } removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
clone() {
return new MockMediaStream(this.id, this.tracks);
}
} }
export class MockMediaDeviceInfo { export class MockMediaDeviceInfo {
@@ -149,6 +184,9 @@ export class MockMediaDeviceInfo {
} }
export class MockMediaHandler { export class MockMediaHandler {
public userMediaStreams: MediaStream[] = [];
public screensharingStreams: MediaStream[] = [];
getUserMediaStream(audio: boolean, video: boolean) { getUserMediaStream(audio: boolean, video: boolean) {
const tracks = []; const tracks = [];
if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio")); if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio"));
@@ -160,3 +198,32 @@ export class MockMediaHandler {
hasAudioDevice() { return true; } hasAudioDevice() { return true; }
stopAllStreams() {} 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 = {};
}

View File

@@ -22,9 +22,7 @@ import {
MockMediaHandler, MockMediaHandler,
MockMediaStream, MockMediaStream,
MockMediaStreamTrack, MockMediaStreamTrack,
MockMediaDeviceInfo, installWebRTCMocks,
MockRTCPeerConnection,
MockAudioContext,
} from "../../test-utils/webrtc"; } from "../../test-utils/webrtc";
import { CallFeed } from "../../../src/webrtc/callFeed"; import { CallFeed } from "../../../src/webrtc/callFeed";
@@ -48,29 +46,7 @@ describe('Call', function() {
prevDocument = global.document; prevDocument = global.document;
prevWindow = global.window; prevWindow = global.window;
global.navigator = { installWebRTCMocks();
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;
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); client = new TestClient("@alice:foo", "somedevice", "token", undefined, {});
// We just stub out sendEvent: we're not interested in testing the client's // We just stub out sendEvent: we're not interested in testing the client's

View File

@@ -14,85 +14,308 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { GroupCall } from "../../../src/webrtc/groupCall";
import { MatrixClient } from "../../../src/client"; 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_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() { describe('Group Call', function() {
beforeEach(function() { beforeEach(function() {
// @ts-ignore Mock installWebRTCMocks();
global.AudioContext = MockAudioContext;
}); });
it("sends state event to room when creating", async () => { describe('Basic functionality', function() {
const mockSendState = jest.fn(); let mockSendState: jest.Mock;
let mockClient: MatrixClient;
let room: Room;
let groupCall: GroupCall;
const mockClient = { beforeEach(function() {
sendStateEvent: mockSendState, const typedMockClient = new MockCallMatrixClient(
groupCallEventHandler: { FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1,
groupCalls: new Map(), );
}, mockSendState = typedMockClient.sendStateEvent;
} as unknown as MatrixClient;
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_SELF_USER_ID); mockClient = typedMockClient as unknown as MatrixClient;
const groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt);
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); it("sends state event to room when creating", async () => {
expect(mockSendState.mock.calls[0][1]).toEqual(EventType.GroupCallPrefix); await groupCall.create();
expect(mockSendState.mock.calls[0][2]["m.type"]).toEqual(GroupCallType.Video);
expect(mockSendState.mock.calls[0][2]["m.intent"]).toEqual(GroupCallIntent.Prompt); 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 () => { describe('Placing calls', function() {
const mockSendState = jest.fn(); let groupCall1: GroupCall;
const mockMediaHandler = new MockMediaHandler(); let groupCall2: GroupCall;
let client1: MatrixClient;
let client2: MatrixClient;
const mockClient = { beforeEach(function() {
sendStateEvent: mockSendState, MockRTCPeerConnection.resetInstances();
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;
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_SELF_USER_ID); client1 = new MockCallMatrixClient(
const groupCall = new GroupCall(mockClient, room, GroupCallType.Video, false, GroupCallIntent.Prompt); FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1,
) as unknown as MatrixClient;
room.currentState.members[FAKE_SELF_USER_ID] = { client2 = new MockCallMatrixClient(
userId: FAKE_SELF_USER_ID, FAKE_USER_ID_2, FAKE_DEVICE_ID_2, FAKE_SESSION_ID_2,
} as unknown as RoomMember; ) 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 { let subMap = client1Room.currentState.events.get(eventType);
await groupCall.enter(); 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);
expect(mockSendState.mock.lastCall[0]).toEqual(FAKE_ROOM_ID); groupCall1.onMemberStateChanged(fakeEvent);
expect(mockSendState.mock.lastCall[1]).toEqual(EventType.GroupCallMemberPrefix); groupCall2.onMemberStateChanged(fakeEvent);
expect(mockSendState.mock.lastCall[2]['m.calls'].length).toEqual(1); }
expect(mockSendState.mock.lastCall[2]['m.calls'][0]["m.call_id"]).toEqual(groupCall.groupCallId); return Promise.resolve(null);
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 { const client1Room = new Room(FAKE_ROOM_ID, client1, FAKE_USER_ID_1);
groupCall.leave();
} 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<Promise<{}>, [
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
]>();
const toDeviceProm = new Promise<void>(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()]);
}
});
}); });
}); });