1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-06-08 15:21:53 +03:00
matrix-js-sdk/spec/unit/webrtc/call.spec.ts
Florian D 810f7142e6
Remove legacy crypto (#4653)
* Remove deprecated calls in `webrtc/call.ts`

* Throw error when legacy call was used

* Remove `MatrixClient.initLegacyCrypto` (#4620)

* Remove `MatrixClient.initLegacyCrypto`

* Remove `MatrixClient.initLegacyCrypto` in README.md

* Remove tests using `MatrixClient.initLegacyCrypto`

* Remove legacy crypto support in `sync` api (#4622)

* Remove deprecated `DeviceInfo` in `webrtc/call.ts` (#4654)

* chore(legacy call): Remove `DeviceInfo` usage

* refactor(legacy call): throw `GroupCallUnknownDeviceError` at the end of `initOpponentCrypto`

* Remove deprecated methods and attributes of `MatrixClient` (#4659)

* feat(legacy crypto)!: remove deprecated methods of `MatrixClient`

* test(legacy crypto): update existing tests to not use legacy crypto

- `Embedded.spec.ts`: casting since `encryptAndSendToDevices` is removed from `MatrixClient`.
- `room.spec.ts`: remove deprecated usage of `MatrixClient.crypto`
- `matrix-client.spec.ts` & `matrix-client-methods.spec.ts`: remove calls of deprecated methods of `MatrixClient`

* test(legacy crypto): remove test files using `MatrixClient` deprecated methods

* test(legacy crypto): update existing integ tests to run successfully

* feat(legacy crypto!): remove `ICreateClientOpts.deviceToImport`.

`ICreateClientOpts.deviceToImport` was used in the legacy cryto. The rust crypto doesn't support to import devices in this way.

* feat(legacy crypto!): remove `{get,set}GlobalErrorOnUnknownDevices`

`globalErrorOnUnknownDevices` is not used in the rust-crypto. The API is marked as unstable, we can remove it.

* Remove usage of legacy crypto in `event.ts` (#4666)

* feat(legacy crypto!): remove legacy crypto usage in `event.ts`

* test(legacy crypto): update event.spec.ts to not use legacy crypto types

* Remove legacy crypto export in `matrix.ts` (#4667)

* feat(legacy crypto!): remove legacy crypto export in `matrix.ts`

* test(legacy crypto): update `megolm-backup.spec.ts` to import directly `CryptoApi`

* Remove usage of legacy crypto in integ tests (#4669)

* Clean up legacy stores (#4663)

* feat(legacy crypto!): keep legacy methods used in lib olm migration

The rust cryto needs these legacy stores in order to do the migration from the legacy crypto to the rust crypto. We keep the following methods of the stores:
- Used in `libolm_migration.ts`.
- Needed in the legacy store tests.
- Needed in the rust crypto test migration.

* feat(legacy crypto): extract legacy crypto types in legacy stores

In order to be able to delete the legacy crypto, these stores shouldn't rely on the legacy crypto. We need to extract the used types.

* feat(crypto store): remove `CryptoStore` functions used only by tests

* test(crypto store): use legacy `MemoryStore` type

* Remove deprecated methods of `CryptoBackend` (#4671)

* feat(CryptoBackend)!: remove deprecated methods

* feat(rust-crypto)!: remove deprecated methods of `CryptoBackend`

* test(rust-crypto): remove tests of deprecated methods of `CryptoBackend`

* Remove usage of legacy crypto in `embedded.ts` (#4668)

The interface of `encryptAndSendToDevices` changes because `DeviceInfo` is from the legacy crypto. In fact `encryptAndSendToDevices` only need pairs of userId and deviceId.

* Remove legacy crypto files (#4672)

* fix(legacy store): fix legacy store typing

In https://github.com/matrix-org/matrix-js-sdk/pull/4663, the storeXXX methods were removed of the CryptoStore interface but they are used internally by IndexedDBCryptoStore.

* feat(legacy crypto)!: remove content of `crypto/*` except legacy stores

* test(legacy crypto): remove `spec/unit/crypto/*` except legacy store tests

* refactor: remove unused types

* doc: fix broken link

* doc: remove link tag when typedoc is unable to find the CryptoApi

* Clean up integ test after legacy crypto removal (#4682)

* test(crypto): remove `newBackendOnly` test closure

* test(crypto): fix duplicate test name

* test(crypto): remove `oldBackendOnly` test closure

* test(crypto): remove `rust-sdk` comparison

* test(crypto): remove iteration on `CRYPTO_BACKEND`

* test(crypto): remove old legacy comments and tests

* test(crypto): fix documentations and removed unused expect

* Restore broken link to `CryptoApi` (#4692)

* chore: fix linting and formatting due to merge

* Remove unused crypto type and missing doc (#4696)

* chore(crypto): remove unused types

* doc(crypto): add missing link

* test(call): add test when crypto is enabled
2025-02-07 12:31:40 +00:00

1872 lines
68 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 { mocked } from "jest-mock";
import { TestClient } from "../../TestClient";
import {
MatrixCall,
CallErrorCode,
CallEvent,
supportsMatrixCall,
CallType,
CallState,
CallParty,
CallDirection,
} from "../../../src/webrtc/call";
import {
type MCallAnswer,
type MCallHangupReject,
type SDPStreamMetadata,
SDPStreamMetadataKey,
SDPStreamMetadataPurpose,
} from "../../../src/webrtc/callEventTypes";
import {
DUMMY_SDP,
MockMediaHandler,
MockMediaStream,
MockMediaStreamTrack,
installWebRTCMocks,
MockRTCPeerConnection,
MockRTCRtpTransceiver,
SCREENSHARE_STREAM_ID,
MockRTCRtpSender,
} from "../../test-utils/webrtc";
import { CallFeed } from "../../../src/webrtc/callFeed";
import { EventType, type IContent, type ISendEventResponse, type MatrixEvent, type Room } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import type { CryptoApi } from "../../../src/crypto-api";
import { GroupCallUnknownDeviceError } from "../../../src/webrtc/groupCall";
const FAKE_ROOM_ID = "!foo:bar";
const CALL_LIFETIME = 60000;
const startVoiceCall = async (client: TestClient, call: MatrixCall, userId?: string): Promise<void> => {
const callPromise = call.placeVoiceCall();
await client.httpBackend!.flush("");
await callPromise;
call.getOpponentMember = jest.fn().mockReturnValue({ userId: userId ?? "@bob:bar.uk" });
};
const startVideoCall = async (client: TestClient, call: MatrixCall, userId?: string): Promise<void> => {
const callPromise = call.placeVideoCall();
await client.httpBackend.flush("");
await callPromise;
call.getOpponentMember = jest.fn().mockReturnValue({ userId: 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",
deviceId: undefined,
stream: new MockMediaStream("remote_stream_id", [
new MockMediaStreamTrack("remote_tack_id", "audio"),
]) as unknown as MediaStream,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
}),
);
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<Promise<ISendEventResponse>, [string, string, IContent, string]>;
const errorListener = () => {};
beforeEach(function () {
prevNavigator = globalThis.navigator;
prevDocument = globalThis.document;
prevWindow = globalThis.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;
};
client.client.getProfileInfo = jest.fn();
call = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
// call checks one of these is wired up
call.on(CallEvent.Error, errorListener);
});
afterEach(function () {
// Hangup to stop timers
call.hangup(CallErrorCode.UserHangup, true);
client.stop();
globalThis.navigator = prevNavigator;
globalThis.window = prevWindow;
globalThis.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(),
);
// XXX: Lots of inspecting the prvate state of the call object here
const transceivers: Map<string, RTCRtpTransceiver> = (call as any).transceivers;
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");
// call has a function for generating these but we hardcode here to avoid exporting it
expect(transceivers.get("m.usermedia:audio")!.sender.track!.id).toBe("new_audio_track");
expect(transceivers.get("m.usermedia:video")!.sender.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);
// XXX: More inspecting private state of the call object
const transceivers: Map<string, RTCRtpTransceiver> = (call as any).transceivers;
expect(call.localUsermediaStream!.getAudioTracks()[0].id).toBe("usermedia_audio_track");
expect(call.localUsermediaStream!.getVideoTracks()[0].id).toBe("usermedia_video_track");
expect(transceivers.get("m.usermedia:audio")!.sender.track!.id).toBe("usermedia_audio_track");
expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track");
});
it("should handle error on call upgrade", async () => {
const onError = jest.fn();
call.on(CallEvent.Error, onError);
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]: {},
}),
);
const mockGetUserMediaStream = jest.fn().mockRejectedValue(new Error("Test error"));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
expect(onError).toHaveBeenCalled();
});
it("should unmute video after upgrading to video call", async () => {
// Regression test for https://github.com/vector-im/element-call/issues/925
await startVoiceCall(client, call);
// start off with video muted
await call.setLocalVideoMuted(true);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
// video should now be unmuted
expect(call.isLocalVideoMuted()).toBe(false);
});
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: string) => {
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", () => {
beforeEach(async () => {
// start an incoming call, but add no feeds
await call.initWithInvite({
getContent: jest.fn().mockReturnValue({
version: "1",
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);
});
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" });
// since this is testing for the presence of a local sender, we need to add a transciever
// rather than just a source track
const mockTrack = new MockMediaStreamTrack("track_id", "video");
const mockTransceiver = new MockRTCRtpTransceiver(call.peerConn as unknown as MockRTCPeerConnection);
mockTransceiver.sender = new MockRTCRtpSender(mockTrack) as unknown as RTCRtpSender;
(call as any).transceivers.set("m.usermedia:video", mockTransceiver);
(call as any).pushNewLocalFeed(
new MockMediaStream("remote_stream1", [mockTrack]),
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,
stream: new MockMediaStream("local_stream1", [
new MockMediaStreamTrack("track_id", "audio"),
]) as unknown as MediaStream,
roomId: call.roomId,
userId: client.getUserId(),
deviceId: undefined,
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"),
]) as unknown as MediaStream,
SDPStreamMetadataPurpose.Screenshare,
);
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(),
deviceId: undefined,
stream: localUsermediaStream as unknown as MediaStream,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
}),
new CallFeed({
client: client.client,
userId: client.getUserId(),
deviceId: undefined,
stream: localScreensharingStream as unknown as MediaStream,
purpose: SDPStreamMetadataPurpose.Screenshare,
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, call);
expect(dataChannel.label).toBe("data_channel_label");
expect(dataChannel.id).toBe(123);
});
it("should emit a data channel event when the other side adds a data channel", async () => {
await startVoiceCall(client, call);
const dataChannelCallback = jest.fn();
call.on(CallEvent.DataChannel, dataChannelCallback);
(call.peerConn as unknown as MockRTCPeerConnection).triggerIncomingDataChannel();
expect(dataChannelCallback).toHaveBeenCalled();
});
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", () => {
globalThis.window = undefined!;
expect(supportsMatrixCall()).toBe(false);
globalThis.window = prevWindow;
globalThis.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
globalThis.window = {};
Object.defineProperty(globalThis.window, "RTCPeerConnection", {
get: () => {
throw Error("Secure mode, naaah!");
},
});
expect(supportsMatrixCall()).toBe(false);
});
it(
"should return false if RTCPeerConnection & RTCSessionDescription " +
"& RTCIceCandidate & mediaDevices are unavailable",
() => {
globalThis.window.RTCPeerConnection = undefined!;
globalThis.window.RTCSessionDescription = undefined!;
globalThis.window.RTCIceCandidate = undefined!;
// @ts-ignore - writing to a read-only property as we are simulating faulty browsers
globalThis.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("transferToCall", () => {
it("should send the required events", async () => {
const targetCall = new MatrixCall({ client: client.client, roomId: "!roomId:server" });
const sendEvent = jest.spyOn(client.client, "sendEvent");
await call.transferToCall(targetCall);
const newCallId = (sendEvent.mock.calls[0][2] as any)!.await_call;
expect(sendEvent).toHaveBeenCalledWith(
call.roomId,
EventType.CallReplaces,
expect.objectContaining({
create_call: newCallId,
}),
);
});
});
describe("muting", () => {
let mockSendVoipEvent: jest.Mock<Promise<void>, [string, object]>;
beforeEach(async () => {
(call as any).sendVoipEvent = mockSendVoipEvent = jest.fn();
await startVideoCall(client, call);
});
afterEach(() => {
jest.useRealTimers();
});
it("should not remove video sender on video mute", async () => {
await call.setLocalVideoMuted(true);
expect((call as any).hasUserMediaVideoSender).toBe(true);
});
it("should release camera after short delay on video mute", async () => {
jest.useFakeTimers();
await call.setLocalVideoMuted(true);
jest.advanceTimersByTime(500);
expect(call.hasLocalUserMediaVideoTrack).toBe(false);
});
it("should re-request video feed on video unmute if it doesn't have one", async () => {
jest.useFakeTimers();
const mockGetUserMediaStream = jest
.fn()
.mockReturnValue(client.client.getMediaHandler().getUserMediaStream(true, true));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
await call.setLocalVideoMuted(true);
jest.advanceTimersByTime(500);
await call.setLocalVideoMuted(false);
expect(mockGetUserMediaStream).toHaveBeenCalled();
});
it("should not release camera on fast mute and unmute", async () => {
const mockGetUserMediaStream = jest.fn();
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
await call.setLocalVideoMuted(true);
await call.setLocalVideoMuted(false);
expect(mockGetUserMediaStream).not.toHaveBeenCalled();
expect(call.hasLocalUserMediaVideoTrack).toBe(true);
});
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();
expect(() => call.reject()).toThrow();
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,
}),
);
});
});
describe("answering calls", () => {
const realSetTimeout = setTimeout;
beforeEach(async () => {
await fakeIncomingCall(client, call, "1");
});
const untilEventSent = async (...args: any[]) => {
const maxTries = 20;
for (let tries = 0; tries < maxTries; ++tries) {
if (tries) {
await new Promise((resolve) => {
realSetTimeout(resolve, 100);
});
}
// We might not always be in fake timer mode, but it's
// fine to run this if not, so we just call it anyway.
jest.runOnlyPendingTimers();
try {
expect(mockSendEvent).toHaveBeenCalledWith(...args);
return;
} catch (e) {
if (tries == maxTries - 1) {
throw e;
}
}
}
};
it("sends an answer event", async () => {
await call.answer();
await untilEventSent(
FAKE_ROOM_ID,
EventType.CallAnswer,
expect.objectContaining({
call_id: call.callId,
answer: expect.objectContaining({
type: "offer",
}),
}),
);
});
describe("ICE candidate sending", () => {
let mockPeerConn: MockRTCPeerConnection;
const fakeCandidateString = "here is a fake candidate!";
const fakeCandidateEvent = {
candidate: {
candidate: fakeCandidateString,
sdpMLineIndex: 0,
sdpMid: "0",
toJSON: jest.fn().mockReturnValue(fakeCandidateString),
},
} as unknown as RTCPeerConnectionIceEvent;
beforeEach(async () => {
await call.answer();
await untilEventSent(FAKE_ROOM_ID, EventType.CallAnswer, expect.objectContaining({}));
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
});
afterEach(() => {
jest.useRealTimers();
});
it("sends ICE candidates as separate events if they arrive after the answer", async () => {
mockPeerConn!.iceCandidateListener!(fakeCandidateEvent);
await untilEventSent(
FAKE_ROOM_ID,
EventType.CallCandidates,
expect.objectContaining({
candidates: [fakeCandidateString],
}),
);
});
it("retries sending ICE candidates", async () => {
jest.useFakeTimers();
mockSendEvent.mockRejectedValueOnce(new Error("Fake error"));
mockPeerConn!.iceCandidateListener!(fakeCandidateEvent);
await untilEventSent(
FAKE_ROOM_ID,
EventType.CallCandidates,
expect.objectContaining({
candidates: [fakeCandidateString],
}),
);
mockSendEvent.mockClear();
await untilEventSent(
FAKE_ROOM_ID,
EventType.CallCandidates,
expect.objectContaining({
candidates: [fakeCandidateString],
}),
);
});
it("gives up on call after 5 attempts at sending ICE candidates", async () => {
jest.useFakeTimers();
mockSendEvent.mockImplementation((roomId: string, eventType: string) => {
if (eventType === EventType.CallCandidates) {
return Promise.reject(new Error());
} else {
return Promise.resolve({ event_id: "foo" });
}
});
mockPeerConn!.iceCandidateListener!(fakeCandidateEvent);
while (!call.callHasEnded()) {
jest.runOnlyPendingTimers();
await untilEventSent(
FAKE_ROOM_ID,
EventType.CallCandidates,
expect.objectContaining({
candidates: [fakeCandidateString],
}),
);
if (!call.callHasEnded) {
mockSendEvent.mockReset();
}
}
expect(call.callHasEnded()).toEqual(true);
});
});
});
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", () => {
const waitNegotiateFunc = (resolve: () => void): void => {
mockSendEvent.mockImplementationOnce(() => {
// Note that the peer connection here is a dummy one and always returns
// dummy SDP, so there's not much point returning the content: the SDP will
// always be the same.
resolve();
return Promise.resolve({ event_id: "foo" });
});
};
beforeEach(async () => {
await startVoiceCall(client, call);
const sendNegotiatePromise = new Promise<void>(waitNegotiateFunc);
MockRTCPeerConnection.triggerAllNegotiations();
await sendNegotiatePromise;
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)).toHaveLength(
1,
);
mockSendEvent.mockReset();
const sendNegotiatePromise = new Promise<void>(waitNegotiateFunc);
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)).toHaveLength(
0,
);
});
it("removes RTX codec from screen sharing transcievers", async () => {
mocked(globalThis.RTCRtpSender.getCapabilities).mockReturnValue({
codecs: [
{ mimeType: "video/rtx", clockRate: 90000 },
{ mimeType: "video/somethingelse", clockRate: 90000 },
],
headerExtensions: [],
});
mockSendEvent.mockReset();
const sendNegotiatePromise = new Promise<void>(waitNegotiateFunc);
await call.setScreensharingEnabled(true);
MockRTCPeerConnection.triggerAllNegotiations();
await sendNegotiatePromise;
const mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
expect(
mockPeerConn.transceivers[mockPeerConn.transceivers.length - 1].setCodecPreferences,
).toHaveBeenCalledWith([expect.objectContaining({ mimeType: "video/somethingelse" })]);
});
it("re-uses transceiver when screen sharing is re-enabled", async () => {
const mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
// sanity check: we should start with one transciever (user media audio)
expect(mockPeerConn.transceivers.length).toEqual(1);
const screenshareOnProm1 = new Promise<void>(waitNegotiateFunc);
await call.setScreensharingEnabled(true);
MockRTCPeerConnection.triggerAllNegotiations();
await screenshareOnProm1;
// we should now have another transciever for the screenshare
expect(mockPeerConn.transceivers.length).toEqual(2);
const screenshareOffProm = new Promise<void>(waitNegotiateFunc);
await call.setScreensharingEnabled(false);
MockRTCPeerConnection.triggerAllNegotiations();
await screenshareOffProm;
// both transceivers should still be there
expect(mockPeerConn.transceivers.length).toEqual(2);
const screenshareOnProm2 = new Promise<void>(waitNegotiateFunc);
await call.setScreensharingEnabled(true);
MockRTCPeerConnection.triggerAllNegotiations();
await screenshareOnProm2;
// should still be two, ie. another one should not have been created
// when re-enabling the screen share.
expect(mockPeerConn.transceivers.length).toEqual(2);
});
});
it("falls back to replaceTrack for opponents that don't support stream metadata", async () => {
await startVideoCall(client, call);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
}),
);
MockRTCPeerConnection.triggerAllNegotiations();
const mockVideoSender = call.peerConn!.getSenders().find((s) => s.track!.kind === "video");
const mockReplaceTrack = (mockVideoSender!.replaceTrack = jest.fn());
await call.setScreensharingEnabled(true);
// our local feed should still reflect the purpose of the feed (ie. screenshare)
expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare).length).toEqual(1);
// but we should not have re-negotiated
expect(MockRTCPeerConnection.hasAnyPendingNegotiations()).toEqual(false);
expect(mockReplaceTrack).toHaveBeenCalledWith(
expect.objectContaining({
id: "screenshare_video_track",
}),
);
mockReplaceTrack.mockClear();
await call.setScreensharingEnabled(false);
expect(call.getLocalFeeds().filter((f) => f.purpose == SDPStreamMetadataPurpose.Screenshare)).toHaveLength(0);
expect(call.getLocalFeeds()).toHaveLength(1);
expect(MockRTCPeerConnection.hasAnyPendingNegotiations()).toEqual(false);
expect(mockReplaceTrack).toHaveBeenCalledWith(
expect.objectContaining({
id: "usermedia_video_track",
}),
);
});
describe("call transfers", () => {
const ALICE_USER_ID = "@alice:foo";
const ALICE_DISPLAY_NAME = "Alice";
const ALICE_AVATAR_URL = "avatar.alice.foo";
const BOB_USER_ID = "@bob:foo";
const BOB_DISPLAY_NAME = "Bob";
const BOB_AVATAR_URL = "avatar.bob.foo";
beforeEach(() => {
mocked(client.client.getProfileInfo).mockImplementation(async (userId) => {
if (userId === ALICE_USER_ID) {
return {
displayname: ALICE_DISPLAY_NAME,
avatar_url: ALICE_AVATAR_URL,
};
} else if (userId === BOB_USER_ID) {
return {
displayname: BOB_DISPLAY_NAME,
avatar_url: BOB_AVATAR_URL,
};
} else {
return {};
}
});
});
it("transfers call to another call", async () => {
const newCall = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
const callHangupListener = jest.fn();
const newCallHangupListener = jest.fn();
call.on(CallEvent.Hangup, callHangupListener);
newCall.on(CallEvent.Error, () => {});
newCall.on(CallEvent.Hangup, newCallHangupListener);
await startVoiceCall(client, call, ALICE_USER_ID);
await startVoiceCall(client, newCall, BOB_USER_ID);
await call.transferToCall(newCall);
expect(mockSendEvent).toHaveBeenCalledWith(
FAKE_ROOM_ID,
EventType.CallReplaces,
expect.objectContaining({
target_user: {
id: ALICE_USER_ID,
display_name: ALICE_DISPLAY_NAME,
avatar_url: ALICE_AVATAR_URL,
},
}),
);
expect(mockSendEvent).toHaveBeenCalledWith(
FAKE_ROOM_ID,
EventType.CallReplaces,
expect.objectContaining({
target_user: {
id: BOB_USER_ID,
display_name: BOB_DISPLAY_NAME,
avatar_url: BOB_AVATAR_URL,
},
}),
);
expect(callHangupListener).toHaveBeenCalledWith(call);
expect(newCallHangupListener).toHaveBeenCalledWith(newCall);
});
it("transfers a call to another user", async () => {
// @ts-ignore Mock
jest.spyOn(call, "terminate");
await startVoiceCall(client, call, ALICE_USER_ID);
await call.transfer(BOB_USER_ID);
expect(mockSendEvent).toHaveBeenCalledWith(
FAKE_ROOM_ID,
EventType.CallReplaces,
expect.objectContaining({
target_user: {
id: BOB_USER_ID,
display_name: BOB_DISPLAY_NAME,
avatar_url: BOB_AVATAR_URL,
},
}),
);
// @ts-ignore Mock
expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transferred, true);
});
});
describe("onTrack", () => {
it("ignores streamless track", async () => {
// @ts-ignore Mock pushRemoteFeed() is private
jest.spyOn(call, "pushRemoteFeed");
await call.placeVoiceCall();
(call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!({
streams: [],
track: new MockMediaStreamTrack("track_ev", "audio"),
} as unknown as RTCTrackEvent);
// @ts-ignore Mock pushRemoteFeed() is private
expect(call.pushRemoteFeed).not.toHaveBeenCalled();
});
it("correctly pushes", async () => {
// @ts-ignore Mock pushRemoteFeed() is private
jest.spyOn(call, "pushRemoteFeed");
await call.placeVoiceCall();
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "the_correct_party_id",
answer: {
sdp: DUMMY_SDP,
},
}),
);
const stream = new MockMediaStream("stream_ev", [new MockMediaStreamTrack("track_ev", "audio")]);
(call.peerConn as unknown as MockRTCPeerConnection).onTrackListener!({
streams: [stream],
track: stream.getAudioTracks()[0],
} as unknown as RTCTrackEvent);
// @ts-ignore Mock pushRemoteFeed() is private
expect(call.pushRemoteFeed).toHaveBeenCalledWith(stream);
// @ts-ignore Mock pushRemoteFeed() is private
expect(call.removeTrackListeners.has(stream)).toBe(true);
});
});
describe("onHangupReceived()", () => {
it("ends call on onHangupReceived() if state is ringing", async () => {
expect(call.callHasEnded()).toBe(false);
(call as any).state = CallState.Ringing;
call.onHangupReceived({} as MCallHangupReject);
expect(call.callHasEnded()).toBe(true);
});
it("ends call on onHangupReceived() if party id matches", async () => {
expect(call.callHasEnded()).toBe(false);
await call.initWithInvite({
getContent: jest.fn().mockReturnValue({
version: "1",
call_id: "call_id",
party_id: "remote_party_id",
lifetime: CALL_LIFETIME,
offer: {
sdp: DUMMY_SDP,
},
}),
getSender: () => "@test:foo",
} as unknown as MatrixEvent);
call.onHangupReceived({ version: "1", party_id: "remote_party_id" } as MCallHangupReject);
expect(call.callHasEnded()).toBe(true);
});
});
it.each(Object.values(CallState))(
"ends call on onRejectReceived() if in correct state (state=%s)",
async (state: CallState) => {
expect(call.callHasEnded()).toBe(false);
(call as any).state = state;
call.onRejectReceived({} as MCallHangupReject);
expect(call.callHasEnded()).toBe(
[CallState.InviteSent, CallState.Ringing, CallState.Ended].includes(state),
);
},
);
it("terminates call when answered elsewhere", async () => {
await call.placeVoiceCall();
expect(call.callHasEnded()).toBe(false);
call.onAnsweredElsewhere({} as MCallAnswer);
expect(call.callHasEnded()).toBe(true);
});
it("throws when there is no error listener", async () => {
call.off(CallEvent.Error, errorListener);
await expect(call.placeVoiceCall()).rejects.toThrow();
});
describe("hasPeerConnection()", () => {
it("hasPeerConnection() returns false if there is no peer connection", () => {
expect(call.hasPeerConnection).toBe(false);
});
it("hasPeerConnection() returns true if there is a peer connection", async () => {
await call.placeVoiceCall();
expect(call.hasPeerConnection).toBe(true);
});
});
it("should correctly emit LengthChanged", async () => {
const advanceByArray = [2, 3, 5];
const lengthChangedListener = jest.fn();
jest.useFakeTimers();
call.addListener(CallEvent.LengthChanged, lengthChangedListener);
await fakeIncomingCall(client, call, "1");
(call.peerConn as unknown as MockRTCPeerConnection).iceConnectionStateChangeListener!();
let hasAdvancedBy = 0;
for (const advanceBy of advanceByArray) {
jest.advanceTimersByTime(advanceBy * 1000);
hasAdvancedBy += advanceBy;
expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy, call);
}
});
describe("ICE disconnected timeout", () => {
let mockPeerConn: MockRTCPeerConnection;
beforeEach(async () => {
jest.useFakeTimers();
jest.spyOn(call, "hangup");
await fakeIncomingCall(client, call, "1");
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
mockPeerConn.iceConnectionState = "disconnected";
mockPeerConn.iceConnectionStateChangeListener!();
jest.spyOn(mockPeerConn, "restartIce");
});
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
jest.advanceTimersByTime(3 * 1000);
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should hang up after being disconnected for 30 seconds", () => {
jest.advanceTimersByTime(31 * 1000);
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should restart ICE gathering once again after ICE being failed", () => {
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
// @ts-ignore
mockPeerConn.restartIce = null;
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should not hangup if we've managed to re-connect", () => {
mockPeerConn.iceConnectionState = "connected";
mockPeerConn.iceConnectionStateChangeListener!();
jest.advanceTimersByTime(31 * 1000);
expect(call.hangup).not.toHaveBeenCalled();
});
});
describe("Call replace", () => {
it("Fires event when call replaced", async () => {
const onReplace = jest.fn();
call.on(CallEvent.Replaced, onReplace);
await call.placeVoiceCall();
const call2 = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
call2.on(CallEvent.Error, errorListener);
await fakeIncomingCall(client, call2);
call.replacedBy(call2);
expect(onReplace).toHaveBeenCalled();
});
});
describe("should handle glare in negotiation process", () => {
beforeEach(async () => {
// cut methods not want to test
call.hangup = () => null;
call.isLocalOnHold = () => true;
// @ts-ignore
call.updateRemoteSDPStreamMetadata = jest.fn();
// @ts-ignore
call.getRidOfRTXCodecs = jest.fn();
// @ts-ignore
call.createAnswer = jest.fn().mockResolvedValue({});
// @ts-ignore
call.sendVoipEvent = jest.fn();
});
it("and reject remote offer if not polite and have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do have pending answer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have not a local offer
// @ts-ignore
call.makingOffer = false;
// If we have a setRemoteDescription() answer operation pending, then
// we will be "stable" by the time the next setRemoteDescription() is
// executed, so we count this being readyForOffer when deciding whether to
// ignore the offer.
// @ts-ignore
call.isSettingRemoteAnswerPending = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have no local offer
// @ts-ignore
call.makingOffer = false;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "stable",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and if polite do rollback pending local offer", async () => {
// polite user == CallDirection.Inbound
call.direction = CallDirection.Inbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
});
it("should emit IceFailed error on the successor call if RTCPeerConnection throws", async () => {
// @ts-ignore - writing to window as we are simulating browser edge-cases
globalThis.window = {};
Object.defineProperty(globalThis.window, "RTCPeerConnection", {
get: () => {
throw Error("Secure mode, naaah!");
},
});
const call = new MatrixCall({
client: client.client,
roomId: "!room_id",
});
const successor = new MatrixCall({
client: client.client,
roomId: "!room_id",
});
call.replacedBy(successor);
const prom = emitPromise(successor, CallEvent.Error);
call.placeCall(true, true);
const err = await prom;
expect(err.code).toBe(CallErrorCode.IceFailed);
});
it("should throw an error when trying to call 'placeCallWithCallFeeds' when crypto is enabled", async () => {
jest.spyOn(client.client, "getCrypto").mockReturnValue({} as unknown as CryptoApi);
call = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
opponentDeviceId: "opponent_device_id",
invitee: "invitee",
});
call.on(CallEvent.Error, jest.fn());
await expect(
call.placeCallWithCallFeeds([
new CallFeed({
client: client.client,
stream: new MockMediaStream("local_stream1", [
new MockMediaStreamTrack("track_id", "audio"),
]) as unknown as MediaStream,
userId: client.getUserId(),
deviceId: undefined,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
}),
]),
).rejects.toThrow(new GroupCallUnknownDeviceError("invitee"));
});
});