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-06 10:50:10 +02:00
|
|
|
import { afterEach, describe, expect, it, type Mock, Mocked, type MockedObject, vi } from "vitest";
|
2025-10-01 14:21:37 +02:00
|
|
|
import { type CallMembership, type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
2025-10-06 10:50:10 +02:00
|
|
|
import { BehaviorSubject, of } from "rxjs";
|
|
|
|
|
import {
|
|
|
|
|
ConnectionState,
|
|
|
|
|
type LocalParticipant,
|
|
|
|
|
type RemoteParticipant,
|
|
|
|
|
type Room as LivekitRoom,
|
|
|
|
|
RoomEvent, type RoomOptions
|
|
|
|
|
} from "livekit-client";
|
2025-10-01 14:21:37 +02:00
|
|
|
import fetchMock from "fetch-mock";
|
2025-10-01 15:23:24 +02:00
|
|
|
import EventEmitter from "events";
|
2025-10-01 17:24:19 +02:00
|
|
|
import { type IOpenIDToken } from "matrix-js-sdk";
|
2025-10-06 10:50:10 +02:00
|
|
|
import { type BackgroundOptions, type ProcessorWrapper } from "@livekit/track-processors";
|
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-06 10:50:10 +02:00
|
|
|
import { PublishConnection } from "./PublishConnection.ts";
|
|
|
|
|
import { mockMediaDevices, mockMuteStates } from "../utils/test.ts";
|
|
|
|
|
import type { ProcessorState } from "../livekit/TrackProcessorContext.tsx";
|
|
|
|
|
import { type MuteStates } from "./MuteStates.ts";
|
|
|
|
|
import { DeviceLabel, MediaDevice, SelectedDevice } from "./MediaDevices.ts";
|
2025-10-01 14:21:37 +02:00
|
|
|
|
|
|
|
|
|
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-06 10:50:10 +02:00
|
|
|
let localParticipantEventEmiter: EventEmitter;
|
|
|
|
|
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
|
|
|
|
|
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 }[]>([]);
|
|
|
|
|
|
2025-10-06 10:50:10 +02:00
|
|
|
localParticipantEventEmiter = new EventEmitter();
|
|
|
|
|
|
|
|
|
|
fakeLocalParticipant = vi.mocked<LocalParticipant>({
|
|
|
|
|
identity: "@me:example.org",
|
|
|
|
|
isMicrophoneEnabled: vi.fn().mockReturnValue(true),
|
|
|
|
|
getTrackPublication: vi.fn().mockReturnValue(undefined),
|
|
|
|
|
on: localParticipantEventEmiter.on.bind(localParticipantEventEmiter),
|
|
|
|
|
off: localParticipantEventEmiter.off.bind(localParticipantEventEmiter),
|
|
|
|
|
addListener: localParticipantEventEmiter.addListener.bind(localParticipantEventEmiter),
|
|
|
|
|
removeListener: localParticipantEventEmiter.removeListener.bind(localParticipantEventEmiter),
|
|
|
|
|
removeAllListeners: localParticipantEventEmiter.removeAllListeners.bind(localParticipantEventEmiter)
|
|
|
|
|
} as unknown as LocalParticipant);
|
2025-10-01 17:24:19 +02:00
|
|
|
fakeRoomEventEmiter = new EventEmitter();
|
|
|
|
|
|
|
|
|
|
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
|
|
|
|
connect: vi.fn(),
|
|
|
|
|
disconnect: vi.fn(),
|
|
|
|
|
remoteParticipants: new Map(),
|
2025-10-06 10:50:10 +02:00
|
|
|
localParticipant: fakeLocalParticipant,
|
2025-10-01 17:24:19 +02:00
|
|
|
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-06 10:50:10 +02:00
|
|
|
removeAllListeners: fakeRoomEventEmiter.removeAllListeners.bind(fakeRoomEventEmiter),
|
|
|
|
|
setE2EEEnabled: vi.fn().mockResolvedValue(undefined)
|
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
|
|
|
|
2025-10-02 13:08:00 +02:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
fetchMock.reset();
|
|
|
|
|
});
|
2025-10-01 17:24:19 +02:00
|
|
|
|
2025-10-02 13:08:00 +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");
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-01 15:23:24 +02:00
|
|
|
it("should relay livekit events once connected", async () => {
|
2025-10-02 12:53:59 +02:00
|
|
|
setupTest();
|
2025-10-01 15:23:24 +02:00
|
|
|
|
|
|
|
|
const connection = setupRemoteConnection();
|
|
|
|
|
|
|
|
|
|
await connection.start();
|
|
|
|
|
|
2025-10-01 16:39:21 +02:00
|
|
|
let capturedState: FocusConnectionState[] = [];
|
2025-10-01 15:23:24 +02:00
|
|
|
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
|
|
|
|
|
];
|
2025-10-01 15:23:24 +02:00
|
|
|
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);
|
2025-10-01 15:23:24 +02:00
|
|
|
|
|
|
|
|
// 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 15:23:24 +02:00
|
|
|
}
|
|
|
|
|
|
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 15:23:24 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
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,
|
2025-10-06 10:50:10 +02:00
|
|
|
deviceId: deviceId
|
2025-10-02 12:53:59 +02:00
|
|
|
} 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) => {
|
2025-10-06 10:50:10 +02:00
|
|
|
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();
|
|
|
|
|
}
|
2025-10-02 12:53:59 +02:00
|
|
|
});
|
|
|
|
|
// 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.
|
|
|
|
|
|
2025-10-06 10:50:10 +02:00
|
|
|
let participants: RemoteParticipant[] = [
|
2025-10-02 12:53:59 +02:00
|
|
|
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"
|
2025-10-06 10:50:10 +02:00
|
|
|
};
|
2025-10-02 12:53:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 },
|
2025-10-06 10:50:10 +02:00
|
|
|
{ membership: fakeRtcMemberShip("@carol:example.org", "DEV222"), focus: otherFocus }
|
2025-10-02 12:53:59 +02:00
|
|
|
// 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();
|
2025-10-06 10:50:10 +02:00
|
|
|
});
|
2025-10-02 12:53:59 +02:00
|
|
|
|
2025-10-02 13:08:00 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-06 10:50:10 +02:00
|
|
|
let participants: RemoteParticipant[] = [
|
|
|
|
|
fakeRemoteLivekitParticipant("@bob:example.org:DEV111")
|
2025-10-02 13:08:00 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-10-06 10:50:10 +02:00
|
|
|
{ membership: fakeRtcMemberShip("@bob:example.org", "DEV111"), focus: livekitFocus }
|
2025-10-02 13:08:00 +02:00
|
|
|
];
|
|
|
|
|
// 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-06 10:50:10 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe("PublishConnection", () => {
|
|
|
|
|
|
|
|
|
|
let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
|
|
|
|
|
let roomFactoryMock: Mock<() => LivekitRoom>;
|
|
|
|
|
let muteStates: MockedObject<MuteStates>;
|
|
|
|
|
|
|
|
|
|
function setUpPublishConnection() {
|
|
|
|
|
setupTest();
|
|
|
|
|
|
|
|
|
|
roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
muteStates = mockMuteStates();
|
|
|
|
|
|
|
|
|
|
fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
|
|
|
|
|
name: "BackgroundBlur",
|
|
|
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
setOptions: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
|
|
|
|
|
isRunning: vi.fn().mockReturnValue(false)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
describe("Livekit room creation", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function createSetup() {
|
|
|
|
|
setUpPublishConnection();
|
2025-10-02 13:08:00 +02:00
|
|
|
|
2025-10-06 10:50:10 +02:00
|
|
|
const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
|
|
|
|
|
supported: true,
|
|
|
|
|
processor: undefined
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const opts: ConnectionOpts = {
|
|
|
|
|
client: client,
|
|
|
|
|
focus: livekitFocus,
|
|
|
|
|
membershipsFocusMap$: fakeMembershipsFocusMap$,
|
|
|
|
|
scope: testScope,
|
|
|
|
|
livekitRoomFactory: roomFactoryMock
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const audioInput = {
|
|
|
|
|
available$: of(new Map([["mic1", { id: "mic1" }]])),
|
|
|
|
|
selected$: new BehaviorSubject({ id: "mic1" }),
|
|
|
|
|
select(): void {
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-10-02 13:08:00 +02:00
|
|
|
|
2025-10-06 10:50:10 +02:00
|
|
|
const videoInput = {
|
|
|
|
|
available$: of(new Map([["cam1", { id: "cam1" }]])),
|
|
|
|
|
selected$: new BehaviorSubject({ id: "cam1" }),
|
|
|
|
|
select(): void {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const audioOutput = {
|
|
|
|
|
available$: of(new Map([["speaker", { id: "speaker" }]])),
|
|
|
|
|
selected$: new BehaviorSubject({ id: "speaker" }),
|
|
|
|
|
select(): void {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fakeDevices = mockMediaDevices({
|
|
|
|
|
audioInput,
|
|
|
|
|
videoInput,
|
|
|
|
|
audioOutput
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
new PublishConnection(
|
|
|
|
|
opts,
|
|
|
|
|
fakeDevices,
|
|
|
|
|
muteStates,
|
|
|
|
|
undefined,
|
|
|
|
|
fakeTrackProcessorSubject$
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("should create room with proper initial audio and video settings", () => {
|
|
|
|
|
|
|
|
|
|
createSetup();
|
|
|
|
|
|
|
|
|
|
expect(roomFactoryMock).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const lastCallArgs = roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
|
|
|
|
|
|
|
|
|
|
const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
|
|
|
|
|
expect(roomOptions).toBeDefined();
|
|
|
|
|
|
|
|
|
|
expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
|
|
|
|
|
expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
|
|
|
|
|
expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("respect controlledAudioDevices", () => {
|
|
|
|
|
// TODO: Refactor the code to make it testable.
|
|
|
|
|
// The UrlParams module is a singleton has a cache and is very hard to test.
|
|
|
|
|
// This breaks other tests as well if not handled properly.
|
|
|
|
|
// vi.mock(import("./../UrlParams"), () => {
|
|
|
|
|
// return {
|
|
|
|
|
// getUrlParams: vi.fn().mockReturnValue({
|
|
|
|
|
// controlledAudioDevices: true
|
|
|
|
|
// })
|
|
|
|
|
// };
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-10-02 12:53:59 +02:00
|
|
|
});
|