diff --git a/tests/helpers/Participant.ts b/tests/helpers/Participant.ts index 2d12c39ff728..25c8ba750ba5 100644 --- a/tests/helpers/Participant.ts +++ b/tests/helpers/Participant.ts @@ -6,7 +6,9 @@ import { IConfig } from '../../react/features/base/config/configType'; import { urlObjectToString } from '../../react/features/base/util/uri'; import Filmstrip from '../pageobjects/Filmstrip'; import IframeAPI from '../pageobjects/IframeAPI'; +import ParticipantsPane from '../pageobjects/ParticipantsPane'; import Toolbar from '../pageobjects/Toolbar'; +import VideoQualityDialog from '../pageobjects/VideoQualityDialog'; import { LOG_PREFIX, logInfo } from './browserLogger'; import { IContext } from './types'; @@ -322,6 +324,24 @@ export class Participant { return new Filmstrip(this); } + /** + * Returns the participants pane. + * + * @returns {ParticipantsPane} + */ + getParticipantsPane(): ParticipantsPane { + return new ParticipantsPane(this); + } + + /** + * Returns the videoQuality Dialog. + * + * @returns {VideoQualityDialog} + */ + getVideoQualityDialog(): VideoQualityDialog { + return new VideoQualityDialog(this); + } + /** * Switches to the iframe API context */ diff --git a/tests/pageobjects/BaseDialog.ts b/tests/pageobjects/BaseDialog.ts new file mode 100644 index 000000000000..bab64b961875 --- /dev/null +++ b/tests/pageobjects/BaseDialog.ts @@ -0,0 +1,26 @@ +import { Participant } from '../helpers/Participant'; + +const CLOSE_BUTTON = 'modal-header-close-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; + } + + /** + * Clicks on the X (close) button. + */ + async clickCloseButton(): Promise { + await this.participant.driver.$(`#${CLOSE_BUTTON}`).click(); + } +} diff --git a/tests/pageobjects/Filmstrip.ts b/tests/pageobjects/Filmstrip.ts index d208afc3148a..e9cc14919b1b 100644 --- a/tests/pageobjects/Filmstrip.ts +++ b/tests/pageobjects/Filmstrip.ts @@ -20,7 +20,7 @@ export default class Filmstrip { * mute icon for the conference participant identified by * {@code testee}. * - * @param {Participant} testee - The {@code WebParticipant} for whom we're checking the status of audio muted icon. + * @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon. * @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon; * otherwise, it will assert its presence. * @returns {Promise} @@ -40,10 +40,41 @@ export default class Filmstrip { await this.participant.driver.$(mutedIconXPath).waitForDisplayed({ reverse, timeout: 2000, - timeoutMsg: `Audio mute icon is not displayed for ${testee.name}` + timeoutMsg: `Audio mute icon is ${reverse ? '' : 'not'} displayed for ${testee.name}` }); } + /** + * Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant + * identified by {@code testee}. + * + * @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon. + * @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon; + * otherwise, it will assert its presence. + * @returns {Promise} + */ + async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise { + const isOpen = await this.participant.getParticipantsPane().isOpen(); + + if (!isOpen) { + await this.participant.getParticipantsPane().open(); + } + + const id = `participant-item-${await testee.getEndpointId()}`; + const mutedIconXPath + = `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='videoMuted']`; + + await this.participant.driver.$(mutedIconXPath).waitForDisplayed({ + reverse, + timeout: 2000, + timeoutMsg: `Video mute icon is ${reverse ? '' : 'not'} displayed for ${testee.name}` + }); + + if (!isOpen) { + await this.participant.getParticipantsPane().close(); + } + } + /** * Returns the remote display name for an endpoint. * @param endpointId The endpoint id. diff --git a/tests/pageobjects/ParticipantsPane.ts b/tests/pageobjects/ParticipantsPane.ts new file mode 100644 index 000000000000..a792eac9f636 --- /dev/null +++ b/tests/pageobjects/ParticipantsPane.ts @@ -0,0 +1,47 @@ +import { Participant } from '../helpers/Participant'; + +/** + * Classname of the closed/hidden participants pane + */ +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; + } + + /** + * Checks if the pane is open. + */ + async isOpen() { + return this.participant.driver.$(`.${PARTICIPANTS_PANE}`).isExisting(); + } + + /** + * Clicks the "participants" toolbar button to open the participants pane. + */ + async open() { + await this.participant.getToolbar().clickParticipantsPaneButton(); + + await this.participant.driver.$(`.${PARTICIPANTS_PANE}`).waitForDisplayed(); + } + + /** + * Clicks the "participants" toolbar button to close the participants pane. + */ + async close() { + await this.participant.getToolbar().clickCloseParticipantsPaneButton(); + + await this.participant.driver.$(`.${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true }); + } +} diff --git a/tests/pageobjects/Toolbar.ts b/tests/pageobjects/Toolbar.ts index d986f31bd747..6ae797deb12b 100644 --- a/tests/pageobjects/Toolbar.ts +++ b/tests/pageobjects/Toolbar.ts @@ -3,6 +3,13 @@ import { Participant } from '../helpers/Participant'; const AUDIO_MUTE = 'Mute microphone'; const AUDIO_UNMUTE = 'Unmute microphone'; +const CLOSE_PARTICIPANTS_PANE = 'Close participants pane'; +const OVERFLOW_MENU = 'More actions menu'; +const OVERFLOW = 'More actions'; +const PARTICIPANTS = 'Open participants pane'; +const VIDEO_QUALITY = 'Manage video quality'; +const VIDEO_MUTE = 'Stop camera'; +const VIDEO_UNMUTE = 'Start camera'; /** * The toolbar elements. @@ -63,4 +70,137 @@ export default class Toolbar { this.participant.log('Clicking on: Audio Unmute Button'); await this.audioUnMuteBtn.click(); } + + /** + * The video mute button. + */ + get videoMuteBtn() { + return this.getButton(VIDEO_MUTE); + } + + /** + * The video unmute button. + */ + get videoUnMuteBtn() { + return this.getButton(VIDEO_UNMUTE); + } + + /** + * Clicks video mute button. + * + * @returns {Promise} + */ + async clickVideoMuteButton(): Promise { + this.participant.log('Clicking on: Video Mute Button'); + await this.videoMuteBtn.click(); + } + + /** + * Clicks video unmute button. + * + * @returns {Promise} + */ + async clickVideoUnmuteButton(): Promise { + this.participant.log('Clicking on: Video Unmute Button'); + await this.videoUnMuteBtn.click(); + } + + /** + * Clicks Participants pane button. + * + * @returns {Promise} + */ + async clickCloseParticipantsPaneButton(): Promise { + this.participant.log('Clicking on: Close Participants pane Button'); + await this.getButton(CLOSE_PARTICIPANTS_PANE).click(); + } + + + /** + * Clicks Participants pane button. + * + * @returns {Promise} + */ + async clickParticipantsPaneButton(): Promise { + this.participant.log('Clicking on: Participants pane Button'); + await this.getButton(PARTICIPANTS).click(); + } + + /** + * Clicks on the video quality toolbar button which opens the + * dialog for adjusting max-received video quality. + */ + async clickVideoQualityButton(): Promise { + return this.clickButtonInOverflowMenu(VIDEO_QUALITY); + } + + /** + * Ensure the overflow menu is open and clicks on a specified button. + * @param accessibilityLabel The accessibility label of the button to be clicked. + * @private + */ + private async clickButtonInOverflowMenu(accessibilityLabel: string) { + await this.openOverflowMenu(); + + await this.getButton(accessibilityLabel).click(); + + await this.closeOverflowMenu(); + } + + /** + * Checks if the overflow menu is open and visible. + * @private + */ + private async isOverflowMenuOpen() { + return await this.participant.driver.$$(`[aria-label^="${OVERFLOW_MENU}"]`).length > 0; + } + + /** + * Clicks on the overflow toolbar button which opens or closes the overflow menu. + * @private + */ + private async clickOverflowButton(): Promise { + await this.getButton(OVERFLOW).click(); + } + + /** + * Ensure the overflow menu is displayed. + * @private + */ + private async openOverflowMenu() { + if (await this.isOverflowMenuOpen()) { + return; + } + + await this.clickOverflowButton(); + + await this.waitForOverFlowMenu(true); + } + + /** + * Ensures the overflow menu is not displayed. + * @private + */ + private async closeOverflowMenu() { + if (!await this.isOverflowMenuOpen()) { + return; + } + + await this.clickOverflowButton(); + + await this.waitForOverFlowMenu(false); + } + + /** + * Waits for the overflow menu to be visible or hidden. + * @param visible + * @private + */ + private async waitForOverFlowMenu(visible: boolean) { + await this.participant.driver.$(`[aria-label^="${OVERFLOW_MENU}"]`).waitForDisplayed({ + reverse: !visible, + timeout: 3000, + timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}` + }); + } } diff --git a/tests/pageobjects/VideoQualityDialog.ts b/tests/pageobjects/VideoQualityDialog.ts new file mode 100644 index 000000000000..abc685f49ecd --- /dev/null +++ b/tests/pageobjects/VideoQualityDialog.ts @@ -0,0 +1,42 @@ +import { Key } from 'webdriverio'; + +import BaseDialog from './BaseDialog'; + +const VIDEO_QUALITY_SLIDER_CLASS = 'custom-slider'; + +/** + * The video quality dialog. + */ +export default class VideoQualityDialog extends BaseDialog { + /** + * Opens the video quality dialog and sets the video quality to the minimum or maximum definition. + * @param audioOnly - Whether to set the video quality to audio only (minimum). + * @private + */ + async setVideoQuality(audioOnly: boolean) { + await this.participant.getToolbar().clickVideoQualityButton(); + + const videoQualitySlider = this.participant.driver.$(`.${VIDEO_QUALITY_SLIDER_CLASS}`); + + const audioOnlySliderValue = parseInt(await videoQualitySlider.getAttribute('min'), 10); + + const maxDefinitionSliderValue = parseInt(await videoQualitySlider.getAttribute('max'), 10); + const activeValue = parseInt(await videoQualitySlider.getAttribute('value'), 10); + + const targetValue = audioOnly ? audioOnlySliderValue : maxDefinitionSliderValue; + const distanceToTargetValue = targetValue - activeValue; + const keyDirection = distanceToTargetValue > 0 ? Key.ArrowRight : Key.ArrowLeft; + + // we need to click the element to activate it so it will receive the keys + await videoQualitySlider.click(); + + // Move the slider to the target value. + for (let i = 0; i < Math.abs(distanceToTargetValue); i++) { + + await this.participant.driver.keys(keyDirection); + } + + // Close the video quality dialog. + await this.clickCloseButton(); + } +} diff --git a/tests/specs/2way/audioOnly.spec.ts b/tests/specs/2way/audioOnly.spec.ts new file mode 100644 index 000000000000..d0e778b7b903 --- /dev/null +++ b/tests/specs/2way/audioOnly.spec.ts @@ -0,0 +1,86 @@ +import { ensureTwoParticipants } from '../../helpers/participants'; + +describe('Audio only - ', () => { + it('joining the meeting', async () => { + await ensureTwoParticipants(context); + }); + + /** + * Enables audio only mode for p1 and verifies that the other participant sees participant1 as video muted. + */ + it('set and check', async () => { + await setAudioOnlyAndCheck(true); + }); + + /** + * Verifies that participant1 sees avatars for itself and other participants. + */ + it('avatars check', async () => { + await context.p1.driver.$('//div[@id="dominantSpeaker"]').waitForDisplayed(); + + // Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed. + await context.p1.driver.$('//span[@id="localVideoContainer"]//div[contains(@class,"userAvatar")]') + .waitForDisplayed(); + await context.p1.driver.$('//span[@id="localVideoWrapper"]//video').waitForDisplayed({ reverse: true }); + }); + + /** + * Disables audio only mode and verifies that both participants see p1 as not video muted. + */ + it('disable and check', async () => { + 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) { + await context.p1.getVideoQualityDialog().setVideoQuality(enable); + + await verifyVideoMute(enable); + + await context.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) { + // Verify the observer sees the testee in the desired muted state. + await context.p2.getFilmstrip().assertVideoMuteIconIsDisplayed(context.p1, !muted); + + // Verify the testee sees itself in the desired muted state. + await context.p1.getFilmstrip().assertVideoMuteIconIsDisplayed(context.p1, !muted); + } + + /** + * Mutes video on participant1, toggles audio-only twice and then verifies if both participants see participant1 + * as video muted. + */ + it('mute video, set twice and check muted', async () => { + // Mute video on participant1. + await context.p1.getToolbar().clickVideoMuteButton(); + + await verifyVideoMute(true); + + // Enable audio-only mode. + await setAudioOnlyAndCheck(true); + + // Disable audio-only mode. + await context.p1.getVideoQualityDialog().setVideoQuality(false); + + // p1 should stay muted since it was muted before audio-only was enabled. + await verifyVideoMute(true); + }); + + it('unmute video and check not muted', async () => { + // Unmute video on participant1. + await context.p1.getToolbar().clickVideoUnmuteButton(); + + await verifyVideoMute(false); + }); +});