snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
465
tests/pageobjects/Toolbar.ts
Normal file
465
tests/pageobjects/Toolbar.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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<boolean>}
|
||||
*/
|
||||
async hasRecordingButton(): Promise<boolean> {
|
||||
// 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<void>}
|
||||
*/
|
||||
async clickRecordingButton(): Promise<void> {
|
||||
// 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<boolean>}
|
||||
*/
|
||||
async hasLiveStreamingButton(): Promise<boolean> {
|
||||
// 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<void>}
|
||||
*/
|
||||
async clickAudioMuteButton(): Promise<void> {
|
||||
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<void>}
|
||||
*/
|
||||
async clickAudioUnmuteButton(): Promise<void> {
|
||||
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<void>}
|
||||
*/
|
||||
async clickVideoMuteButton(): Promise<void> {
|
||||
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<void>}
|
||||
*/
|
||||
async clickVideoUnmuteButton(): Promise<void> {
|
||||
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<void>}
|
||||
*/
|
||||
async clickCloseParticipantsPaneButton(): Promise<void> {
|
||||
await this.participant.log('Clicking on: Close Participants pane Button');
|
||||
|
||||
return this.getButton(CLOSE_PARTICIPANTS_PANE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks Participants pane button.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clickParticipantsPaneButton(): Promise<void> {
|
||||
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<void> {
|
||||
return this.clickButtonInOverflowMenu(VIDEO_QUALITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the profile toolbar button which opens or closes the profile panel.
|
||||
*/
|
||||
clickProfileButton(): Promise<void> {
|
||||
return this.clickButtonInOverflowMenu(PROFILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the raise hand button that enables participants will to speak.
|
||||
*/
|
||||
async clickRaiseHandButton(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user