Show errors that occur in GroupCallView using the error boundary

We were previously using the useGroupCallErrorBoundary hook to surface errors that happened during joining, but because that part is outside the GroupCallErrorBoundary it just ended up sending them to the app-level error boundary where they got displayed with a more generic message.
This commit is contained in:
Robin
2025-03-21 14:59:27 -04:00
parent 6043b3949b
commit 9a5dd10e27
3 changed files with 88 additions and 43 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. 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 { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
@@ -15,6 +22,7 @@ import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { useState } from "react"; import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
@@ -28,20 +36,33 @@ import {
MockRTCSession, MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget"; import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter"; import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
vitest.mock("../soundUtils"); vi.mock("../soundUtils");
vitest.mock("../useAudioContext"); vi.mock("../useAudioContext");
vitest.mock("./InCallView"); 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? // TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>(); const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
vitest.spyOn(orig, "leaveRTCSession"); return { ...orig, enterRTCSession, leaveRTCSession };
return orig;
}); });
let playSound: MockedFunction< let playSound: MockedFunction<
@@ -55,11 +76,11 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar"; const roomId = "!foo:bar";
beforeEach(() => { beforeEach(() => {
vitest.clearAllMocks(); vi.clearAllMocks();
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({ (prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0), sound: new ArrayBuffer(0),
}); });
playSound = vitest.fn(); playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
}); });
@@ -75,7 +96,10 @@ beforeEach(() => {
); );
}); });
function createGroupCallView(widget: WidgetHelpers | null): { function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
): {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"]; getByText: ReturnType<typeof render>["getByText"];
} { } {
@@ -88,7 +112,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
relations: { relations: {
getChildEventsForEvent: () => getChildEventsForEvent: () =>
vitest.mocked({ vi.mocked({
getRelations: () => [], getRelations: () => [],
}), }),
} as unknown as RelationsContainer, } as unknown as RelationsContainer,
@@ -106,24 +130,27 @@ function createGroupCallView(widget: WidgetHelpers | null): {
localRtcMember, localRtcMember,
[], [],
).withMemberships(of([])); ).withMemberships(of([]));
rtcSession.joined = joined;
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },
video: { enabled: false }, video: { enabled: false },
} as MuteStates; } as MuteStates;
const { getByText } = render( const { getByText } = render(
<BrowserRouter> <BrowserRouter>
<GroupCallView <TooltipProvider>
client={client} <GroupCallView
isPasswordlessUser={false} client={client}
confineToRoom={false} isPasswordlessUser={false}
preload={false} confineToRoom={false}
skipLobby={false} preload={false}
hideHeader={true} skipLobby={false}
rtcSession={rtcSession as unknown as MatrixRTCSession} hideHeader={true}
isJoined rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState} isJoined={joined}
widget={widget} muteStates={muteState}
/> widget={widget}
/>
</TooltipProvider>
</BrowserRouter>, </BrowserRouter>,
); );
return { return {
@@ -132,7 +159,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 user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null); const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave"); const leaveButton = getByText("Leave");
@@ -143,13 +170,13 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
"user", "user",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
// Ensure that the playSound promise resolves within this test to avoid // Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests // impacting the results of other tests
await waitFor(() => expect(leaveRTCSession).toHaveResolved()); 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 user = userEvent.setup();
const widget = { const widget = {
api: { api: {
@@ -158,7 +185,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
lazyActions: new LazyEventEmitter(), lazyActions: new LazyEventEmitter(),
}; };
let resolvePlaySound: () => void; let resolvePlaySound: () => void;
playSound = vitest playSound = vi
.fn() .fn()
.mockReturnValue( .mockReturnValue(
new Promise<void>((resolve) => (resolvePlaySound = resolve)), new Promise<void>((resolve) => (resolvePlaySound = resolve)),
@@ -183,7 +210,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
"user", "user",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
}); });
test("GroupCallView leaves the session when an error occurs", async () => { test("GroupCallView leaves the session when an error occurs", async () => {
@@ -205,8 +232,15 @@ test("GroupCallView leaves the session when an error occurs", async () => {
"error", "error",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); });
// Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests test("GroupCallView shows errors that occur during joining", async () => {
await waitFor(() => expect(leaveRTCSession).toHaveResolved()); 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

@@ -67,7 +67,6 @@ import {
useSetting, useSetting,
} from "../settings/settings"; } from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
declare global { declare global {
interface Window { interface Window {
@@ -100,6 +99,11 @@ export const GroupCallView: FC<Props> = ({
muteStates, muteStates,
widget, 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 memberships = useMatrixRTCSessionMemberships(rtcSession);
const leaveSoundContext = useLatest( const leaveSoundContext = useLatest(
useAudioContext({ useAudioContext({
@@ -121,13 +125,11 @@ export const GroupCallView: FC<Props> = ({
}; };
}, [rtcSession]); }, [rtcSession]);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,
(error) => { (error) => {
showGroupCallErrorBoundary( setExternalError(
new RTCSessionError( new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE, ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error, error.message ?? error,
@@ -190,17 +192,17 @@ export const GroupCallView: FC<Props> = ({
); );
} catch (e) { } catch (e) {
if (e instanceof ElementCallError) { if (e instanceof ElementCallError) {
showGroupCallErrorBoundary(e); setExternalError(e);
} else { } else {
logger.error(`Unknown Error while entering RTC session`, e); logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError( const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }), e instanceof Error ? e : new Error("Unknown error", { cause: e }),
); );
showGroupCallErrorBoundary(error); setExternalError(error);
} }
} }
}, },
[showGroupCallErrorBoundary], [setExternalError],
); );
useEffect(() => { useEffect(() => {
@@ -422,7 +424,15 @@ export const GroupCallView: FC<Props> = ({
); );
let body: ReactNode; let body: ReactNode;
if (isJoined) { 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 externalError;
};
body = <ErrorComponent />;
} else if (isJoined) {
body = ( body = (
<> <>
{shareModal} {shareModal}

View File

@@ -286,8 +286,9 @@ export class MockRTCSession extends TypedEventEmitter<
super(); super();
} }
public isJoined(): true { public joined = true;
return true; public isJoined(): boolean {
return this.joined;
} }
public withMemberships( public withMemberships(