From be6041f8eff9b5eadcb2be5d70a85e4c1cb64a04 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 23 Mar 2022 16:07:14 -0400 Subject: [PATCH] Update voice room tests --- test/components/views/rooms/RoomTile-test.tsx | 150 ++++++++++-------- .../views/voip/VoiceChannelRadio-test.tsx | 44 +---- test/stores/VoiceChannelStore-test.ts | 95 +++++++++++ test/test-utils/index.ts | 1 + test/test-utils/test-utils.ts | 1 + test/test-utils/voice.ts | 60 +++++++ 6 files changed, 243 insertions(+), 108 deletions(-) create mode 100644 test/stores/VoiceChannelStore-test.ts create mode 100644 test/test-utils/voice.ts diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 680b3da509b..4e960afe46c 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -17,79 +17,78 @@ limitations under the License. import React from "react"; import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; -import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; import { mocked } from "jest-mock"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "../../../skinned-sdk"; -import { stubClient, mkStubRoom } from "../../../test-utils"; +import { + stubClient, + mockStateEventImplementation, + mkRoom, + mkEvent, +} from "../../../test-utils"; +import { stubVoiceChannelStore } from "../../../test-utils/voice"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; +import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import WidgetStore from "../../../../src/stores/WidgetStore"; -import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; -import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; import { DefaultTagID } from "../../../../src/stores/room-list/models"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { VOICE_CHANNEL_ID } from "../../../../src/utils/VoiceChannelUtils"; +import { VOICE_CHANNEL_MEMBER } from "../../../../src/utils/VoiceChannelUtils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; +const mkVoiceChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({ + event: true, + type: VOICE_CHANNEL_MEMBER, + room: "!1:example.org", + user: userId, + skey: userId, + content: { devices }, +}); + describe("RoomTile", () => { jest.spyOn(PlatformPeg, 'get') .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); - const cli = mocked(MatrixClientPeg.get()); + let cli; + let store; beforeEach(() => { const realGetValue = SettingsStore.getValue; - jest.spyOn(SettingsStore, 'getValue').mockImplementation((name, roomId) => { + SettingsStore.getValue = (name: string, roomId?: string): T => { if (name === "feature_voice_rooms") { - return true; + return true as unknown as T; } return realGetValue(name, roomId); - }); + }; stubClient(); + stubVoiceChannelStore(); DMRoomMap.makeShared(); + + cli = mocked(MatrixClientPeg.get()); + store = VoiceChannelStore.instance; }); + afterEach(() => jest.clearAllMocks()); + describe("voice rooms", () => { - const room = mkStubRoom("!1:example.org", "voice room", cli); - room.isCallRoom = () => true; - - // Set up mocks to simulate the remote end of the widget API - let messageSent; - let messageSendMock; - let onceMock; - beforeEach(() => { - let resolveMessageSent; - messageSent = new Promise(resolve => resolveMessageSent = resolve); - messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); - onceMock = jest.fn(); - - jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ - id: VOICE_CHANNEL_ID, - eventId: "$1:example.org", - roomId: "!1:example.org", - type: MatrixWidgetType.JitsiMeet, - url: "", - name: "Voice channel", - creatorUserId: "@alice:example.org", - avatar_url: null, - }]); - jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ - on: () => {}, - off: () => {}, - once: onceMock, - transport: { - send: messageSendMock, - reply: () => {}, - }, - } as unknown as ClientWidgetApi); - }); + const room = mkRoom(cli, "!1:example.org"); + room.isCallRoom.mockReturnValue(true); it("tracks connection state", async () => { + // Insert a breakpoint in the connect method, so we can see the intermediate connecting state + let continueJoin; + const breakpoint = new Promise(resolve => continueJoin = resolve); + const realConnect = store.connect; + store.connect = async () => { + await breakpoint; + await realConnect(); + }; + const tile = mount( { tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connecting..."); - // Wait for the VoiceChannelStore to connect to the widget API - await messageSent; - // Then, locate the callback that will confirm the join - const [, join] = onceMock.mock.calls.find(([action]) => - action === `action:${ElementWidgetActions.JoinCall}`, - ); - - // Now we confirm the join and wait for the VoiceChannelStore to update + // Now we confirm the join and wait for the store to update const waitForConnect = new Promise(resolve => - VoiceChannelStore.instance.once(VoiceChannelEvent.Connect, resolve), + store.once(VoiceChannelEvent.Connect, resolve), ); - join({ detail: {} }); + continueJoin(); await waitForConnect; - // Wait yet another tick for the room tile to update + // Wait exactly 2 ticks for the room tile to update + await Promise.resolve(); await Promise.resolve(); tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connected"); - // Locate the callback that will perform the hangup - const [, hangup] = onceMock.mock.calls.find(([action]) => - action === `action:${ElementWidgetActions.HangupCall}`, - ); - - // Hangup and wait for the VoiceChannelStore, once again - const waitForHangup = new Promise(resolve => - VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, resolve), - ); - hangup({ detail: {} }); - await waitForHangup; - // Wait yet another tick for the room tile to update - await Promise.resolve(); + await store.disconnect(); tile.update(); expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room"); }); + + it("displays connected members", async () => { + mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ + // A user connected from 2 devices + mkVoiceChannelMember("@alice:example.org", ["device 1", "device 2"]), + // A disconnected user + mkVoiceChannelMember("@bob:example.org", []), + // A user that claims to have a connected device, but has left the room + mkVoiceChannelMember("@chris:example.org", ["device 1"]), + ])); + + mocked(room.currentState).getMember.mockImplementation(userId => ({ + userId, + membership: userId === "@chris:example.org" ? "leave" : "join", + name: userId, + rawDisplayName: userId, + roomId: "!1:example.org", + getAvatarUrl: () => {}, + getMxcAvatarUrl: () => {}, + }) as unknown as RoomMember); + + const tile = mount( + , + ); + + // Only Alice should display as connected + const avatar = tile.find(MemberAvatar); + expect(avatar.length).toEqual(1); + expect(avatar.props().member.userId).toEqual("@alice:example.org"); + }); }); }); diff --git a/test/components/views/voip/VoiceChannelRadio-test.tsx b/test/components/views/voip/VoiceChannelRadio-test.tsx index d53d7a2daa8..bd9c3365648 100644 --- a/test/components/views/voip/VoiceChannelRadio-test.tsx +++ b/test/components/views/voip/VoiceChannelRadio-test.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import React from "react"; import { mount } from "enzyme"; import { act } from "react-dom/test-utils"; @@ -22,49 +21,14 @@ import { mocked } from "jest-mock"; import "../../../skinned-sdk"; import { stubClient, mkStubRoom, wrapInMatrixClientContext } from "../../../test-utils"; +import { stubVoiceChannelStore } from "../../../test-utils/voice"; import _VoiceChannelRadio from "../../../../src/components/views/voip/VoiceChannelRadio"; -import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; +import VoiceChannelStore from "../../../../src/stores/VoiceChannelStore"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; const VoiceChannelRadio = wrapInMatrixClientContext(_VoiceChannelRadio); -class StubVoiceChannelStore extends EventEmitter { - private _roomId: string; - public get roomId(): string { return this._roomId; } - private _audioMuted: boolean; - public get audioMuted(): boolean { return this._audioMuted; } - private _videoMuted: boolean; - public get videoMuted(): boolean { return this._videoMuted; } - - public connect = jest.fn().mockImplementation(async (roomId: string) => { - this._roomId = roomId; - this._audioMuted = true; - this._videoMuted = true; - this.emit(VoiceChannelEvent.Connect); - }); - public disconnect = jest.fn().mockImplementation(async () => { - this._roomId = null; - this.emit(VoiceChannelEvent.Disconnect); - }); - public muteAudio = jest.fn().mockImplementation(async () => { - this._audioMuted = true; - this.emit(VoiceChannelEvent.MuteAudio); - }); - public unmuteAudio = jest.fn().mockImplementation(async () => { - this._audioMuted = false; - this.emit(VoiceChannelEvent.UnmuteAudio); - }); - public muteVideo = jest.fn().mockImplementation(async () => { - this._videoMuted = true; - this.emit(VoiceChannelEvent.MuteVideo); - }); - public unmuteVideo = jest.fn().mockImplementation(async () => { - this._videoMuted = false; - this.emit(VoiceChannelEvent.UnmuteVideo); - }); -} - describe("VoiceChannelRadio", () => { const cli = mocked(MatrixClientPeg.get()); const room = mkStubRoom("!1:example.org", "voice channel", cli); @@ -72,10 +36,8 @@ describe("VoiceChannelRadio", () => { beforeEach(() => { stubClient(); + stubVoiceChannelStore(); DMRoomMap.makeShared(); - // Stub out the VoiceChannelStore - jest.spyOn(VoiceChannelStore, "instance", "get") - .mockReturnValue(new StubVoiceChannelStore() as unknown as VoiceChannelStore); }); it("shows when connecting voice", async () => { diff --git a/test/stores/VoiceChannelStore-test.ts b/test/stores/VoiceChannelStore-test.ts new file mode 100644 index 00000000000..cf70e7314c5 --- /dev/null +++ b/test/stores/VoiceChannelStore-test.ts @@ -0,0 +1,95 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; + +import "../skinned-sdk"; +import { stubClient } from "../test-utils"; +import WidgetStore from "../../src/stores/WidgetStore"; +import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; +import VoiceChannelStore, { VoiceChannelEvent } from "../../src/stores/VoiceChannelStore"; +import { VOICE_CHANNEL } from "../../src/utils/VoiceChannelUtils"; + +describe("VoiceChannelStore", () => { + // Set up mocks to simulate the remote end of the widget API + let messageSent; + let messageSendMock; + let onceMock; + beforeEach(() => { + stubClient(); + let resolveMessageSent; + messageSent = new Promise(resolve => resolveMessageSent = resolve); + messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); + onceMock = jest.fn(); + + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + id: VOICE_CHANNEL, + eventId: "$1:example.org", + roomId: "!1:example.org", + type: MatrixWidgetType.JitsiMeet, + url: "", + name: "Voice channel", + creatorUserId: "@alice:example.org", + avatar_url: null, + }]); + jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ + on: () => {}, + off: () => {}, + once: onceMock, + transport: { + send: messageSendMock, + reply: () => {}, + }, + } as unknown as ClientWidgetApi); + }); + + it("connects and disconnects", async () => { + const store = VoiceChannelStore.instance; + + expect(store.roomId).toBeFalsy(); + + store.connect("!1:example.org"); + // Wait for the store to contact the widget API + await messageSent; + // Then, locate the callback that will confirm the join + const [, join] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.JoinCall}`, + ); + // Confirm the join, and wait for the store to update + const waitForConnect = new Promise(resolve => + store.once(VoiceChannelEvent.Connect, resolve), + ); + join({ detail: {} }); + await waitForConnect; + + expect(store.roomId).toEqual("!1:example.org"); + + store.disconnect(); + // Locate the callback that will perform the hangup + const [, hangup] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.HangupCall}`, + ); + // Hangup and wait for the store, once again + const waitForHangup = new Promise(resolve => + store.once(VoiceChannelEvent.Disconnect, resolve), + ); + hangup({ detail: {} }); + await waitForHangup; + + expect(store.roomId).toBeFalsy(); + }); +}); diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index c6e4cbd182e..6cc84474491 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -2,5 +2,6 @@ export * from './beacon'; export * from './client'; export * from './platform'; export * from './test-utils'; +// TODO @@TR: Export voice.ts, which currently isn't exported here because it causes all tests to depend on skinning export * from './wrappers'; export * from './utilities'; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 550e6b6a80e..06e800bff73 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -62,6 +62,7 @@ export function createTestClient(): MatrixClient { getDomain: jest.fn().mockReturnValue("matrix.rog"), getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"), getUser: jest.fn().mockReturnValue({ on: jest.fn() }), + getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"), credentials: { userId: "@userId:matrix.rog" }, getPushActionsForEvent: jest.fn(), diff --git a/test/test-utils/voice.ts b/test/test-utils/voice.ts new file mode 100644 index 00000000000..962e4c6c56b --- /dev/null +++ b/test/test-utils/voice.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; + +import VoiceChannelStore, { VoiceChannelEvent } from "../../src/stores/VoiceChannelStore"; + +class StubVoiceChannelStore extends EventEmitter { + private _roomId: string; + public get roomId(): string { return this._roomId; } + private _audioMuted: boolean; + public get audioMuted(): boolean { return this._audioMuted; } + private _videoMuted: boolean; + public get videoMuted(): boolean { return this._videoMuted; } + + public connect = jest.fn().mockImplementation(async (roomId: string) => { + this._roomId = roomId; + this._audioMuted = true; + this._videoMuted = true; + this.emit(VoiceChannelEvent.Connect); + }); + public disconnect = jest.fn().mockImplementation(async () => { + this._roomId = null; + this.emit(VoiceChannelEvent.Disconnect); + }); + public muteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = true; + this.emit(VoiceChannelEvent.MuteAudio); + }); + public unmuteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = false; + this.emit(VoiceChannelEvent.UnmuteAudio); + }); + public muteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = true; + this.emit(VoiceChannelEvent.MuteVideo); + }); + public unmuteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = false; + this.emit(VoiceChannelEvent.UnmuteVideo); + }); +} + +export const stubVoiceChannelStore = () => { + jest.spyOn(VoiceChannelStore, "instance", "get") + .mockReturnValue(new StubVoiceChannelStore() as unknown as VoiceChannelStore); +};