diff --git a/src/room/RoomPage.test.tsx b/src/room/RoomPage.test.tsx
new file mode 100644
index 00000000..65d15a8d
--- /dev/null
+++ b/src/room/RoomPage.test.tsx
@@ -0,0 +1,683 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import {
+ beforeEach,
+ afterEach,
+ describe,
+ expect,
+ test,
+ vi,
+ type Mock,
+} from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { type MatrixClient, MatrixError } from "matrix-js-sdk";
+import { BrowserRouter } from "react-router-dom";
+import { type FC, type ReactNode } from "react";
+
+import { RoomPage } from "./RoomPage";
+import { ClientContextProvider } from "../ClientContext";
+import { mockConfig, mockMediaDevices } from "../utils/test";
+import * as useRegisterPasswordlessUserModule from "../auth/useRegisterPasswordlessUser";
+import * as UrlParamsModule from "../UrlParams";
+import { HeaderStyle, type UrlParams } from "../UrlParams";
+import * as useLoadGroupCallModule from "./useLoadGroupCall";
+import * as useProfileModule from "../profile/useProfile";
+import * as settingsModule from "../settings/settings";
+import * as MediaDevicesContextModule from "../MediaDevicesContext";
+import * as widgetModule from "../widget";
+import * as ConfigModule from "../config/Config";
+
+// Mock modules
+vi.mock("../auth/useRegisterPasswordlessUser");
+vi.mock("../UrlParams");
+vi.mock("./useLoadGroupCall");
+vi.mock("../profile/useProfile");
+vi.mock("../settings/settings");
+vi.mock("../widget");
+vi.mock("../MediaDevicesContext");
+vi.mock("./GroupCallView", () => ({
+ GroupCallView: vi.fn(() =>
GroupCallView Mock
),
+}));
+vi.mock("./LobbyView", () => ({
+ LobbyView: vi.fn(() => LobbyView Mock
),
+}));
+vi.mock("./RoomAuthView", () => ({
+ RoomAuthView: vi.fn(() => RoomAuthView Mock
),
+}));
+vi.mock("../home/HomePage", () => ({
+ HomePage: vi.fn(() => HomePage Mock
),
+}));
+vi.mock("./AppSelectionModal", () => ({
+ AppSelectionModal: vi.fn(() => AppSelectionModal Mock
),
+}));
+
+const mockClient = vi.mocked({
+ getUserId: () => "@alice:example.org",
+} as unknown as MatrixClient);
+
+interface WrapperProps {
+ children: ReactNode;
+}
+
+const TestWrapper: FC = ({ children }) => {
+ return {children};
+};
+
+// Helper function to create mock URL params with optional overrides
+// Only includes properties actually used by RoomPage component
+function createMockUrlParams(
+ overrides: Partial> = {},
+): ReturnType {
+ return {
+ // Properties used by RoomPage
+ confineToRoom: false,
+ preload: false,
+ header: HeaderStyle.Standard,
+ displayName: null,
+ skipLobby: false,
+ callIntent: undefined,
+ ...overrides,
+ } as unknown as UrlParams;
+}
+
+describe("RoomPage", () => {
+ let mockRegisterPasswordlessUser: Mock;
+ let mockUseLoadGroupCall: Mock;
+ let mockUseOptInAnalytics: Mock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockConfig({});
+
+ // Default mock for widget
+ vi.mocked(widgetModule).widget = null;
+
+ // Default mock for useUrlParams
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams(),
+ );
+
+ // Default mock for useRoomIdentifier
+ vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({
+ roomAlias: "#test:example.org",
+ roomId: null,
+ viaServers: [],
+ });
+
+ // Default mock for useRegisterPasswordlessUser
+ mockRegisterPasswordlessUser = vi.fn();
+ vi.spyOn(
+ useRegisterPasswordlessUserModule,
+ "useRegisterPasswordlessUser",
+ ).mockReturnValue({
+ registerPasswordlessUser: mockRegisterPasswordlessUser,
+ privacyPolicyUrl: undefined,
+ recaptchaId: undefined,
+ });
+
+ // Default mock for useLoadGroupCall
+ mockUseLoadGroupCall = vi
+ .spyOn(useLoadGroupCallModule, "useLoadGroupCall")
+ .mockReturnValue({
+ kind: "loading",
+ });
+
+ // Default mock for useProfile
+ vi.spyOn(useProfileModule, "useProfile").mockReturnValue({
+ success: false,
+ loading: false,
+ displayName: "Alice",
+ avatarUrl: undefined,
+ saveProfile: vi.fn(),
+ });
+
+ // Default mock for useOptInAnalytics
+ mockUseOptInAnalytics = vi.fn();
+ vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([
+ null,
+ mockUseOptInAnalytics,
+ ]);
+
+ // Default mock for useMediaDevices
+ vi.spyOn(MediaDevicesContextModule, "useMediaDevices").mockReturnValue(
+ mockMediaDevices({}),
+ );
+ });
+
+ afterEach(() => {
+ // Reset widget mock to prevent cross-test pollution
+ vi.mocked(widgetModule).widget = null;
+ });
+
+ describe("Loading states", () => {
+ test("shows RoomAuthView when not authenticated without displayName", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ // When not authenticated and no displayName, should show RoomAuthView
+ expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument();
+ });
+
+ test("shows loading page when registering", async () => {
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams({ displayName: "Bob" }),
+ );
+
+ mockRegisterPasswordlessUser.mockImplementation(
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async () => new Promise(() => {}),
+ ); // Never resolves
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith("Bob");
+ });
+ });
+ });
+
+ describe("Error states", () => {
+ test("shows error page when client has error", () => {
+ const error = new Error("Test error");
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
+ });
+
+ test("shows error view when call not found", () => {
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "failed",
+ error: new MatrixError({ errcode: "M_NOT_FOUND" }),
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ // Should show the call not found error message
+ expect(
+ screen.getByText(/that link doesn't appear to belong/i),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Authentication", () => {
+ test("shows RoomAuthView when not authenticated", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument();
+ });
+
+ test("auto-registers passwordless user with displayName param", async () => {
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams({ displayName: "AutoRegisterUser" }),
+ );
+
+ mockRegisterPasswordlessUser.mockResolvedValue(undefined);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith(
+ "AutoRegisterUser",
+ );
+ });
+ });
+
+ test("does not auto-register in widget mode", () => {
+ // Mock widget with proper EventEmitter
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const EventEmitter = require("events");
+ const mockLazyActions = new EventEmitter();
+
+ vi.mocked(widgetModule).widget = {
+ api: {
+ transport: {
+ send: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ lazyActions: mockLazyActions,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any;
+
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams({ displayName: "ShouldNotRegister" }),
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(mockRegisterPasswordlessUser).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Room states", () => {
+ test("shows HomePage when no room is specified", () => {
+ vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({
+ roomAlias: null,
+ roomId: null,
+ viaServers: [],
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("HomePage Mock")).toBeInTheDocument();
+ });
+
+ test("shows loading state for group call", () => {
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "loading",
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ // The loading state renders with the heading
+ expect(screen.getByRole("heading")).toBeInTheDocument();
+ });
+
+ test("shows GroupCallView when call is loaded", () => {
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "loaded",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ rtcSession: {} as any,
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("GroupCallView Mock")).toBeInTheDocument();
+ });
+
+ test("shows LobbyView when waiting for invite", () => {
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "waitForInvite",
+ roomSummary: {
+ room_id: "!test:example.org",
+ name: "Test Room",
+ avatar_url: null,
+ "im.nheko.summary.encryption": undefined,
+ },
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("LobbyView Mock")).toBeInTheDocument();
+ });
+
+ test("shows LobbyView when can knock", () => {
+ const mockKnock = vi.fn();
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "canKnock",
+ roomSummary: {
+ room_id: "!test:example.org",
+ name: "Test Room",
+ avatar_url: null,
+ "im.nheko.summary.encryption": undefined,
+ },
+ knock: mockKnock,
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("LobbyView Mock")).toBeInTheDocument();
+ });
+ });
+
+ describe("Analytics opt-in", () => {
+ test("auto opts-in to analytics when null", async () => {
+ mockUseOptInAnalytics.mockClear();
+ const setOptInAnalytics = vi.fn();
+
+ vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([
+ null,
+ setOptInAnalytics,
+ ]);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(setOptInAnalytics).toHaveBeenCalledWith(true);
+ });
+ });
+
+ test("does not opt-in when already set", () => {
+ const setOptInAnalytics = vi.fn();
+
+ vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([
+ false,
+ setOptInAnalytics,
+ ]);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(setOptInAnalytics).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("App selection modal", () => {
+ test("shows AppSelectionModal on Android when appPrompt is enabled", () => {
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams(),
+ );
+
+ vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({
+ roomAlias: null,
+ roomId: "!test:example.org",
+ viaServers: [],
+ });
+
+ // Mock platform
+ vi.doMock("../Platform", () => ({
+ platform: "android",
+ }));
+
+ // Mock Config
+ vi.spyOn(ConfigModule.Config, "get").mockReturnValue({
+ app_prompt: true,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ mockUseLoadGroupCall.mockReturnValue({
+ kind: "loaded",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ rtcSession: {} as any,
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ // The component should render, even though the AppSelectionModal might not show
+ // because platform module is mocked differently in different contexts
+ expect(screen.getByText("GroupCallView Mock")).toBeInTheDocument();
+ });
+ });
+
+ describe("Passwordless user registration error handling", () => {
+ test("handles registration error gracefully", async () => {
+ vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue(
+ createMockUrlParams({ displayName: "FailUser" }),
+ );
+
+ mockRegisterPasswordlessUser.mockRejectedValue(
+ new Error("Registration failed"),
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith("FailUser");
+ });
+
+ // After error, should show RoomAuthView
+ await waitFor(() => {
+ expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument();
+ });
+ });
+ });
+});