mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-09 12:41:07 +01:00
923 lines
38 KiB
TypeScript
923 lines
38 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import EventEmitter from "events";
|
|
import { mocked } from "jest-mock";
|
|
import { waitFor } from "jest-matrix-react";
|
|
import {
|
|
RoomType,
|
|
type Room,
|
|
RoomEvent,
|
|
MatrixEvent,
|
|
type MatrixClient,
|
|
type IMyDevice,
|
|
type RoomMember,
|
|
} from "matrix-js-sdk/src/matrix";
|
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
|
import { Widget } from "matrix-widget-api";
|
|
import {
|
|
type CallMembership,
|
|
MatrixRTCSessionManagerEvents,
|
|
MatrixRTCSession,
|
|
MatrixRTCSessionEvent,
|
|
} from "matrix-js-sdk/src/matrixrtc";
|
|
|
|
import type { Mocked } from "jest-mock";
|
|
import type { ClientWidgetApi } from "matrix-widget-api";
|
|
import {
|
|
type JitsiCallMemberContent,
|
|
Call,
|
|
CallEvent,
|
|
ConnectionState,
|
|
JitsiCall,
|
|
ElementCall,
|
|
ElementCallIntent,
|
|
} from "../../../src/models/Call";
|
|
import { cleanUpClientRoomAndStores, enableCalls, mockPlatformPeg, setUpClientRoomAndStores } from "../../test-utils";
|
|
import WidgetStore from "../../../src/stores/WidgetStore";
|
|
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
|
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../../src/stores/ActiveWidgetStore";
|
|
import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
|
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
|
import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics";
|
|
import { type SettingKey } from "../../../src/settings/Settings.tsx";
|
|
import SdkConfig from "../../../src/SdkConfig.ts";
|
|
import DMRoomMap from "../../../src/utils/DMRoomMap.ts";
|
|
|
|
const { enabledSettings } = enableCalls();
|
|
|
|
const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked<ClientWidgetApi> } => {
|
|
call.widget.data = { ...call.widget, skipLobby: true };
|
|
const widget = new Widget(call.widget);
|
|
|
|
const eventEmitter = new EventEmitter();
|
|
const messaging = {
|
|
on: eventEmitter.on.bind(eventEmitter),
|
|
off: eventEmitter.off.bind(eventEmitter),
|
|
once: eventEmitter.once.bind(eventEmitter),
|
|
emit: eventEmitter.emit.bind(eventEmitter),
|
|
stop: jest.fn(),
|
|
transport: {
|
|
send: jest.fn(),
|
|
reply: jest.fn(),
|
|
},
|
|
} as unknown as Mocked<ClientWidgetApi>;
|
|
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
|
|
|
|
return { widget, messaging };
|
|
};
|
|
|
|
async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
|
|
async function sessionConnect() {
|
|
await new Promise<void>((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<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();
|
|
jest.clearAllMocks();
|
|
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
|
|
};
|
|
|
|
describe("JitsiCall", () => {
|
|
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
|
|
|
|
let client: Mocked<MatrixClient>;
|
|
let room: Room;
|
|
let alice: RoomMember;
|
|
let bob: RoomMember;
|
|
let carol: RoomMember;
|
|
|
|
beforeEach(() => {
|
|
({ client, room, alice, bob, carol } = setUpClientRoomAndStores());
|
|
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
|
|
});
|
|
|
|
afterEach(() => cleanUpClientRoomAndStores(client, room));
|
|
|
|
describe("get", () => {
|
|
it("finds no calls", () => {
|
|
expect(Call.get(room)).toBeNull();
|
|
});
|
|
|
|
it("finds calls", async () => {
|
|
await JitsiCall.create(room);
|
|
expect(Call.get(room)).toBeInstanceOf(JitsiCall);
|
|
});
|
|
|
|
it("ignores terminated calls", async () => {
|
|
await JitsiCall.create(room);
|
|
|
|
// Terminate the call
|
|
const [event] = room.currentState.getStateEvents("im.vector.modular.widgets");
|
|
await client.sendStateEvent(room.roomId, "im.vector.modular.widgets", {}, event.getStateKey()!);
|
|
|
|
expect(Call.get(room)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("instance in a video room", () => {
|
|
let call: JitsiCall;
|
|
let widget: Widget;
|
|
let messaging: Mocked<ClientWidgetApi>;
|
|
|
|
beforeEach(async () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(0);
|
|
|
|
await JitsiCall.create(room);
|
|
const maybeCall = JitsiCall.get(room);
|
|
if (maybeCall === null) throw new Error("Failed to create call");
|
|
call = maybeCall;
|
|
|
|
({ widget, messaging } = setUpWidget(call));
|
|
|
|
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
|
|
if (action === ElementWidgetActions.JoinCall) {
|
|
messaging.emit(
|
|
`action:${ElementWidgetActions.JoinCall}`,
|
|
new CustomEvent("widgetapirequest", { detail: { data } }),
|
|
);
|
|
} else if (action === ElementWidgetActions.HangupCall) {
|
|
messaging.emit(
|
|
`action:${ElementWidgetActions.HangupCall}`,
|
|
new CustomEvent("widgetapirequest", { detail: { data } }),
|
|
);
|
|
}
|
|
return {};
|
|
});
|
|
});
|
|
|
|
afterEach(() => cleanUpCallAndWidget(call, widget));
|
|
|
|
it("connects", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
await connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
});
|
|
|
|
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 startup = call.start();
|
|
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
|
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 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 connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
|
|
const callback = jest.fn();
|
|
|
|
call.on(CallEvent.ConnectionState, callback);
|
|
|
|
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
|
|
await waitFor(() => {
|
|
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected);
|
|
});
|
|
// in video rooms we expect the call to immediately reconnect
|
|
call.off(CallEvent.ConnectionState, callback);
|
|
});
|
|
|
|
it("disconnects", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
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 connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
});
|
|
|
|
it("reconnects after disconnect in video rooms", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
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 connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
});
|
|
|
|
it("tracks participants in room state", async () => {
|
|
expect(call.participants).toEqual(new Map());
|
|
|
|
// A participant with multiple devices (should only show up once)
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
{ devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 },
|
|
bob.userId,
|
|
);
|
|
// A participant with an expired device (should not show up)
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
{ devices: ["carolandroid"], expires_ts: -1000 * 60 },
|
|
carol.userId,
|
|
);
|
|
|
|
// Now, stub out client.sendStateEvent so we can test our local echo
|
|
client.sendStateEvent.mockReset();
|
|
await connect(call, messaging);
|
|
expect(call.participants).toEqual(
|
|
new Map([
|
|
[alice, new Set(["alices_device"])],
|
|
[bob, new Set(["bobweb", "bobdesktop"])],
|
|
]),
|
|
);
|
|
|
|
await call.disconnect();
|
|
expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]]));
|
|
});
|
|
|
|
it("updates room state when connecting and disconnecting", async () => {
|
|
await connect(call, messaging);
|
|
const now1 = Date.now();
|
|
await waitFor(
|
|
() =>
|
|
expect(
|
|
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)?.getContent(),
|
|
).toEqual({
|
|
devices: [client.getDeviceId()],
|
|
expires_ts: now1 + call.STUCK_DEVICE_TIMEOUT_MS,
|
|
}),
|
|
{ interval: 5 },
|
|
);
|
|
|
|
const now2 = Date.now();
|
|
await call.disconnect();
|
|
await waitFor(
|
|
() =>
|
|
expect(
|
|
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)?.getContent(),
|
|
).toEqual({
|
|
devices: [],
|
|
expires_ts: now2 + call.STUCK_DEVICE_TIMEOUT_MS,
|
|
}),
|
|
{ interval: 5 },
|
|
);
|
|
});
|
|
|
|
it("repeatedly updates room state while connected", async () => {
|
|
await connect(call, messaging);
|
|
await waitFor(
|
|
() =>
|
|
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
{ devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
|
|
alice.userId,
|
|
),
|
|
{ interval: 5 },
|
|
);
|
|
|
|
client.sendStateEvent.mockClear();
|
|
jest.advanceTimersByTime(call.STUCK_DEVICE_TIMEOUT_MS);
|
|
await waitFor(
|
|
() =>
|
|
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
{ devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
|
|
alice.userId,
|
|
),
|
|
{ interval: 5 },
|
|
);
|
|
});
|
|
|
|
it("emits events when connection state changes", async () => {
|
|
const onConnectionState = jest.fn();
|
|
call.on(CallEvent.ConnectionState, onConnectionState);
|
|
|
|
await connect(call, messaging);
|
|
await call.disconnect();
|
|
expect(onConnectionState.mock.calls).toEqual([
|
|
[ConnectionState.Connected, ConnectionState.Disconnected],
|
|
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
|
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
|
]);
|
|
|
|
call.off(CallEvent.ConnectionState, onConnectionState);
|
|
});
|
|
|
|
it("emits events when participants change", async () => {
|
|
const onParticipants = jest.fn();
|
|
call.on(CallEvent.Participants, onParticipants);
|
|
|
|
await connect(call, messaging);
|
|
await call.disconnect();
|
|
expect(onParticipants.mock.calls).toEqual([
|
|
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
|
|
[new Map([[alice, new Set(["alices_device"])]]), new Map([[alice, new Set(["alices_device"])]])],
|
|
[new Map(), new Map([[alice, new Set(["alices_device"])]])],
|
|
[new Map(), new Map()],
|
|
]);
|
|
|
|
call.off(CallEvent.Participants, onParticipants);
|
|
});
|
|
|
|
it("switches to spotlight layout when the widget becomes a PiP", async () => {
|
|
await connect(call, messaging);
|
|
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
|
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
|
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
|
|
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
|
|
});
|
|
|
|
describe("clean", () => {
|
|
const aliceWeb: IMyDevice = {
|
|
device_id: "aliceweb",
|
|
last_seen_ts: 0,
|
|
};
|
|
const aliceDesktop: IMyDevice = {
|
|
device_id: "alicedesktop",
|
|
last_seen_ts: 0,
|
|
};
|
|
const aliceDesktopOffline: IMyDevice = {
|
|
device_id: "alicedesktopoffline",
|
|
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
|
|
};
|
|
const aliceDesktopNeverOnline: IMyDevice = {
|
|
device_id: "alicedesktopneveronline",
|
|
};
|
|
|
|
const mkContent = (devices: IMyDevice[]): JitsiCallMemberContent => ({
|
|
expires_ts: 1000 * 60 * 10,
|
|
devices: devices.map((d) => d.device_id),
|
|
});
|
|
const expectDevices = (devices: IMyDevice[]) =>
|
|
expect(
|
|
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)?.getContent(),
|
|
).toEqual({
|
|
expires_ts: expect.any(Number),
|
|
devices: devices.map((d) => d.device_id),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
client.getDeviceId.mockReturnValue(aliceWeb.device_id);
|
|
client.getDevices.mockResolvedValue({
|
|
devices: [aliceWeb, aliceDesktop, aliceDesktopOffline, aliceDesktopNeverOnline],
|
|
});
|
|
});
|
|
|
|
it("doesn't clean up valid devices", async () => {
|
|
await connect(call, messaging);
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
mkContent([aliceWeb, aliceDesktop]),
|
|
alice.userId,
|
|
);
|
|
|
|
await call.clean();
|
|
expectDevices([aliceWeb, aliceDesktop]);
|
|
});
|
|
|
|
it("cleans up our own device if we're disconnected", async () => {
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
mkContent([aliceWeb, aliceDesktop]),
|
|
alice.userId,
|
|
);
|
|
|
|
await call.clean();
|
|
expectDevices([aliceDesktop]);
|
|
});
|
|
|
|
it("cleans up devices that have been offline for too long", async () => {
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
mkContent([aliceDesktop, aliceDesktopOffline]),
|
|
alice.userId,
|
|
);
|
|
|
|
await call.clean();
|
|
expectDevices([aliceDesktop]);
|
|
});
|
|
|
|
it("cleans up devices that have never been online", async () => {
|
|
await client.sendStateEvent(
|
|
room.roomId,
|
|
JitsiCall.MEMBER_EVENT_TYPE,
|
|
mkContent([aliceDesktop, aliceDesktopNeverOnline]),
|
|
alice.userId,
|
|
);
|
|
|
|
await call.clean();
|
|
expectDevices([aliceDesktop]);
|
|
});
|
|
|
|
it("no-ops if there are no state events", async () => {
|
|
await call.clean();
|
|
expect(room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)).toBe(null);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ElementCall", () => {
|
|
let client: Mocked<MatrixClient>;
|
|
let room: Room;
|
|
let alice: RoomMember;
|
|
let roomSession: Mocked<MatrixRTCSession>;
|
|
function setRoomMembers(memberIds: string[]) {
|
|
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
({ client, room, alice, roomSession } = setUpClientRoomAndStores());
|
|
SdkConfig.reset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.runOnlyPendingTimers();
|
|
jest.useRealTimers();
|
|
cleanUpClientRoomAndStores(client, room);
|
|
});
|
|
|
|
describe("get", () => {
|
|
let getUserIdForRoomIdSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
getUserIdForRoomIdSpy = jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId");
|
|
});
|
|
|
|
afterEach(() => {
|
|
Call.get(room)?.destroy();
|
|
getUserIdForRoomIdSpy.mockRestore();
|
|
});
|
|
|
|
it("finds no calls", () => {
|
|
expect(Call.get(room)).toBeNull();
|
|
});
|
|
|
|
it("finds calls", async () => {
|
|
ElementCall.create(room);
|
|
expect(Call.get(room)).toBeInstanceOf(ElementCall);
|
|
});
|
|
|
|
it("should use element call URL from developer settings if present", async () => {
|
|
const originalGetValue = SettingsStore.getValue;
|
|
SettingsStore.getValue = (name: SettingKey, roomId: string | null = null, excludeDefault = false): any => {
|
|
if (name === "Developer.elementCallUrl") {
|
|
return "https://call.element.dev";
|
|
}
|
|
return excludeDefault
|
|
? originalGetValue(name, roomId, excludeDefault)
|
|
: originalGetValue(name, roomId, excludeDefault);
|
|
};
|
|
await ElementCall.create(room);
|
|
const call = ElementCall.get(room);
|
|
expect(call?.widget.url.startsWith("https://call.element.dev/")).toBeTruthy();
|
|
SettingsStore.getValue = originalGetValue;
|
|
});
|
|
|
|
it("finds ongoing calls that are created by the session manager", async () => {
|
|
// There is an existing session created by another user in this room.
|
|
roomSession.memberships.push({} as CallMembership);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
});
|
|
|
|
it("passes font settings through widget URL", async () => {
|
|
const originalGetValue = SettingsStore.getValue;
|
|
SettingsStore.getValue = (name: SettingKey, roomId: string | null = null, excludeDefault = false): any => {
|
|
switch (name) {
|
|
case "fontSizeDelta":
|
|
return 4;
|
|
case "useSystemFont":
|
|
return true;
|
|
case "systemFont":
|
|
return "OpenDyslexic, DejaVu Sans";
|
|
default:
|
|
return excludeDefault
|
|
? originalGetValue(name, roomId, excludeDefault)
|
|
: originalGetValue(name, roomId, excludeDefault);
|
|
}
|
|
};
|
|
document.documentElement.style.fontSize = "12px";
|
|
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("fontScale")).toBe("1.5");
|
|
expect(urlParams.getAll("font")).toEqual(["OpenDyslexic", "DejaVu Sans"]);
|
|
|
|
SettingsStore.getValue = originalGetValue;
|
|
});
|
|
|
|
it("passes ICE fallback preference through widget URL", async () => {
|
|
// Test with the preference set to false
|
|
ElementCall.create(room);
|
|
const call1 = Call.get(room);
|
|
if (!(call1 instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams1 = new URLSearchParams(new URL(call1.widget.url).hash.slice(1));
|
|
expect(urlParams1.has("allowIceFallback")).toBe(false);
|
|
call1.destroy();
|
|
|
|
// Now test with the preference set to true
|
|
const originalGetValue = SettingsStore.getValue;
|
|
SettingsStore.getValue = (name: SettingKey, roomId: string | null = null, excludeDefault = false): any => {
|
|
switch (name) {
|
|
case "fallbackICEServerAllowed":
|
|
return true;
|
|
default:
|
|
return excludeDefault
|
|
? originalGetValue(name, roomId, excludeDefault)
|
|
: originalGetValue(name, roomId, excludeDefault);
|
|
}
|
|
};
|
|
|
|
ElementCall.create(room);
|
|
const call2 = Call.get(room);
|
|
if (!(call2 instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams2 = new URLSearchParams(new URL(call2.widget.url).hash.slice(1));
|
|
expect(urlParams2.has("allowIceFallback")).toBe(true);
|
|
|
|
SettingsStore.getValue = originalGetValue;
|
|
});
|
|
|
|
it("passes analyticsID and posthog params through widget URL", async () => {
|
|
SdkConfig.put({
|
|
posthog: {
|
|
api_host: "https://posthog",
|
|
project_api_key: "DEADBEEF",
|
|
},
|
|
});
|
|
jest.spyOn(PosthogAnalytics.instance, "getAnonymity").mockReturnValue(Anonymity.Pseudonymous);
|
|
client.getAccountData.mockImplementation((eventType: string) => {
|
|
if (eventType === PosthogAnalytics.ANALYTICS_EVENT_TYPE) {
|
|
return new MatrixEvent({ content: { id: "123456789987654321", pseudonymousAnalyticsOptIn: true } });
|
|
}
|
|
return undefined;
|
|
});
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("analyticsID")).toBe("123456789987654321");
|
|
expect(urlParams.get("posthogUserId")).toBe("123456789987654321");
|
|
expect(urlParams.get("posthogApiHost")).toBe("https://posthog");
|
|
expect(urlParams.get("posthogApiKey")).toBe("DEADBEEF");
|
|
});
|
|
|
|
it("does not pass analyticsID if `pseudonymousAnalyticsOptIn` set to false", async () => {
|
|
client.getAccountData.mockImplementation((eventType: string) => {
|
|
if (eventType === PosthogAnalytics.ANALYTICS_EVENT_TYPE) {
|
|
return new MatrixEvent({
|
|
content: { id: "123456789987654321", pseudonymousAnalyticsOptIn: false },
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("analyticsID")).toBeFalsy();
|
|
});
|
|
|
|
it("passes empty analyticsID if the id is not in the account data", async () => {
|
|
client.getAccountData.mockImplementation((eventType: string) => {
|
|
if (eventType === PosthogAnalytics.ANALYTICS_EVENT_TYPE) {
|
|
return new MatrixEvent({ content: {} });
|
|
}
|
|
return undefined;
|
|
});
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("analyticsID")).toBeFalsy();
|
|
});
|
|
|
|
it("requests correct intent in DMs", async () => {
|
|
getUserIdForRoomIdSpy.mockImplementation((roomId: string) =>
|
|
room.roomId === roomId ? "any-user" : undefined,
|
|
);
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("intent")).toBe(ElementCallIntent.StartCallDM);
|
|
});
|
|
|
|
it("requests correct intent when answering DMs", async () => {
|
|
roomSession.getOldestMembership.mockReturnValue({} as CallMembership);
|
|
getUserIdForRoomIdSpy.mockImplementation((roomId: string) =>
|
|
room.roomId === roomId ? "any-user" : undefined,
|
|
);
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("intent")).toBe(ElementCallIntent.JoinExistingDM);
|
|
});
|
|
|
|
it("requests correct intent when creating a non-DM call", async () => {
|
|
roomSession.getOldestMembership.mockReturnValue(undefined);
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("intent")).toBe(ElementCallIntent.StartCall);
|
|
});
|
|
|
|
it("requests correct intent when joining a non-DM call", async () => {
|
|
roomSession.getOldestMembership.mockReturnValue({} as CallMembership);
|
|
ElementCall.create(room);
|
|
const call = Call.get(room);
|
|
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
|
|
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
|
|
expect(urlParams.get("intent")).toBe(ElementCallIntent.JoinExisting);
|
|
});
|
|
});
|
|
|
|
describe("instance in a non-video room", () => {
|
|
let call: ElementCall;
|
|
let widget: Widget;
|
|
let messaging: Mocked<ClientWidgetApi>;
|
|
|
|
beforeEach(async () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(0);
|
|
|
|
ElementCall.create(room);
|
|
const maybeCall = ElementCall.get(room);
|
|
if (maybeCall === null) throw new Error("Failed to create call");
|
|
call = maybeCall;
|
|
|
|
({ widget, messaging } = setUpWidget(call));
|
|
});
|
|
|
|
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 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 startup = call.start({});
|
|
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
|
|
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 connect(call, messaging);
|
|
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
|
|
await expect(call.disconnect()).rejects.toBeDefined();
|
|
});
|
|
|
|
it("handles remote disconnection", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
|
|
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", {}));
|
|
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
|
|
});
|
|
|
|
it("disconnects", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
await connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
await disconnect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
});
|
|
|
|
it("disconnects when we leave the room", async () => {
|
|
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 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 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 connect(call, messaging);
|
|
const preventDefault = jest.fn();
|
|
const mockEv = {
|
|
preventDefault,
|
|
detail: { video_enabled: false },
|
|
};
|
|
messaging.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv);
|
|
expect(messaging.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {});
|
|
expect(preventDefault).toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits events when connection state changes", async () => {
|
|
// const wait = jest.spyOn(CallModule, "waitForEvent");
|
|
const onConnectionState = jest.fn();
|
|
call.on(CallEvent.ConnectionState, onConnectionState);
|
|
|
|
await connect(call, messaging);
|
|
await disconnect(call, messaging);
|
|
expect(onConnectionState.mock.calls).toEqual([
|
|
[ConnectionState.Connected, ConnectionState.Disconnected],
|
|
[ConnectionState.Disconnecting, ConnectionState.Connected],
|
|
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
|
|
]);
|
|
|
|
call.off(CallEvent.ConnectionState, onConnectionState);
|
|
});
|
|
|
|
it("emits events when participants change", async () => {
|
|
const onParticipants = jest.fn();
|
|
call.session.memberships = [{ sender: alice.userId, deviceId: "alices_device" } as CallMembership];
|
|
call.on(CallEvent.Participants, onParticipants);
|
|
call.session.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
|
|
|
|
expect(onParticipants.mock.calls).toEqual([[new Map([[alice, new Set(["alices_device"])]]), new Map()]]);
|
|
|
|
call.off(CallEvent.Participants, onParticipants);
|
|
});
|
|
|
|
it("ends the call immediately if the session ended", async () => {
|
|
await connect(call, messaging);
|
|
const onDestroy = jest.fn();
|
|
call.on(CallEvent.Destroy, onDestroy);
|
|
await disconnect(call, messaging);
|
|
// this will be called automatically
|
|
// disconnect -> widget sends state event -> session manager notices no-one left
|
|
client.matrixRTC.emit(
|
|
MatrixRTCSessionManagerEvents.SessionEnded,
|
|
room.roomId,
|
|
{} as unknown as MatrixRTCSession,
|
|
);
|
|
expect(onDestroy).toHaveBeenCalled();
|
|
call.off(CallEvent.Destroy, onDestroy);
|
|
});
|
|
|
|
it("clears widget persistence when destroyed", async () => {
|
|
const destroyPersistentWidgetSpy = jest.spyOn(ActiveWidgetStore.instance, "destroyPersistentWidget");
|
|
call.destroy();
|
|
expect(destroyPersistentWidgetSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("the perParticipantE2EE url flag is used in encrypted rooms while respecting the feature_disable_call_per_sender_encryption flag", async () => {
|
|
// We destroy the call created in beforeEach because we test the call creation process.
|
|
call.destroy();
|
|
const addWidgetSpy = jest.spyOn(WidgetStore.instance, "addVirtualWidget");
|
|
// If a room is not encrypted we will never add the perParticipantE2EE flag.
|
|
const roomSpy = jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
|
|
|
|
// should create call with perParticipantE2EE flag
|
|
ElementCall.create(room);
|
|
expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(true);
|
|
|
|
// should create call without perParticipantE2EE flag
|
|
enabledSettings.add("feature_disable_call_per_sender_encryption");
|
|
expect(Call.get(room)?.widget?.data?.perParticipantE2EE).toBe(false);
|
|
enabledSettings.delete("feature_disable_call_per_sender_encryption");
|
|
roomSpy.mockRestore();
|
|
addWidgetSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("instance in a video room", () => {
|
|
let call: ElementCall;
|
|
let widget: Widget;
|
|
let messaging: Mocked<ClientWidgetApi>;
|
|
|
|
beforeEach(async () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(0);
|
|
|
|
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
|
|
|
|
ElementCall.create(room);
|
|
const maybeCall = ElementCall.get(room);
|
|
if (maybeCall === null) throw new Error("Failed to create call");
|
|
call = maybeCall;
|
|
|
|
({ widget, messaging } = setUpWidget(call));
|
|
});
|
|
|
|
afterEach(() => cleanUpCallAndWidget(call, widget));
|
|
|
|
it("doesn't end the call when the last participant leaves", async () => {
|
|
await connect(call, messaging);
|
|
const onDestroy = jest.fn();
|
|
call.on(CallEvent.Destroy, onDestroy);
|
|
await disconnect(call, messaging);
|
|
expect(onDestroy).not.toHaveBeenCalled();
|
|
call.off(CallEvent.Destroy, onDestroy);
|
|
});
|
|
|
|
it("handles remote disconnection and reconnect right after", async () => {
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
await connect(call, messaging);
|
|
expect(call.connectionState).toBe(ConnectionState.Connected);
|
|
|
|
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
|
|
// We should now be able to reconnect without manually starting the widget
|
|
expect(call.connectionState).toBe(ConnectionState.Disconnected);
|
|
await connect(call, messaging, false);
|
|
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
|
|
});
|
|
});
|
|
describe("create call", () => {
|
|
beforeEach(async () => {
|
|
setRoomMembers(["@user:example.com", "@user2:example.com", "@user4:example.com"]);
|
|
});
|
|
it("don't sent notify event if there are existing room call members", async () => {
|
|
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([
|
|
{ application: "m.call", callId: "" } as unknown as CallMembership,
|
|
]);
|
|
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
|
|
ElementCall.create(room);
|
|
expect(sendEventSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|