diff --git a/spec/test-utils/flushPromises.ts b/spec/test-utils/flushPromises.ts new file mode 100644 index 000000000..8512410aa --- /dev/null +++ b/spec/test-utils/flushPromises.ts @@ -0,0 +1,28 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of +// other async methods which break the event loop, letting scheduled promise +// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do +// it manually (this is what sinon does under the hood). We do both in a loop +// until the thing we expect happens: hopefully this is the least flakey way +// and avoids assuming anything about the app's behaviour. +const realSetTimeout = setTimeout; +export function flushPromises() { + return new Promise(r => { + realSetTimeout(r, 1); + }); +} diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index b82137bbe..4dd7e1633 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -79,6 +79,8 @@ export class MockRTCPeerConnection { private negotiationNeededListener: () => void; private needsNegotiation = false; + public readyToNegotiate: Promise; + private onReadyToNegotiate: () => void; localDescription: RTCSessionDescription; signalingState: RTCSignalingState = "stable"; @@ -99,6 +101,10 @@ export class MockRTCPeerConnection { toJSON: function() { }, }; + this.readyToNegotiate = new Promise(resolve => { + this.onReadyToNegotiate = resolve; + }); + MockRTCPeerConnection.instances.push(this); } @@ -128,11 +134,13 @@ export class MockRTCPeerConnection { getStats() { return []; } addTrack(track: MockMediaStreamTrack) { this.needsNegotiation = true; + this.onReadyToNegotiate(); return new MockRTCRtpSender(track); } removeTrack() { this.needsNegotiation = true; + this.onReadyToNegotiate(); } doNegotiation() { diff --git a/spec/unit/queueToDevice.spec.ts b/spec/unit/queueToDevice.spec.ts index a1ae2bcfe..d4a8b05a6 100644 --- a/spec/unit/queueToDevice.spec.ts +++ b/spec/unit/queueToDevice.spec.ts @@ -22,6 +22,7 @@ import { MatrixClient } from "../../src/client"; import { ToDeviceBatch } from '../../src/models/ToDeviceMessage'; import { logger } from '../../src/logger'; import { IStore } from '../../src/store'; +import { flushPromises } from '../test-utils/flushPromises'; const FAKE_USER = "@alice:example.org"; const FAKE_DEVICE_ID = "AAAAAAAA"; @@ -47,19 +48,6 @@ enum StoreType { IndexedDB = 'IndexedDB', } -// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of -// other async methods which break the event loop, letting scheduled promise -// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do -// it manually (this is what sinon does under the hood). We do both in a loop -// until the thing we expect happens: hopefully this is the least flakey way -// and avoids assuming anything about the app's behaviour. -const realSetTimeout = setTimeout; -function flushPromises() { - return new Promise(r => { - realSetTimeout(r, 1); - }); -} - async function flushAndRunTimersUntil(cond: () => boolean) { while (!cond()) { await flushPromises(); diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 86758ff89..18ca1b543 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -40,7 +40,8 @@ import { TypedEventEmitter } from '../../../src/models/typed-event-emitter'; import { MediaHandler } from '../../../src/webrtc/mediaHandler'; import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from '../../../src/webrtc/callEventHandler'; import { CallFeed } from '../../../src/webrtc/callFeed'; -import { CallState } from '../../../src/webrtc/call'; +import { CallEvent, CallEventHandlerMap, CallState } from '../../../src/webrtc/call'; +import { flushPromises } from '../../test-utils/flushPromises'; const FAKE_ROOM_ID = "!fake:test.dummy"; const FAKE_CONF_ID = "fakegroupcallid"; @@ -106,7 +107,10 @@ const createAndEnterGroupCall = async (cli: MatrixClient, room: Room): Promise { +type EmittedEvents = CallEventHandlerEvent | CallEvent; +type EmittedEventMap = CallEventHandlerEventHandlerMap & CallEventHandlerMap; + +class MockCallMatrixClient extends TypedEventEmitter { public mediaHandler = new MockMediaHandler(); constructor(public userId: string, public deviceId: string, public sessionId: string) { @@ -494,6 +498,8 @@ describe('Group Call', function() { }); afterEach(function() { + jest.useRealTimers(); + MockRTCPeerConnection.resetInstances(); }); @@ -530,6 +536,66 @@ describe('Group Call', function() { await Promise.all([groupCall1.leave(), groupCall2.leave()]); } }); + + it("Retries calls", async function() { + jest.useFakeTimers(); + await groupCall1.create(); + + try { + const toDeviceProm = new Promise(resolve => { + client1.sendToDevice.mockImplementation(() => { + resolve(); + return Promise.resolve({}); + }); + }); + + await Promise.all([groupCall1.enter(), groupCall2.enter()]); + + MockRTCPeerConnection.triggerAllNegotiations(); + + await toDeviceProm; + + expect(client1.sendToDevice).toHaveBeenCalled(); + + const oldCall = groupCall1.getCallByUserId(client2.userId); + oldCall.emit(CallEvent.Hangup, oldCall); + + client1.sendToDevice.mockClear(); + + const toDeviceProm2 = new Promise(resolve => { + client1.sendToDevice.mockImplementation(() => { + resolve(); + return Promise.resolve({}); + }); + }); + + jest.advanceTimersByTime(groupCall1.retryCallInterval + 500); + + // when we placed the call, we could await on enter which waited for the call to + // be made. We don't have that luxury now, so first have to wait for the call + // to even be created... + let newCall: MatrixCall; + while ( + (newCall = groupCall1.getCallByUserId(client2.userId)) === undefined || + newCall.callId == oldCall.callId + ) { + await flushPromises(); + } + const mockPc = newCall.peerConn as unknown as MockRTCPeerConnection; + + // ...then wait for it to be ready to negotiate + await mockPc.readyToNegotiate; + + MockRTCPeerConnection.triggerAllNegotiations(); + + // ...and then finally we can wait for the invite to be sent + await toDeviceProm2; + + expect(client1.sendToDevice).toHaveBeenCalledWith(EventType.CallInvite, expect.objectContaining({})); + } finally { + await Promise.all([groupCall1.leave(), groupCall2.leave()]); + } + }); }); describe("muting", () => {