/* Copyright 2023, 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { map, type Observable, of, type SchedulerLike } from "rxjs"; import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { expect, vi, vitest } from "vitest"; import { type RoomMember, type Room as MatrixRoom, MatrixEvent, type Room, TypedEventEmitter, } from "matrix-js-sdk"; import { CallMembership, type Focus, MatrixRTCSessionEvent, type MatrixRTCSessionEventHandlerMap, type SessionMembershipData, } from "matrix-js-sdk/lib/matrixrtc"; import { type LocalParticipant, type LocalTrackPublication, type RemoteParticipant, type RemoteTrackPublication, type Room as LivekitRoom, Track, } from "livekit-client"; import { randomUUID } from "crypto"; import { type RoomAndToDeviceEvents, type RoomAndToDeviceEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { type TrackReference } from "@livekit/components-core"; import { LocalUserMediaViewModel, RemoteUserMediaViewModel, } from "../state/MediaViewModel"; import { E2eeType } from "../e2ee/e2eeType"; import { DEFAULT_CONFIG, type ResolvedConfigOptions, } from "../config/ConfigOptions"; import { Config } from "../config/Config"; import { type MediaDevices } from "../state/MediaDevices"; import { constant } from "../state/Behavior"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); try { continuation(); } finally { vi.useRealTimers(); } } export async function flushPromises(): Promise { await new Promise((resolve) => window.setTimeout(resolve)); } export interface OurRunHelpers extends RunHelpers { /** * Schedules a sequence of actions to happen, as described by a marble * diagram. */ schedule: (marbles: string, actions: Record void>) => void; } interface TestRunnerGlobal { rxjsTestScheduler?: SchedulerLike; } /** * Run Observables with a scheduler that virtualizes time, for testing purposes. */ export function withTestScheduler( continuation: (helpers: OurRunHelpers) => void, ): void { const scheduler = new TestScheduler((actual, expected) => { expect(actual).deep.equals(expected); }); // we set the test scheduler as a global so that you can watch it in a debugger // and get the frame number. e.g. `rxjsTestScheduler?.now()` (global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler; scheduler.run((helpers) => continuation({ ...helpers, schedule(marbles, actions) { const actionsObservable$ = helpers .cold(marbles) .pipe(map((value) => actions[value]())); const results = Object.fromEntries( Object.keys(actions).map((value) => [value, undefined] as const), ); // Run the actions and verify that none of them error helpers.expectObservable(actionsObservable$).toBe(marbles, results); }, }), ); } interface EmitterMock { on: () => T; off: () => T; addListener: () => T; removeListener: () => T; } export function mockEmitter(): EmitterMock { return { on(): T { return this as T; }, off(): T { return this as T; }, addListener(): T { return this as T; }, removeListener(): T { return this as T; }, }; } export function mockRtcMembership( user: string | RoomMember, deviceId: string, callId = "", fociPreferred: Focus[] = [], focusActive: Focus = { type: "oldest_membership" }, membership: Partial = {}, ): CallMembership { const data: SessionMembershipData = { application: "m.call", call_id: callId, device_id: deviceId, foci_preferred: fociPreferred, focus_active: focusActive, ...membership, }; const event = new MatrixEvent({ sender: typeof user === "string" ? user : user.userId, event_id: `$-ev-${randomUUID()}:example.org`, }); return new CallMembership(event, data); } // Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are // rather simple, but if one util to mock a member is good enough for us, maybe // it's useful for matrix-js-sdk consumers in general. export function mockMatrixRoomMember( rtcMembership: CallMembership, member: Partial = {}, ): RoomMember { return { ...mockEmitter(), userId: rtcMembership.sender, ...member, } as RoomMember; } export function mockMatrixRoom(room: Partial): MatrixRoom { return { ...mockEmitter(), ...room } as Partial as MatrixRoom; } export function mockLivekitRoom( room: Partial, { remoteParticipants$, }: { remoteParticipants$?: Observable } = {}, ): LivekitRoom { const livekitRoom = { ...mockEmitter(), ...room, } as Partial as LivekitRoom; if (remoteParticipants$) { livekitRoom.remoteParticipants = new Map(); remoteParticipants$.subscribe((newRemoteParticipants) => { livekitRoom.remoteParticipants.clear(); newRemoteParticipants.forEach((p) => { livekitRoom.remoteParticipants.set(p.identity, p); }); }); } return livekitRoom; } export function mockLocalParticipant( participant: Partial, ): LocalParticipant { return { isLocal: true, getTrackPublication: () => ({}) as Partial as LocalTrackPublication, ...mockEmitter(), ...participant, } as Partial as LocalParticipant; } export async function withLocalMedia( localRtcMember: CallMembership, roomMember: Partial, continuation: (vm: LocalUserMediaViewModel) => void | Promise, ): Promise { const localParticipant = mockLocalParticipant({}); const vm = new LocalUserMediaViewModel( "local", mockMatrixRoomMember(localRtcMember, roomMember), of(localParticipant), { kind: E2eeType.PER_PARTICIPANT, }, mockLivekitRoom({ localParticipant }), constant(roomMember.rawDisplayName ?? "nodisplayname"), constant(null), constant(null), ); try { await continuation(vm); } finally { vm.destroy(); } } export function mockRemoteParticipant( participant: Partial, ): RemoteParticipant { return { isLocal: false, setVolume() {}, getTrackPublication: () => ({}) as Partial as RemoteTrackPublication, ...mockEmitter(), ...participant, } as RemoteParticipant; } export async function withRemoteMedia( localRtcMember: CallMembership, roomMember: Partial, participant: Partial, continuation: (vm: RemoteUserMediaViewModel) => void | Promise, ): Promise { const remoteParticipant = mockRemoteParticipant(participant); const vm = new RemoteUserMediaViewModel( "remote", mockMatrixRoomMember(localRtcMember, roomMember), of(remoteParticipant), { kind: E2eeType.PER_PARTICIPANT, }, mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), constant(roomMember.rawDisplayName ?? "nodisplayname"), constant(null), constant(null), ); try { await continuation(vm); } finally { vm.destroy(); } } export function mockConfig(config: Partial = {}): void { vi.spyOn(Config, "get").mockReturnValue({ ...DEFAULT_CONFIG, ...config, }); // simulate loading the config vi.spyOn(Config, "init").mockResolvedValue(void 0); } export class MockRTCSession extends TypedEventEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents, MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap > { public readonly statistics = { counters: {}, }; public leaveRoomSession = vitest.fn().mockResolvedValue(undefined); public constructor( public readonly room: Room, private localMembership: CallMembership, public memberships: CallMembership[] = [], ) { super(); } public joined = true; public isJoined(): boolean { return this.joined; } public withMemberships( rtcMembers$: Observable[]>, ): MockRTCSession { rtcMembers$.subscribe((m) => { const old = this.memberships; // always prepend the local participant const updated = [this.localMembership, ...(m as CallMembership[])]; this.memberships = updated; this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated); }); return this; } } export const mockTrack = (identity: string): TrackReference => ({ participant: { identity, }, publication: { kind: Track.Kind.Audio, source: "mic", trackSid: "123", track: { attach: vi.fn(), detach: vi.fn(), setAudioContext: vi.fn(), setWebAudioPlugins: vi.fn(), setVolume: vi.fn(), }, }, track: {}, source: {}, }) as unknown as TrackReference; export const deviceStub = { available$: of(new Map()), selected$: of(undefined), select(): void {}, }; export function mockMediaDevices(data: Partial): MediaDevices { return { audioInput: deviceStub, audioOutput: deviceStub, videoInput: deviceStub, ...data, } as MediaDevices; }