From 6b8c620bbb3ae309e7f645606d531e09716beffe Mon Sep 17 00:00:00 2001 From: Timo Date: Wed, 14 May 2025 10:41:08 +0200 Subject: [PATCH] Add tests --- playwright/access.spec.ts | 2 +- src/UserMenu.tsx | 2 +- src/livekit/MatrixAudioRenderer.test.tsx | 104 ++++++++++++++ src/livekit/MediaDevicesContext.tsx | 2 +- src/room/InCallView.test.tsx | 18 +-- src/room/MuteStates.test.tsx | 1 + .../__snapshots__/InCallView.test.tsx.snap | 2 +- src/useAudioContext.test.tsx | 136 +++++++++++------- src/utils/test.ts | 23 +++ 9 files changed, 228 insertions(+), 62 deletions(-) create mode 100644 src/livekit/MatrixAudioRenderer.test.tsx diff --git a/playwright/access.spec.ts b/playwright/access.spec.ts index 14a70873..da7ec364 100644 --- a/playwright/access.spec.ts +++ b/playwright/access.spec.ts @@ -49,7 +49,7 @@ test("Sign up a new account, then login, then logout", async ({ browser }) => { // logout await returningUserPage.getByTestId("usermenu_open").click(); - await returningUserPage.locator('[data-test-id="usermenu_logout"]').click(); + await returningUserPage.locator('[data-testid="usermenu_logout"]').click(); await expect( returningUserPage.getByRole("link", { name: "Log In" }), diff --git a/src/UserMenu.tsx b/src/UserMenu.tsx index 52cc4a5a..e431c328 100644 --- a/src/UserMenu.tsx +++ b/src/UserMenu.tsx @@ -119,7 +119,7 @@ export const UserMenu: FC = ({ key={key} Icon={Icon} label={label} - data-test-id={dataTestid} + data-testid={dataTestid} onSelect={() => onAction(key)} /> ))} diff --git a/src/livekit/MatrixAudioRenderer.test.tsx b/src/livekit/MatrixAudioRenderer.test.tsx new file mode 100644 index 00000000..9d91e6fa --- /dev/null +++ b/src/livekit/MatrixAudioRenderer.test.tsx @@ -0,0 +1,104 @@ +/* +Copyright 2023, 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. +*/ + +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; +import { + getTrackReferenceId, + type TrackReference, +} from "@livekit/components-core"; +import { type RemoteAudioTrack } from "livekit-client"; +import { type ReactNode } from "react"; +import { useTracks } from "@livekit/components-react"; + +import { testAudioContext } from "../useAudioContext.test"; +import * as MediaDevicesContext from "./MediaDevicesContext"; +import { MatrixAudioRenderer } from "./MatrixAudioRenderer"; +import { mockTrack } from "../utils/test"; + +export const TestAudioContextConstructor = vi.fn(() => testAudioContext); + +beforeEach(() => { + vi.stubGlobal("AudioContext", TestAudioContextConstructor); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +vi.mock("@livekit/components-react", async (importOriginal) => { + return { + ...(await importOriginal()), // this will only affect "foo" outside of the original module + AudioTrack: (props: { trackRef: TrackReference }): ReactNode => { + return ( + + ); + }, + useTracks: vi.fn(), + }; +}); + +const tracks = [mockTrack("test:123")]; +vi.mocked(useTracks).mockReturnValue(tracks); + +it("should render for member", () => { + const { container, queryAllByTestId } = render( + , + ); + expect(container).toBeTruthy(); + expect(queryAllByTestId("audio")).toHaveLength(1); +}); +it("should not render without member", () => { + const { container, queryAllByTestId } = render( + , + ); + expect(container).toBeTruthy(); + expect(queryAllByTestId("audio")).toHaveLength(0); +}); + +it("should not setup audioContext gain and pan if there is no need to.", () => { + render( + , + ); + const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; + + expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1); + expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]); + + expect(testAudioContext.gain.gain.value).toEqual(1); + expect(testAudioContext.pan.pan.value).toEqual(0); +}); +it("should setup audioContext gain and pan", () => { + vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({ + pan: 1, + volume: 0.1, + }); + render( + , + ); + + const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; + expect(audioTrack.setAudioContext).toHaveBeenCalled(); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled(); + + expect(testAudioContext.gain.gain.value).toEqual(0.1); + expect(testAudioContext.pan.pan.value).toEqual(1); +}); diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index 3e30c04d..7d82032a 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -26,8 +26,8 @@ import { audioInput as audioInputSetting, audioOutput as audioOutputSetting, videoInput as videoInputSetting, + alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, type Setting, - alwaysShowIphoneEarpieceSetting, } from "../settings/settings"; export const EARPIECE_CONFIG_ID = "earpiece-id"; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 4d02160c..f7bed6fe 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -21,11 +21,7 @@ import { ConnectionState, type LocalParticipant } from "livekit-client"; import { of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; -import { - RoomAudioRenderer, - RoomContext, - useLocalParticipant, -} from "@livekit/components-react"; +import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { type MuteStates } from "./MuteStates"; @@ -48,6 +44,8 @@ import { } from "../settings/settings"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; +// import { testAudioContext } from "../useAudioContext.test"; +import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; // vi.hoisted(() => { // localStorage = {} as unknown as Storage; @@ -65,6 +63,7 @@ vi.mock("../tile/GridTile"); vi.mock("../tile/SpotlightTile"); vi.mock("@livekit/components-react"); vi.mock("../e2ee/sharedKeyManagement"); +vi.mock("../livekit/MatrixAudioRenderer"); vi.mock("react-use-measure", () => ({ default: (): [() => void, object] => [(): void => {}, {}], })); @@ -81,13 +80,15 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p])); const roomId = "!foo:bar"; let useRoomEncryptionSystemMock: MockedFunction; + beforeEach(() => { vi.clearAllMocks(); - // RoomAudioRenderer is tested separately. + + // MatrixAudioRenderer is tested separately. ( - RoomAudioRenderer as MockedFunction + MatrixAudioRenderer as MockedFunction ).mockImplementation((_props) => { - return
mocked: RoomAudioRenderer
; + return
mocked: MatrixAudioRenderer
; }); ( useLocalParticipant as MockedFunction @@ -98,7 +99,6 @@ beforeEach(() => { localParticipant: localRtcMember as unknown as LocalParticipant, }) as unknown as ReturnType, ); - useRoomEncryptionSystemMock = useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock; useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE }); diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index 4a375c8f..eb066603 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -79,6 +79,7 @@ function mockDevices(available: Map): MediaDevice { selectedId: "", selectedGroupId: "", select: (): void => {}, + useAsEarpiece: false, }; } diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 427973b6..98edb8a4 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -9,7 +9,7 @@ exports[`InCallView > rendering > renders 1`] = ` class="header filler" />
- mocked: RoomAudioRenderer + mocked: MatrixAudioRenderer
{ ); }; -class MockAudioContext { - public static testContext: MockAudioContext; - - public constructor() { - MockAudioContext.testContext = this; - } - - public gain = vitest.mocked( - { - connect: () => {}, - gain: { - setValueAtTime: vitest.fn(), - }, +const gainNode = vi.mocked( + { + connect: (node: AudioNode) => node, + gain: { + setValueAtTime: vi.fn(), + value: 1, }, - true, - ); - - public setSinkId = vitest.fn().mockResolvedValue(undefined); - public decodeAudioData = vitest.fn().mockReturnValue(1); - public createBufferSource = vitest.fn().mockReturnValue( - vitest.mocked({ + }, + true, +); +const panNode = vi.mocked( + { + connect: (node: AudioNode) => node, + pan: { + setValueAtTime: vi.fn(), + value: 0, + }, + }, + true, +); +/** + * A shared audio context test instance. + * It can also be used to mock the `AudioContext` constructor in tests: + * `vi.stubGlobal("AudioContext", () => testAudioContext);` + */ +export const testAudioContext = { + gain: gainNode, + pan: panNode, + setSinkId: vi.fn().mockResolvedValue(undefined), + decodeAudioData: vi.fn().mockReturnValue(1), + createBufferSource: vi.fn().mockReturnValue( + vi.mocked({ connect: (v: unknown) => v, start: () => {}, addEventListener: (_name: string, cb: () => void) => cb(), }), - ); - public createGain = vitest.fn().mockReturnValue(this.gain); - public close = vitest.fn().mockResolvedValue(undefined); -} + ), + createGain: vi.fn().mockReturnValue(gainNode), + createStereoPanner: vi.fn().mockReturnValue(panNode), + close: vi.fn().mockResolvedValue(undefined), +}; +export const TestAudioContextConstructor = vi.fn(() => testAudioContext); + +let user: UserEvent; +beforeEach(() => { + vi.stubGlobal("AudioContext", TestAudioContextConstructor); + user = userEvent.setup(); +}); afterEach(() => { - vitest.unstubAllGlobals(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); }); test("can play a single sound", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); const { findByText } = render(); await user.click(await findByText("Valid sound")); - expect( - MockAudioContext.testContext.createBufferSource, - ).toHaveBeenCalledOnce(); + expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce(); }); + test("will ignore sounds that are not registered", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); const { findByText } = render(); await user.click(await findByText("Invalid sound")); - expect( - MockAudioContext.testContext.createBufferSource, - ).not.toHaveBeenCalled(); + expect(testAudioContext.createBufferSource).not.toHaveBeenCalled(); }); test("will use the correct device", () => { - vitest.stubGlobal("AudioContext", MockAudioContext); render( { selectedGroupId: "", available: new Map(), select: () => {}, + useAsEarpiece: false, }, videoInput: deviceStub, startUsingDeviceNames: () => {}, @@ -112,21 +125,46 @@ test("will use the correct device", () => { , ); - expect( - MockAudioContext.testContext.createBufferSource, - ).not.toHaveBeenCalled(); - expect(MockAudioContext.testContext.setSinkId).toHaveBeenCalledWith( - "chosen-device", - ); + expect(testAudioContext.createBufferSource).not.toHaveBeenCalled(); + expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device"); }); test("will use the correct volume level", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); soundEffectVolumeSetting.setValue(0.33); const { findByText } = render(); await user.click(await findByText("Valid sound")); - expect( - MockAudioContext.testContext.gain.gain.setValueAtTime, - ).toHaveBeenCalledWith(0.33, 0); + expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith( + 0.33, + 0, + ); + expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(0, 0); +}); + +test("will use the pan if earpice is selected", async () => { + const { findByText } = render( + {}, + useAsEarpiece: true, + }, + videoInput: deviceStub, + startUsingDeviceNames: () => {}, + stopUsingDeviceNames: () => {}, + }} + > + + , + ); + await user.click(await findByText("Valid sound")); + expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(1, 0); + + expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith( + soundEffectVolumeSetting.getValue() * 0.1, + 0, + ); }); diff --git a/src/utils/test.ts b/src/utils/test.ts index 6e1b5457..51ed1ed2 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -27,12 +27,14 @@ import { type RemoteParticipant, type RemoteTrackPublication, type Room as LivekitRoom, + Track, } from "livekit-client"; import { randomUUID } from "crypto"; import { type RoomAndToDeviceEvents, type RoomAndToDeviceEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; +import { type TrackReference } from "@livekit/components-core"; import { LocalUserMediaViewModel, @@ -309,3 +311,24 @@ export class MockRTCSession extends TypedEventEmitter< return this; } } + +export const mockTrack = (identity: string): TrackReference => + ({ + participant: { + identity, + }, + publication: { + kind: Track.Kind.Audio, + source: "mic", + trackSid: "123", + track: { + attach: vi.fn(), + detach: vi.fn(), + setAudioContext: vi.fn(), + setWebAudioPlugins: vi.fn(), + setVolume: vi.fn(), + }, + }, + track: {}, + source: {}, + }) as unknown as TrackReference;