Merge branch 'livekit' into toger5/src-to-lib-import

This commit is contained in:
Robin
2025-03-27 14:35:08 -04:00
87 changed files with 17774 additions and 9530 deletions

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
import {
beforeEach,
expect,
type MockedFunction,
onTestFinished,
test,
vi,
} from "vitest";
import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
@@ -14,6 +21,7 @@ import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils";
@@ -27,20 +35,33 @@ import {
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
vitest.mock("../soundUtils");
vitest.mock("../useAudioContext");
vitest.mock("./InCallView");
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
vi.mock("./InCallView");
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
vitest.mock("../rtcSessionHelpers", async (importOriginal) => {
const enterRTCSession = vi.hoisted(() => vi.fn(async () => Promise.resolve()));
const leaveRTCSession = vi.hoisted(() =>
vi.fn(
async (
rtcSession: unknown,
cause: unknown,
promiseBeforeHangup = Promise.resolve(),
) => await promiseBeforeHangup,
),
);
vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
vitest.spyOn(orig, "leaveRTCSession");
return orig;
return { ...orig, enterRTCSession, leaveRTCSession };
});
let playSound: MockedFunction<
@@ -54,11 +75,11 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
beforeEach(() => {
vitest.clearAllMocks();
vi.clearAllMocks();
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
playSound = vitest.fn();
playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
});
@@ -74,7 +95,10 @@ beforeEach(() => {
);
});
function createGroupCallView(widget: WidgetHelpers | null): {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
): {
rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"];
} {
@@ -87,7 +111,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vitest.mocked({
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
@@ -105,24 +129,27 @@ function createGroupCallView(widget: WidgetHelpers | null): {
localRtcMember,
[],
).withMemberships(of([]));
rtcSession.joined = joined;
const muteState = {
audio: { enabled: false },
video: { enabled: false },
} as MuteStates;
const { getByText } = render(
<BrowserRouter>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined
muteStates={muteState}
widget={widget}
/>
<TooltipProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</TooltipProvider>
</BrowserRouter>,
);
return {
@@ -131,7 +158,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
};
}
test("will play a leave sound asynchronously in SPA mode", async () => {
test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave");
@@ -142,13 +169,13 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
"user",
expect.any(Promise),
);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
expect(leaveRTCSession).toHaveBeenCalledOnce();
// Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
});
test("will play a leave sound synchronously in widget mode", async () => {
test("GroupCallView plays a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup();
const widget = {
api: {
@@ -157,7 +184,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
lazyActions: new LazyEventEmitter(),
};
let resolvePlaySound: () => void;
playSound = vitest
playSound = vi
.fn()
.mockReturnValue(
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
@@ -182,7 +209,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
"user",
expect.any(Promise),
);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("GroupCallView leaves the session when an error occurs", async () => {
@@ -204,8 +231,15 @@ test("GroupCallView leaves the session when an error occurs", async () => {
"error",
expect.any(Promise),
);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
// Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
});
test("GroupCallView shows errors that occur during joining", async () => {
const user = userEvent.setup();
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError(""));
onTestFinished(() => {
enterRTCSession.mockReset();
});
createGroupCallView(null, false);
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});

View File

@@ -98,6 +98,11 @@ export const GroupCallView: FC<Props> = ({
muteStates,
widget,
}) => {
// Used to thread through any errors that occur outside the error boundary
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const leaveSoundContext = useLatest(
useAudioContext({
@@ -123,7 +128,7 @@ export const GroupCallView: FC<Props> = ({
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setError(
setExternalError(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
@@ -172,30 +177,32 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
setError(error);
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
}
}
};
},
[setExternalError],
);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -288,11 +295,12 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
const navigate = useNavigate();
const onLeave = useCallback(
@@ -415,11 +423,12 @@ export const GroupCallView: FC<Props> = ({
);
let body: ReactNode;
if (error) {
// If an ElementCallError was recorded, then create a component that will fail to render and throw
// the error. This will then be handled by the ErrorBoundary component.
if (externalError) {
// If an error was recorded within this component but outside
// GroupCallErrorBoundary, create a component that rethrows the error from
// within the error boundary, so it can be handled uniformly
const ErrorComponent = (): ReactNode => {
throw error;
throw externalError;
};
body = <ErrorComponent />;
} else if (isJoined) {