import BasePageObject from './BasePageObject'; const AUDIO_MUTE = 'Mute microphone'; const RECORDING = 'Record & Transcribe'; // Non-moderators (who can only do local recording) get a "Record" button instead of the // moderator's "Record & Transcribe" — see AbstractRecordButton._getAccessibilityLabel(). const RECORDING_NON_MODERATOR = 'Record'; const LIVE_STREAMING = 'Start live stream'; const AUDIO_UNMUTE = 'Unmute microphone'; const CHAT = 'Open chat'; const CLOSE_CHAT = 'Close chat'; const CLOSE_PARTICIPANTS_PANE = 'Close participants pane'; const DESKTOP = 'Start sharing your screen'; const HANGUP = 'Leave the meeting'; const OVERFLOW_MENU = 'More actions menu'; const OVERFLOW = 'More actions'; const CLOSE_OVERFLOW = 'Close more actions menu'; const PARTICIPANTS = 'Open participants pane'; const PROFILE = 'Edit your profile'; const RAISE_HAND = 'Raise your hand'; const SECURITY = 'Security options'; const SETTINGS = 'Open settings'; const STOP_DESKTOP = 'Stop sharing your screen'; const ENTER_TILE_VIEW_BUTTON = 'Enter tile view'; const EXIT_TILE_VIEW_BUTTON = 'Exit tile view'; const VIDEO_QUALITY = 'Manage video quality'; const VIRTUAL_BACKGROUND = 'Select Background'; const VIDEO_MUTE = 'Stop camera'; const VIDEO_UNMUTE = 'Start camera'; /** * The toolbar elements. */ export default class Toolbar extends BasePageObject { /** * Returns the button. * * @param {string} accessibilityCSSSelector - The selector to find the button. * @returns {WebdriverIO.Element} The button. * @private */ private getButton(accessibilityCSSSelector: string) { return this.participant.driver.$(`[aria-label="${accessibilityCSSSelector}"]`); } /** * The audio mute button. */ get audioMuteBtn() { return this.getButton(AUDIO_MUTE); } /** * The audio unmute button. */ get audioUnMuteBtn() { return this.getButton(AUDIO_UNMUTE); } /** * Returns whether the recording button exists in the DOM for this participant. * * @returns {Promise} */ async hasRecordingButton(): Promise { // The recording button lives in the overflow ("More actions") menu, so it is only in the // DOM while that menu is open. isExisting() searches the whole DOM, so opening the menu // first also covers the case where the button is promoted to the main toolbar. await this.openOverflowMenu(); // Match either label since moderators get "Record & Transcribe" while non-moderators // (local recording only) get "Record". const exists = await this.getButton(RECORDING).isExisting() || await this.getButton(RECORDING_NON_MODERATOR).isExisting(); await this.closeOverflowMenu(); return exists; } /** * Clicks the recording button to open the recording/transcription dialog. * * @returns {Promise} */ async clickRecordingButton(): Promise { // The recording button lives in the overflow ("More actions") menu; open it, click, close // (matches clickSettings/clickSecurity — closeOverflowMenu is a no-op once the dialog opens). // Match either label, same as hasRecordingButton: moderators who can't transcribe (e.g. // transcription.enabled=false) get "Record" too, not just non-moderators — see // AbstractRecordButton._getAccessibilityLabel()/canAddTranscriber(). return this.clickButtonInOverflowMenu([ RECORDING, RECORDING_NON_MODERATOR ]); } /** * Returns whether the live streaming button exists in the DOM for this participant. * * @returns {Promise} */ async hasLiveStreamingButton(): Promise { // Same reasoning as hasRecordingButton: the button lives in the overflow menu, so open it // first to make sure it is in the DOM regardless of whether it got promoted to the main bar. await this.openOverflowMenu(); const exists = await this.getButton(LIVE_STREAMING).isExisting(); await this.closeOverflowMenu(); return exists; } /** * Clicks audio mute button. * * @returns {Promise} */ async clickAudioMuteButton(): Promise { await this.participant.log('Clicking on: Audio Mute Button'); await this.audioMuteBtn.waitForExist({ timeout: 2000, timeoutMsg: 'Audio mute button not found' }); // not directly clicking the button to avoid issues of UI notifications preventing it return this.participant.execute(() => JitsiMeetJS.app.testing.audioMute()); } /** * Clicks audio unmute button. * * @returns {Promise} */ async clickAudioUnmuteButton(): Promise { await this.participant.log('Clicking on: Audio Unmute Button'); await this.audioUnMuteBtn.waitForExist({ timeout: 2000, timeoutMsg: 'Audio unmute button not found' }); // not directly clicking the button to avoid issues of UI notifications preventing it return this.participant.execute(() => JitsiMeetJS.app.testing.audioUnmute()); } /** * 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 { await this.participant.log('Clicking on: Video Mute Button'); await this.videoMuteBtn.waitForExist({ timeout: 2000, timeoutMsg: 'Video mute button not found' }); // not directly clicking the button to avoid issues of UI notifications preventing it return this.participant.execute(() => JitsiMeetJS.app.testing.videoMute()); } /** * Clicks video unmute button. * * @returns {Promise} */ async clickVideoUnmuteButton(): Promise { await this.participant.log('Clicking on: Video Unmute Button'); await this.videoUnMuteBtn.waitForExist({ timeout: 2000, timeoutMsg: 'Video unmute button not found' }); // not directly clicking the button to avoid issues of UI notifications preventing it return this.participant.execute(() => JitsiMeetJS.app.testing.videoUnmute()); } /** * Clicks Participants pane button. * * @returns {Promise} */ async clickCloseParticipantsPaneButton(): Promise { await this.participant.log('Clicking on: Close Participants pane Button'); return this.getButton(CLOSE_PARTICIPANTS_PANE).click(); } /** * Clicks Participants pane button. * * @returns {Promise} */ async clickParticipantsPaneButton(): Promise { await this.participant.log('Clicking on: Participants pane Button'); // Special case for participants pane button, as it contains the number of participants and its label // is changing return this.participant.driver.$(`[aria-label^="${PARTICIPANTS}"]`).click(); } /** * Clicks on the video quality toolbar button which opens the * dialog for adjusting max-received video quality. */ clickVideoQualityButton(): Promise { return this.clickButtonInOverflowMenu(VIDEO_QUALITY); } /** * Clicks on the profile toolbar button which opens or closes the profile panel. */ clickProfileButton(): Promise { return this.clickButtonInOverflowMenu(PROFILE); } /** * Clicks on the raise hand button that enables participants will to speak. */ async clickRaiseHandButton(): Promise { await this.participant.log('Clicking on: Raise hand Button'); return this.getButton(RAISE_HAND).click(); } /** * Clicks on the chat button that opens chat panel. */ async clickChatButton(): Promise { await this.participant.log('Clicking on: Chat Button'); return this.getButton(CHAT).click(); } /** * Clicks on the chat button that closes chat panel. */ async clickCloseChatButton(): Promise { await this.participant.log('Clicking on: Close Chat Button'); return this.getButton(CLOSE_CHAT).click(); } /** * Clicks on the desktop sharing button that starts desktop sharing. Waits until the toggle takes effect * (button flips to STOP_DESKTOP) so a subsequent call doesn't race a still-in-flight stop. */ clickDesktopSharingButton() { return this._toggleDesktopSharing(DESKTOP, STOP_DESKTOP, 'start'); } /** * Clicks on the desktop sharing button to stop it. Waits until the toggle takes effect (button flips to * DESKTOP). On a busy main thread the click can land on a DOM node React has already detached during a * track-replace / participantLeft cascade and the React onClick never fires — re-locate and re-click once * before failing. */ clickStopDesktopSharingButton() { return this._toggleDesktopSharing(STOP_DESKTOP, DESKTOP, 'stop'); } /** * Clicks the desktop-sharing toggle and waits for the button to flip to the opposite aria-label. If the * flip doesn't happen within a short window, re-locates the source button (it may have been re-rendered * out from under the original element handle) and clicks once more before failing. * * @private */ private async _toggleDesktopSharing(fromLabel: string, toLabel: string, action: 'start' | 'stop') { await this.getButton(fromLabel).click(); try { await this.getButton(toLabel).waitForExist({ timeout: 2_000 }); return; } catch { // Fall through to retry. } // If the source button is still present, the first click was dropped — re-locate and click again. If // it isn't present, the toggle is in flight; just keep waiting. if (await this.getButton(fromLabel).isExisting()) { await this.participant.log(`Retrying desktop-sharing ${action} click for ${this.participant.name}`); await this.getButton(fromLabel).click(); } await this.getButton(toLabel).waitForExist({ timeout: 3_000, timeoutMsg: `Desktop sharing did not ${action} for ${this.participant.name}` }); } /** * Clicks on the tile view button which enables tile layout. */ clickEnterTileViewButton() { return this.getButton(ENTER_TILE_VIEW_BUTTON).click(); } /** * Clicks on the tile view button which exits tile layout. */ clickExitTileViewButton() { return this.getButton(EXIT_TILE_VIEW_BUTTON).click(); } /** * Clicks on the hangup button that ends the conference. */ async clickHangupButton(): Promise { await this.participant.log('Clicking on: Hangup Button'); return this.getButton(HANGUP).click(); } /** * Clicks on the security toolbar button which opens the security panel. */ clickSecurityButton() { return this.clickButtonInOverflowMenu(SECURITY); } /** * Clicks on the settings toolbar button which opens or closes the settings panel. */ clickSettingsButton() { return this.clickButtonInOverflowMenu(SETTINGS); } /** * Clicks on the virtual background toolbar button which opens the virtual background settings. */ clickVirtualBackgroundButton() { return this.clickButtonInOverflowMenu(VIRTUAL_BACKGROUND); } /** * Ensure the overflow menu is open and clicks on a specified button. * @param accessibilityLabel The accessibility label of the button to be clicked, or a list of * candidate labels (e.g. a moderator vs. non-moderator variant) — the first one found in the * DOM is clicked. * @private */ private async clickButtonInOverflowMenu(accessibilityLabel: string | string[]) { await this.openOverflowMenu(); // sometimes the overflow button tooltip is over the last entry in the menu, // so let's move focus away before clicking the button await this.participant.driver.$('#overflow-context-menu').moveTo(); const labels = Array.isArray(accessibilityLabel) ? accessibilityLabel : [ accessibilityLabel ]; let label = labels[0]; for (const candidate of labels) { if (await this.getButton(candidate).isExisting()) { label = candidate; break; } } await this.participant.log(`Clicking on: ${label}`); await this.getButton(label).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 clickOverflowButton(): Promise { return this.getButton(OVERFLOW).click(); } /** * Ensure the overflow menu is displayed. * @private */ private async openOverflowMenu() { if (await this.isOverflowMenuOpen()) { return; } // Wake the auto-hidden toolbar by hovering the local video tile; otherwise the overflow // button click can fire on an invisible element and the menu never appears. When the // self-view is hidden (e.g. via settings), localVideoContainer is not in the DOM, so fall // back to the large video container which is always present while in conference. const localVideoContainer = this.participant.driver.$('span[id="localVideoContainer"]'); if (await localVideoContainer.isExisting()) { await localVideoContainer.moveTo(); } else { await this.participant.driver.$('#largeVideoContainer').moveTo(); } await this.clickOverflowButton(); await this.waitForOverFlowMenu(true); } /** * Ensures the overflow menu is not displayed. * @private */ async closeOverflowMenu() { if (!await this.isOverflowMenuOpen()) { return; } // When the overflow menu is open the toggle button's aria-label changes from // "More actions" to "Close more actions menu", so we cannot reuse clickOverflowButton here. await this.getButton(CLOSE_OVERFLOW).click(); await this.waitForOverFlowMenu(false); } /** * Waits for the overflow menu to be visible or hidden. * @param visible * @private */ private waitForOverFlowMenu(visible: boolean) { return this.getButton(OVERFLOW_MENU).waitForDisplayed({ reverse: !visible, // The menu opens with an enter animation and its render can be delayed by several // seconds when the main thread is busy (e.g. right after a rejoin or while a virtual // background effect is loading), so allow a generous timeout before giving up. timeout: 10000, timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}` }); } /** * Gets the participant's avatar image element located in the toolbar. */ async getProfileImage() { await this.openOverflowMenu(); const elem = this.participant.driver.$(`[aria-label^="${PROFILE}"] img`); return await elem.isExisting() ? await elem.getAttribute('src') : null; } }