diff --git a/tests/helpers/Participant.ts b/tests/helpers/Participant.ts index 18814451518f..15ef37207d85 100644 --- a/tests/helpers/Participant.ts +++ b/tests/helpers/Participant.ts @@ -4,6 +4,7 @@ import { multiremotebrowser } from '@wdio/globals'; import { IConfig } from '../../react/features/base/config/configType'; import { urlObjectToString } from '../../react/features/base/util/uri'; +import BreakoutRooms from '../pageobjects/BreakoutRooms'; import Filmstrip from '../pageobjects/Filmstrip'; import IframeAPI from '../pageobjects/IframeAPI'; import Notifications from '../pageobjects/Notifications'; @@ -251,6 +252,22 @@ export class Participant { && APP.store?.getState()['features/base/participants']?.local?.role === 'moderator'); } + /** + * Checks if the meeting supports breakout rooms. + */ + async isBreakoutRoomsSupported() { + return await this.driver.execute(() => typeof APP !== 'undefined' + && APP.store?.getState()['features/base/conference'].conference?.getBreakoutRooms()?.isSupported()); + } + + /** + * Checks if the participant is in breakout room. + */ + async isInBreakoutRoom() { + return await this.driver.execute(() => typeof APP !== 'undefined' + && APP.store?.getState()['features/base/conference'].conference?.getBreakoutRooms()?.isBreakoutRoom()); + } + /** * Waits to join the muc. * @@ -321,6 +338,15 @@ export class Participant { }); } + /** + * Returns the BreakoutRooms for this participant. + * + * @returns {BreakoutRooms} + */ + getBreakoutRooms(): BreakoutRooms { + return new BreakoutRooms(this); + } + /** * Returns the toolbar for this participant. * diff --git a/tests/helpers/participants.ts b/tests/helpers/participants.ts index 2ed58664a0fb..57e47de0cc5f 100644 --- a/tests/helpers/participants.ts +++ b/tests/helpers/participants.ts @@ -6,6 +6,8 @@ import { v4 as uuidv4 } from 'uuid'; import { Participant } from './Participant'; import { IContext, IJoinOptions } from './types'; +const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]'; + /** * Ensure that there is on participant. * @@ -237,3 +239,19 @@ export function parseJid(str: string): { resource: domainParts.length > 0 ? domainParts[1] : undefined }; } + +/** + * Check the subject of the participant. + * @param participant + * @param subject + */ +export async function checkSubject(participant: Participant, subject: string) { + const localTile = participant.driver.$(SUBJECT_XPATH); + + await localTile.waitForExist(); + await localTile.moveTo(); + + const txt = await localTile.getText(); + + expect(txt.startsWith(subject)).toBe(true); +} diff --git a/tests/pageobjects/AVModerationMenu.ts b/tests/pageobjects/AVModerationMenu.ts index 62e91eaa7fc2..7d0c77d6b053 100644 --- a/tests/pageobjects/AVModerationMenu.ts +++ b/tests/pageobjects/AVModerationMenu.ts @@ -1,4 +1,4 @@ -import { Participant } from '../helpers/Participant'; +import BasePageObject from './BasePageObject'; const START_AUDIO_MODERATION = 'participants-pane-context-menu-start-audio-moderation'; const STOP_AUDIO_MODERATION = 'participants-pane-context-menu-stop-audio-moderation'; @@ -8,17 +8,7 @@ const STOP_VIDEO_MODERATION = 'participants-pane-context-menu-stop-video-moderat /** * Represents the Audio Video Moderation menu in the participants pane. */ -export default class AVModerationMenu { - private participant: Participant; - - /** - * Represents the Audio Video Moderation menu in the participants pane. - * @param participant - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class AVModerationMenu extends BasePageObject { /** * Clicks the start audio moderation menu item. */ diff --git a/tests/pageobjects/BaseDialog.ts b/tests/pageobjects/BaseDialog.ts index 603a16d9679e..fffd35cf1bf7 100644 --- a/tests/pageobjects/BaseDialog.ts +++ b/tests/pageobjects/BaseDialog.ts @@ -1,4 +1,4 @@ -import { Participant } from '../helpers/Participant'; +import BasePageObject from './BasePageObject'; const CLOSE_BUTTON = 'modal-header-close-button'; const OK_BUTTON = 'modal-dialog-ok-button'; @@ -6,18 +6,7 @@ const OK_BUTTON = 'modal-dialog-ok-button'; /** * Base class for all dialogs. */ -export default class BaseDialog { - participant: Participant; - - /** - * Initializes for a participant. - * - * @param {Participant} participant - The participant. - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class BaseDialog extends BasePageObject { /** * Clicks on the X (close) button. */ diff --git a/tests/pageobjects/BasePageObject.ts b/tests/pageobjects/BasePageObject.ts new file mode 100644 index 000000000000..6c555abc5b0f --- /dev/null +++ b/tests/pageobjects/BasePageObject.ts @@ -0,0 +1,16 @@ +import { Participant } from '../helpers/Participant'; + +/** + * Represents the base page object. + * All page object has the current participant (holding the driver/browser session). + */ +export default class BasePageObject { + participant: Participant; + + /** + * Represents the base page object. + */ + constructor(participant: Participant) { + this.participant = participant; + } +} diff --git a/tests/pageobjects/BreakoutRooms.ts b/tests/pageobjects/BreakoutRooms.ts new file mode 100644 index 000000000000..a106af9dbb47 --- /dev/null +++ b/tests/pageobjects/BreakoutRooms.ts @@ -0,0 +1,230 @@ +import { Participant } from '../helpers/Participant'; + +import BaseDialog from './BaseDialog'; +import BasePageObject from './BasePageObject'; + +const BREAKOUT_ROOMS_CLASS = 'breakout-room-container'; +const ADD_BREAKOUT_ROOM = 'Add breakout room'; +const MORE_LABEL = 'More'; +const LEAVE_ROOM_LABEL = 'Leave breakout room'; +const AUTO_ASSIGN_LABEL = 'Auto assign to breakout rooms'; + +/** + * Represents a single breakout room and the operations for it. + */ +class BreakoutRoom extends BasePageObject { + title: string; + id: string; + count: number; + + /** + * Constructs a breakout room. + */ + constructor(participant: Participant, title: string, id: string) { + super(participant); + + this.title = title; + this.id = id; + + const tMatch = title.match(/.*\((.*)\)/); + + if (tMatch) { + this.count = parseInt(tMatch[1], 10); + } + } + + /** + * Returns room name. + */ + get name() { + return this.title.split('(')[0].trim(); + } + + /** + * Returns the number of participants in the room. + */ + get participantCount() { + return this.count; + } + + /** + * Collapses the breakout room. + */ + async collapse() { + const collapseElem = this.participant.driver.$( + `div[data-testid="${this.id}"]`); + + await collapseElem.click(); + } + + /** + * Joins the breakout room. + */ + async joinRoom() { + const joinButton = this.participant.driver + .$(`button[data-testid="join-room-${this.id}"]`); + + await joinButton.waitForClickable(); + await joinButton.click(); + } + + /** + * Removes the breakout room. + */ + async removeRoom() { + await this.openContextMenu(); + + const removeButton = this.participant.driver.$(`#remove-room-${this.id}`); + + await removeButton.waitForClickable(); + await removeButton.click(); + } + + /** + * Renames the breakout room. + */ + async renameRoom(newName: string) { + await this.openContextMenu(); + + const renameButton = this.participant.driver.$(`#rename-room-${this.id}`); + + await renameButton.click(); + + const newNameInput = this.participant.driver.$('input[name="breakoutRoomName"]'); + + await newNameInput.waitForStable(); + await newNameInput.setValue(newName); + + await new BaseDialog(this.participant).clickOkButton(); + } + + /** + * Closes the breakout room. + */ + async closeRoom() { + await this.openContextMenu(); + + const closeButton = this.participant.driver.$(`#close-room-${this.id}`); + + await closeButton.waitForClickable(); + await closeButton.click(); + } + + /** + * Opens the context menu. + * @private + */ + private async openContextMenu() { + const listItem = this.participant.driver.$(`div[data-testid="${this.id}"]`); + + await listItem.click(); + + const button = listItem.$(`aria/${MORE_LABEL}`); + + await button.waitForClickable(); + await button.click(); + } +} + +/** + * All breakout rooms objects and operations. + */ +export default class BreakoutRooms extends BasePageObject { + /** + * Returns the number of breakout rooms. + */ + async getRoomsCount() { + const participantsPane = this.participant.getParticipantsPane(); + + if (!await participantsPane.isOpen()) { + await participantsPane.open(); + } + + return await this.participant.driver.$$(`.${BREAKOUT_ROOMS_CLASS}`).length; + } + + /** + * Adds a breakout room. + */ + async addBreakoutRoom() { + const participantsPane = this.participant.getParticipantsPane(); + + if (!await participantsPane.isOpen()) { + await participantsPane.open(); + } + + const addBreakoutButton = this.participant.driver.$(`aria/${ADD_BREAKOUT_ROOM}`); + + await addBreakoutButton.waitForDisplayed(); + await addBreakoutButton.click(); + } + + /** + * Returns all breakout rooms. + */ + async getRooms(): Promise { + const rooms = this.participant.driver.$$(`.${BREAKOUT_ROOMS_CLASS}`); + + return rooms.map(async room => new BreakoutRoom( + this.participant, await room.$('span').getText(), await room.getAttribute('data-testid'))); + } + + /** + * Leave by clicking the leave button in participant pane. + */ + async leaveBreakoutRoom() { + const participantsPane = this.participant.getParticipantsPane(); + + if (!await participantsPane.isOpen()) { + await participantsPane.open(); + } + + const leaveButton = this.participant.driver.$(`aria/${LEAVE_ROOM_LABEL}`); + + await leaveButton.isClickable(); + await leaveButton.click(); + } + + /** + * Auto assign participants to breakout rooms. + */ + async autoAssignToBreakoutRooms() { + const button = this.participant.driver.$(`aria/${AUTO_ASSIGN_LABEL}`); + + await button.waitForClickable(); + await button.click(); + } + + /** + * Tries to send a participant to a breakout room. + */ + async sendParticipantToBreakoutRoom(participant: Participant, roomName: string) { + const participantsPane = this.participant.getParticipantsPane(); + + await participantsPane.selectParticipant(participant); + await participantsPane.openParticipantContextMenu(participant); + + const sendButton = this.participant.driver.$(`aria/${roomName}`); + + await sendButton.waitForClickable(); + await sendButton.click(); + } + + // /** + // * Open context menu for given participant. + // */ + // async openParticipantContextMenu(participant: Participant) { + // const listItem = this.participant.driver.$( + // `div[@id="participant-item-${await participant.getEndpointId()}"]`); + // + // await listItem.waitForDisplayed(); + // await listItem.moveTo(); + // + // const button = listItem.$(`aria/${PARTICIPANT_MORE_LABEL}`); + // + // await button.waitForClickable(); + // await button.click(); + // } +} + + diff --git a/tests/pageobjects/Filmstrip.ts b/tests/pageobjects/Filmstrip.ts index 1bd003e59158..110a551846ee 100644 --- a/tests/pageobjects/Filmstrip.ts +++ b/tests/pageobjects/Filmstrip.ts @@ -1,22 +1,12 @@ import { Participant } from '../helpers/Participant'; import BaseDialog from './BaseDialog'; +import BasePageObject from './BasePageObject'; /** * Filmstrip elements. */ -export default class Filmstrip { - private participant: Participant; - - /** - * Initializes for a participant. - * - * @param {Participant} participant - The participant. - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class Filmstrip extends BasePageObject { /** * Asserts that {@code participant} shows or doesn't show the audio * mute icon for the conference participant identified by diff --git a/tests/pageobjects/IframeAPI.ts b/tests/pageobjects/IframeAPI.ts index 11a4458a90fe..682b467a991a 100644 --- a/tests/pageobjects/IframeAPI.ts +++ b/tests/pageobjects/IframeAPI.ts @@ -1,20 +1,11 @@ -import { Participant } from '../helpers/Participant'; import { LOG_PREFIX } from '../helpers/browserLogger'; +import BasePageObject from './BasePageObject'; + /** * The Iframe API and helpers from iframeAPITest.html */ -export default class IframeAPI { - private participant: Participant; - - /** - * Initializes for a participant. - * @param participant - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class IframeAPI extends BasePageObject { /** * Returns the json object from the iframeAPI helper. * @param event diff --git a/tests/pageobjects/Notifications.ts b/tests/pageobjects/Notifications.ts index e1ffc9ac9c18..d596260892ff 100644 --- a/tests/pageobjects/Notifications.ts +++ b/tests/pageobjects/Notifications.ts @@ -1,4 +1,4 @@ -import { Participant } from '../helpers/Participant'; +import BasePageObject from './BasePageObject'; const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute'; const JOIN_ONE_TEST_ID = 'notify.connectedOneMember'; @@ -9,17 +9,7 @@ const RAISE_HAND_NOTIFICATION_ID = 'notify.raisedHand'; /** * Gathers all notifications logic in the UI and obtaining those. */ -export default class Notifications { - private participant: Participant; - - /** - * Represents the Audio Video Moderation menu in the participants pane. - * @param participant - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class Notifications extends BasePageObject { /** * Waits for the raised hand notification to be displayed. * The notification on moderators page when the participant tries to unmute. diff --git a/tests/pageobjects/ParticipantsPane.ts b/tests/pageobjects/ParticipantsPane.ts index 2e636ed72ea4..38515fbe787c 100644 --- a/tests/pageobjects/ParticipantsPane.ts +++ b/tests/pageobjects/ParticipantsPane.ts @@ -1,6 +1,7 @@ import { Participant } from '../helpers/Participant'; import AVModerationMenu from './AVModerationMenu'; +import BasePageObject from './BasePageObject'; /** * Classname of the closed/hidden participants pane @@ -10,18 +11,7 @@ const PARTICIPANTS_PANE = 'participants_pane'; /** * Represents the participants pane from the UI. */ -export default class ParticipantsPane { - private participant: Participant; - - /** - * Initializes for a participant. - * - * @param {Participant} participant - The participant. - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class ParticipantsPane extends BasePageObject { /** * Gets the audio video moderation menu. */ @@ -138,22 +128,10 @@ export default class ParticipantsPane { await this.participant.getNotifications().dismissAnyJoinNotification(); const participantId = await participantToUnmute.getEndpointId(); - const participantItem = this.participant.driver.$(`#participant-item-${participantId}`); - - await participantItem.waitForExist(); - await participantItem.waitForStable(); - await participantItem.waitForDisplayed(); - await participantItem.moveTo(); + await this.selectParticipant(participantToUnmute); if (fromContextMenu) { - const meetingParticipantMoreOptions = this.participant.driver - .$(`[data-testid="participant-more-options-${participantId}"]`); - - await meetingParticipantMoreOptions.waitForExist(); - await meetingParticipantMoreOptions.waitForDisplayed(); - await meetingParticipantMoreOptions.waitForStable(); - await meetingParticipantMoreOptions.moveTo(); - await meetingParticipantMoreOptions.click(); + await this.openParticipantContextMenu(participantToUnmute); } const unmuteButton = this.participant.driver @@ -162,4 +140,32 @@ export default class ParticipantsPane { await unmuteButton.waitForExist(); await unmuteButton.click(); } + + /** + * Open context menu for given participant. + */ + async selectParticipant(participant: Participant) { + const participantId = await participant.getEndpointId(); + const participantItem = this.participant.driver.$(`#participant-item-${participantId}`); + + await participantItem.waitForExist(); + await participantItem.waitForStable(); + await participantItem.waitForDisplayed(); + await participantItem.moveTo(); + } + + /** + * Open context menu for given participant. + */ + async openParticipantContextMenu(participant: Participant) { + const participantId = await participant.getEndpointId(); + const meetingParticipantMoreOptions = this.participant.driver + .$(`[data-testid="participant-more-options-${participantId}"]`); + + await meetingParticipantMoreOptions.waitForExist(); + await meetingParticipantMoreOptions.waitForDisplayed(); + await meetingParticipantMoreOptions.waitForStable(); + await meetingParticipantMoreOptions.moveTo(); + await meetingParticipantMoreOptions.click(); + } } diff --git a/tests/pageobjects/Toolbar.ts b/tests/pageobjects/Toolbar.ts index 2a4b7a05f73b..e855fc9af13d 100644 --- a/tests/pageobjects/Toolbar.ts +++ b/tests/pageobjects/Toolbar.ts @@ -1,5 +1,4 @@ -// eslint-disable-next-line no-unused-vars -import { Participant } from '../helpers/Participant'; +import BasePageObject from './BasePageObject'; const AUDIO_MUTE = 'Mute microphone'; const AUDIO_UNMUTE = 'Unmute microphone'; @@ -16,18 +15,7 @@ const VIDEO_UNMUTE = 'Start camera'; /** * The toolbar elements. */ -export default class Toolbar { - private participant: Participant; - - /** - * Creates toolbar for a participant. - * - * @param {Participant} participant - The participants. - */ - constructor(participant: Participant) { - this.participant = participant; - } - +export default class Toolbar extends BasePageObject { /** * Returns the button. * @@ -36,7 +24,7 @@ export default class Toolbar { * @private */ private getButton(accessibilityCSSSelector: string) { - return this.participant.driver.$(`[aria-label^="${accessibilityCSSSelector}"]`); + return this.participant.driver.$(`aria/${accessibilityCSSSelector}`); } /** @@ -125,7 +113,10 @@ export default class Toolbar { */ async clickParticipantsPaneButton(): Promise { this.participant.log('Clicking on: Participants pane Button'); - await this.getButton(PARTICIPANTS).click(); + + // Special case for participants pane button, as it contains the number of participants and its label + // is changing + await this.participant.driver.$(`[aria-label^="${PARTICIPANTS}"]`).click(); } /** @@ -170,7 +161,7 @@ export default class Toolbar { * @private */ private async isOverflowMenuOpen() { - return await this.participant.driver.$$(`[aria-label^="${OVERFLOW_MENU}"]`).length > 0; + return await this.participant.driver.$$(`aria/${OVERFLOW_MENU}`).length > 0; } /** @@ -215,7 +206,7 @@ export default class Toolbar { * @private */ private async waitForOverFlowMenu(visible: boolean) { - await this.participant.driver.$(`[aria-label^="${OVERFLOW_MENU}"]`).waitForDisplayed({ + await this.getButton(OVERFLOW_MENU).waitForDisplayed({ reverse: !visible, timeout: 3000, timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}` diff --git a/tests/specs/2way/audioOnly.spec.ts b/tests/specs/2way/audioOnly.spec.ts index 951c6831f9d0..938f658582a9 100644 --- a/tests/specs/2way/audioOnly.spec.ts +++ b/tests/specs/2way/audioOnly.spec.ts @@ -33,36 +33,6 @@ describe('Audio only - ', () => { await setAudioOnlyAndCheck(false); }); - /** - * Toggles the audio only state of a p1 participant and verifies participant sees the audio only label and that - * p2 participant sees a video mute state for the former. - * @param enable - */ - async function setAudioOnlyAndCheck(enable: boolean) { - const { p1 } = ctx; - - await p1.getVideoQualityDialog().setVideoQuality(enable); - - await verifyVideoMute(enable); - - await p1.driver.$('//div[@id="videoResolutionLabel"][contains(@class, "audio-only")]') - .waitForDisplayed({ reverse: !enable }); - } - - /** - * Verifies that p1 and p2 see p1 as video muted or not. - * @param muted - */ - async function verifyVideoMute(muted: boolean) { - const { p1, p2 } = ctx; - - // Verify the observer sees the testee in the desired muted state. - await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted); - - // Verify the testee sees itself in the desired muted state. - await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted); - } - /** * Mutes video on participant1, toggles audio-only twice and then verifies if both participants see participant1 * as video muted. @@ -92,3 +62,33 @@ describe('Audio only - ', () => { await verifyVideoMute(false); }); }); + +/** + * Toggles the audio only state of a p1 participant and verifies participant sees the audio only label and that + * p2 participant sees a video mute state for the former. + * @param enable + */ +async function setAudioOnlyAndCheck(enable: boolean) { + const { p1 } = ctx; + + await p1.getVideoQualityDialog().setVideoQuality(enable); + + await verifyVideoMute(enable); + + await p1.driver.$('//div[@id="videoResolutionLabel"][contains(@class, "audio-only")]') + .waitForDisplayed({ reverse: !enable }); +} + +/** + * Verifies that p1 and p2 see p1 as video muted or not. + * @param muted + */ +async function verifyVideoMute(muted: boolean) { + const { p1, p2 } = ctx; + + // Verify the observer sees the testee in the desired muted state. + await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted); + + // Verify the testee sees itself in the desired muted state. + await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted); +} diff --git a/tests/specs/3way/breakoutRooms.spec.ts b/tests/specs/3way/breakoutRooms.spec.ts new file mode 100644 index 000000000000..76c5354c91e7 --- /dev/null +++ b/tests/specs/3way/breakoutRooms.spec.ts @@ -0,0 +1,464 @@ +import type { ChainablePromiseElement } from 'webdriverio'; + +import type { Participant } from '../../helpers/Participant'; +import { checkSubject, ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants'; + +const MAIN_ROOM_NAME = 'Main room'; +const BREAKOUT_ROOMS_LIST_ID = 'breakout-rooms-list'; +const LIST_ITEM_CONTAINER = 'list-item-container'; + +describe('BreakoutRooms ', () => { + it('check support', async () => { + await ensureTwoParticipants(ctx); + + if (!await ctx.p1.isBreakoutRoomsSupported()) { + ctx.skipSuiteTests = true; + } + }); + + it('add breakout room', async () => { + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // there should be no breakout rooms initially, list is sent with a small delay + await p1.driver.pause(2000); + expect(await p1BreakoutRooms.getRoomsCount()).toBe(0); + + // add one breakout room + await p1BreakoutRooms.addBreakoutRoom(); + + await p1.driver.waitUntil( + async () => await p1BreakoutRooms.getRoomsCount() === 1, { + timeout: 2000, + timeoutMsg: 'No breakout room added for p1' + }); + + + // second participant should also see one breakout room + await p2.driver.waitUntil( + async () => await p2.getBreakoutRooms().getRoomsCount() === 1, { + timeout: 2000, + timeoutMsg: 'No breakout room seen by p2' + }); + }); + + it('join breakout room', async () => { + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // there should be one breakout room + await p1.driver.waitUntil( + async () => await p1BreakoutRooms.getRoomsCount() === 1, { + timeout: 1000, + timeoutMsg: 'No breakout room seen by p1' + }); + + const roomsList = await p1BreakoutRooms.getRooms(); + + expect(roomsList.length).toBe(1); + + // join the room + await roomsList[0].joinRoom(); + + // the participant should see the main room as the only breakout room + await p1.driver.waitUntil( + async () => { + if (await p1BreakoutRooms.getRoomsCount() !== 1) { + return false; + } + + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].name === MAIN_ROOM_NAME; + }, { + timeout: 2000, + timeoutMsg: 'P1 did not join breakout room' + }); + + // the second participant should see one participant in the breakout room + await p2.driver.waitUntil( + async () => { + const list = await p2.getBreakoutRooms().getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'P2 is not seeing p1 in the breakout room' + }); + }); + + it('leave breakout room', async () => { + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // leave room + await p1BreakoutRooms.leaveBreakoutRoom(); + + // there should be one breakout room and that should not be the main room + await p1.driver.waitUntil( + async () => { + if (await p1BreakoutRooms.getRoomsCount() !== 1) { + return false; + } + + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].name !== MAIN_ROOM_NAME; + }, { + timeout: 2000, + timeoutMsg: 'P1 did not leave breakout room' + }); + + // the second participant should see no participants in the breakout room + await p2.driver.waitUntil( + async () => { + const list = await p2.getBreakoutRooms().getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 0; + }, { + timeout: 2000, + timeoutMsg: 'P2 is seeing p1 in the breakout room' + }); + }); + + it('remove breakout room', async () => { + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // remove the room + await (await p1BreakoutRooms.getRooms())[0].removeRoom(); + + // there should be no breakout rooms + await p1.driver.waitUntil( + async () => await p1BreakoutRooms.getRoomsCount() === 0, { + timeout: 2000, + timeoutMsg: 'Breakout room was not removed for p1' + }); + + // the second participant should also see no breakout rooms + await p2.driver.waitUntil( + async () => await p2.getBreakoutRooms().getRoomsCount() === 0, { + timeout: 2000, + timeoutMsg: 'Breakout room was not removed for p2' + }); + }); + + it('auto assign', async () => { + await ensureThreeParticipants(ctx); + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // create two rooms + await p1BreakoutRooms.addBreakoutRoom(); + await p1BreakoutRooms.addBreakoutRoom(); + + // there should be two breakout rooms + await p1.driver.waitUntil( + async () => await p1BreakoutRooms.getRoomsCount() === 2, { + timeout: 2000, + timeoutMsg: 'Breakout room was not created by p1' + }); + + // auto assign participants to rooms + await p1BreakoutRooms.autoAssignToBreakoutRooms(); + + // each room should have one participant + await p1.driver.waitUntil( + async () => { + if (await p1BreakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list[0].participantCount === 1 && list[1].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'P1 did not auto assigned participants to breakout rooms' + }); + + // the second participant should see one participant in the main room + const p2BreakoutRooms = p2.getBreakoutRooms(); + + await p2.driver.waitUntil( + async () => { + if (await p2BreakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await p2BreakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list[0].participantCount === 1 && list[1].participantCount === 1 + && (list[0].name === MAIN_ROOM_NAME || list[1].name === MAIN_ROOM_NAME); + }, { + timeout: 2000, + timeoutMsg: 'P2 is not seeing p1 in the main room' + }); + }); + + it('close breakout room', async () => { + const { p1, p2, p3 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // there should be two non-empty breakout rooms + await p1.driver.waitUntil( + async () => { + if (await p1BreakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list[0].participantCount === 1 && list[1].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'P1 is not seeing two breakout rooms' + }); + + // close the first room + await (await p1BreakoutRooms.getRooms())[0].closeRoom(); + + // there should be two rooms and first one should be empty + await p1.driver.waitUntil( + async () => { + if (await p1BreakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list[0].participantCount === 0 || list[1].participantCount === 0; + }, { + timeout: 2000, + timeoutMsg: 'P1 is not seeing an empty breakout room' + }); + + // there should be two participants in the main room, either p2 or p3 got moved to the main room + const checkParticipants = async (p: Participant) => { + await p.driver.waitUntil( + async () => { + const isInBreakoutRoom = await p.isInBreakoutRoom(); + const breakoutRooms = p.getBreakoutRooms(); + + if (isInBreakoutRoom) { + if (await breakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await breakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list.every(r => { // eslint-disable-line arrow-body-style + return r.name === MAIN_ROOM_NAME ? r.participantCount === 2 : r.participantCount === 0; + }); + } + + if (await breakoutRooms.getRoomsCount() !== 2) { + return false; + } + + const list = await breakoutRooms.getRooms(); + + if (list?.length !== 2) { + return false; + } + + return list[0].participantCount + list[1].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: `${p.name} is not seeing an empty breakout room and one with one participant` + }); + }; + + await checkParticipants(p2); + await checkParticipants(p3); + }); + + it('send participants to breakout room', async () => { + await Promise.all([ ctx.p1.hangup(), ctx.p2.hangup(), ctx.p3.hangup() ]); + + // because the participants rejoin so fast, the meeting is not properly ended, + // so the previous breakout rooms would still be there. + // To avoid this issue we use a different meeting + ctx.roomName += '-breakout-rooms'; + + await ensureTwoParticipants(ctx); + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // there should be no breakout rooms + expect(await p1BreakoutRooms.getRoomsCount()).toBe(0); + + // add one breakout room + await p1BreakoutRooms.addBreakoutRoom(); + + // there should be one empty room + await p1.driver.waitUntil( + async () => await p1BreakoutRooms.getRoomsCount() === 1 + && (await p1BreakoutRooms.getRooms())[0].participantCount === 0, { + timeout: 2000, + timeoutMsg: 'No breakout room added for p1' + }); + + // send the second participant to the first breakout room + await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, (await p1BreakoutRooms.getRooms())[0].name); + + // there should be one room with one participant + await p1.driver.waitUntil( + async () => { + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'P1 is not seeing p2 in the breakout room' + }); + }); + + it('collapse breakout room', async () => { + const { p1 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // there should be one breakout room with one participant + await p1.driver.waitUntil( + async () => { + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'P1 is not seeing p2 in the breakout room' + }); + + // get id of the breakout room participant + const breakoutList = p1.driver.$(`#${BREAKOUT_ROOMS_LIST_ID}`); + const breakoutRoomItem = await breakoutList.$$(`.${LIST_ITEM_CONTAINER}`).find( + async el => { + const id = await el.getAttribute('id'); + + return id !== '' && id !== null; + }) as ChainablePromiseElement; + + const pId = await breakoutRoomItem.getAttribute('id'); + const breakoutParticipant = p1.driver.$(`//div[@id="${pId}"]`); + + expect(await breakoutParticipant.isDisplayed()).toBe(true); + + // collapse the first + await (await p1BreakoutRooms.getRooms())[0].collapse(); + + // the participant should not be visible + expect(await breakoutParticipant.isDisplayed()).toBe(false); + + // the collapsed room should still have one participant + expect((await p1BreakoutRooms.getRooms())[0].participantCount).toBe(1); + }); + + it('rename breakout room', async () => { + const myNewRoomName = `breakout-${crypto.randomUUID()}`; + const { p1, p2 } = ctx; + const p1BreakoutRooms = p1.getBreakoutRooms(); + + // let's rename breakout room and see it in local and remote + await (await p1BreakoutRooms.getRooms())[0].renameRoom(myNewRoomName); + + await p1.driver.waitUntil( + async () => { + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].name === myNewRoomName; + }, { + timeout: 2000, + timeoutMsg: 'The breakout room was not renamed for p1' + }); + + await checkSubject(p2, myNewRoomName); + + // leave room + await p2.getBreakoutRooms().leaveBreakoutRoom(); + + // there should be one empty room + await p1.driver.waitUntil( + async () => { + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 0; + }, { + timeout: 2000, + timeoutMsg: 'The breakout room was not renamed for p1' + }); + + expect((await p2.getBreakoutRooms().getRooms())[0].name).toBe(myNewRoomName); + + // send the second participant to the first breakout room + await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, myNewRoomName); + + // there should be one room with one participant + await p1.driver.waitUntil( + async () => { + const list = await p1BreakoutRooms.getRooms(); + + if (list?.length !== 1) { + return false; + } + + return list[0].participantCount === 1; + }, { + timeout: 2000, + timeoutMsg: 'The breakout room was not rename for p1' + }); + + await checkSubject(p2, myNewRoomName); + }); +}); diff --git a/tests/wdio.conf.ts b/tests/wdio.conf.ts index 84f6772d4617..b6ffa6382b3f 100644 --- a/tests/wdio.conf.ts +++ b/tests/wdio.conf.ts @@ -178,9 +178,6 @@ export const config: WebdriverIO.MultiremoteConfig = { return; } - // if (process.env.GRID_HOST_URL) { - // TODO: make sure we use uploadFile only with chrome (it does not work with FF), - // we need to test it with the grid and FF, does it work there const rpath = await bInstance.uploadFile('tests/resources/iframeAPITest.html'); // @ts-ignore @@ -199,7 +196,7 @@ export const config: WebdriverIO.MultiremoteConfig = { after() { const { ctx }: any = global; - if (ctx.webhooksProxy) { + if (ctx?.webhooksProxy) { ctx.webhooksProxy.disconnect(); } }, diff --git a/tests/wdio.dev.conf.ts b/tests/wdio.dev.conf.ts index 8bb03e48a698..b23d7b52dc9c 100644 --- a/tests/wdio.dev.conf.ts +++ b/tests/wdio.dev.conf.ts @@ -1,11 +1,11 @@ // wdio.dev.conf.ts // extends the main configuration file for the development environment (make dev) // it will connect to the webpack-dev-server running locally on port 8080 -import { deepmerge } from 'deepmerge-ts'; +import { merge } from 'lodash-es'; // @ts-ignore import { config as defaultConfig } from './wdio.conf.ts'; -export const config = deepmerge(defaultConfig, { +export const config = merge(defaultConfig, { baseUrl: 'https://127.0.0.1:8080/torture' }, { clone: false }); diff --git a/tests/wdio.firefox.conf.ts b/tests/wdio.firefox.conf.ts index 154476877c75..f9b13ce3ab39 100644 --- a/tests/wdio.firefox.conf.ts +++ b/tests/wdio.firefox.conf.ts @@ -19,11 +19,12 @@ if (process.env.HEADLESS === 'true') { ffArgs.push('--headless'); } +const ffExcludes = [ + 'specs/2way/iFrameParticipantsPresence.spec.ts', // FF does not support uploading files (uploadFile) + 'specs/3way/activeSpeaker.spec.ts' // FF does not support setting a file as mic input +]; + export const config = merge(defaultConfig, { - exclude: [ - 'specs/2way/iFrameParticipantsPresence.spec.ts', // FF does not support uploading files (uploadFile) - 'specs/3way/activeSpeaker.spec.ts' // FF does not support setting a file as mic input - ], capabilities: { participant1: { capabilities: { @@ -34,6 +35,30 @@ export const config = merge(defaultConfig, { }, acceptInsecureCerts: process.env.ALLOW_INSECURE_CERTS === 'true' } + }, + participant2: { + capabilities: { + 'wdio:exclude': [ + ...defaultConfig.capabilities.participant2.capabilities['wdio:exclude'], + ...ffExcludes + ] + } + }, + participant3: { + capabilities: { + 'wdio:exclude': [ + ...defaultConfig.capabilities.participant3.capabilities['wdio:exclude'], + ...ffExcludes + ] + } + }, + participant4: { + capabilities: { + 'wdio:exclude': [ + ...defaultConfig.capabilities.participant4.capabilities['wdio:exclude'], + ...ffExcludes + ] + } } } }, { clone: false }); diff --git a/tests/wdio.grid.conf.ts b/tests/wdio.grid.conf.ts index 09166c0ffc45..07015bfdea4b 100644 --- a/tests/wdio.grid.conf.ts +++ b/tests/wdio.grid.conf.ts @@ -1,6 +1,6 @@ // wdio.grid.conf.ts // extends the main configuration file to add the selenium grid address -import { deepmerge } from 'deepmerge-ts'; +import { merge } from 'lodash-es'; import { URL } from 'url'; // @ts-ignore @@ -9,7 +9,7 @@ import { config as defaultConfig } from './wdio.conf.ts'; const gridUrl = new URL(process.env.GRID_HOST_URL as string); const protocol = gridUrl.protocol.replace(':', ''); -export const config = deepmerge(defaultConfig, { +export const config = merge(defaultConfig, { protocol, hostname: gridUrl.hostname, port: gridUrl.port ? parseInt(gridUrl.port, 10) // Convert port to number