You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-09 10:22:46 +03:00
* Add types to the call unit test suites Still involves quite a few casts to any unfortunately as it turns out we access quite a few private methods on the Call class in these tests. * Remove commented line & use better expect syntax * Replace more calls.length with toHaveBeenCalled * Remove mistakenly added id field
963 lines
34 KiB
TypeScript
963 lines
34 KiB
TypeScript
/*
|
|
Copyright 2020 - 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 { TestClient } from '../../TestClient';
|
|
import {
|
|
MatrixCall,
|
|
CallErrorCode,
|
|
CallEvent,
|
|
supportsMatrixCall,
|
|
CallType,
|
|
CallState,
|
|
} from '../../../src/webrtc/call';
|
|
import { SDPStreamMetadata, SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes';
|
|
import {
|
|
DUMMY_SDP,
|
|
MockMediaHandler,
|
|
MockMediaStream,
|
|
MockMediaStreamTrack,
|
|
installWebRTCMocks,
|
|
MockRTCPeerConnection,
|
|
SCREENSHARE_STREAM_ID,
|
|
} from "../../test-utils/webrtc";
|
|
import { CallFeed } from "../../../src/webrtc/callFeed";
|
|
import { Callback, EventType, IContent, MatrixEvent, Room } from "../../../src";
|
|
|
|
const FAKE_ROOM_ID = "!foo:bar";
|
|
const CALL_LIFETIME = 60000;
|
|
|
|
const startVoiceCall = async (client: TestClient, call: MatrixCall): Promise<void> => {
|
|
const callPromise = call.placeVoiceCall();
|
|
await client.httpBackend.flush("");
|
|
await callPromise;
|
|
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
};
|
|
|
|
const startVideoCall = async (client: TestClient, call: MatrixCall): Promise<void> => {
|
|
const callPromise = call.placeVideoCall();
|
|
await client.httpBackend.flush("");
|
|
await callPromise;
|
|
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
};
|
|
|
|
const fakeIncomingCall = async (client: TestClient, call: MatrixCall, version: string | number = "1") => {
|
|
const callPromise = call.initWithInvite({
|
|
getContent: jest.fn().mockReturnValue({
|
|
version,
|
|
call_id: "call_id",
|
|
party_id: "remote_party_id",
|
|
lifetime: CALL_LIFETIME,
|
|
offer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}),
|
|
getSender: () => "@test:foo",
|
|
getLocalAge: () => 1,
|
|
} as unknown as MatrixEvent);
|
|
call.getFeeds().push(new CallFeed({
|
|
client: client.client,
|
|
userId: "remote_user_id",
|
|
// @ts-ignore Mock
|
|
stream: new MockMediaStream("remote_stream_id", [new MockMediaStreamTrack("remote_tack_id")]),
|
|
id: "remote_feed_id",
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
}));
|
|
await callPromise;
|
|
};
|
|
|
|
function makeMockEvent(sender: string, content: Record<string, any>): MatrixEvent {
|
|
return {
|
|
getContent: () => {
|
|
return content;
|
|
},
|
|
getSender: () => sender,
|
|
} as MatrixEvent;
|
|
}
|
|
|
|
describe('Call', function() {
|
|
let client: TestClient;
|
|
let call: MatrixCall;
|
|
let prevNavigator: Navigator;
|
|
let prevDocument: Document;
|
|
let prevWindow: Window & typeof globalThis;
|
|
// We retain a reference to this in the correct Mock type
|
|
let mockSendEvent: jest.Mock<void, [string, string, IContent, string, Callback<any>]>;
|
|
|
|
beforeEach(function() {
|
|
prevNavigator = global.navigator;
|
|
prevDocument = global.document;
|
|
prevWindow = global.window;
|
|
|
|
installWebRTCMocks();
|
|
|
|
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {});
|
|
// We just stub out sendEvent: we're not interested in testing the client's
|
|
// event sending code here
|
|
client.client.sendEvent = mockSendEvent = jest.fn();
|
|
{
|
|
// in which we do naughty assignments to private members
|
|
const untypedClient = (client.client as any);
|
|
untypedClient.mediaHandler = new MockMediaHandler;
|
|
untypedClient.turnServersExpiry = Date.now() + 60 * 60 * 1000;
|
|
}
|
|
|
|
client.httpBackend.when("GET", "/voip/turnServer").respond(200, {});
|
|
client.client.getRoom = () => {
|
|
return {
|
|
getMember: () => {
|
|
return {};
|
|
},
|
|
} as unknown as Room;
|
|
};
|
|
|
|
call = new MatrixCall({
|
|
client: client.client,
|
|
roomId: FAKE_ROOM_ID,
|
|
});
|
|
// call checks one of these is wired up
|
|
call.on(CallEvent.Error, () => {});
|
|
});
|
|
|
|
afterEach(function() {
|
|
// Hangup to stop timers
|
|
call.hangup(CallErrorCode.UserHangup, true);
|
|
|
|
client.stop();
|
|
global.navigator = prevNavigator;
|
|
global.window = prevWindow;
|
|
global.document = prevDocument;
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('should ignore candidate events from non-matching party ID', async function() {
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'the_correct_party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}));
|
|
|
|
const mockAddIceCandidate = call.peerConn.addIceCandidate = jest.fn();
|
|
call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'the_correct_party_id',
|
|
candidates: [
|
|
{
|
|
candidate: '',
|
|
sdpMid: '',
|
|
},
|
|
],
|
|
}));
|
|
expect(mockAddIceCandidate).toHaveBeenCalled();
|
|
|
|
call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'some_other_party_id',
|
|
candidates: [
|
|
{
|
|
candidate: '',
|
|
sdpMid: '',
|
|
},
|
|
],
|
|
}));
|
|
expect(mockAddIceCandidate).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add candidates received before answer if party ID is correct', async function() {
|
|
await startVoiceCall(client, call);
|
|
const mockAddIceCandidate = call.peerConn.addIceCandidate = jest.fn();
|
|
|
|
call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'the_correct_party_id',
|
|
candidates: [
|
|
{
|
|
candidate: 'the_correct_candidate',
|
|
sdpMid: '',
|
|
},
|
|
],
|
|
}));
|
|
|
|
call.onRemoteIceCandidatesReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'some_other_party_id',
|
|
candidates: [
|
|
{
|
|
candidate: 'the_wrong_candidate',
|
|
sdpMid: '',
|
|
},
|
|
],
|
|
}));
|
|
|
|
expect(mockAddIceCandidate).not.toHaveBeenCalled();
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'the_correct_party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}));
|
|
|
|
expect(mockAddIceCandidate).toHaveBeenCalled();
|
|
expect(mockAddIceCandidate).toHaveBeenCalledWith({
|
|
candidate: 'the_correct_candidate',
|
|
sdpMid: '',
|
|
});
|
|
});
|
|
|
|
it('should map asserted identity messages to remoteAssertedIdentity', async function() {
|
|
await startVoiceCall(client, call);
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}));
|
|
|
|
const identChangedCallback = jest.fn();
|
|
call.on(CallEvent.AssertedIdentityChanged, identChangedCallback);
|
|
|
|
await call.onAssertedIdentityReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
asserted_identity: {
|
|
id: "@steve:example.com",
|
|
display_name: "Steve Gibbons",
|
|
},
|
|
}));
|
|
|
|
expect(identChangedCallback).toHaveBeenCalled();
|
|
|
|
const ident = call.getRemoteAssertedIdentity();
|
|
expect(ident.id).toEqual("@steve:example.com");
|
|
expect(ident.displayName).toEqual("Steve Gibbons");
|
|
});
|
|
|
|
it("should map SDPStreamMetadata to feeds", async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
[SDPStreamMetadataKey]: {
|
|
"remote_stream": {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: true,
|
|
video_muted: false,
|
|
},
|
|
},
|
|
}));
|
|
|
|
(call as any).pushRemoteFeed(
|
|
new MockMediaStream(
|
|
"remote_stream",
|
|
[
|
|
new MockMediaStreamTrack("remote_audio_track", "audio"),
|
|
new MockMediaStreamTrack("remote_video_track", "video"),
|
|
],
|
|
),
|
|
);
|
|
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream");
|
|
expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Usermedia);
|
|
expect(feed?.isAudioMuted()).toBeTruthy();
|
|
expect(feed?.isVideoMuted()).not.toBeTruthy();
|
|
});
|
|
|
|
it("should fallback to replaceTrack() if the other side doesn't support SPDStreamMetadata", async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}));
|
|
|
|
const mockScreenshareNoMetadata = (call as any).setScreensharingEnabledWithoutMetadataSupport = jest.fn();
|
|
|
|
call.setScreensharingEnabled(true);
|
|
expect(mockScreenshareNoMetadata).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should fallback to answering with no video", async () => {
|
|
await client.httpBackend.flush("");
|
|
|
|
(call as any).shouldAnswerWithMediaType = (wantedValue: boolean) => wantedValue;
|
|
client.client.getMediaHandler().getUserMediaStream = jest.fn().mockRejectedValue("reject");
|
|
|
|
await call.answer(true, true);
|
|
|
|
expect(client.client.getMediaHandler().getUserMediaStream).toHaveBeenNthCalledWith(1, true, true);
|
|
expect(client.client.getMediaHandler().getUserMediaStream).toHaveBeenNthCalledWith(2, true, false);
|
|
});
|
|
|
|
it("should handle mid-call device changes", async () => {
|
|
client.client.getMediaHandler().getUserMediaStream = jest.fn().mockReturnValue(
|
|
new MockMediaStream(
|
|
"stream", [
|
|
new MockMediaStreamTrack("audio_track", "audio"),
|
|
new MockMediaStreamTrack("video_track", "video"),
|
|
],
|
|
),
|
|
);
|
|
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
}));
|
|
|
|
await call.updateLocalUsermediaStream(
|
|
new MockMediaStream(
|
|
"replacement_stream",
|
|
[
|
|
new MockMediaStreamTrack("new_audio_track", "audio"),
|
|
new MockMediaStreamTrack("video_track", "video"),
|
|
],
|
|
).typed(),
|
|
);
|
|
|
|
const usermediaSenders: Array<RTCRtpSender> = (call as any).usermediaSenders;
|
|
|
|
expect(call.localUsermediaStream.id).toBe("stream");
|
|
expect(call.localUsermediaStream.getAudioTracks()[0].id).toBe("new_audio_track");
|
|
expect(call.localUsermediaStream.getVideoTracks()[0].id).toBe("video_track");
|
|
expect(usermediaSenders.find((sender) => {
|
|
return sender?.track?.kind === "audio";
|
|
}).track.id).toBe("new_audio_track");
|
|
expect(usermediaSenders.find((sender) => {
|
|
return sender?.track?.kind === "video";
|
|
}).track.id).toBe("video_track");
|
|
});
|
|
|
|
it("should handle upgrade to video call", async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
[SDPStreamMetadataKey]: {},
|
|
}));
|
|
|
|
// XXX Should probably test using the public interfaces, ie.
|
|
// setLocalVideoMuted probably?
|
|
await (call as any).upgradeCall(false, true);
|
|
|
|
const usermediaSenders: Array<RTCRtpSender> = (call as any).usermediaSenders;
|
|
|
|
expect(call.localUsermediaStream.getAudioTracks()[0].id).toBe("audio_track");
|
|
expect(call.localUsermediaStream.getVideoTracks()[0].id).toBe("video_track");
|
|
expect(usermediaSenders.find((sender) => {
|
|
return sender?.track?.kind === "audio";
|
|
}).track.id).toBe("audio_track");
|
|
expect(usermediaSenders.find((sender) => {
|
|
return sender?.track?.kind === "video";
|
|
}).track.id).toBe("video_track");
|
|
});
|
|
|
|
it("should handle SDPStreamMetadata changes", async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
(call as any).updateRemoteSDPStreamMetadata({
|
|
"remote_stream": {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: false,
|
|
video_muted: false,
|
|
},
|
|
});
|
|
(call as any).pushRemoteFeed(new MockMediaStream("remote_stream", []));
|
|
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream");
|
|
|
|
call.onSDPStreamMetadataChangedReceived(makeMockEvent("@test:foo", {
|
|
[SDPStreamMetadataKey]: {
|
|
"remote_stream": {
|
|
purpose: SDPStreamMetadataPurpose.Screenshare,
|
|
audio_muted: true,
|
|
video_muted: true,
|
|
id: "feed_id2",
|
|
},
|
|
},
|
|
}));
|
|
|
|
expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Screenshare);
|
|
expect(feed?.isAudioMuted()).toBe(true);
|
|
expect(feed?.isVideoMuted()).toBe(true);
|
|
});
|
|
|
|
it("should choose opponent member", async () => {
|
|
const callPromise = call.placeVoiceCall();
|
|
await client.httpBackend.flush("");
|
|
await callPromise;
|
|
|
|
const opponentMember = {
|
|
roomId: call.roomId,
|
|
userId: "opponentUserId",
|
|
};
|
|
|
|
client.client.getRoom = () => {
|
|
return {
|
|
getMember: (userId) => {
|
|
if (userId === opponentMember.userId) {
|
|
return opponentMember;
|
|
}
|
|
},
|
|
} as unknown as Room;
|
|
};
|
|
|
|
const opponentCaps = {
|
|
"m.call.transferee": true,
|
|
"m.call.dtmf": false,
|
|
};
|
|
(call as any).chooseOpponent(makeMockEvent(opponentMember.userId, {
|
|
version: 1,
|
|
party_id: "party_id",
|
|
capabilities: opponentCaps,
|
|
}));
|
|
|
|
expect(call.getOpponentMember()).toBe(opponentMember);
|
|
expect((call as any).opponentPartyId).toBe("party_id");
|
|
expect((call as any).opponentCaps).toBe(opponentCaps);
|
|
expect(call.opponentCanBeTransferred()).toBe(true);
|
|
expect(call.opponentSupportsDTMF()).toBe(false);
|
|
});
|
|
|
|
describe("should deduce the call type correctly", () => {
|
|
it("if no video", async () => {
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
|
|
(call as any).pushRemoteFeed(new MockMediaStream("remote_stream1", []));
|
|
expect(call.type).toBe(CallType.Voice);
|
|
});
|
|
|
|
it("if remote video", async () => {
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
|
|
(call as any).pushRemoteFeed(
|
|
new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]),
|
|
);
|
|
expect(call.type).toBe(CallType.Video);
|
|
});
|
|
|
|
it("if local video", async () => {
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
|
|
(call as any).pushNewLocalFeed(
|
|
new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]),
|
|
SDPStreamMetadataPurpose.Usermedia,
|
|
false,
|
|
);
|
|
expect(call.type).toBe(CallType.Video);
|
|
});
|
|
});
|
|
|
|
it("should correctly generate local SDPStreamMetadata", async () => {
|
|
const callPromise = call.placeCallWithCallFeeds([new CallFeed({
|
|
client: client.client,
|
|
// @ts-ignore Mock
|
|
stream: new MockMediaStream("local_stream1", [new MockMediaStreamTrack("track_id", "audio")]),
|
|
roomId: call.roomId,
|
|
userId: client.getUserId(),
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audioMuted: false,
|
|
videoMuted: false,
|
|
})]);
|
|
await client.httpBackend.flush("");
|
|
await callPromise;
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
|
|
(call as any).pushNewLocalFeed(
|
|
new MockMediaStream("local_stream2", [new MockMediaStreamTrack("track_id", "video")]),
|
|
SDPStreamMetadataPurpose.Screenshare, "feed_id2",
|
|
);
|
|
await call.setMicrophoneMuted(true);
|
|
|
|
expect((call as any).getLocalSDPStreamMetadata()).toStrictEqual({
|
|
"local_stream1": {
|
|
"purpose": SDPStreamMetadataPurpose.Usermedia,
|
|
"audio_muted": true,
|
|
"video_muted": true,
|
|
},
|
|
"local_stream2": {
|
|
"purpose": SDPStreamMetadataPurpose.Screenshare,
|
|
"audio_muted": true,
|
|
"video_muted": false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("feed and stream getters return correctly", async () => {
|
|
const localUsermediaStream = new MockMediaStream("local_usermedia_stream_id", []);
|
|
const localScreensharingStream = new MockMediaStream("local_screensharing_stream_id", []);
|
|
const remoteUsermediaStream = new MockMediaStream("remote_usermedia_stream_id", []);
|
|
const remoteScreensharingStream = new MockMediaStream("remote_screensharing_stream_id", []);
|
|
|
|
const callPromise = call.placeCallWithCallFeeds([
|
|
new CallFeed({
|
|
client: client.client,
|
|
userId: client.getUserId(),
|
|
// @ts-ignore Mock
|
|
stream: localUsermediaStream,
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
id: "local_usermedia_feed_id",
|
|
audioMuted: false,
|
|
videoMuted: false,
|
|
}),
|
|
new CallFeed({
|
|
client: client.client,
|
|
userId: client.getUserId(),
|
|
// @ts-ignore Mock
|
|
stream: localScreensharingStream,
|
|
purpose: SDPStreamMetadataPurpose.Screenshare,
|
|
id: "local_screensharing_feed_id",
|
|
audioMuted: false,
|
|
videoMuted: false,
|
|
}),
|
|
]);
|
|
await client.httpBackend.flush("");
|
|
await callPromise;
|
|
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
|
|
|
|
(call as any).updateRemoteSDPStreamMetadata({
|
|
"remote_usermedia_stream_id": {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: false,
|
|
video_muted: false,
|
|
},
|
|
"remote_screensharing_stream_id": {
|
|
purpose: SDPStreamMetadataPurpose.Screenshare,
|
|
id: "remote_screensharing_feed_id",
|
|
audio_muted: false,
|
|
video_muted: false,
|
|
},
|
|
});
|
|
(call as any).pushRemoteFeed(remoteUsermediaStream);
|
|
(call as any).pushRemoteFeed(remoteScreensharingStream);
|
|
|
|
expect(call.localUsermediaFeed.stream).toBe(localUsermediaStream);
|
|
expect(call.localUsermediaStream).toBe(localUsermediaStream);
|
|
expect(call.localScreensharingFeed.stream).toBe(localScreensharingStream);
|
|
expect(call.localScreensharingStream).toBe(localScreensharingStream);
|
|
expect(call.remoteUsermediaFeed.stream).toBe(remoteUsermediaStream);
|
|
expect(call.remoteUsermediaStream).toBe(remoteUsermediaStream);
|
|
expect(call.remoteScreensharingFeed.stream).toBe(remoteScreensharingStream);
|
|
expect(call.remoteScreensharingStream).toBe(remoteScreensharingStream);
|
|
expect(call.hasRemoteUserMediaAudioTrack).toBe(false);
|
|
});
|
|
|
|
it("should end call after receiving a select event with a different party id", async () => {
|
|
await fakeIncomingCall(client, call);
|
|
|
|
const callHangupCallback = jest.fn();
|
|
call.on(CallEvent.Hangup, callHangupCallback);
|
|
|
|
await call.onSelectAnswerReceived(makeMockEvent("@test:foo.bar", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
selected_party_id: "different_party_id",
|
|
}));
|
|
|
|
expect(callHangupCallback).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("turn servers", () => {
|
|
it("should fallback if allowed", async () => {
|
|
client.client.isFallbackICEServerAllowed = () => true;
|
|
const localCall = new MatrixCall({
|
|
client: client.client,
|
|
roomId: '!room_id',
|
|
});
|
|
|
|
expect((localCall as any).turnServers).toStrictEqual([{ urls: ["stun:turn.matrix.org"] }]);
|
|
});
|
|
|
|
it("should not fallback if not allowed", async () => {
|
|
client.client.isFallbackICEServerAllowed = () => false;
|
|
const localCall = new MatrixCall({
|
|
client: client.client,
|
|
roomId: '!room_id',
|
|
});
|
|
|
|
expect((localCall as any).turnServers).toStrictEqual([]);
|
|
});
|
|
|
|
it("should not fallback if we supplied turn servers", async () => {
|
|
client.client.isFallbackICEServerAllowed = () => true;
|
|
const turnServers = [{ urls: ["turn.server.org"] }];
|
|
const localCall = new MatrixCall({
|
|
client: client.client,
|
|
roomId: '!room_id',
|
|
turnServers,
|
|
});
|
|
|
|
expect((localCall as any).turnServers).toStrictEqual(turnServers);
|
|
});
|
|
});
|
|
|
|
it("should handle creating a data channel", async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
const dataChannelCallback = jest.fn();
|
|
call.on(CallEvent.DataChannel, dataChannelCallback);
|
|
|
|
const dataChannel = call.createDataChannel("data_channel_label", { id: 123 });
|
|
|
|
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel);
|
|
expect(dataChannel.label).toBe("data_channel_label");
|
|
expect(dataChannel.id).toBe(123);
|
|
});
|
|
|
|
describe("supportsMatrixCall", () => {
|
|
it("should return true when the environment is right", () => {
|
|
expect(supportsMatrixCall()).toBe(true);
|
|
});
|
|
|
|
it("should return false if window or document are undefined", () => {
|
|
global.window = undefined;
|
|
expect(supportsMatrixCall()).toBe(false);
|
|
global.window = prevWindow;
|
|
global.document = undefined;
|
|
expect(supportsMatrixCall()).toBe(false);
|
|
});
|
|
|
|
it("should return false if RTCPeerConnection throws", () => {
|
|
// @ts-ignore - writing to window as we are simulating browser edge-cases
|
|
global.window = {};
|
|
Object.defineProperty(global.window, "RTCPeerConnection", {
|
|
get: () => {
|
|
throw Error("Secure mode, naaah!");
|
|
},
|
|
});
|
|
expect(supportsMatrixCall()).toBe(false);
|
|
});
|
|
|
|
it("should return false if RTCPeerConnection & RTCSessionDescription " +
|
|
"& RTCIceCandidate & mediaDevices are unavailable",
|
|
() => {
|
|
global.window.RTCPeerConnection = undefined;
|
|
global.window.RTCSessionDescription = undefined;
|
|
global.window.RTCIceCandidate = undefined;
|
|
// @ts-ignore - writing to a read-only property as we are simulating faulty browsers
|
|
global.navigator.mediaDevices = undefined;
|
|
expect(supportsMatrixCall()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ignoring streams with ids for which we already have a feed", () => {
|
|
const STREAM_ID = "stream_id";
|
|
let FEEDS_CHANGED_CALLBACK: jest.Mock<void, []>;
|
|
|
|
beforeEach(async () => {
|
|
FEEDS_CHANGED_CALLBACK = jest.fn();
|
|
|
|
await startVoiceCall(client, call);
|
|
call.on(CallEvent.FeedsChanged, FEEDS_CHANGED_CALLBACK);
|
|
jest.spyOn(call, "pushLocalFeed");
|
|
});
|
|
|
|
afterEach(() => {
|
|
call.off(CallEvent.FeedsChanged, FEEDS_CHANGED_CALLBACK);
|
|
});
|
|
|
|
it("should ignore stream passed to pushRemoteFeed()", async () => {
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
version: 1,
|
|
call_id: call.callId,
|
|
party_id: 'party_id',
|
|
answer: {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
[SDPStreamMetadataKey]: {
|
|
[STREAM_ID]: {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
},
|
|
},
|
|
}));
|
|
|
|
(call as any).pushRemoteFeed(new MockMediaStream(STREAM_ID));
|
|
(call as any).pushRemoteFeed(new MockMediaStream(STREAM_ID));
|
|
|
|
expect(call.getRemoteFeeds().length).toBe(1);
|
|
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should ignore stream passed to pushRemoteFeedWithoutMetadata()", async () => {
|
|
(call as any).pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
|
|
(call as any).pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
|
|
|
|
expect(call.getRemoteFeeds().length).toBe(1);
|
|
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should ignore stream passed to pushNewLocalFeed()", async () => {
|
|
(call as any).pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
|
|
(call as any).pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
|
|
|
|
// We already have one local feed from placeVoiceCall()
|
|
expect(call.getLocalFeeds().length).toBe(2);
|
|
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
|
expect(call.pushLocalFeed).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("muting", () => {
|
|
let mockSendVoipEvent: jest.Mock<Promise<void>, [string, object]>;
|
|
beforeEach(async () => {
|
|
(call as any).sendVoipEvent = mockSendVoipEvent = jest.fn();
|
|
await startVideoCall(client, call);
|
|
});
|
|
|
|
describe("sending sdp_stream_metadata_changed events", () => {
|
|
it("should send sdp_stream_metadata_changed when muting audio", async () => {
|
|
await call.setMicrophoneMuted(true);
|
|
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
|
|
[SDPStreamMetadataKey]: {
|
|
mock_stream_from_media_handler: {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: true,
|
|
video_muted: false,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should send sdp_stream_metadata_changed when muting video", async () => {
|
|
await call.setLocalVideoMuted(true);
|
|
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
|
|
[SDPStreamMetadataKey]: {
|
|
mock_stream_from_media_handler: {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: false,
|
|
video_muted: true,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("receiving sdp_stream_metadata_changed events", () => {
|
|
const setupCall = (audio: boolean, video: boolean): SDPStreamMetadata => {
|
|
const metadata = {
|
|
stream: {
|
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
audio_muted: audio,
|
|
video_muted: video,
|
|
},
|
|
};
|
|
(call as any).pushRemoteFeed(new MockMediaStream("stream", [
|
|
new MockMediaStreamTrack("track1", "audio"),
|
|
new MockMediaStreamTrack("track1", "video"),
|
|
]));
|
|
call.onSDPStreamMetadataChangedReceived({
|
|
getContent: () => ({
|
|
[SDPStreamMetadataKey]: metadata,
|
|
}),
|
|
} as MatrixEvent);
|
|
return metadata;
|
|
};
|
|
|
|
it("should handle incoming sdp_stream_metadata_changed with audio muted", async () => {
|
|
const metadata = setupCall(true, false);
|
|
expect((call as any).remoteSDPStreamMetadata).toStrictEqual(metadata);
|
|
expect(call.getRemoteFeeds()[0].isAudioMuted()).toBe(true);
|
|
expect(call.getRemoteFeeds()[0].isVideoMuted()).toBe(false);
|
|
});
|
|
|
|
it("should handle incoming sdp_stream_metadata_changed with video muted", async () => {
|
|
const metadata = setupCall(false, true);
|
|
expect((call as any).remoteSDPStreamMetadata).toStrictEqual(metadata);
|
|
expect(call.getRemoteFeeds()[0].isAudioMuted()).toBe(false);
|
|
expect(call.getRemoteFeeds()[0].isVideoMuted()).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("rejecting calls", () => {
|
|
it("sends hangup event when rejecting v0 calls", async () => {
|
|
await fakeIncomingCall(client, call, 0);
|
|
|
|
call.reject();
|
|
|
|
expect(client.client.sendEvent).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.CallHangup,
|
|
expect.objectContaining({
|
|
call_id: call.callId,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sends reject event when rejecting v1 calls", async () => {
|
|
await fakeIncomingCall(client, call, "1");
|
|
|
|
call.reject();
|
|
|
|
expect(client.client.sendEvent).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.CallReject,
|
|
expect.objectContaining({
|
|
call_id: call.callId,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not reject a call that has already been answered", async () => {
|
|
await fakeIncomingCall(client, call, "1");
|
|
|
|
await call.answer();
|
|
|
|
mockSendEvent.mockReset();
|
|
|
|
let caught = false;
|
|
try {
|
|
call.reject();
|
|
} catch (e) {
|
|
caught = true;
|
|
}
|
|
|
|
expect(caught).toEqual(true);
|
|
expect(client.client.sendEvent).not.toHaveBeenCalled();
|
|
|
|
call.hangup(CallErrorCode.UserHangup, true);
|
|
});
|
|
|
|
it("hangs up a call", async () => {
|
|
await fakeIncomingCall(client, call, "1");
|
|
|
|
await call.answer();
|
|
|
|
mockSendEvent.mockReset();
|
|
|
|
call.hangup(CallErrorCode.UserHangup, true);
|
|
|
|
expect(client.client.sendEvent).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.CallHangup,
|
|
expect.objectContaining({
|
|
call_id: call.callId,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("times out an incoming call", async () => {
|
|
jest.useFakeTimers();
|
|
await fakeIncomingCall(client, call, "1");
|
|
|
|
expect(call.state).toEqual(CallState.Ringing);
|
|
|
|
jest.advanceTimersByTime(CALL_LIFETIME + 1000);
|
|
|
|
expect(call.state).toEqual(CallState.Ended);
|
|
});
|
|
|
|
describe("Screen sharing", () => {
|
|
beforeEach(async () => {
|
|
await startVoiceCall(client, call);
|
|
|
|
await call.onAnswerReceived(makeMockEvent("@test:foo", {
|
|
"version": 1,
|
|
"call_id": call.callId,
|
|
"party_id": 'party_id',
|
|
"answer": {
|
|
sdp: DUMMY_SDP,
|
|
},
|
|
"org.matrix.msc3077.sdp_stream_metadata": {
|
|
"foo": {
|
|
"purpose": "m.usermedia",
|
|
"audio_muted": false,
|
|
"video_muted": false,
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Hangup to stop timers
|
|
call.hangup(CallErrorCode.UserHangup, true);
|
|
});
|
|
|
|
it("enables and disables screensharing", async () => {
|
|
await call.setScreensharingEnabled(true);
|
|
|
|
expect(
|
|
call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare).length,
|
|
).toEqual(1);
|
|
|
|
mockSendEvent.mockReset();
|
|
const sendNegotiatePromise = new Promise<void>(resolve => {
|
|
mockSendEvent.mockImplementationOnce(() => {
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
MockRTCPeerConnection.triggerAllNegotiations();
|
|
await sendNegotiatePromise;
|
|
|
|
expect(client.client.sendEvent).toHaveBeenCalledWith(
|
|
FAKE_ROOM_ID,
|
|
EventType.CallNegotiate,
|
|
expect.objectContaining({
|
|
"version": "1",
|
|
"call_id": call.callId,
|
|
"org.matrix.msc3077.sdp_stream_metadata": expect.objectContaining({
|
|
[SCREENSHARE_STREAM_ID]: expect.objectContaining({
|
|
purpose: SDPStreamMetadataPurpose.Screenshare,
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await call.setScreensharingEnabled(false);
|
|
|
|
expect(
|
|
call.getLocalFeeds().filter(f => f.purpose == SDPStreamMetadataPurpose.Screenshare).length,
|
|
).toEqual(0);
|
|
});
|
|
});
|
|
});
|