mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
// TODO-MULTI-SFU: Restore or discard these tests. The role of CallView has
|
|
// changed (it no longer manages the connection to the same extent), so they may
|
|
// need extra work to adapt.
|
|
|
|
import {
|
|
beforeEach,
|
|
expect,
|
|
type MockedFunction,
|
|
onTestFinished,
|
|
test,
|
|
vi,
|
|
vitest,
|
|
} from "vitest";
|
|
import { render, waitFor, screen, act } from "@testing-library/react";
|
|
import {
|
|
type MatrixClient,
|
|
JoinRule,
|
|
type RoomState,
|
|
UnsupportedStickyEventsEndpointError,
|
|
} from "matrix-js-sdk";
|
|
import {
|
|
MatrixRTCSessionEvent,
|
|
type MatrixRTCSession,
|
|
} from "matrix-js-sdk/lib/matrixrtc";
|
|
import { BrowserRouter } from "react-router-dom";
|
|
import userEvent, {
|
|
PointerEventsCheckLevel,
|
|
} 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 { Subject } from "rxjs";
|
|
import { Room as LivekitRoom } from "livekit-client";
|
|
|
|
import { prefetchSounds } from "../soundUtils";
|
|
import { useAudioContext } from "../useAudioContext";
|
|
import { ActiveCall } from "./InCallView";
|
|
import {
|
|
flushPromises,
|
|
mockEmitter,
|
|
mockMatrixRoom,
|
|
mockMatrixRoomMember,
|
|
mockMediaDevices,
|
|
mockRtcMembership,
|
|
MockRTCSession,
|
|
} from "../utils/test";
|
|
import { CallView } from "./CallView";
|
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
|
import {
|
|
type HostBridge,
|
|
HostBridgeProvider,
|
|
type HostRequest,
|
|
nullHostBridge,
|
|
} from "../HostBridge";
|
|
import { type JoinCallData } from "../widget";
|
|
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
|
import { constant } from "../state/Behavior";
|
|
|
|
vi.mock("../soundUtils");
|
|
vi.mock("../useAudioContext");
|
|
vi.mock("./InCallView");
|
|
vi.mock("react-use-measure", () => ({
|
|
default: (): [() => void, object] => [(): void => {}, {}],
|
|
}));
|
|
|
|
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),
|
|
);
|
|
|
|
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")>();
|
|
// // TODO: leaveRTCSession no longer exists! Tests need adapting.
|
|
// return { ...orig, enterRTCSession, leaveRTCSession };
|
|
// });
|
|
|
|
let playSound: MockedFunction<
|
|
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
|
>;
|
|
|
|
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
|
const carol = mockMatrixRoomMember(localRtcMember);
|
|
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
|
|
|
const roomId = "!foo:bar";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
|
sound: new ArrayBuffer(0),
|
|
});
|
|
playSound = vi.fn();
|
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
|
playSound,
|
|
playSoundLooping: vi.fn(),
|
|
soundDuration: {},
|
|
});
|
|
// A trivial implementation of Active call to ensure we are testing CallView exclusively here.
|
|
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
|
({ onLeft: onLeave }) => {
|
|
return (
|
|
<div>
|
|
<button onClick={() => onLeave("user")}>Leave</button>
|
|
<button onClick={() => onLeave("allOthersLeft")}>
|
|
SimulateOtherLeft
|
|
</button>
|
|
<button onClick={() => onLeave("error")}>SimulateErrorLeft</button>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
function createCallView(
|
|
hostBridge: HostBridge,
|
|
joined = true,
|
|
options: {
|
|
withErrorBoundary?: boolean;
|
|
/** Wait for the host to say when to join, rather than joining at once. */
|
|
preload?: boolean;
|
|
} = {},
|
|
): {
|
|
rtcSession: MatrixRTCSession;
|
|
getByText: ReturnType<typeof render>["getByText"];
|
|
} {
|
|
const client = {
|
|
getUser: () => null,
|
|
getUserId: () => localRtcMember.userId,
|
|
getDeviceId: () => localRtcMember.deviceId,
|
|
getRoom: (rId) => (rId === roomId ? room : null),
|
|
} as Partial<MatrixClient> as MatrixClient;
|
|
const room = mockMatrixRoom({
|
|
relations: {
|
|
getChildEventsForEvent: () =>
|
|
vi.mocked({
|
|
getRelations: () => [],
|
|
}),
|
|
} as unknown as RelationsContainer,
|
|
client,
|
|
roomId,
|
|
getMember: (userId) => roomMembers.get(userId) ?? null,
|
|
getMxcAvatarUrl: () => null,
|
|
getCanonicalAlias: () => null,
|
|
currentState: {
|
|
...mockEmitter(),
|
|
getJoinRule: () => JoinRule.Invite,
|
|
} as Partial<RoomState> as RoomState,
|
|
});
|
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
|
constant([localRtcMember]),
|
|
);
|
|
rtcSession.joined = joined;
|
|
const callView = (
|
|
<CallView
|
|
client={client}
|
|
isPasswordlessUser={false}
|
|
confineToRoom={false}
|
|
preload={options.preload ?? false}
|
|
// Straight into the (mocked) call, past the lobby
|
|
skipLobby
|
|
rtcSession={rtcSession.asMockedSession()}
|
|
/>
|
|
);
|
|
const { getByText } = render(
|
|
<BrowserRouter>
|
|
<HostBridgeProvider value={hostBridge}>
|
|
<TooltipProvider>
|
|
<MediaDevicesContext value={mockMediaDevices({})}>
|
|
<ProcessorProvider>
|
|
{options.withErrorBoundary ? (
|
|
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
|
{callView}
|
|
</GroupCallErrorBoundary>
|
|
) : (
|
|
callView
|
|
)}
|
|
</ProcessorProvider>
|
|
</MediaDevicesContext>
|
|
</TooltipProvider>
|
|
</HostBridgeProvider>
|
|
</BrowserRouter>,
|
|
);
|
|
return {
|
|
getByText,
|
|
rtcSession: rtcSession.asMockedSession(),
|
|
};
|
|
}
|
|
|
|
test.skip("CallView plays a leave sound asynchronously in SPA mode", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByText, rtcSession } = createCallView(nullHostBridge);
|
|
const leaveButton = getByText("Leave");
|
|
await user.click(leaveButton);
|
|
expect(playSound).toHaveBeenCalledWith("left");
|
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
|
rtcSession,
|
|
"user",
|
|
expect.any(Promise),
|
|
);
|
|
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.skip("CallView plays a leave sound synchronously in widget mode", async () => {
|
|
const user = userEvent.setup();
|
|
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
|
let resolvePlaySound: () => void;
|
|
playSound = vi
|
|
.fn()
|
|
.mockReturnValue(
|
|
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
|
|
);
|
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
|
playSound,
|
|
playSoundLooping: vitest.fn(),
|
|
soundDuration: {},
|
|
});
|
|
|
|
const { getByText, rtcSession } = createCallView(hostBridge);
|
|
const leaveButton = getByText("Leave");
|
|
await user.click(leaveButton);
|
|
await flushPromises();
|
|
expect(leaveRTCSession).not.toHaveResolved();
|
|
resolvePlaySound!();
|
|
await flushPromises();
|
|
|
|
expect(playSound).toHaveBeenCalledWith("left");
|
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
|
rtcSession,
|
|
"user",
|
|
expect.any(Promise),
|
|
);
|
|
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("Should ask the host to close when all other left and play a sound", async () => {
|
|
const user = userEvent.setup();
|
|
const close = vi.fn().mockResolvedValue(undefined);
|
|
const hostBridge: HostBridge = {
|
|
...nullHostBridge,
|
|
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
|
close,
|
|
};
|
|
const resolvePlaySound = Promise.withResolvers<void>();
|
|
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
|
playSound,
|
|
playSoundLooping: vitest.fn(),
|
|
soundDuration: {},
|
|
});
|
|
|
|
const { getByText } = createCallView(hostBridge);
|
|
const leaveButton = getByText("SimulateOtherLeft");
|
|
await user.click(leaveButton);
|
|
await flushPromises();
|
|
expect(close).not.toHaveBeenCalled();
|
|
resolvePlaySound.resolve();
|
|
|
|
expect(playSound).toHaveBeenCalledWith("left", 0);
|
|
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
|
}, 80000);
|
|
|
|
test("Waits for the host to say when to join, when preloaded", async () => {
|
|
// Nothing to match device names against; the host names none anyway
|
|
vi.spyOn(LivekitRoom, "getLocalDevices").mockResolvedValue([]);
|
|
const join$ = new Subject<HostRequest<JoinCallData>>();
|
|
const hostBridge: HostBridge = { ...nullHostBridge, join$ };
|
|
|
|
createCallView(hostBridge, false, { preload: true });
|
|
await flushPromises();
|
|
// Past the lobby, but not in the call: the host has not asked yet
|
|
expect(screen.queryByText("Leave")).toBeNull();
|
|
|
|
const reply = vi.fn();
|
|
act(() =>
|
|
join$.next({ data: { audioInput: null, videoInput: null }, reply }),
|
|
);
|
|
// Then in the call, and the host told so
|
|
await waitFor(() => expect(reply).toHaveBeenCalledOnce());
|
|
expect(screen.getByText("Leave")).toBeInTheDocument();
|
|
});
|
|
|
|
test("Should not ask the host to close when auto leave due to error", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const close = vi.fn().mockResolvedValue(undefined);
|
|
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
|
const hostBridge: HostBridge = {
|
|
...nullHostBridge,
|
|
setAlwaysOnScreen,
|
|
close,
|
|
};
|
|
|
|
const { getByText } = createCallView(hostBridge);
|
|
const leaveButton = getByText("SimulateErrorLeft");
|
|
await user.click(leaveButton);
|
|
await flushPromises();
|
|
|
|
// When onLeft is called, we first set always on screen to false
|
|
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
|
|
await flushPromises();
|
|
// But then we do not ask to be closed automatically
|
|
expect(close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.skip("CallView leaves the session when an error occurs", async () => {
|
|
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
|
|
const [error, setError] = useState<Error | null>(null);
|
|
if (error !== null) throw error;
|
|
return (
|
|
<div>
|
|
<button onClick={() => setError(new Error())}>Panic!</button>
|
|
</div>
|
|
);
|
|
});
|
|
const user = userEvent.setup();
|
|
const { rtcSession } = createCallView(nullHostBridge);
|
|
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
|
screen.getByText("Something went wrong");
|
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
|
rtcSession,
|
|
"error",
|
|
expect.any(Promise),
|
|
);
|
|
});
|
|
|
|
test.skip("CallView shows errors that occur during joining", async () => {
|
|
const user = userEvent.setup();
|
|
// This should not mock this error that deep. it should only mock the CallViewModel.
|
|
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
|
|
onTestFinished(() => {
|
|
enterRTCSession.mockReset();
|
|
});
|
|
createCallView(nullHostBridge, false);
|
|
await user.click(screen.getByRole("button", { name: "Join call" }));
|
|
screen.getByText("Call is not supported");
|
|
});
|
|
|
|
test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
|
|
// Mirror the shape the SDK emits: the MembershipManager scheduler wraps
|
|
// the original UnsupportedStickyEventsEndpointError in a generic Error
|
|
// but preserves the original on `.cause`.
|
|
const stickyError = new UnsupportedStickyEventsEndpointError(
|
|
"Server does not support the sticky events",
|
|
"sendStickyEvent",
|
|
);
|
|
const wrappedError = new Error(
|
|
"The MembershipManager shut down because of the end condition: " +
|
|
String(stickyError),
|
|
{ cause: stickyError },
|
|
);
|
|
|
|
const { rtcSession } = createCallView(nullHostBridge, true, {
|
|
withErrorBoundary: true,
|
|
});
|
|
|
|
await act(() =>
|
|
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
|
|
);
|
|
|
|
await screen.findByText("Homeserver does not support Matrix 2.0 calls");
|
|
});
|
|
|
|
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
|
const { rtcSession } = createCallView(nullHostBridge, true, {
|
|
withErrorBoundary: true,
|
|
});
|
|
|
|
await act(() =>
|
|
rtcSession.emit(
|
|
MatrixRTCSessionEvent.MembershipManagerError,
|
|
new Error("something else broke"),
|
|
),
|
|
);
|
|
|
|
await screen.findByText("Connection lost");
|
|
});
|
|
|
|
test("user can reconnect after a membership manager error", async () => {
|
|
const user = userEvent.setup();
|
|
const { rtcSession } = createCallView(nullHostBridge, true);
|
|
await act(() =>
|
|
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
|
);
|
|
// XXX: Wrapping the following click in act() shouldn't be necessary (the
|
|
// async state update should be processed automatically by the waitFor call),
|
|
// and yet here we are.
|
|
await act(async () =>
|
|
user
|
|
// With css vitest turned on this test thinks that the button has pointer_events: none;.
|
|
// TODO investigate if this is a test setup issue or an actual problem.
|
|
.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never })
|
|
.click(screen.getByRole("button", { name: "Reconnect" })),
|
|
);
|
|
// In-call controls should be visible again
|
|
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
|
|
});
|