/* Copyright 2025 New Vector Ltd. 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, describe, expect, it, type MockedFunction, vi, } from "vitest"; import { act, render, type RenderResult } from "@testing-library/react"; import { BehaviorSubject } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; import userEvent from "@testing-library/user-event"; import { ActiveCall, InCallView } from "./InCallView"; import { mockMediaDevices, mockMuteStates, mockRtcMembership, type MockRTCSession, mockRTCSlot, mockRTCMember, } from "../utils/test"; import { E2eeType } from "../e2ee/e2eeType"; import { getBasicCallViewModelEnvironment, getBasicRTCSession, } from "../utils/test-viewmodel"; import { type CallViewModel, type CallViewModelOptions, } from "../state/CallViewModel/CallViewModel"; import { alice, local, aliceRtcMember } from "../utils/test-fixtures"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer"; import type * as MatrixRTCSdk from "@element-hq/matrixrtc-sdk"; import { constant, type ObservableScope } from "@element-hq/matrixrtc-sdk"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { AppBar } from "../AppBar"; import { type MatrixInfo } from "./VideoPreview"; import { ProcessorProvider } from "../tracks/TrackProcessorContext"; import { initializeWidget } from "../widget"; import { RootElementProvider } from "../RootElementContext"; initializeWidget(); vi.hoisted( () => // Use globalThis rather than global because vite-plugin-node-polyfills seems // to rewrite global into an import which then interferes with vitest's hoisting // which runs before imports. (globalThis.ImageData = class MockImageData { public data: number[] = []; } as unknown as typeof ImageData), ); vi.mock("../soundUtils"); vi.mock("../useAudioContext"); vi.mock("../tile/GridTile"); vi.mock("../tile/SpotlightTile"); vi.mock("../e2ee/sharedKeyManagement"); vi.mock("../tracks/MemberAudioRenderer"); // ActiveCall builds the slot itself; the view is what these tests are about const createRTCSlotMock = vi.hoisted(() => vi.fn()); vi.mock("@element-hq/matrixrtc-sdk", async (importOriginal) => ({ ...(await importOriginal()), createRTCSlot: createRTCSlotMock, })); vi.mock("react-use-measure", () => ({ default: (): [() => void, object] => [(): void => {}, {}], })); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); const matrixInfo = { userId: "", displayName: "", avatarUrl: "", roomId: "", roomName: "", roomAlias: null, roomAvatar: null, e2eeSystem: { kind: E2eeType.NONE }, } satisfies MatrixInfo; let useRoomEncryptionSystemMock: MockedFunction; beforeEach(() => { vi.clearAllMocks(); createRTCSlotMock.mockImplementation((scope: ObservableScope) => mockRTCSlot(scope, { localMember$: constant( mockRTCMember(true, { membership: localRtcMember, tracks$: constant([]), }), ), remoteMembers$: constant([ mockRTCMember(false, { membership: aliceRtcMember, tracks$: constant([]), }), ]), }), ); // MemberAudioRenderer is tested separately. ( MemberAudioRenderer as MockedFunction ).mockImplementation(() =>
mocked: MemberAudioRenderer
); useRoomEncryptionSystemMock = useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock; useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE }); }); interface CreateInCallViewArgs { mediaDevices?: ECMediaDevices; callViewModelOptions?: Partial; /** If true, wraps the rendered tree in an AppBar provider */ withAppBar?: boolean; } function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { rtcSession: MockRTCSession; vm: CallViewModel; } { const mediaDevices = args.mediaDevices ?? mockMediaDevices({}); const muteState = mockMuteStates(); const { vm, footerVm, developerSettingsVm, rtcSession } = getBasicCallViewModelEnvironment( [local, alice], undefined, mediaDevices, args.callViewModelOptions, ); rtcSession.joined = true; const room = rtcSession.room; const client = room.client; const inCallView = ( ); const content = args.withAppBar ? {inCallView} : inCallView; const renderResult = render( {content} , ); return { ...renderResult, rtcSession, vm, }; } describe("InCallView", () => { describe("rendering", () => { it("renders", () => { const { container } = createInCallView(); expect(container).toMatchSnapshot(); }); }); describe("audioOutputSwitcher", () => { it("is visible and can be clicked", async () => { const user = userEvent.setup(); const switchFn = vi.fn(); // Create mediaDevices with a speaker and an earpiece available, // with the speaker currently selected. // This is needed so that the audio switcher button is visible const available$ = new BehaviorSubject( new Map([ ["speaker-id", { type: "speaker" }], ["earpiece-id", { type: "earpiece" }], ]), ); const selected$ = new BehaviorSubject({ id: "speaker-id", virtualEarpiece: false, }); const mediaDevices = mockMediaDevices({ audioOutput: { available$, selected$, select: switchFn, }, }); const { getByRole } = createInCallView({ mediaDevices }); // The button should be visible. When current output is "speaker", const audioOutputBtn = getByRole("button", { name: "Loudspeaker" }); expect(audioOutputBtn).toBeVisible(); await user.click(audioOutputBtn); // Clicking the button should call select -> switchFn with the earpiece device id expect(switchFn).toHaveBeenCalledWith("earpiece-id"); }); }); }); describe("ActiveCall", () => { it("creates the view models and renders the call", async () => { const mediaDevices = mockMediaDevices({}); const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]); const { findByTestId } = render( {}} /> , ); // Rendering at all proves ActiveCall created all of its view models expect(await findByTestId("incall_leave")).toBeVisible(); }); it("lays the call out for the size of its root element", async () => { // jsdom has no layout and no ResizeObserver: the root reports whatever // size we say, and the observer notifies whenever we tell it to let size = { width: 1000, height: 800 }; const root = document.createElement("div"); Object.defineProperty(root, "clientWidth", { get: () => size.width }); Object.defineProperty(root, "clientHeight", { get: () => size.height }); document.body.appendChild(root); const observers: (() => void)[] = []; const originalResizeObserver = window.ResizeObserver; window.ResizeObserver = class { public constructor(private readonly callback: ResizeObserverCallback) {} public observe(): void { observers.push(() => this.callback([], this as ResizeObserver)); } public unobserve(): void {} public disconnect(): void {} } as unknown as typeof ResizeObserver; try { const mediaDevices = mockMediaDevices({}); const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]); const { findByTestId, container } = render( {}} /> , { container: root }, ); await findByTestId("incall_leave"); const call = container.querySelector("[data-layout]")!; expect(call.getAttribute("data-layout")).not.toBe("pip"); // The host shrinks the container to a corner of its page. The window has // not changed at all — what matters is the element we were given. size = { width: 300, height: 300 }; act(() => observers.forEach((notify) => notify())); expect(call.getAttribute("data-layout")).toBe("pip"); size = { width: 1000, height: 800 }; act(() => observers.forEach((notify) => notify())); expect(call.getAttribute("data-layout")).not.toBe("pip"); } finally { window.ResizeObserver = originalResizeObserver; root.remove(); } }); });