mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-22 06:30:25 +00:00
This moves the media devices state out of React to further our transition to a MVVM architecture in which we can more easily model and store complex application state. I have created an AppViewModel to act as the overarching state holder for any future non-React state we end up creating, and the MediaDevices reside within this. We should move more application logic (including the CallViewModel itself) there in the future.
351 lines
9.1 KiB
TypeScript
351 lines
9.1 KiB
TypeScript
/*
|
|
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";
|
|
|
|
export function withFakeTimers(continuation: () => void): void {
|
|
vi.useFakeTimers();
|
|
try {
|
|
continuation();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}
|
|
|
|
export async function flushPromises(): Promise<void> {
|
|
await new Promise<void>((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<string, () => 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<T> {
|
|
on: () => T;
|
|
off: () => T;
|
|
addListener: () => T;
|
|
removeListener: () => T;
|
|
}
|
|
|
|
export function mockEmitter<T>(): EmitterMock<T> {
|
|
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<SessionMembershipData> = {},
|
|
): 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> = {},
|
|
): RoomMember {
|
|
return {
|
|
...mockEmitter(),
|
|
userId: rtcMembership.sender,
|
|
...member,
|
|
} as RoomMember;
|
|
}
|
|
|
|
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
|
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
|
}
|
|
|
|
export function mockLivekitRoom(
|
|
room: Partial<LivekitRoom>,
|
|
{
|
|
remoteParticipants$,
|
|
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
|
|
): LivekitRoom {
|
|
const livekitRoom = {
|
|
...mockEmitter(),
|
|
...room,
|
|
} as Partial<LivekitRoom> 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>,
|
|
): LocalParticipant {
|
|
return {
|
|
isLocal: true,
|
|
getTrackPublication: () =>
|
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
|
...mockEmitter(),
|
|
...participant,
|
|
} as Partial<LocalParticipant> as LocalParticipant;
|
|
}
|
|
|
|
export async function withLocalMedia(
|
|
localRtcMember: CallMembership,
|
|
roomMember: Partial<RoomMember>,
|
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
|
): Promise<void> {
|
|
const localParticipant = mockLocalParticipant({});
|
|
const vm = new LocalUserMediaViewModel(
|
|
"local",
|
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
|
of(localParticipant),
|
|
{
|
|
kind: E2eeType.PER_PARTICIPANT,
|
|
},
|
|
mockLivekitRoom({ localParticipant }),
|
|
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
|
of(null),
|
|
of(null),
|
|
);
|
|
try {
|
|
await continuation(vm);
|
|
} finally {
|
|
vm.destroy();
|
|
}
|
|
}
|
|
|
|
export function mockRemoteParticipant(
|
|
participant: Partial<RemoteParticipant>,
|
|
): RemoteParticipant {
|
|
return {
|
|
isLocal: false,
|
|
setVolume() {},
|
|
getTrackPublication: () =>
|
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
|
...mockEmitter(),
|
|
...participant,
|
|
} as RemoteParticipant;
|
|
}
|
|
|
|
export async function withRemoteMedia(
|
|
localRtcMember: CallMembership,
|
|
roomMember: Partial<RoomMember>,
|
|
participant: Partial<RemoteParticipant>,
|
|
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
|
): Promise<void> {
|
|
const remoteParticipant = mockRemoteParticipant(participant);
|
|
const vm = new RemoteUserMediaViewModel(
|
|
"remote",
|
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
|
of(remoteParticipant),
|
|
{
|
|
kind: E2eeType.PER_PARTICIPANT,
|
|
},
|
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
|
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
|
of(null),
|
|
of(null),
|
|
);
|
|
try {
|
|
await continuation(vm);
|
|
} finally {
|
|
vm.destroy();
|
|
}
|
|
}
|
|
|
|
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): 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<Partial<CallMembership>[]>,
|
|
): 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<never, never>()),
|
|
selected$: of(undefined),
|
|
select(): void {},
|
|
};
|
|
|
|
export function mockMediaDevices(data: Partial<MediaDevices>): MediaDevices {
|
|
return {
|
|
audioInput: deviceStub,
|
|
audioOutput: deviceStub,
|
|
videoInput: deviceStub,
|
|
...data,
|
|
} as MediaDevices;
|
|
}
|