Watch for a 'join' action to know when the call is connected (#29492)

Previously we were watching for changes to the room state to know when you become connected to a call. However, the room state might not change if you had a stuck membership event prior to re-joining the call. It's going to be more reliable to watch for the 'join' action that Element Call sends, and use that to track the connection state.
This commit is contained in:
Robin 2025-08-27 10:04:36 +02:00 committed by GitHub
parent 6a1c0502aa
commit 4b4cb896eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 145 additions and 394 deletions

View File

@ -23,14 +23,12 @@ import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "
import { import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type CallMembership,
MatrixRTCSessionManagerEvents, MatrixRTCSessionManagerEvents,
} from "matrix-js-sdk/src/matrixrtc"; } from "matrix-js-sdk/src/matrixrtc";
import type EventEmitter from "events"; import type EventEmitter from "events";
import type { IApp } from "../stores/WidgetStore"; import type { IApp } from "../stores/WidgetStore";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise"; import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType"; import { WidgetType } from "../widgets/WidgetType";
@ -193,18 +191,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/ */
public abstract clean(): Promise<void>; public abstract clean(): Promise<void>;
/**
* 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<void>;
/** /**
* Contacts the widget to disconnect from the call. * Contacts the widget to disconnect from the call.
*/ */
@ -212,28 +198,10 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
/** /**
* Starts the communication between the widget and the call. * Starts the communication between the widget and the call.
* The call then waits for the necessary requirements to actually perform the connection * The widget associated with the call must be active for this to succeed.
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
* It uses the media devices set in MediaDeviceHandler.
* The widget associated with the call must be active
* for this to succeed.
* Only call this if the call state is: ConnectionState.Disconnected. * Only call this if the call state is: ConnectionState.Disconnected.
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
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; const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null;
if (!this.messaging) { if (!this.messaging) {
@ -254,13 +222,23 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`); throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
} }
} }
await this.performConnection(audioInput, videoInput); }
protected setConnected(): void {
this.room.on(RoomEvent.MyMembership, this.onMyMembership); this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload); window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected; this.connectionState = ConnectionState.Connected;
} }
/**
* Manually marks the call as disconnected.
*/
protected setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/** /**
* Disconnects the user from the call. * Disconnects the user from the call.
*/ */
@ -273,15 +251,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
this.close(); this.close();
} }
/**
* Manually marks the call as disconnected.
*/
public setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/** /**
* Stops further communication with the widget and tells the UI to close. * Stops further communication with the widget and tells the UI to close.
*/ */
@ -467,66 +436,10 @@ export class JitsiCall extends Call {
}); });
} }
protected async performConnection( public async start(): Promise<void> {
audioInput: MediaDeviceInfo | null, await super.start();
videoInput: MediaDeviceInfo | null, this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((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
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
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.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
} }
@ -549,18 +462,17 @@ export class JitsiCall extends Call {
} }
} }
public setDisconnected(): void { public close(): void {
// During tests this.messaging can be undefined this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
super.close();
super.setDisconnected();
} }
public destroy(): void { public destroy(): void {
this.room.off(RoomStateEvent.Update, this.onRoomState); this.room.off(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState); this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) { if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer); clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null; this.participantsExpirationTimer = null;
@ -612,27 +524,21 @@ export class JitsiCall extends Call {
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
}; };
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => { private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection, // If we're already in the middle of a client-initiated disconnection,
// ignore the event // ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return; if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault(); 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.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected(); this.setDisconnected();
this.close(); if (!isVideoRoom(this.room)) 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();
}
}; };
} }
@ -860,54 +766,38 @@ export class ElementCall extends Call {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
} }
protected async performConnection( public async start(): Promise<void> {
audioInput: MediaDeviceInfo | null, await super.start();
videoInput: MediaDeviceInfo | null, this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
): Promise<void> {
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); 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); 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<void> { protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try { try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); await Promise.all([request, response]);
await waitForEvent(
this.session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
!newMemberships.some((m) => m.sender === this.client.getUserId()),
);
} catch (e) { } catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${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.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
super.setDisconnected(); super.close();
} }
public destroy(): void { public destroy(): void {
@ -954,22 +844,27 @@ export class ElementCall extends Call {
this.messaging!.transport.reply(ev.detail, {}); // ack this.messaging!.transport.reply(ev.detail, {}); // ack
}; };
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => { private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault(); ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected(); 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<IWidgetApiRequest>): Promise<void> => { private readonly onClose = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault(); ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack this.messaging!.transport.reply(ev.detail, {}); // ack
// User is done with the call; tell the UI to close it this.setDisconnected(); // Just in case the widget forgot to emit a hangup action (maybe it's in an error state)
this.close(); this.close(); // User is done with the call; tell the UI to close it
}; };
public clean(): Promise<void> { public clean(): Promise<void> {

View File

@ -79,7 +79,6 @@ export class MockedCall extends Call {
// No action needed for any of the following methods since this is just a mock // No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {} public async clean(): Promise<void> {}
// Public to allow spying // Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {} public async performDisconnection(): Promise<void> {}
public destroy() { public destroy() {

View File

@ -151,7 +151,7 @@ describe("CallEvent", () => {
}), }),
); );
defaultDispatcher.unregister(dispatcherRef); defaultDispatcher.unregister(dispatcherRef);
await act(() => call.start()); act(() => call.setConnectionState(ConnectionState.Connected));
// Test that the leave button works // Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" })); fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View File

@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore"; import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
@ -215,7 +216,7 @@ describe("RoomTile", () => {
it("tracks connection state", async () => { it("tracks connection state", async () => {
renderRoomTile(); renderRoomTile();
screen.getByText("Video"); screen.getByText("Video");
await act(() => call.start()); act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined"); screen.getByText("Joined");
await act(() => call.disconnect()); await act(() => call.disconnect());
screen.getByText("Video"); screen.getByText("Video");

View File

@ -41,7 +41,6 @@ import {
ElementCall, ElementCall,
} from "../../../src/models/Call"; } from "../../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils"; import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import WidgetStore from "../../../src/stores/WidgetStore"; import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; 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 { DefaultTagID } from "../../../src/stores/room-list/models.ts";
import DMRoomMap from "../../../src/utils/DMRoomMap.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"]); const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
jest.spyOn(SettingsStore, "getValue").mockImplementation( jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName): any => enabledSettings.has(settingName) || undefined, (settingName): any => enabledSettings.has(settingName) || undefined,
@ -140,14 +127,7 @@ const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
}; };
const setUpWidget = ( const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked<ClientWidgetApi> } => {
call: Call,
): {
widget: Widget;
messaging: Mocked<ClientWidgetApi>;
audioMutedSpy: jest.SpyInstance<boolean, []>;
videoMutedSpy: jest.SpyInstance<boolean, []>;
} => {
call.widget.data = { ...call.widget, skipLobby: true }; call.widget.data = { ...call.widget, skipLobby: true };
const widget = new Widget(call.widget); const widget = new Widget(call.widget);
@ -165,23 +145,45 @@ const setUpWidget = (
} as unknown as Mocked<ClientWidgetApi>; } as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get"); return { widget, messaging };
const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
return { widget, messaging, audioMutedSpy, videoMutedSpy };
}; };
const cleanUpCallAndWidget = ( async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
call: Call, async function sessionConnect() {
widget: Widget, await new Promise<void>((r) => {
audioMutedSpy: jest.SpyInstance<boolean, []>, setTimeout(() => r(), 400);
videoMutedSpy: jest.SpyInstance<boolean, []>, });
) => { 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<ClientWidgetApi>): Promise<void> {
async function sessionDisconnect() {
await new Promise<void>((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(); call.destroy();
jest.clearAllMocks(); jest.clearAllMocks();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
}; };
describe("JitsiCall", () => { describe("JitsiCall", () => {
@ -225,8 +227,6 @@ describe("JitsiCall", () => {
let call: JitsiCall; let call: JitsiCall;
let widget: Widget; let widget: Widget;
let messaging: Mocked<ClientWidgetApi>; let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -237,7 +237,7 @@ describe("JitsiCall", () => {
if (maybeCall === null) throw new Error("Failed to create call"); if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall; call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); ({ widget, messaging } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => { mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) { 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); expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true); await connect(call, messaging);
videoMutedSpy.mockReturnValue(true);
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
videoInput: null,
});
}); });
it("connects unmuted", async () => { it("waits for messaging when starting", 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 () => {
// Temporarily remove the messaging to simulate connecting while the // Temporarily remove the messaging to simulate connecting while the
// widget is still initializing // widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start(); const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect; await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected); 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<any> => {
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 () => { it("fails to disconnect if the widget returns an error", async () => {
await call.start(); await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); mocked(messaging.transport).send.mockRejectedValue(new Error("never!"));
await expect(call.disconnect()).rejects.toBeDefined(); await expect(call.disconnect()).rejects.toBeDefined();
}); });
it("handles remote disconnection", async () => { it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start(); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
const callback = jest.fn(); const callback = jest.fn();
@ -358,7 +293,6 @@ describe("JitsiCall", () => {
call.on(CallEvent.ConnectionState, callback); call.on(CallEvent.ConnectionState, callback);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => { await waitFor(() => {
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected); expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected);
}); });
@ -368,14 +302,14 @@ describe("JitsiCall", () => {
it("disconnects", async () => { it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start(); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect(); await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("disconnects when we leave the room", async () => { it("disconnects when we leave the room", async () => {
await call.start(); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
@ -383,14 +317,14 @@ describe("JitsiCall", () => {
it("reconnects after disconnect in video rooms", async () => { it("reconnects after disconnect in video rooms", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start(); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect(); await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("remains connected if we stay in the room", async () => { it("remains connected if we stay in the room", async () => {
await call.start(); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
@ -416,7 +350,7 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo // Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset(); client.sendStateEvent.mockReset();
await call.start(); await connect(call, messaging);
expect(call.participants).toEqual( expect(call.participants).toEqual(
new Map([ new Map([
[alice, new Set(["alices_device"])], [alice, new Set(["alices_device"])],
@ -429,8 +363,8 @@ describe("JitsiCall", () => {
}); });
it("updates room state when connecting and disconnecting", async () => { it("updates room state when connecting and disconnecting", async () => {
await connect(call, messaging);
const now1 = Date.now(); const now1 = Date.now();
await call.start();
await waitFor( await waitFor(
() => () =>
expect( expect(
@ -457,7 +391,7 @@ describe("JitsiCall", () => {
}); });
it("repeatedly updates room state while connected", async () => { it("repeatedly updates room state while connected", async () => {
await call.start(); await connect(call, messaging);
await waitFor( await waitFor(
() => () =>
expect(client.sendStateEvent).toHaveBeenLastCalledWith( expect(client.sendStateEvent).toHaveBeenLastCalledWith(
@ -487,7 +421,7 @@ describe("JitsiCall", () => {
const onConnectionState = jest.fn(); const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState); call.on(CallEvent.ConnectionState, onConnectionState);
await call.start(); await connect(call, messaging);
await call.disconnect(); await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([ expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Connected, ConnectionState.Disconnected],
@ -502,7 +436,7 @@ describe("JitsiCall", () => {
const onParticipants = jest.fn(); const onParticipants = jest.fn();
call.on(CallEvent.Participants, onParticipants); call.on(CallEvent.Participants, onParticipants);
await call.start(); await connect(call, messaging);
await call.disconnect(); await call.disconnect();
expect(onParticipants.mock.calls).toEqual([ expect(onParticipants.mock.calls).toEqual([
[new Map([[alice, new Set(["alices_device"])]]), new Map()], [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 () => { it("switches to spotlight layout when the widget becomes a PiP", async () => {
await call.start(); await connect(call, messaging);
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
@ -559,7 +493,7 @@ describe("JitsiCall", () => {
}); });
it("doesn't clean up valid devices", async () => { it("doesn't clean up valid devices", async () => {
await call.start(); await connect(call, messaging);
await client.sendStateEvent( await client.sendStateEvent(
room.roomId, room.roomId,
JitsiCall.MEMBER_EVENT_TYPE, JitsiCall.MEMBER_EVENT_TYPE,
@ -624,47 +558,6 @@ describe("ElementCall", () => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
} }
const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise<void> => {
async function sessionConnect() {
await new Promise<void>((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<void> = async (call) => {
async function sessionDisconnect() {
await new Promise<void>((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(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
({ client, room, alice } = setUpClientRoomAndStores()); ({ client, room, alice } = setUpClientRoomAndStores());
@ -886,8 +779,6 @@ describe("ElementCall", () => {
let call: ElementCall; let call: ElementCall;
let widget: Widget; let widget: Widget;
let messaging: Mocked<ClientWidgetApi>; let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -898,27 +789,28 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call"); if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall; 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. // TODO refactor initial device configuration to use the EW settings.
// Add tests for passing EW device configuration to the widget. // 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 // Temporarily remove the messaging to simulate connecting while the
// widget is still initializing // widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = callConnectProcedure(call); const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect; await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
}); });
it("fails to disconnect if the widget returns an error", async () => { 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! >:(")); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined(); await expect(call.disconnect()).rejects.toBeDefined();
}); });
@ -926,7 +818,7 @@ describe("ElementCall", () => {
it("handles remote disconnection", async () => { it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
@ -936,35 +828,35 @@ describe("ElementCall", () => {
it("disconnects", async () => { it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
await callDisconnectionProcedure(call); await disconnect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("disconnects when we leave the room", async () => { it("disconnects when we leave the room", async () => {
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("remains connected if we stay in the room", async () => { it("remains connected if we stay in the room", async () => {
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
}); });
it("disconnects if the widget dies", async () => { it("disconnects if the widget dies", async () => {
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("acknowledges mute_device widget action", async () => { it("acknowledges mute_device widget action", async () => {
await callConnectProcedure(call); await connect(call, messaging);
const preventDefault = jest.fn(); const preventDefault = jest.fn();
const mockEv = { const mockEv = {
preventDefault, preventDefault,
@ -980,8 +872,8 @@ describe("ElementCall", () => {
const onConnectionState = jest.fn(); const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState); call.on(CallEvent.ConnectionState, onConnectionState);
await callConnectProcedure(call); await connect(call, messaging);
await callDisconnectionProcedure(call); await disconnect(call, messaging);
expect(onConnectionState.mock.calls).toEqual([ expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected], [ConnectionState.Disconnecting, ConnectionState.Connected],
@ -1003,10 +895,10 @@ describe("ElementCall", () => {
}); });
it("ends the call immediately if the session ended", async () => { it("ends the call immediately if the session ended", async () => {
await callConnectProcedure(call); await connect(call, messaging);
const onDestroy = jest.fn(); const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy); call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call); await disconnect(call, messaging);
// this will be called automatically // this will be called automatically
// disconnect -> widget sends state event -> session manager notices no-one left // disconnect -> widget sends state event -> session manager notices no-one left
client.matrixRTC.emit( client.matrixRTC.emit(
@ -1048,8 +940,6 @@ describe("ElementCall", () => {
let call: ElementCall; let call: ElementCall;
let widget: Widget; let widget: Widget;
let messaging: Mocked<ClientWidgetApi>; let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -1062,64 +952,29 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call"); if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall; 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 () => { it("doesn't end the call when the last participant leaves", async () => {
await callConnectProcedure(call); await connect(call, messaging);
const onDestroy = jest.fn(); const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy); call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call); await disconnect(call, messaging);
expect(onDestroy).not.toHaveBeenCalled(); expect(onDestroy).not.toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy); 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 () => { it("handles remote disconnection and reconnect right after", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call); await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); 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 // We should now be able to reconnect without manually starting the widget
expect(call.connectionState).toBe(ConnectionState.Disconnected); 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 }); await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
}); });
}); });

View File

@ -28,6 +28,7 @@ import "../../../../../src/stores/room-list/RoomListStore"; // must be imported
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm"; import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
import { CallStore } from "../../../../../src/stores/CallStore"; import { CallStore } from "../../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../../src/models/Call";
describe("Algorithm", () => { describe("Algorithm", () => {
useMockedCalls(); useMockedCalls();
@ -83,7 +84,7 @@ describe("Algorithm", () => {
MockedCall.create(roomWithCall, "1"); MockedCall.create(roomWithCall, "1");
const call = CallStore.instance.getCall(roomWithCall.roomId); 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); const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, { WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
@ -93,7 +94,7 @@ describe("Algorithm", () => {
// End of setup // End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
await call.start(); call.setConnectionState(ConnectionState.Connected);
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect(); await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);