mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
A transport with only a LiveKit SFU `url` has to be authenticated through our own homeserver, which needs to know the name of the server owning the SFU. Carry that name alongside the transport as a TransportLocator, derived from the member's user ID for remote transports and from the client's domain for our own, and key the connection map on the SFU URL, the service URL and the server name together so that `url`, service URL and combined transport shapes are deduplicated consistently.
343 lines
10 KiB
TypeScript
343 lines
10 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 { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import { render, type RenderResult } from "@testing-library/react";
|
|
import {
|
|
getTrackReferenceId,
|
|
type TrackReference,
|
|
} from "@livekit/components-core";
|
|
import {
|
|
type Participant,
|
|
type RemoteAudioTrack,
|
|
type Room,
|
|
Track,
|
|
} from "livekit-client";
|
|
import { type ReactNode } from "react";
|
|
import { useTracks } from "@livekit/components-react";
|
|
|
|
import { testAudioContext } from "../useAudioContext.test";
|
|
import * as MediaDevicesContext from "../MediaDevicesContext";
|
|
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
|
import {
|
|
exampleTransportLocator,
|
|
mockLocalParticipant,
|
|
mockMediaDevices,
|
|
mockRemoteParticipant,
|
|
mockTrack,
|
|
} from "../utils/test";
|
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
|
import { initializeWidget } from "../widget";
|
|
initializeWidget();
|
|
export const TestAudioContextConstructor = vi.fn(
|
|
class {
|
|
public constructor() {
|
|
return testAudioContext;
|
|
}
|
|
},
|
|
);
|
|
|
|
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
vi.mock("@livekit/components-react", async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
|
return (
|
|
<audio data-testid={"audio"}>
|
|
{getTrackReferenceId(props.trackRef)}
|
|
</audio>
|
|
);
|
|
},
|
|
useTracks: vi.fn(),
|
|
};
|
|
});
|
|
|
|
let tracks: TrackReference[] = [];
|
|
|
|
/**
|
|
* Render the test component with given rtc members and livekit participant identities.
|
|
*
|
|
* It is possible to have rtc members that are not in livekit (e.g. not yet joined) and vice versa.
|
|
*
|
|
* @param rtcMembers - Array of active rtc members with userId and deviceId.
|
|
* @param livekitParticipantIdentities - Array of livekit participant (that are publishing).
|
|
* @param explicitTracks - Array of tracks available in livekit, if not provided, one audio track per livekitParticipantIdentities will be created.
|
|
* */
|
|
|
|
function renderTestComponent(
|
|
rtcMembers: { userId: string; deviceId: string }[],
|
|
livekitParticipantIdentities: string[],
|
|
explicitTracks?: {
|
|
participantId: string;
|
|
kind: Track.Kind;
|
|
source: Track.Source;
|
|
}[],
|
|
): RenderResult {
|
|
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
|
mockRemoteParticipant({ identity }),
|
|
);
|
|
const participants = rtcMembers.flatMap(({ userId, deviceId }) => {
|
|
const p = liveKitParticipants.find(
|
|
(p) => p.identity === `${userId}:${deviceId}`,
|
|
);
|
|
return p === undefined ? [] : [p];
|
|
});
|
|
const livekitRoom = {
|
|
remoteParticipants: new Map<string, Participant>(
|
|
liveKitParticipants.map((p) => [p.identity, p]),
|
|
),
|
|
} as unknown as Room;
|
|
|
|
if ((explicitTracks?.length ?? 0) > 0) {
|
|
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
|
|
const participant =
|
|
liveKitParticipants.find((p) => p.identity === participantId) ??
|
|
mockRemoteParticipant({ identity: participantId });
|
|
return mockTrack(participant, kind, source);
|
|
});
|
|
} else {
|
|
tracks = participants.map((p) => mockTrack(p));
|
|
}
|
|
|
|
vi.mocked(useTracks).mockReturnValue(tracks);
|
|
return render(
|
|
<MediaDevicesProvider value={mockMediaDevices({})}>
|
|
<LivekitRoomAudioRenderer
|
|
validIdentities={participants.map((p) => p.identity)}
|
|
livekitRoom={livekitRoom}
|
|
transport={exampleTransportLocator}
|
|
/>
|
|
</MediaDevicesProvider>,
|
|
);
|
|
}
|
|
|
|
it("should render for member", () => {
|
|
const { container, queryAllByTestId } = renderTestComponent(
|
|
[{ userId: "@alice", deviceId: "DEV0" }],
|
|
["@alice:DEV0"],
|
|
);
|
|
expect(container).toBeTruthy();
|
|
expect(queryAllByTestId("audio")).toHaveLength(1);
|
|
});
|
|
|
|
function spyOnWarn(): ReturnType<typeof vi.fn> {
|
|
const warn = vi.fn();
|
|
vi.spyOn(rootLogger, "getChild").mockReturnValue({
|
|
warn,
|
|
} as unknown as typeof rootLogger);
|
|
return warn;
|
|
}
|
|
|
|
it("should not render or warn for the local participant", () => {
|
|
const warn = spyOnWarn();
|
|
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
|
|
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
|
|
const { queryAllByTestId } = render(
|
|
<MediaDevicesProvider value={mockMediaDevices({})}>
|
|
<LivekitRoomAudioRenderer
|
|
validIdentities={[]}
|
|
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
|
transport={exampleTransportLocator}
|
|
/>
|
|
</MediaDevicesProvider>,
|
|
);
|
|
expect(queryAllByTestId("audio")).toHaveLength(0);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should warn only once per unexpected participant", () => {
|
|
const warn = spyOnWarn();
|
|
const { rerender } = renderTestComponent(
|
|
[{ userId: "@bob", deviceId: "DEV0" }],
|
|
["@alice:DEV0"],
|
|
[
|
|
{
|
|
participantId: "@alice:DEV0",
|
|
kind: Track.Kind.Audio,
|
|
source: Track.Source.Microphone,
|
|
},
|
|
],
|
|
);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
rerender(
|
|
<MediaDevicesProvider value={mockMediaDevices({})}>
|
|
<LivekitRoomAudioRenderer
|
|
validIdentities={[]}
|
|
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
|
transport={exampleTransportLocator}
|
|
/>
|
|
</MediaDevicesProvider>,
|
|
);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should not render without member", () => {
|
|
const { container, queryAllByTestId } = renderTestComponent(
|
|
[{ userId: "@bob", deviceId: "DEV0" }],
|
|
["@alice:DEV0"],
|
|
);
|
|
expect(container).toBeTruthy();
|
|
expect(queryAllByTestId("audio")).toHaveLength(0);
|
|
});
|
|
|
|
const TEST_CASES: {
|
|
name: string;
|
|
rtcUsers: { userId: string; deviceId: string }[];
|
|
livekitParticipantIdentities: string[];
|
|
explicitTracks?: {
|
|
participantId: string;
|
|
kind: Track.Kind;
|
|
source: Track.Source;
|
|
}[];
|
|
expectedAudioTracks: number;
|
|
}[] = [
|
|
{
|
|
name: "single user single device",
|
|
rtcUsers: [
|
|
{ userId: "@alice", deviceId: "DEV0" },
|
|
{ userId: "@alice", deviceId: "DEV1" },
|
|
{ userId: "@bob", deviceId: "DEV0" },
|
|
],
|
|
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@alice:DEV1"],
|
|
expectedAudioTracks: 3,
|
|
},
|
|
// Charlie is a rtc member but not in livekit
|
|
{
|
|
name: "Charlie is rtc member but not in livekit",
|
|
rtcUsers: [
|
|
{ userId: "@alice", deviceId: "DEV0" },
|
|
{ userId: "@bob", deviceId: "DEV0" },
|
|
{ userId: "@charlie", deviceId: "DEV0" },
|
|
],
|
|
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0"],
|
|
expectedAudioTracks: 2,
|
|
},
|
|
// Charlie is in livekit but not rtc member
|
|
{
|
|
name: "Charlie is in livekit but not rtc member",
|
|
rtcUsers: [
|
|
{ userId: "@alice", deviceId: "DEV0" },
|
|
{ userId: "@bob", deviceId: "DEV0" },
|
|
],
|
|
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@charlie:DEV0"],
|
|
expectedAudioTracks: 2,
|
|
},
|
|
{
|
|
name: "no audio track, only video track",
|
|
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
|
livekitParticipantIdentities: ["@alice:DEV0"],
|
|
explicitTracks: [
|
|
{
|
|
participantId: "@alice:DEV0",
|
|
kind: Track.Kind.Video,
|
|
source: Track.Source.Camera,
|
|
},
|
|
],
|
|
expectedAudioTracks: 0,
|
|
},
|
|
{
|
|
name: "Audio track from unknown source",
|
|
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
|
livekitParticipantIdentities: ["@alice:DEV0"],
|
|
explicitTracks: [
|
|
{
|
|
participantId: "@alice:DEV0",
|
|
kind: Track.Kind.Audio,
|
|
source: Track.Source.Unknown,
|
|
},
|
|
],
|
|
expectedAudioTracks: 1,
|
|
},
|
|
{
|
|
name: "Audio track from other device",
|
|
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
|
livekitParticipantIdentities: ["@alice:DEV0"],
|
|
explicitTracks: [
|
|
{
|
|
participantId: "@alice:DEV1",
|
|
kind: Track.Kind.Audio,
|
|
source: Track.Source.Microphone,
|
|
},
|
|
],
|
|
expectedAudioTracks: 0,
|
|
},
|
|
{
|
|
name: "two audio tracks, microphone and screenshare",
|
|
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
|
livekitParticipantIdentities: ["@alice:DEV0"],
|
|
explicitTracks: [
|
|
{
|
|
participantId: "@alice:DEV0",
|
|
kind: Track.Kind.Audio,
|
|
source: Track.Source.Microphone,
|
|
},
|
|
{
|
|
participantId: "@alice:DEV0",
|
|
kind: Track.Kind.Audio,
|
|
source: Track.Source.ScreenShareAudio,
|
|
},
|
|
],
|
|
expectedAudioTracks: 2,
|
|
},
|
|
];
|
|
|
|
it.each(TEST_CASES)(
|
|
`should render sound test cases $name`,
|
|
({
|
|
rtcUsers,
|
|
livekitParticipantIdentities,
|
|
explicitTracks,
|
|
expectedAudioTracks,
|
|
}) => {
|
|
const { queryAllByTestId } = renderTestComponent(
|
|
rtcUsers,
|
|
livekitParticipantIdentities,
|
|
explicitTracks,
|
|
);
|
|
expect(queryAllByTestId("audio")).toHaveLength(expectedAudioTracks);
|
|
},
|
|
);
|
|
|
|
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
|
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
|
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
|
|
|
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
|
|
expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined);
|
|
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1);
|
|
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]);
|
|
|
|
expect(testAudioContext.gain.gain.value).toEqual(1);
|
|
expect(testAudioContext.pan.pan.value).toEqual(0);
|
|
});
|
|
|
|
it("should setup audioContext gain and pan", () => {
|
|
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
|
|
pan: 1,
|
|
volume: 0.1,
|
|
});
|
|
|
|
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
|
|
|
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
|
expect(audioTrack.setAudioContext).toHaveBeenCalled();
|
|
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled();
|
|
|
|
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
|
expect(testAudioContext.pan.pan.value).toEqual(1);
|
|
});
|