Files
element-call/src/room/GroupCallErrorBoundary.test.tsx
T
Lotus CIandClaude Opus 5.5 3e69a18a39 fix(lotus): explain a failed voice connection and offer Try again
"Failed to connect to Livekit server (Reason: ServerUnreachable)" left a
user with nothing to try; they retried for a long time and gave up.

- Title "Couldn't connect to voice". The message depends on the reason:
  unreachable/timeout-style errors explain that voice needs its own live
  connection, which VPNs, antivirus web shields and some networks block,
  even when chat works, and say what to try; NotAllowed says the call may
  be full or access changed.
- The raw reason, status and message move under Technical details.
- LivekitConnectionError and PeerConnectionTimeoutError now get a
  Try again button (the existing reconnect path), shown as primary: the
  Lotus theme renders `secondary` as dark-on-dark.

Verified in the Lotus client against a local LiveKit with the signal
socket and validate request blocked: the dialog appears after ~20 s;
unblocking and pressing Try again joins with the mic published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 20:51:43 -04:00

446 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Copyright 2025 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 { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useState,
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { ConnectionError } from "livekit-client";
import { MatrixError } from "matrix-js-sdk";
import {
type CallErrorRecoveryAction,
GroupCallErrorBoundary,
} from "./GroupCallErrorBoundary.tsx";
import {
ConnectionLostError,
E2EENotSupportedError,
type ElementCallError,
FailToGetOpenIdToken,
InsufficientCapacityError,
LivekitConnectionError,
MatrixRTCTransportMissingError,
PeerConnectionTimeoutError,
StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
error: new MatrixRTCTransportMissingError("example.com"),
expectedTitle: "Call is not supported",
},
{
error: new ConnectionLostError(),
expectedTitle: "Connection lost",
expectedDescription: "You were disconnected from the call.",
},
{
error: new E2EENotSupportedError(),
expectedTitle: "Incompatible browser",
expectedDescription:
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
},
{
error: new InsufficientCapacityError(),
expectedTitle: "Insufficient capacity",
expectedDescription:
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
},
{
error: new StickyEventsRequiredError(),
expectedTitle: "Homeserver does not support Matrix 2.0 calls",
expectedDescription:
"This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
},
])(
"should report correct error for $expectedTitle",
async ({ error, expectedTitle, expectedDescription }) => {
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText(expectedTitle);
if (expectedDescription) {
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
}
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
},
);
test("should render the error page with link back to home", async () => {
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
).toBeInTheDocument();
await screen.findByRole("button", { name: "Return to home screen" });
expect(onErrorMock).toHaveBeenCalledOnce();
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
});
test("ConnectionLostError: Action handling should reset error state", async () => {
const user = userEvent.setup();
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
if (fail) {
throw new ConnectionLostError();
}
return <div>HELLO</div>;
};
const reconnectCallbackSpy = vi.fn();
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
async (action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
return Promise.resolve();
},
[setFailState],
);
return (
<BrowserRouter>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
);
};
const { asFragment } = render(<WrapComponent />);
// Should fail first
await screen.findByText("Connection lost");
await screen.findByRole("button", { name: "Reconnect" });
await screen.findByRole("button", { name: "Return to home screen" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Reconnect" }));
// reconnect should have reset the error, thus rendering should be ok
await screen.findByText("HELLO");
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
});
describe("Rageshake button", () => {
function setupTest(testError: ElementCallError): void {
mockConfig({
rageshake: {
submit_url: "https://rageshake.example.com.localhost",
},
});
const TestComponent = (): ReactElement => {
throw testError;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
}
test("should show send rageshake button for unknown errors", () => {
setupTest(new UnknownCallError(new Error("FOO")));
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).toBeInTheDocument();
});
test("should not show send rageshake button for call errors", () => {
setupTest(new E2EENotSupportedError());
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).not.toBeInTheDocument();
});
});
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const user = userEvent.setup();
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
await screen.findByRole("button", { name: "Close" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});
test("should show technical details when error has a matrixError cause", async () => {
const underlyingError = new MatrixError(
{
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
500,
"https://matrix-rtc.m.localhost/livekit/jwt/sfu/get",
);
const error = new FailToGetOpenIdToken(underlyingError);
const TestComponent = (): ReactNode => {
throw error;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Something went wrong");
// Technical details should be present
const detailsElement = screen.getByText("Technical details");
expect(detailsElement).toBeInTheDocument();
// Verify error details are shown
expect(
screen.getByText(/Failed to look up user info from homeserver/i, {
selector: "pre",
}),
).toBeInTheDocument();
});
test("should not show technical details when error has no matrix error cause", async () => {
const error = new ConnectionLostError();
const TestComponent = (): ReactNode => {
throw error;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection lost");
// Technical details should not be present (ConnectionLostError has no cause)
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
});
describe("LiveKit ConnectionError variants", () => {
test.each([
{
name: "notAllowed",
error: ConnectionError.notAllowed("Permission denied by server", 403),
expectedReason: "NotAllowed",
},
{
name: "timeout",
error: ConnectionError.timeout("Connection timed out"),
expectedReason: "Timeout",
},
{
name: "serverUnreachable",
error: ConnectionError.serverUnreachable("Server is unreachable", 503),
expectedReason: "ServerUnreachable",
},
{
name: "serviceNotFound",
error: ConnectionError.serviceNotFound(
"RTC service not found",
"v0-rtc" as const,
),
expectedReason: "ServiceNotFound",
},
{
name: "internal",
error: ConnectionError.internal("Internal server error", {
status: 500,
statusText: "Internal Server Error",
}),
expectedReason: "InternalError",
},
])(
"should explain the LiveKit $name error and offer a retry",
async ({ error, expectedReason }) => {
const TestComponent = (): ReactNode => {
throw new LivekitConnectionError(error);
};
const recoveryActionHandler = vi.fn(async () => Promise.resolve());
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={recoveryActionHandler}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Couldn’t connect to voice");
// [lotus] Plain-language guidance instead of "(Reason: X)".
expect(
screen.getByText(
expectedReason === "NotAllowed"
? /The voice server didn’t let you in/
: /Your device couldn’t reach the voice server/,
),
).toBeInTheDocument();
// The raw reason is still there, under Technical details.
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`Reason: ${expectedReason}`), {
selector: "pre",
}),
).toBeInTheDocument();
// Try again re-enters the call.
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(recoveryActionHandler).toHaveBeenCalledWith("reconnect");
expect(asFragment()).toMatchSnapshot();
},
);
test("should link to troubleshoot guide when timeout error", async () => {
const error = new PeerConnectionTimeoutError();
const TestComponent = (): ReactNode => {
throw error;
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection timeout");
// Verify the link is present and has correct href
const link = screen.getByText("troubleshooting guide");
expect(link).toHaveAttribute(
"href",
"https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/#sfu-connectivity-troubleshooting",
);
// Snapshot the complete rendered error
expect(asFragment()).toMatchSnapshot();
});
});