Files
element-call/src/state/Connection.test.ts

582 lines
18 KiB
TypeScript
Raw Normal View History

2025-10-01 14:21:37 +02:00
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
2025-10-01 17:24:19 +02:00
import { afterEach, describe, expect, it, type MockedObject, vi } from "vitest";
2025-10-01 14:21:37 +02:00
import { type CallMembership, type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
2025-10-02 12:53:59 +02:00
import { ConnectionState, type RemoteParticipant, type Room as LivekitRoom, RoomEvent } from "livekit-client";
2025-10-01 14:21:37 +02:00
import fetchMock from "fetch-mock";
import EventEmitter from "events";
2025-10-01 17:24:19 +02:00
import { type IOpenIDToken } from "matrix-js-sdk";
2025-10-01 14:21:37 +02:00
import { type ConnectionOpts, type FocusConnectionState, RemoteConnection } from "./Connection.ts";
import { ObservableScope } from "./ObservableScope.ts";
2025-10-01 17:24:19 +02:00
import { type OpenIDClientParts } from "../livekit/openIDSFU.ts";
2025-10-01 14:21:37 +02:00
import { FailToGetOpenIdToken } from "../utils/errors.ts";
2025-10-01 17:24:19 +02:00
let testScope: ObservableScope;
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
let client: MockedObject<OpenIDClientParts>;
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
let fakeLivekitRoom: MockedObject<LivekitRoom>;
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
let fakeRoomEventEmiter: EventEmitter;
let fakeMembershipsFocusMap$: BehaviorSubject<{ membership: CallMembership; focus: LivekitFocus }[]>;
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
const livekitFocus: LivekitFocus = {
livekit_alias: "!roomID:example.org",
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
2025-10-02 12:53:59 +02:00
type: "livekit"
};
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
function setupTest(): void {
testScope = new ObservableScope();
client = vi.mocked<OpenIDClientParts>({
getOpenIdToken: vi.fn().mockResolvedValue(
{
"access_token": "rYsmGUEwNjKgJYyeNUkZseJN",
"token_type": "Bearer",
"matrix_server_name": "example.org",
"expires_in": 3600
}
),
2025-10-02 12:53:59 +02:00
getDeviceId: vi.fn().mockReturnValue("ABCDEF")
2025-10-01 17:24:19 +02:00
} as unknown as OpenIDClientParts);
fakeMembershipsFocusMap$ = new BehaviorSubject<{ membership: CallMembership; focus: LivekitFocus }[]>([]);
fakeRoomEventEmiter = new EventEmitter();
fakeLivekitRoom = vi.mocked<LivekitRoom>({
connect: vi.fn(),
disconnect: vi.fn(),
remoteParticipants: new Map(),
state: ConnectionState.Disconnected,
on: fakeRoomEventEmiter.on.bind(fakeRoomEventEmiter),
off: fakeRoomEventEmiter.off.bind(fakeRoomEventEmiter),
addListener: fakeRoomEventEmiter.addListener.bind(fakeRoomEventEmiter),
removeListener: fakeRoomEventEmiter.removeListener.bind(fakeRoomEventEmiter),
2025-10-02 12:53:59 +02:00
removeAllListeners: fakeRoomEventEmiter.removeAllListeners.bind(fakeRoomEventEmiter)
2025-10-01 17:24:19 +02:00
} as unknown as LivekitRoom);
}
function setupRemoteConnection(): RemoteConnection {
const opts: ConnectionOpts = {
client: client,
focus: livekitFocus,
membershipsFocusMap$: fakeMembershipsFocusMap$,
scope: testScope,
2025-10-02 12:53:59 +02:00
livekitRoomFactory: () => fakeLivekitRoom
};
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`,
() => {
return {
status: 200,
body:
{
"url": "wss://matrix-rtc.m.localhost/livekit/sfu",
2025-10-02 12:53:59 +02:00
"jwt": "ATOKEN"
}
};
2025-10-01 14:47:45 +02:00
}
2025-10-01 17:24:19 +02:00
);
2025-10-01 14:47:45 +02:00
2025-10-01 17:24:19 +02:00
fakeLivekitRoom
.connect
.mockResolvedValue(undefined);
2025-10-01 14:47:45 +02:00
2025-10-01 17:24:19 +02:00
return new RemoteConnection(
opts,
2025-10-02 12:53:59 +02:00
undefined
2025-10-01 17:24:19 +02:00
);
}
2025-10-01 14:47:45 +02:00
2025-10-01 17:24:19 +02:00
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
fetchMock.reset();
});
2025-10-01 17:24:19 +02:00
describe("Start connection states", () => {
2025-10-01 14:47:45 +02:00
2025-10-01 14:21:37 +02:00
it("start in initialized state", () => {
setupTest();
const opts: ConnectionOpts = {
client: client,
focus: livekitFocus,
membershipsFocusMap$: fakeMembershipsFocusMap$,
scope: testScope,
2025-10-02 12:53:59 +02:00
livekitRoomFactory: () => fakeLivekitRoom
};
2025-10-01 14:21:37 +02:00
const connection = new RemoteConnection(
2025-10-01 16:39:21 +02:00
opts,
2025-10-02 12:53:59 +02:00
undefined
2025-10-01 14:21:37 +02:00
);
expect(connection.focusedConnectionState$.getValue().state)
.toEqual("Initialized");
});
it("fail to getOpenId token then error state", async () => {
setupTest();
vi.useFakeTimers();
const opts: ConnectionOpts = {
client: client,
focus: livekitFocus,
membershipsFocusMap$: fakeMembershipsFocusMap$,
scope: testScope,
2025-10-02 12:53:59 +02:00
livekitRoomFactory: () => fakeLivekitRoom
};
2025-10-01 14:21:37 +02:00
const connection = new RemoteConnection(
opts,
2025-10-02 12:53:59 +02:00
undefined
2025-10-01 14:21:37 +02:00
);
2025-10-01 17:24:19 +02:00
const capturedStates: FocusConnectionState[] = [];
2025-10-01 14:21:37 +02:00
connection.focusedConnectionState$.subscribe((value) => {
2025-10-01 17:24:19 +02:00
capturedStates.push(value);
2025-10-01 14:21:37 +02:00
});
2025-10-01 17:24:19 +02:00
const deferred = Promise.withResolvers<IOpenIDToken>();
2025-10-01 14:21:37 +02:00
2025-10-01 17:24:19 +02:00
client.getOpenIdToken.mockImplementation(async (): Promise<IOpenIDToken> => {
return await deferred.promise;
2025-10-02 12:53:59 +02:00
});
2025-10-01 14:21:37 +02:00
connection.start()
.catch(() => {
// expected to throw
2025-10-02 12:53:59 +02:00
});
2025-10-01 14:21:37 +02:00
2025-10-02 12:53:59 +02:00
let capturedState = capturedStates.pop();
2025-10-01 17:24:19 +02:00
expect(capturedState).toBeDefined();
expect(capturedState!.state).toEqual("FetchingConfig");
2025-10-01 14:21:37 +02:00
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
await vi.runAllTimersAsync();
2025-10-02 12:53:59 +02:00
capturedState = capturedStates.pop();
2025-10-01 17:24:19 +02:00
if (capturedState!.state === "FailedToStart") {
expect(capturedState!.error.message).toEqual("Something went wrong");
expect(capturedState!.focus.livekit_alias).toEqual(livekitFocus.livekit_alias);
2025-10-01 14:21:37 +02:00
} else {
2025-10-01 17:24:19 +02:00
expect.fail("Expected FailedToStart state but got " + capturedState?.state);
2025-10-01 14:21:37 +02:00
}
});
it("fail to get JWT token and error state", async () => {
setupTest();
vi.useFakeTimers();
const opts: ConnectionOpts = {
client: client,
focus: livekitFocus,
membershipsFocusMap$: fakeMembershipsFocusMap$,
scope: testScope,
2025-10-02 12:53:59 +02:00
livekitRoomFactory: () => fakeLivekitRoom
};
2025-10-01 14:21:37 +02:00
const connection = new RemoteConnection(
opts,
2025-10-02 12:53:59 +02:00
undefined
2025-10-01 14:21:37 +02:00
);
2025-10-01 17:24:19 +02:00
const capturedStates: FocusConnectionState[] = [];
2025-10-01 14:21:37 +02:00
connection.focusedConnectionState$.subscribe((value) => {
2025-10-01 17:24:19 +02:00
capturedStates.push(value);
2025-10-01 14:21:37 +02:00
});
const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`,
async () => {
await deferredSFU.promise;
return {
status: 500,
2025-10-02 12:53:59 +02:00
body: "Internal Server Error"
};
2025-10-01 14:21:37 +02:00
}
);
connection.start()
.catch(() => {
// expected to throw
2025-10-02 12:53:59 +02:00
});
2025-10-01 14:21:37 +02:00
2025-10-02 12:53:59 +02:00
let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined();
2025-10-01 17:24:19 +02:00
expect(capturedState?.state).toEqual("FetchingConfig");
2025-10-01 14:21:37 +02:00
deferredSFU.resolve();
await vi.runAllTimersAsync();
2025-10-02 12:53:59 +02:00
capturedState = capturedStates.pop();
2025-10-01 17:24:19 +02:00
if (capturedState?.state === "FailedToStart") {
expect(capturedState?.error.message).toContain("SFU Config fetch failed with exception Error");
expect(capturedState?.focus.livekit_alias).toEqual(livekitFocus.livekit_alias);
2025-10-01 14:21:37 +02:00
} else {
2025-10-01 17:24:19 +02:00
expect.fail("Expected FailedToStart state but got " + capturedState?.state);
2025-10-01 14:21:37 +02:00
}
});
it("fail to connect to livekit error state", async () => {
setupTest();
vi.useFakeTimers();
const opts: ConnectionOpts = {
client: client,
focus: livekitFocus,
membershipsFocusMap$: fakeMembershipsFocusMap$,
scope: testScope,
2025-10-02 12:53:59 +02:00
livekitRoomFactory: () => fakeLivekitRoom
};
2025-10-01 14:21:37 +02:00
const connection = new RemoteConnection(
opts,
2025-10-02 12:53:59 +02:00
undefined
2025-10-01 14:21:37 +02:00
);
2025-10-01 17:24:19 +02:00
const capturedStates: FocusConnectionState[] = [];
2025-10-01 14:21:37 +02:00
connection.focusedConnectionState$.subscribe((value) => {
2025-10-02 12:53:59 +02:00
capturedStates.push(value);
2025-10-01 14:21:37 +02:00
});
const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`,
2025-10-01 16:39:21 +02:00
() => {
2025-10-01 14:21:37 +02:00
return {
status: 200,
body:
{
"url": "wss://matrix-rtc.m.localhost/livekit/sfu",
2025-10-02 12:53:59 +02:00
"jwt": "ATOKEN"
}
};
2025-10-01 14:21:37 +02:00
}
);
fakeLivekitRoom
.connect
.mockImplementation(async () => {
await deferredSFU.promise;
throw new Error("Failed to connect to livekit");
});
connection.start()
.catch(() => {
// expected to throw
2025-10-02 12:53:59 +02:00
});
2025-10-01 14:21:37 +02:00
2025-10-02 12:53:59 +02:00
let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined();
2025-10-01 17:24:19 +02:00
expect(capturedState?.state).toEqual("FetchingConfig");
2025-10-01 14:21:37 +02:00
deferredSFU.resolve();
await vi.runAllTimersAsync();
2025-10-02 12:53:59 +02:00
capturedState = capturedStates.pop();
2025-10-01 17:24:19 +02:00
if (capturedState && capturedState?.state === "FailedToStart") {
2025-10-01 14:21:37 +02:00
expect(capturedState.error.message).toContain("Failed to connect to livekit");
expect(capturedState.focus.livekit_alias).toEqual(livekitFocus.livekit_alias);
} else {
2025-10-01 17:24:19 +02:00
expect.fail("Expected FailedToStart state but got " + JSON.stringify(capturedState));
2025-10-01 14:21:37 +02:00
}
});
it("connection states happy path", async () => {
vi.useFakeTimers();
2025-10-02 12:53:59 +02:00
setupTest();
2025-10-01 14:21:37 +02:00
2025-10-01 14:47:45 +02:00
const connection = setupRemoteConnection();
2025-10-01 14:21:37 +02:00
2025-10-01 14:37:03 +02:00
const capturedState: FocusConnectionState[] = [];
2025-10-01 14:21:37 +02:00
connection.focusedConnectionState$.subscribe((value) => {
capturedState.push(value);
});
await connection.start();
await vi.runAllTimersAsync();
2025-10-01 14:37:03 +02:00
const initialState = capturedState.shift();
2025-10-01 14:21:37 +02:00
expect(initialState?.state).toEqual("Initialized");
2025-10-01 14:37:03 +02:00
const fetchingState = capturedState.shift();
2025-10-01 14:21:37 +02:00
expect(fetchingState?.state).toEqual("FetchingConfig");
2025-10-01 14:37:03 +02:00
const connectingState = capturedState.shift();
2025-10-01 14:21:37 +02:00
expect(connectingState?.state).toEqual("ConnectingToLkRoom");
2025-10-01 14:37:03 +02:00
const connectedState = capturedState.shift();
2025-10-01 14:21:37 +02:00
expect(connectedState?.state).toEqual("ConnectedToLkRoom");
});
it("should relay livekit events once connected", async () => {
2025-10-02 12:53:59 +02:00
setupTest();
const connection = setupRemoteConnection();
await connection.start();
2025-10-01 16:39:21 +02:00
let capturedState: FocusConnectionState[] = [];
connection.focusedConnectionState$.subscribe((value) => {
capturedState.push(value);
});
const states = [
ConnectionState.Disconnected,
ConnectionState.Connecting,
ConnectionState.Connected,
ConnectionState.SignalReconnecting,
ConnectionState.Connecting,
ConnectionState.Connected,
2025-10-02 12:53:59 +02:00
ConnectionState.Reconnecting
];
for (const state of states) {
fakeRoomEventEmiter.emit(RoomEvent.ConnectionStateChanged, state);
}
for (const state of states) {
const s = capturedState.shift();
expect(s?.state).toEqual("ConnectedToLkRoom");
2025-10-01 17:24:19 +02:00
const connectedState = s as FocusConnectionState & { state: "ConnectedToLkRoom" };
expect(connectedState.connectionState).toEqual(state);
// should always have the focus info
2025-10-01 17:24:19 +02:00
expect(connectedState.focus.livekit_alias).toEqual(livekitFocus.livekit_alias);
expect(connectedState.focus.livekit_service_url).toEqual(livekitFocus.livekit_service_url);
}
2025-10-01 16:39:21 +02:00
// If the state is not ConnectedToLkRoom, no events should be relayed anymore
await connection.stop();
capturedState = [];
for (const state of states) {
fakeRoomEventEmiter.emit(RoomEvent.ConnectionStateChanged, state);
}
expect(capturedState.length).toEqual(0);
});
2025-10-01 16:39:21 +02:00
it("shutting down the scope should stop the connection", async () => {
2025-10-02 12:53:59 +02:00
setupTest();
2025-10-01 16:39:21 +02:00
vi.useFakeTimers();
const connection = setupRemoteConnection();
let capturedState: FocusConnectionState[] = [];
connection.focusedConnectionState$.subscribe((value) => {
capturedState.push(value);
});
await connection.start();
const stopSpy = vi.spyOn(connection, "stop");
testScope.end();
expect(stopSpy).toHaveBeenCalled();
expect(fakeLivekitRoom.disconnect).toHaveBeenCalled();
/// Ensures that focusedConnectionState$ is bound to the scope.
capturedState = [];
// the subscription should be closed, and no new state should be received
// @ts-expect-error: Accessing private field for testing purposes
connection._focusedConnectionState$.next({ state: "Initialized" });
// @ts-expect-error: Accessing private field for testing purposes
connection._focusedConnectionState$.next({ state: "ConnectingToLkRoom" });
expect(capturedState.length).toEqual(0);
});
});
2025-10-02 12:53:59 +02:00
function fakeRemoteLivekitParticipant(id: string): RemoteParticipant {
return vi.mocked<RemoteParticipant>({
identity: id
} as unknown as RemoteParticipant);
}
function fakeRtcMemberShip(userId: string, deviceId: string): CallMembership {
return vi.mocked<CallMembership>({
sender: userId,
deviceId: deviceId,
} as unknown as CallMembership);
}
describe("Publishing participants observations", () => {
it("should emit the list of publishing participants", async () => {
setupTest();
const connection = setupRemoteConnection();
const bobIsAPublisher = Promise.withResolvers<void>();
const danIsAPublisher = Promise.withResolvers<void>();
const observedPublishers: { participant: RemoteParticipant; membership: CallMembership }[][] = [];
connection.publishingParticipants$.subscribe((publishers) => {
observedPublishers.push(publishers);
if (publishers.some((p) => p.participant.identity === "@bob:example.org:DEV111")) {
bobIsAPublisher.resolve();
}
if (publishers.some((p) => p.participant.identity === "@dan:example.org:DEV333")) {
danIsAPublisher.resolve();
}
});
// The publishingParticipants$ observable is derived from the current members of the
// livekitRoom and the rtc membership in order to publish the members that are publishing
// on this connection.
let participants: RemoteParticipant[]= [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000"),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222"),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333")
];
// Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get")
.mockReturnValue(
new Map(participants.map((p) => [p.identity, p]))
);
for (const participant of participants) {
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
}
// At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(0);
const otherFocus: LivekitFocus = {
livekit_alias: "!roomID:example.org",
livekit_service_url: "https://other-matrix-rtc.example.org/livekit/jwt",
type: "livekit"
}
const rtcMemberships = [
// Say bob is on the same focus
{ membership: fakeRtcMemberShip("@bob:example.org", "DEV111"), focus: livekitFocus },
// Alice and carol is on a different focus
{ membership: fakeRtcMemberShip("@alice:example.org", "DEV000"), focus: otherFocus },
{ membership: fakeRtcMemberShip("@carol:example.org", "DEV222"), focus: otherFocus },
// NO DAVE YET
];
// signal this change in rtc memberships
fakeMembershipsFocusMap$.next(rtcMemberships);
// We should have bob has a publisher now
await bobIsAPublisher.promise;
const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1);
expect(publishers?.[0].participant.identity).toEqual("@bob:example.org:DEV111");
// Now let's make dan join the rtc memberships
rtcMemberships
.push({ membership: fakeRtcMemberShip("@dan:example.org", "DEV333"), focus: livekitFocus });
fakeMembershipsFocusMap$.next(rtcMemberships);
// We should have bob and dan has publishers now
await danIsAPublisher.promise;
const twoPublishers = observedPublishers.pop();
expect(twoPublishers?.length).toEqual(2);
expect(twoPublishers?.some((p) => p.participant.identity === "@bob:example.org:DEV111")).toBeTruthy();
expect(twoPublishers?.some((p) => p.participant.identity === "@dan:example.org:DEV333")).toBeTruthy();
// Now let's make bob leave the livekit room
participants = participants.filter((p) => p.identity !== "@bob:example.org:DEV111");
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get")
.mockReturnValue(
new Map(participants.map((p) => [p.identity, p]))
);
fakeRoomEventEmiter.emit(RoomEvent.ParticipantDisconnected, fakeRemoteLivekitParticipant("@bob:example.org:DEV111"));
const updatedPublishers = observedPublishers.pop();
expect(updatedPublishers?.length).toEqual(1);
expect(updatedPublishers?.some((p) => p.participant.identity === "@dan:example.org:DEV333")).toBeTruthy();
})
it("should be scoped to parent scope", async () => {
setupTest();
const connection = setupRemoteConnection();
let observedPublishers: { participant: RemoteParticipant; membership: CallMembership }[][] = [];
connection.publishingParticipants$.subscribe((publishers) => {
observedPublishers.push(publishers);
});
let participants: RemoteParticipant[]= [
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
];
// Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get")
.mockReturnValue(
new Map(participants.map((p) => [p.identity, p]))
);
for (const participant of participants) {
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
}
// At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(0);
const rtcMemberships = [
// Say bob is on the same focus
{ membership: fakeRtcMemberShip("@bob:example.org", "DEV111"), focus: livekitFocus },
];
// signal this change in rtc memberships
fakeMembershipsFocusMap$.next(rtcMemberships);
// We should have bob has a publisher now
const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1);
expect(publishers?.[0].participant.identity).toEqual("@bob:example.org:DEV111");
// end the parent scope
testScope.end();
observedPublishers = [];
// SHOULD NOT emit any more publishers as the scope is ended
participants = participants.filter((p) => p.identity !== "@bob:example.org:DEV111");
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get")
.mockReturnValue(
new Map(participants.map((p) => [p.identity, p]))
);
fakeRoomEventEmiter.emit(RoomEvent.ParticipantDisconnected, fakeRemoteLivekitParticipant("@bob:example.org:DEV111"));
expect(observedPublishers.length).toEqual(0);
})
2025-10-02 12:53:59 +02:00
});