diff --git a/src/models/Call.ts b/src/models/Call.ts index df22abc9bd..11c4fa18c4 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -23,14 +23,12 @@ import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from " import { type MatrixRTCSession, MatrixRTCSessionEvent, - type CallMembership, MatrixRTCSessionManagerEvents, } from "matrix-js-sdk/src/matrixrtc"; import type EventEmitter from "events"; import type { IApp } from "../stores/WidgetStore"; import SettingsStore from "../settings/SettingsStore"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; import { WidgetType } from "../widgets/WidgetType"; @@ -193,18 +191,6 @@ export abstract class Call extends TypedEventEmitter; - /** - * Contacts the widget to connect to the call or prompt the user to connect to the call. - * @param {MediaDeviceInfo | null} audioInput The audio input to use, or - * null to start muted. - * @param {MediaDeviceInfo | null} audioInput The video input to use, or - * null to start muted. - */ - protected abstract performConnection( - audioInput: MediaDeviceInfo | null, - videoInput: MediaDeviceInfo | null, - ): Promise; - /** * Contacts the widget to disconnect from the call. */ @@ -212,28 +198,10 @@ export abstract class Call extends TypedEventEmitter { - const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = - (await MediaDeviceHandler.getDevices())!; - - let audioInput: MediaDeviceInfo | null = null; - if (!MediaDeviceHandler.startWithAudioMuted) { - const deviceId = MediaDeviceHandler.getAudioInput(); - audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null; - } - let videoInput: MediaDeviceInfo | null = null; - if (!MediaDeviceHandler.startWithVideoMuted) { - const deviceId = MediaDeviceHandler.getVideoInput(); - videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null; - } - const messagingStore = WidgetMessagingStore.instance; this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; if (!this.messaging) { @@ -254,13 +222,23 @@ export abstract class Call extends TypedEventEmitter { - // Ensure that the messaging doesn't get stopped while we're waiting for responses - const dontStopMessaging = new Promise((resolve, reject) => { - const messagingStore = WidgetMessagingStore.instance; - - const listener = (uid: string): void => { - if (uid === this.widgetUid) { - cleanup(); - reject(new Error("Messaging stopped")); - } - }; - const done = (): void => { - cleanup(); - resolve(); - }; - const cleanup = (): void => { - messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener); - this.off(CallEvent.ConnectionState, done); - }; - - messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener); - this.on(CallEvent.ConnectionState, done); - }); - - // Empirically, it's possible for Jitsi Meet to crash instantly at startup, - // sending a hangup event that races with the rest of this method, so we need - // to add the hangup listener now rather than later + public async start(): Promise { + await super.start(); + this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - - // Actually perform the join - const response = waitForEvent( - this.messaging!, - `action:${ElementWidgetActions.JoinCall}`, - (ev: CustomEvent) => { - ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack - return true; - }, - ); - const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.label ?? null, - videoInput: videoInput?.label ?? null, - }); - try { - await Promise.race([Promise.all([request, response]), dontStopMessaging]); - } catch (e) { - // If it timed out, clean up our advance preparations - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - - if (this.messaging!.transport.ready) { - // The messaging still exists, which means Jitsi might still be going in the background - this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true }); - } - - throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); - } - ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); } @@ -549,18 +462,17 @@ export class JitsiCall extends Call { } } - public setDisconnected(): void { - // During tests this.messaging can be undefined - this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + public close(): void { + this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); - - super.setDisconnected(); + super.close(); } public destroy(): void { this.room.off(RoomStateEvent.Update, this.onRoomState); - this.on(CallEvent.ConnectionState, this.onConnectionState); + this.off(CallEvent.ConnectionState, this.onConnectionState); if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); this.participantsExpirationTimer = null; @@ -612,27 +524,21 @@ export class JitsiCall extends Call { await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); }; + private readonly onJoin = (ev: CustomEvent): void => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack + this.setConnected(); + }; + private readonly onHangup = async (ev: CustomEvent): Promise => { // If we're already in the middle of a client-initiated disconnection, // ignore the event if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - - // In case this hangup is caused by Jitsi Meet crashing at startup, - // wait for the connection event in order to avoid racing - if (this.connectionState === ConnectionState.Disconnected) { - await waitForEvent(this, CallEvent.ConnectionState); - } - this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); - this.close(); - // In video rooms we immediately want to restart the call after hangup - // The lobby will be shown again and it connects to all signals from Jitsi. - if (isVideoRoom(this.room)) { - this.start(); - } + if (!isVideoRoom(this.room)) this.close(); }; } @@ -860,54 +766,38 @@ export class ElementCall extends Call { ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); } - protected async performConnection( - audioInput: MediaDeviceInfo | null, - videoInput: MediaDeviceInfo | null, - ): Promise { + public async start(): Promise { + await super.start(); + this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose); + this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose); this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); - - // TODO: Watch for a widget action telling us that the join button was clicked, rather than - // relying on the MatrixRTC session state, to set the state to connecting - const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session) { - await waitForEvent( - session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - newMemberships.some((m) => m.sender === this.client.getUserId()), - false, // allow user to wait as long as they want (no timeout) - ); - } else { - await waitForEvent( - this.client.matrixRTC, - MatrixRTCSessionManagerEvents.SessionStarted, - (roomId: string, session: MatrixRTCSession) => - this.session.callId === session.callId && roomId === this.roomId, - false, // allow user to wait as long as they want (no timeout) - ); - } } protected async performDisconnection(): Promise { + const response = waitForEvent( + this.messaging!, + `action:${ElementWidgetActions.HangupCall}`, + (ev: CustomEvent) => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack + return true; + }, + ); + const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); try { - await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); - await waitForEvent( - this.session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - !newMemberships.some((m) => m.sender === this.client.getUserId()), - ); + await Promise.all([request, response]); } catch (e) { throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } } - public setDisconnected(): void { + public close(): void { + this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose); this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); - super.setDisconnected(); + super.close(); } public destroy(): void { @@ -954,22 +844,27 @@ export class ElementCall extends Call { this.messaging!.transport.reply(ev.detail, {}); // ack }; + private readonly onJoin = (ev: CustomEvent): void => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack + this.setConnected(); + }; + private readonly onHangup = async (ev: CustomEvent): Promise => { + // If we're already in the middle of a client-initiated disconnection, + // ignore the event + if (this.connectionState === ConnectionState.Disconnecting) return; + ev.preventDefault(); this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); - // In video rooms we immediately want to reconnect after hangup - // This starts the lobby again and connects to all signals from EC. - if (isVideoRoom(this.room)) { - this.start(); - } }; private readonly onClose = async (ev: CustomEvent): Promise => { ev.preventDefault(); this.messaging!.transport.reply(ev.detail, {}); // ack - // User is done with the call; tell the UI to close it - this.close(); + this.setDisconnected(); // Just in case the widget forgot to emit a hangup action (maybe it's in an error state) + this.close(); // User is done with the call; tell the UI to close it }; public clean(): Promise { diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index a0a1c84536..36fc2b505f 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -79,7 +79,6 @@ export class MockedCall extends Call { // No action needed for any of the following methods since this is just a mock public async clean(): Promise {} // Public to allow spying - public async performConnection(): Promise {} public async performDisconnection(): Promise {} public destroy() { diff --git a/test/unit-tests/components/views/messages/CallEvent-test.tsx b/test/unit-tests/components/views/messages/CallEvent-test.tsx index cc8e5a0a8e..688c9b190f 100644 --- a/test/unit-tests/components/views/messages/CallEvent-test.tsx +++ b/test/unit-tests/components/views/messages/CallEvent-test.tsx @@ -151,7 +151,7 @@ describe("CallEvent", () => { }), ); defaultDispatcher.unregister(dispatcherRef); - await act(() => call.start()); + act(() => call.setConnectionState(ConnectionState.Connected)); // Test that the leave button works fireEvent.click(screen.getByRole("button", { name: "Leave" })); diff --git a/test/unit-tests/components/views/rooms/RoomTile-test.tsx b/test/unit-tests/components/views/rooms/RoomTile-test.tsx index 68b47a037a..a770b00bd4 100644 --- a/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomTile-test.tsx @@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature"; import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { ConnectionState } from "../../../../../src/models/Call"; jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -215,7 +216,7 @@ describe("RoomTile", () => { it("tracks connection state", async () => { renderRoomTile(); screen.getByText("Video"); - await act(() => call.start()); + act(() => call.setConnectionState(ConnectionState.Connected)); screen.getByText("Joined"); await act(() => call.disconnect()); screen.getByText("Video"); diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index 8fea3ee83b..24dc7f0bbb 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -41,7 +41,6 @@ import { ElementCall, } from "../../../src/models/Call"; import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import WidgetStore from "../../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; @@ -55,18 +54,6 @@ import RoomListStore from "../../../src/stores/room-list/RoomListStore.ts"; import { DefaultTagID } from "../../../src/stores/room-list/models.ts"; import DMRoomMap from "../../../src/utils/DMRoomMap.ts"; -jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ - [MediaDeviceKindEnum.AudioInput]: [ - { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.VideoInput]: [ - { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.AudioOutput]: [], -}); -jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); -jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); - const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]); jest.spyOn(SettingsStore, "getValue").mockImplementation( (settingName): any => enabledSettings.has(settingName) || undefined, @@ -140,14 +127,7 @@ const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }; -const setUpWidget = ( - call: Call, -): { - widget: Widget; - messaging: Mocked; - audioMutedSpy: jest.SpyInstance; - videoMutedSpy: jest.SpyInstance; -} => { +const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked } => { call.widget.data = { ...call.widget, skipLobby: true }; const widget = new Widget(call.widget); @@ -165,23 +145,45 @@ const setUpWidget = ( } as unknown as Mocked; WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); - const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get"); - const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get"); - - return { widget, messaging, audioMutedSpy, videoMutedSpy }; + return { widget, messaging }; }; -const cleanUpCallAndWidget = ( - call: Call, - widget: Widget, - audioMutedSpy: jest.SpyInstance, - videoMutedSpy: jest.SpyInstance, -) => { +async function connect(call: Call, messaging: Mocked, startWidget = true): Promise { + async function sessionConnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); + } + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); + } + sessionConnect(); + await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); +} + +async function disconnect(call: Call, messaging: Mocked): Promise { + async function sessionDisconnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + } + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); + } + sessionDisconnect(); + const promise = call.disconnect(); + runTimers(); + await promise; +} + +const cleanUpCallAndWidget = (call: Call, widget: Widget) => { call.destroy(); jest.clearAllMocks(); WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); - audioMutedSpy.mockRestore(); - videoMutedSpy.mockRestore(); }; describe("JitsiCall", () => { @@ -225,8 +227,6 @@ describe("JitsiCall", () => { let call: JitsiCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -237,7 +237,7 @@ describe("JitsiCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); mocked(messaging.transport).send.mockImplementation(async (action, data): Promise => { if (action === ElementWidgetActions.JoinCall) { @@ -255,102 +255,37 @@ describe("JitsiCall", () => { }); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); - it("connects muted", async () => { + it("connects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(true); - videoMutedSpy.mockReturnValue(true); - - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: null, - videoInput: null, - }); }); - it("connects unmuted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(false); - videoMutedSpy.mockReturnValue(false); - - await call.start(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: "Headphones", - videoInput: "Built-in webcam", - }); - }); - - it("waits for messaging when connecting", async () => { + it("waits for messaging when starting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.start(); + const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; + await startup; + await connect(call, messaging, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); - it("doesn't stop messaging when connecting", async () => { - // Temporarily remove the messaging to simulate connecting while the - // widget is still initializing - jest.useFakeTimers(); - const oldSendMock = messaging.transport.send; - mocked(messaging.transport).send.mockImplementation(async (action: string): Promise => { - if (action === ElementWidgetActions.JoinCall) { - await new Promise((resolve) => setTimeout(resolve, 100)); - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - } - }); - expect(call.connectionState).toBe(ConnectionState.Disconnected); - - const connect = call.start(); - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(1000); - } - async function runStopMessaging() { - await new Promise((resolve) => setTimeout(resolve, 1000)); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - } - runStopMessaging(); - runTimers(); - let connectError; - try { - await connect; - } catch (e) { - console.log(e); - connectError = e; - } - expect(connectError).toBeDefined(); - // const connect2 = await connect; - // expect(connect2).toThrow(); - messaging.transport.send = oldSendMock; - jest.useRealTimers(); - }); - - it("fails to connect if the widget returns an error", async () => { - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); - await expect(call.start()).rejects.toBeDefined(); - }); - it("fails to disconnect if the widget returns an error", async () => { - await call.start(); - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); + await connect(call, messaging); + mocked(messaging.transport).send.mockRejectedValue(new Error("never!")); await expect(call.disconnect()).rejects.toBeDefined(); }); it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); const callback = jest.fn(); @@ -358,7 +293,6 @@ describe("JitsiCall", () => { call.on(CallEvent.ConnectionState, callback); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); await waitFor(() => { expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected); }); @@ -368,14 +302,14 @@ describe("JitsiCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); @@ -383,14 +317,14 @@ describe("JitsiCall", () => { it("reconnects after disconnect in video rooms", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -416,7 +350,7 @@ describe("JitsiCall", () => { // Now, stub out client.sendStateEvent so we can test our local echo client.sendStateEvent.mockReset(); - await call.start(); + await connect(call, messaging); expect(call.participants).toEqual( new Map([ [alice, new Set(["alices_device"])], @@ -429,8 +363,8 @@ describe("JitsiCall", () => { }); it("updates room state when connecting and disconnecting", async () => { + await connect(call, messaging); const now1 = Date.now(); - await call.start(); await waitFor( () => expect( @@ -457,7 +391,7 @@ describe("JitsiCall", () => { }); it("repeatedly updates room state while connected", async () => { - await call.start(); + await connect(call, messaging); await waitFor( () => expect(client.sendStateEvent).toHaveBeenLastCalledWith( @@ -487,7 +421,7 @@ describe("JitsiCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await call.start(); + await connect(call, messaging); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], @@ -502,7 +436,7 @@ describe("JitsiCall", () => { const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); - await call.start(); + await connect(call, messaging); await call.disconnect(); expect(onParticipants.mock.calls).toEqual([ [new Map([[alice, new Set(["alices_device"])]]), new Map()], @@ -515,7 +449,7 @@ describe("JitsiCall", () => { }); it("switches to spotlight layout when the widget becomes a PiP", async () => { - await call.start(); + await connect(call, messaging); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); @@ -559,7 +493,7 @@ describe("JitsiCall", () => { }); it("doesn't clean up valid devices", async () => { - await call.start(); + await connect(call, messaging); await client.sendStateEvent( room.roomId, JitsiCall.MEMBER_EVENT_TYPE, @@ -624,47 +558,6 @@ describe("ElementCall", () => { jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); } - const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise => { - async function sessionConnect() { - await new Promise((r) => { - setTimeout(() => r(), 400); - }); - client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { - sessionId: undefined, - } as unknown as MatrixRTCSession); - call.session?.emit( - MatrixRTCSessionEvent.MembershipsChanged, - [], - [{ sender: client.getUserId() } as CallMembership], - ); - } - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(500); - } - sessionConnect(); - await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); - }; - const callDisconnectionProcedure: (call: ElementCall) => Promise = async (call) => { - async function sessionDisconnect() { - await new Promise((r) => { - setTimeout(() => r(), 400); - }); - client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { - sessionId: undefined, - } as unknown as MatrixRTCSession); - call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []); - } - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(500); - } - sessionDisconnect(); - const promise = call.disconnect(); - runTimers(); - await promise; - }; - beforeEach(() => { jest.useFakeTimers(); ({ client, room, alice } = setUpClientRoomAndStores()); @@ -886,8 +779,6 @@ describe("ElementCall", () => { let call: ElementCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -898,27 +789,28 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); // TODO refactor initial device configuration to use the EW settings. // Add tests for passing EW device configuration to the widget. - it("waits for messaging when connecting", async () => { + it("waits for messaging when starting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = callConnectProcedure(call); + const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; + await startup; + await connect(call, messaging, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("fails to disconnect if the widget returns an error", async () => { - await callConnectProcedure(call); + await connect(call, messaging); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -926,7 +818,7 @@ describe("ElementCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); @@ -936,35 +828,35 @@ describe("ElementCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("disconnects if the widget dies", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("acknowledges mute_device widget action", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const preventDefault = jest.fn(); const mockEv = { preventDefault, @@ -980,8 +872,8 @@ describe("ElementCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await callConnectProcedure(call); - await callDisconnectionProcedure(call); + await connect(call, messaging); + await disconnect(call, messaging); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Disconnecting, ConnectionState.Connected], @@ -1003,10 +895,10 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -1048,8 +940,6 @@ describe("ElementCall", () => { let call: ElementCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -1062,64 +952,29 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); it("doesn't end the call when the last participant leaves", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); - it("connect to call with ongoing session", async () => { - // Mock membership getter used by `roomSessionForRoom`. - // This makes sure the roomSession will not be empty. - jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [ - { fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership, - ]); - // Create ongoing session - const roomSession = MatrixRTCSession.roomSessionForRoom(client, room); - const roomSessionEmitSpy = jest.spyOn(roomSession, "emit"); - - // Make sure the created session ends up in the call. - // `getActiveRoomSession` will be used during `call.connect` - // `getRoomSession` will be used during `Call.get` - client.matrixRTC.getActiveRoomSession.mockImplementation(() => { - return roomSession; - }); - client.matrixRTC.getRoomSession.mockImplementation(() => { - return roomSession; - }); - - ElementCall.create(room); - const call = Call.get(room); - if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); - expect(call.session).toBe(roomSession); - await callConnectProcedure(call); - expect(roomSessionEmitSpy).toHaveBeenCalledWith( - "memberships_changed", - [], - [{ sender: "@alice:example.org" }], - ); - expect(call.connectionState).toBe(ConnectionState.Connected); - call.destroy(); - }); - it("handles remote disconnection and reconnect right after", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); // We should now be able to reconnect without manually starting the widget expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call, false); + await connect(call, messaging, false); await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 }); }); }); diff --git a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts index 4294f82af2..a8b8afe7e7 100644 --- a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts @@ -28,6 +28,7 @@ import "../../../../../src/stores/room-list/RoomListStore"; // must be imported import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm"; import { CallStore } from "../../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; +import { ConnectionState } from "../../../../../src/models/Call"; describe("Algorithm", () => { useMockedCalls(); @@ -83,7 +84,7 @@ describe("Algorithm", () => { MockedCall.create(roomWithCall, "1"); const call = CallStore.instance.getCall(roomWithCall.roomId); - if (call === null) throw new Error("Failed to create call"); + if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); const widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, { @@ -93,7 +94,7 @@ describe("Algorithm", () => { // End of setup expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); - await call.start(); + call.setConnectionState(ConnectionState.Connected); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]); await call.disconnect(); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);