Merge pull request #2629 from robintown/test-call-vm

Test CallViewModel
This commit is contained in:
Robin 2024-09-18 23:13:22 -04:00 committed by GitHub
commit cec7fc8f5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 706 additions and 114 deletions

View File

@ -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"

View File

@ -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
View 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" });
});

View File

@ -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,

View File

@ -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", () => {

View 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);
});

View File

@ -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>
); );

View 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();
});

View 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`],
},
},
);
},
);
});
});

View File

@ -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) =>

View File

@ -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 });

View 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");
},
);
});

View File

@ -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>
)} )}

View 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();
},
);
},
);
});

View File

@ -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}
> >

View File

@ -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

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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"],
}, },
}, },
}), }),

View File

@ -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"