mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-24 00:38:31 +08:00
Merge pull request #2629 from robintown/test-call-vm
Test CallViewModel
This commit is contained in:
commit
cec7fc8f5b
@ -108,7 +108,8 @@
|
|||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-html-template": "^1.1.0",
|
"vite-plugin-html-template": "^1.1.0",
|
||||||
"vite-plugin-svgr": "^4.0.0",
|
"vite-plugin-svgr": "^4.0.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0",
|
||||||
|
"vitest-axe": "^1.0.0-pre.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"strip-ansi": "6.0.1"
|
"strip-ansi": "6.0.1"
|
||||||
|
@ -161,8 +161,8 @@
|
|||||||
"video_tile": {
|
"video_tile": {
|
||||||
"always_show": "Always show",
|
"always_show": "Always show",
|
||||||
"change_fit_contain": "Fit to frame",
|
"change_fit_contain": "Fit to frame",
|
||||||
"exit_full_screen": "Exit full screen",
|
"collapse": "Collapse",
|
||||||
"full_screen": "Full screen",
|
"expand": "Expand",
|
||||||
"mute_for_me": "Mute for me",
|
"mute_for_me": "Mute for me",
|
||||||
"volume": "Volume"
|
"volume": "Volume"
|
||||||
}
|
}
|
||||||
|
30
src/Header.test.tsx
Normal file
30
src/Header.test.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { axe } from "vitest-axe";
|
||||||
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { RoomHeaderInfo } from "./Header";
|
||||||
|
|
||||||
|
test("RoomHeaderInfo is accessible", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<RoomHeaderInfo
|
||||||
|
id="!a:example.org"
|
||||||
|
name="Mission Control"
|
||||||
|
avatarUrl=""
|
||||||
|
encrypted
|
||||||
|
participantCount={11}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Check that the room name acts as a heading
|
||||||
|
screen.getByRole("heading", { name: "Mission Control" });
|
||||||
|
});
|
@ -89,6 +89,9 @@ export const Modal: FC<Props> = ({
|
|||||||
styles.drawer,
|
styles.drawer,
|
||||||
{ [styles.tabbed]: tabbed },
|
{ [styles.tabbed]: tabbed },
|
||||||
)}
|
)}
|
||||||
|
// Suppress the warning about there being no description; the modal
|
||||||
|
// has an accessible title
|
||||||
|
aria-describedby={undefined}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@ -111,7 +114,9 @@ export const Modal: FC<Props> = ({
|
|||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||||
/>
|
/>
|
||||||
<DialogContent asChild {...rest}>
|
{/* Suppress the warning about there being no description; the modal
|
||||||
|
has an accessible title */}
|
||||||
|
<DialogContent asChild aria-describedby={undefined} {...rest}>
|
||||||
<Glass
|
<Glass
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
@ -6,19 +6,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { render, configure } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { Toast } from "../src/Toast";
|
import { Toast } from "../src/Toast";
|
||||||
import { withFakeTimers } from "./utils/test";
|
import { withFakeTimers } from "./utils/test";
|
||||||
|
|
||||||
configure({
|
|
||||||
defaultHidden: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test Explanation:
|
|
||||||
// This test the toast. We need to use { document: window.document } because the toast listens
|
|
||||||
// for user input on `window`.
|
|
||||||
describe("Toast", () => {
|
describe("Toast", () => {
|
||||||
test("renders", () => {
|
test("renders", () => {
|
||||||
const { queryByRole } = render(
|
const { queryByRole } = render(
|
||||||
@ -36,7 +29,7 @@ describe("Toast", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dismisses when Esc is pressed", async () => {
|
test("dismisses when Esc is pressed", async () => {
|
||||||
const user = userEvent.setup({ document: window.document });
|
const user = userEvent.setup();
|
||||||
const onDismiss = vi.fn();
|
const onDismiss = vi.fn();
|
||||||
render(
|
render(
|
||||||
<Toast open={true} onDismiss={onDismiss}>
|
<Toast open={true} onDismiss={onDismiss}>
|
||||||
@ -50,7 +43,7 @@ describe("Toast", () => {
|
|||||||
test("dismisses when background is clicked", async () => {
|
test("dismisses when background is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onDismiss = vi.fn();
|
const onDismiss = vi.fn();
|
||||||
const { getByRole, unmount } = render(
|
const { getByRole } = render(
|
||||||
<Toast open={true} onDismiss={onDismiss}>
|
<Toast open={true} onDismiss={onDismiss}>
|
||||||
Hello world!
|
Hello world!
|
||||||
</Toast>,
|
</Toast>,
|
||||||
@ -58,7 +51,6 @@ describe("Toast", () => {
|
|||||||
const background = getByRole("dialog").previousSibling! as Element;
|
const background = getByRole("dialog").previousSibling! as Element;
|
||||||
await user.click(background);
|
await user.click(background);
|
||||||
expect(onDismiss).toHaveBeenCalled();
|
expect(onDismiss).toHaveBeenCalled();
|
||||||
unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dismisses itself after the specified timeout", () => {
|
test("dismisses itself after the specified timeout", () => {
|
||||||
|
29
src/input/StarRating.test.tsx
Normal file
29
src/input/StarRating.test.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { axe } from "vitest-axe";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { StarRatingInput } from "./StarRatingInput";
|
||||||
|
|
||||||
|
test("StarRatingInput is accessible", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<StarRatingInput starCount={5} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Change the rating to 4 stars
|
||||||
|
await user.click(
|
||||||
|
(
|
||||||
|
await screen.findAllByRole("radio", { name: "star_rating_input_label" })
|
||||||
|
)[3],
|
||||||
|
);
|
||||||
|
expect(onChange).toBeCalledWith(4);
|
||||||
|
});
|
@ -31,7 +31,6 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
|||||||
height={16}
|
height={16}
|
||||||
className={styles.lock}
|
className={styles.lock}
|
||||||
data-encrypted={encrypted}
|
data-encrypted={encrypted}
|
||||||
aria-hidden
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
35
src/room/InviteModal.test.tsx
Normal file
35
src/room/InviteModal.test.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { axe } from "vitest-axe";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { InviteModal } from "./InviteModal";
|
||||||
|
|
||||||
|
// Used by copy-to-clipboard
|
||||||
|
window.prompt = (): null => null;
|
||||||
|
|
||||||
|
test("InviteModal is accessible", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const room = {
|
||||||
|
roomId: "!a:example.org",
|
||||||
|
name: "Mission Control",
|
||||||
|
} as unknown as Room;
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
|
||||||
|
{ wrapper: BrowserRouter },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
await user.click(screen.getByRole("button", { name: "action.copy_link" }));
|
||||||
|
expect(onDismiss).toBeCalled();
|
||||||
|
});
|
276
src/state/CallViewModel.test.ts
Normal file
276
src/state/CallViewModel.test.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, vi, onTestFinished } from "vitest";
|
||||||
|
import { map, Observable } from "rxjs";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
LocalParticipant,
|
||||||
|
RemoteParticipant,
|
||||||
|
} from "livekit-client";
|
||||||
|
import * as ComponentsCore from "@livekit/components-core";
|
||||||
|
|
||||||
|
import { CallViewModel, Layout } from "./CallViewModel";
|
||||||
|
import {
|
||||||
|
mockLivekitRoom,
|
||||||
|
mockLocalParticipant,
|
||||||
|
mockMatrixRoom,
|
||||||
|
mockMember,
|
||||||
|
mockRemoteParticipant,
|
||||||
|
OurRunHelpers,
|
||||||
|
withTestScheduler,
|
||||||
|
} from "../utils/test";
|
||||||
|
import {
|
||||||
|
ECAddonConnectionState,
|
||||||
|
ECConnectionState,
|
||||||
|
} from "../livekit/useECConnectionState";
|
||||||
|
|
||||||
|
vi.mock("@livekit/components-core");
|
||||||
|
|
||||||
|
const aliceId = "@alice:example.org:AAAA";
|
||||||
|
const bobId = "@bob:example.org:BBBB";
|
||||||
|
|
||||||
|
const alice = mockMember({ userId: "@alice:example.org" });
|
||||||
|
const bob = mockMember({ userId: "@bob:example.org" });
|
||||||
|
const carol = mockMember({ userId: "@carol:example.org" });
|
||||||
|
|
||||||
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
|
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||||
|
const aliceSharingScreen = mockRemoteParticipant({
|
||||||
|
identity: aliceId,
|
||||||
|
isScreenShareEnabled: true,
|
||||||
|
});
|
||||||
|
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
||||||
|
const bobSharingScreen = mockRemoteParticipant({
|
||||||
|
identity: bobId,
|
||||||
|
isScreenShareEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = new Map([
|
||||||
|
[alice.userId, alice],
|
||||||
|
[bob.userId, bob],
|
||||||
|
[carol.userId, carol],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface GridLayoutSummary {
|
||||||
|
type: "grid";
|
||||||
|
spotlight?: string[];
|
||||||
|
grid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightLandscapeLayoutSummary {
|
||||||
|
type: "spotlight-landscape";
|
||||||
|
spotlight: string[];
|
||||||
|
grid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightPortraitLayoutSummary {
|
||||||
|
type: "spotlight-portrait";
|
||||||
|
spotlight: string[];
|
||||||
|
grid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightExpandedLayoutSummary {
|
||||||
|
type: "spotlight-expanded";
|
||||||
|
spotlight: string[];
|
||||||
|
pip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneOnOneLayoutSummary {
|
||||||
|
type: "one-on-one";
|
||||||
|
local: string;
|
||||||
|
remote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipLayoutSummary {
|
||||||
|
type: "pip";
|
||||||
|
spotlight: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutSummary =
|
||||||
|
| GridLayoutSummary
|
||||||
|
| SpotlightLandscapeLayoutSummary
|
||||||
|
| SpotlightPortraitLayoutSummary
|
||||||
|
| SpotlightExpandedLayoutSummary
|
||||||
|
| OneOnOneLayoutSummary
|
||||||
|
| PipLayoutSummary;
|
||||||
|
|
||||||
|
function summarizeLayout(l: Layout): LayoutSummary {
|
||||||
|
switch (l.type) {
|
||||||
|
case "grid":
|
||||||
|
return {
|
||||||
|
type: l.type,
|
||||||
|
spotlight: l.spotlight?.map((vm) => vm.id),
|
||||||
|
grid: l.grid.map((vm) => vm.id),
|
||||||
|
};
|
||||||
|
case "spotlight-landscape":
|
||||||
|
case "spotlight-portrait":
|
||||||
|
return {
|
||||||
|
type: l.type,
|
||||||
|
spotlight: l.spotlight.map((vm) => vm.id),
|
||||||
|
grid: l.grid.map((vm) => vm.id),
|
||||||
|
};
|
||||||
|
case "spotlight-expanded":
|
||||||
|
return {
|
||||||
|
type: l.type,
|
||||||
|
spotlight: l.spotlight.map((vm) => vm.id),
|
||||||
|
pip: l.pip?.id,
|
||||||
|
};
|
||||||
|
case "one-on-one":
|
||||||
|
return { type: l.type, local: l.local.id, remote: l.remote.id };
|
||||||
|
case "pip":
|
||||||
|
return { type: l.type, spotlight: l.spotlight.map((vm) => vm.id) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCallViewModel(
|
||||||
|
{ cold }: OurRunHelpers,
|
||||||
|
remoteParticipants: Observable<RemoteParticipant[]>,
|
||||||
|
connectionState: Observable<ECConnectionState>,
|
||||||
|
continuation: (vm: CallViewModel) => void,
|
||||||
|
): void {
|
||||||
|
const participantsSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
|
.mockReturnValue(remoteParticipants);
|
||||||
|
const mediaSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||||
|
.mockImplementation((p) =>
|
||||||
|
cold("a", {
|
||||||
|
a: { participant: p } as Partial<
|
||||||
|
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||||
|
> as ComponentsCore.ParticipantMedia<LocalParticipant>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const eventsSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||||
|
.mockImplementation((p) => cold("a", { a: p }));
|
||||||
|
|
||||||
|
const vm = new CallViewModel(
|
||||||
|
mockMatrixRoom({
|
||||||
|
client: {
|
||||||
|
getUserId: () => "@carol:example.org",
|
||||||
|
} as Partial<MatrixClient> as MatrixClient,
|
||||||
|
getMember: (userId) => members.get(userId) ?? null,
|
||||||
|
}),
|
||||||
|
mockLivekitRoom({ localParticipant }),
|
||||||
|
true,
|
||||||
|
connectionState,
|
||||||
|
);
|
||||||
|
|
||||||
|
onTestFinished(() => {
|
||||||
|
vm!.destroy();
|
||||||
|
participantsSpy!.mockRestore();
|
||||||
|
mediaSpy!.mockRestore();
|
||||||
|
eventsSpy!.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
continuation(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("participants are retained during a focus switch", () => {
|
||||||
|
withTestScheduler((helpers) => {
|
||||||
|
const { hot, expectObservable } = helpers;
|
||||||
|
// Participants disappear on frame 2 and come back on frame 3
|
||||||
|
const partMarbles = "a-ba";
|
||||||
|
// Start switching focus on frame 1 and reconnect on frame 3
|
||||||
|
const connMarbles = "ab-a";
|
||||||
|
// The visible participants should remain the same throughout the switch
|
||||||
|
const laytMarbles = "aaaa 2997ms a 56998ms a";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
helpers,
|
||||||
|
hot(partMarbles, {
|
||||||
|
a: [aliceParticipant, bobParticipant],
|
||||||
|
b: [],
|
||||||
|
}),
|
||||||
|
hot(connMarbles, {
|
||||||
|
a: ConnectionState.Connected,
|
||||||
|
b: ECAddonConnectionState.ECSwitchingFocus,
|
||||||
|
}),
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||||
|
laytMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
type: "grid",
|
||||||
|
spotlight: undefined,
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("screen sharing activates spotlight layout", () => {
|
||||||
|
withTestScheduler((helpers) => {
|
||||||
|
const { hot, schedule, expectObservable } = helpers;
|
||||||
|
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||||
|
// then return to no screen shares, then have just Alice share for a bit
|
||||||
|
const partMarbles = "abc---d---a-b---a";
|
||||||
|
// While there are no screen shares, switch to spotlight manually, and then
|
||||||
|
// switch back to grid at the end
|
||||||
|
const modeMarbles = "-----------a--------b";
|
||||||
|
// We should automatically enter spotlight for the first round of screen
|
||||||
|
// sharing, then return to grid, then manually go into spotlight, and
|
||||||
|
// remain in spotlight until we manually go back to grid
|
||||||
|
const laytMarbles = "ab(cc)(dd)ae(bb)(ee)a 59979ms a";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
helpers,
|
||||||
|
hot(partMarbles, {
|
||||||
|
a: [aliceParticipant, bobParticipant],
|
||||||
|
b: [aliceSharingScreen, bobParticipant],
|
||||||
|
c: [aliceSharingScreen, bobSharingScreen],
|
||||||
|
d: [aliceParticipant, bobSharingScreen],
|
||||||
|
}),
|
||||||
|
hot("a", { a: ConnectionState.Connected }),
|
||||||
|
(vm) => {
|
||||||
|
schedule(modeMarbles, {
|
||||||
|
a: () => vm.setGridMode("spotlight"),
|
||||||
|
b: () => vm.setGridMode("grid"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||||
|
laytMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
type: "grid",
|
||||||
|
spotlight: undefined,
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight: [`${aliceId}:0:screen-share`],
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight: [
|
||||||
|
`${aliceId}:0:screen-share`,
|
||||||
|
`${bobId}:0:screen-share`,
|
||||||
|
],
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
d: {
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight: [`${bobId}:0:screen-share`],
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
e: {
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -30,13 +30,13 @@ import {
|
|||||||
concat,
|
concat,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
|
forkJoin,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
mergeAll,
|
mergeMap,
|
||||||
of,
|
of,
|
||||||
race,
|
race,
|
||||||
sample,
|
|
||||||
scan,
|
scan,
|
||||||
skip,
|
skip,
|
||||||
startWith,
|
startWith,
|
||||||
@ -46,7 +46,7 @@ import {
|
|||||||
take,
|
take,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
zip,
|
withLatestFrom,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
@ -169,10 +169,19 @@ class UserMedia {
|
|||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
callEncrypted: boolean,
|
||||||
) {
|
) {
|
||||||
this.vm =
|
this.vm = participant.isLocal
|
||||||
participant instanceof LocalParticipant
|
? new LocalUserMediaViewModel(
|
||||||
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
id,
|
||||||
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
member,
|
||||||
|
participant as LocalParticipant,
|
||||||
|
callEncrypted,
|
||||||
|
)
|
||||||
|
: new RemoteUserMediaViewModel(
|
||||||
|
id,
|
||||||
|
member,
|
||||||
|
participant as RemoteParticipant,
|
||||||
|
callEncrypted,
|
||||||
|
);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipe(
|
this.speaker = this.vm.speaking.pipe(
|
||||||
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||||
@ -186,6 +195,7 @@ class UserMedia {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
startWith(false),
|
startWith(false),
|
||||||
|
distinctUntilChanged(),
|
||||||
// Make this Observable hot so that the timers don't reset when you
|
// Make this Observable hot so that the timers don't reset when you
|
||||||
// resubscribe
|
// resubscribe
|
||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
@ -256,10 +266,9 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||||
// they've left
|
// they've left
|
||||||
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||||
zip(
|
this.connectionState.pipe(
|
||||||
this.connectionState,
|
withLatestFrom(this.rawRemoteParticipants),
|
||||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
mergeMap(([s, ps]) => {
|
||||||
(s, ps) => {
|
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
// Whenever we switch focuses, we should retain all the previous
|
||||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||||
// give their clients time to switch over and avoid jarring layout shifts
|
// give their clients time to switch over and avoid jarring layout shifts
|
||||||
@ -268,29 +277,19 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Hold these participants
|
// Hold these participants
|
||||||
of({ hold: ps }),
|
of({ hold: ps }),
|
||||||
// Wait for time to pass and the connection state to have changed
|
// Wait for time to pass and the connection state to have changed
|
||||||
Promise.all([
|
forkJoin([
|
||||||
new Promise<void>((resolve) =>
|
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
this.connectionState.pipe(
|
||||||
|
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||||
|
take(1),
|
||||||
),
|
),
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
const subscription = this.connectionState
|
|
||||||
.pipe(this.scope.bind())
|
|
||||||
.subscribe((s) => {
|
|
||||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
|
||||||
resolve();
|
|
||||||
subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
// Then unhold them
|
// Then unhold them
|
||||||
]).then(() => ({ unhold: ps })),
|
]).pipe(map(() => ({ unhold: ps }))),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
).pipe(
|
|
||||||
mergeAll(),
|
|
||||||
// Accumulate the hold instructions into a single list showing which
|
// Accumulate the hold instructions into a single list showing which
|
||||||
// participants are being held
|
// participants are being held
|
||||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||||
|
@ -5,52 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { LocalParticipant, RemoteParticipant } from "livekit-client";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
withLocalMedia,
|
||||||
RemoteUserMediaViewModel,
|
withRemoteMedia,
|
||||||
} from "./MediaViewModel";
|
withTestScheduler,
|
||||||
import { withTestScheduler } from "../utils/test";
|
} from "../utils/test";
|
||||||
|
|
||||||
function withLocal(continuation: (vm: LocalUserMediaViewModel) => void): void {
|
test("set a participant's volume", async () => {
|
||||||
const member = {} as unknown as RoomMember;
|
|
||||||
const vm = new LocalUserMediaViewModel(
|
|
||||||
"a",
|
|
||||||
member,
|
|
||||||
{} as unknown as LocalParticipant,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
continuation(vm);
|
|
||||||
} finally {
|
|
||||||
vm.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withRemote(
|
|
||||||
participant: Partial<RemoteParticipant>,
|
|
||||||
continuation: (vm: RemoteUserMediaViewModel) => void,
|
|
||||||
): void {
|
|
||||||
const member = {} as unknown as RoomMember;
|
|
||||||
const vm = new RemoteUserMediaViewModel(
|
|
||||||
"a",
|
|
||||||
member,
|
|
||||||
{ setVolume() {}, ...participant } as RemoteParticipant,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
continuation(vm);
|
|
||||||
} finally {
|
|
||||||
vm.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("set a participant's volume", () => {
|
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", {
|
schedule("-a|", {
|
||||||
a() {
|
a() {
|
||||||
@ -63,9 +28,9 @@ test("set a participant's volume", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mute and unmute a participant", () => {
|
test("mute and unmute a participant", async () => {
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-abc|", {
|
schedule("-abc|", {
|
||||||
a() {
|
a() {
|
||||||
@ -90,8 +55,8 @@ test("mute and unmute a participant", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toggle fit/contain for a participant's video", () => {
|
test("toggle fit/contain for a participant's video", async () => {
|
||||||
withRemote({}, (vm) =>
|
await withRemoteMedia({}, {}, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab|", {
|
schedule("-ab|", {
|
||||||
a: () => vm.toggleFitContain(),
|
a: () => vm.toggleFitContain(),
|
||||||
@ -106,15 +71,15 @@ test("toggle fit/contain for a participant's video", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("local media remembers whether it should always be shown", () => {
|
test("local media remembers whether it should always be shown", async () => {
|
||||||
withLocal((vm) =>
|
await withLocalMedia({}, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||||
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
|
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Next local media should start out *not* always shown
|
// Next local media should start out *not* always shown
|
||||||
withLocal((vm) =>
|
await withLocalMedia({}, (vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||||
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
|
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
|
||||||
|
43
src/tile/GridTile.test.tsx
Normal file
43
src/tile/GridTile.test.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RemoteTrackPublication } from "livekit-client";
|
||||||
|
import { test, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { axe } from "vitest-axe";
|
||||||
|
|
||||||
|
import { GridTile } from "./GridTile";
|
||||||
|
import { withRemoteMedia } from "../utils/test";
|
||||||
|
|
||||||
|
test("GridTile is accessible", async () => {
|
||||||
|
await withRemoteMedia(
|
||||||
|
{
|
||||||
|
rawDisplayName: "Alice",
|
||||||
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setVolume() {},
|
||||||
|
getTrackPublication: () =>
|
||||||
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
|
},
|
||||||
|
async (vm) => {
|
||||||
|
const { container } = render(
|
||||||
|
<GridTile
|
||||||
|
vm={vm}
|
||||||
|
onOpenProfile={() => {}}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
showVideo
|
||||||
|
showSpeakingIndicators
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Name should be visible
|
||||||
|
screen.getByText("Alice");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
@ -101,7 +101,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className={styles.errorIcon}
|
className={styles.errorIcon}
|
||||||
aria-hidden
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
73
src/tile/SpotlightTile.test.tsx
Normal file
73
src/tile/SpotlightTile.test.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, vi } from "vitest";
|
||||||
|
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||||
|
import { axe } from "vitest-axe";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { SpotlightTile } from "./SpotlightTile";
|
||||||
|
import { withLocalMedia, withRemoteMedia } from "../utils/test";
|
||||||
|
|
||||||
|
global.IntersectionObserver = class MockIntersectionObserver {
|
||||||
|
public observe(): void {}
|
||||||
|
public unobserve(): void {}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
|
||||||
|
test("SpotlightTile is accessible", async () => {
|
||||||
|
await withRemoteMedia(
|
||||||
|
{
|
||||||
|
rawDisplayName: "Alice",
|
||||||
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
async (vm1) => {
|
||||||
|
await withLocalMedia(
|
||||||
|
{
|
||||||
|
rawDisplayName: "Bob",
|
||||||
|
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||||
|
},
|
||||||
|
async (vm2) => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const toggleExpanded = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<SpotlightTile
|
||||||
|
vms={[vm1, vm2]}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
maximised={false}
|
||||||
|
expanded={false}
|
||||||
|
onToggleExpanded={toggleExpanded}
|
||||||
|
showIndicators
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Alice should be in the spotlight, with her name and avatar on the
|
||||||
|
// first page
|
||||||
|
screen.getByText("Alice");
|
||||||
|
const aliceAvatar = screen.getByRole("img");
|
||||||
|
expect(screen.queryByRole("button", { name: "common.back" })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Bob should be out of the spotlight, and therefore invisible
|
||||||
|
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||||
|
// Now navigate to Bob
|
||||||
|
await user.click(screen.getByRole("button", { name: "common.next" }));
|
||||||
|
screen.getByText("Bob");
|
||||||
|
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||||
|
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||||
|
// Can toggle whether the tile is expanded
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "video_tile.expand" }),
|
||||||
|
);
|
||||||
|
expect(toggleExpanded).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
@ -52,6 +52,7 @@ interface SpotlightItemBaseProps {
|
|||||||
member: RoomMember | undefined;
|
member: RoomMember | undefined;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
"aria-hidden"?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
@ -109,10 +110,21 @@ interface SpotlightItemProps {
|
|||||||
* Whether this item should act as a scroll snapping point.
|
* Whether this item should act as a scroll snapping point.
|
||||||
*/
|
*/
|
||||||
snap: boolean;
|
snap: boolean;
|
||||||
|
"aria-hidden"?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||||
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
(
|
||||||
|
{
|
||||||
|
vm,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
intersectionObserver,
|
||||||
|
snap,
|
||||||
|
"aria-hidden": ariaHidden,
|
||||||
|
},
|
||||||
|
theirRef,
|
||||||
|
) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const displayName = useDisplayName(vm);
|
const displayName = useDisplayName(vm);
|
||||||
@ -144,6 +156,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
member: vm.member,
|
member: vm.member,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
displayName,
|
displayName,
|
||||||
|
"aria-hidden": ariaHidden,
|
||||||
};
|
};
|
||||||
|
|
||||||
return vm instanceof ScreenShareViewModel ? (
|
return vm instanceof ScreenShareViewModel ? (
|
||||||
@ -271,7 +284,12 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
intersectionObserver={intersectionObserver}
|
intersectionObserver={intersectionObserver}
|
||||||
|
// This is how we get the container to scroll to the right media
|
||||||
|
// when the previous/next buttons are clicked: we temporarily
|
||||||
|
// remove all scroll snap points except for just the one media
|
||||||
|
// that we want to bring into view
|
||||||
snap={scrollToId === null || scrollToId === vm.id}
|
snap={scrollToId === null || scrollToId === vm.id}
|
||||||
|
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -279,9 +297,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
<button
|
<button
|
||||||
className={classNames(styles.expand)}
|
className={classNames(styles.expand)}
|
||||||
aria-label={
|
aria-label={
|
||||||
expanded
|
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||||
? t("video_tile.full_screen")
|
|
||||||
: t("video_tile.exit_full_screen")
|
|
||||||
}
|
}
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
>
|
>
|
||||||
|
@ -16,8 +16,6 @@ import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcut
|
|||||||
// Test Explanation:
|
// Test Explanation:
|
||||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||||
// The TestComponent just wraps a button around that hook.
|
// The TestComponent just wraps a button around that hook.
|
||||||
// - We need to set `userEvent` to the `{document = window.document}` since we are testing the
|
|
||||||
// `useCallViewKeyboardShortcuts` hook here. Which is listening to window.
|
|
||||||
|
|
||||||
interface TestComponentProps {
|
interface TestComponentProps {
|
||||||
setMicrophoneMuted: (muted: boolean) => void;
|
setMicrophoneMuted: (muted: boolean) => void;
|
||||||
@ -43,7 +41,7 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("spacebar unmutes", async () => {
|
test("spacebar unmutes", async () => {
|
||||||
const user = userEvent.setup({ document: window.document });
|
const user = userEvent.setup();
|
||||||
let muted = true;
|
let muted = true;
|
||||||
render(
|
render(
|
||||||
<TestComponent
|
<TestComponent
|
||||||
@ -62,7 +60,7 @@ test("spacebar unmutes", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("spacebar prioritizes pressing a button", async () => {
|
test("spacebar prioritizes pressing a button", async () => {
|
||||||
const user = userEvent.setup({ document: window.document });
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const setMuted = vi.fn();
|
const setMuted = vi.fn();
|
||||||
const onClick = vi.fn();
|
const onClick = vi.fn();
|
||||||
@ -77,7 +75,7 @@ test("spacebar prioritizes pressing a button", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("unmuting happens in place of the default action", async () => {
|
test("unmuting happens in place of the default action", async () => {
|
||||||
const user = userEvent.setup({ document: window.document });
|
const user = userEvent.setup();
|
||||||
const defaultPrevented = vi.fn();
|
const defaultPrevented = vi.fn();
|
||||||
// In the real application, we mostly just want the spacebar shortcut to avoid
|
// In the real application, we mostly just want the spacebar shortcut to avoid
|
||||||
// scrolling the page. But to test that here in JSDOM, we need some kind of
|
// scrolling the page. But to test that here in JSDOM, we need some kind of
|
||||||
|
@ -7,6 +7,19 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
|
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||||
|
import {
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
|
RemoteParticipant,
|
||||||
|
RemoteTrackPublication,
|
||||||
|
Room as LivekitRoom,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LocalUserMediaViewModel,
|
||||||
|
RemoteUserMediaViewModel,
|
||||||
|
} from "../state/MediaViewModel";
|
||||||
|
|
||||||
export function withFakeTimers(continuation: () => void): void {
|
export function withFakeTimers(continuation: () => void): void {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@ -49,3 +62,102 @@ export function withTestScheduler(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmitterMock<T> {
|
||||||
|
on: () => T;
|
||||||
|
off: () => T;
|
||||||
|
addListener: () => T;
|
||||||
|
removeListener: () => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockEmitter<T>(): EmitterMock<T> {
|
||||||
|
return {
|
||||||
|
on(): T {
|
||||||
|
return this as T;
|
||||||
|
},
|
||||||
|
off(): T {
|
||||||
|
return this as T;
|
||||||
|
},
|
||||||
|
addListener(): T {
|
||||||
|
return this as T;
|
||||||
|
},
|
||||||
|
removeListener(): T {
|
||||||
|
return this as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
|
||||||
|
// rather simple, but if one util to mock a member is good enough for us, maybe
|
||||||
|
// it's useful for matrix-js-sdk consumers in general.
|
||||||
|
export function mockMember(member: Partial<RoomMember>): RoomMember {
|
||||||
|
return { ...mockEmitter(), ...member } as RoomMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
||||||
|
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom {
|
||||||
|
return { ...mockEmitter(), ...room } as Partial<LivekitRoom> as LivekitRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockLocalParticipant(
|
||||||
|
participant: Partial<LocalParticipant>,
|
||||||
|
): LocalParticipant {
|
||||||
|
return {
|
||||||
|
isLocal: true,
|
||||||
|
getTrackPublication: () =>
|
||||||
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||||
|
...mockEmitter(),
|
||||||
|
...participant,
|
||||||
|
} as Partial<LocalParticipant> as LocalParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withLocalMedia(
|
||||||
|
member: Partial<RoomMember>,
|
||||||
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const vm = new LocalUserMediaViewModel(
|
||||||
|
"local",
|
||||||
|
mockMember(member),
|
||||||
|
mockLocalParticipant({}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await continuation(vm);
|
||||||
|
} finally {
|
||||||
|
vm.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockRemoteParticipant(
|
||||||
|
participant: Partial<RemoteParticipant>,
|
||||||
|
): RemoteParticipant {
|
||||||
|
return {
|
||||||
|
isLocal: false,
|
||||||
|
setVolume() {},
|
||||||
|
getTrackPublication: () =>
|
||||||
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
|
...mockEmitter(),
|
||||||
|
...participant,
|
||||||
|
} as RemoteParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withRemoteMedia(
|
||||||
|
member: Partial<RoomMember>,
|
||||||
|
participant: Partial<RemoteParticipant>,
|
||||||
|
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const vm = new RemoteUserMediaViewModel(
|
||||||
|
"remote",
|
||||||
|
mockMember(member),
|
||||||
|
mockRemoteParticipant(participant),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await continuation(vm);
|
||||||
|
} finally {
|
||||||
|
vm.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -4,13 +4,14 @@ Copyright 2024 New Vector Ltd.
|
|||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "global-jsdom/register";
|
import "global-jsdom/register";
|
||||||
import globalJsdom from "global-jsdom";
|
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import { afterEach, beforeEach } from "vitest";
|
import { afterEach } from "vitest";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import "vitest-axe/extend-expect";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
@ -30,12 +31,12 @@ i18n
|
|||||||
Config.initDefault();
|
Config.initDefault();
|
||||||
posthog.opt_out_capturing();
|
posthog.opt_out_capturing();
|
||||||
|
|
||||||
// We need to cleanup the global jsDom
|
afterEach(cleanup);
|
||||||
// Otherwise we will run into issues with async input test overlapping and throwing.
|
|
||||||
|
|
||||||
let cleanupJsDom: { (): void };
|
// Used by a lot of components
|
||||||
beforeEach(() => (cleanupJsDom = globalJsdom()));
|
window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
||||||
afterEach(() => {
|
({
|
||||||
cleanupJsDom();
|
matches: false,
|
||||||
cleanup();
|
addEventListener: () => {},
|
||||||
});
|
removeEventListener: () => {},
|
||||||
|
}) as Partial<MediaQueryList> as MediaQueryList;
|
||||||
|
@ -12,11 +12,11 @@ export default defineConfig((configEnv) =>
|
|||||||
classNameStrategy: "non-scoped",
|
classNameStrategy: "non-scoped",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isolate: false,
|
|
||||||
setupFiles: ["src/vitest.setup.ts"],
|
setupFiles: ["src/vitest.setup.ts"],
|
||||||
coverage: {
|
coverage: {
|
||||||
reporter: ["html", "json"],
|
reporter: ["html", "json"],
|
||||||
include: ["src/"],
|
include: ["src/"],
|
||||||
|
exclude: ["src/**/*.{d,test}.{ts,tsx}", "src/utils/test.ts"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
21
yarn.lock
21
yarn.lock
@ -3423,7 +3423,7 @@ available-typed-arrays@^1.0.7:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names "^1.0.0"
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
axe-core@^4.10.0:
|
axe-core@^4.10.0, axe-core@^4.7.2:
|
||||||
version "4.10.0"
|
version "4.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59"
|
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59"
|
||||||
integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==
|
integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==
|
||||||
@ -3657,6 +3657,11 @@ chalk@^4.0.0, chalk@^4.1.0:
|
|||||||
ansi-styles "^4.1.0"
|
ansi-styles "^4.1.0"
|
||||||
supports-color "^7.1.0"
|
supports-color "^7.1.0"
|
||||||
|
|
||||||
|
chalk@^5.3.0:
|
||||||
|
version "5.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385"
|
||||||
|
integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==
|
||||||
|
|
||||||
check-error@^2.1.1:
|
check-error@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc"
|
resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc"
|
||||||
@ -5820,6 +5825,11 @@ locate-path@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
|
lodash-es@^4.17.21:
|
||||||
|
version "4.17.21"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||||
|
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||||
|
|
||||||
lodash.debounce@^4.0.8:
|
lodash.debounce@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
@ -8177,6 +8187,15 @@ vite@^5.0.0:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.3"
|
fsevents "~2.3.3"
|
||||||
|
|
||||||
|
vitest-axe@^1.0.0-pre.3:
|
||||||
|
version "1.0.0-pre.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/vitest-axe/-/vitest-axe-1.0.0-pre.3.tgz#0ea646c4ebe21c9b7ffb9ff3d6dff60b1c5a6124"
|
||||||
|
integrity sha512-vrsyixV225vMe0vGZV0aZjOYez2Pan5MxIx2RqnYnpbbRrUN2lJpQS9ong6dfF5a7BfQenR0LOD6hei3IQIPSw==
|
||||||
|
dependencies:
|
||||||
|
axe-core "^4.7.2"
|
||||||
|
chalk "^5.3.0"
|
||||||
|
lodash-es "^4.17.21"
|
||||||
|
|
||||||
vitest@^2.0.0:
|
vitest@^2.0.0:
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.0.5.tgz#2f15a532704a7181528e399cc5b754c7f335fd62"
|
resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.0.5.tgz#2f15a532704a7181528e399cc5b754c7f335fd62"
|
||||||
|
Loading…
Reference in New Issue
Block a user