snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
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';
const START_VIDEO_MODERATION = 'participants-pane-context-menu-start-video-moderation';
const STOP_VIDEO_MODERATION = 'participants-pane-context-menu-stop-video-moderation';
/**
* Represents the Audio Video Moderation menu in the participants pane.
*/
export default class AVModerationMenu extends BasePageObject {
/**
* Clicks the start audio moderation menu item.
*/
clickStartAudioModeration() {
return this.clickButton(START_AUDIO_MODERATION);
}
/**
* Clicks the stop audio moderation menu item.
*/
clickStopAudioModeration() {
return this.clickButton(STOP_AUDIO_MODERATION);
}
/**
* Clicks the start video moderation menu item.
*/
clickStartVideoModeration() {
return this.clickButton(START_VIDEO_MODERATION);
}
/**
* Clicks the stop audio moderation menu item.
*/
clickStopVideoModeration() {
return this.clickButton(STOP_VIDEO_MODERATION);
}
/**
* Clicks a context menu button.
* @param id
* @private
*/
private async clickButton(id: string) {
const button = this.participant.driver.$(`#${id}`);
await button.waitForDisplayed();
await button.click();
await button.moveTo({
xOffset: -40,
yOffset: -40
});
}
}

View File

@@ -0,0 +1,23 @@
import BasePageObject from './BasePageObject';
const CLOSE_BUTTON = 'modal-header-close-button';
const OK_BUTTON = 'modal-dialog-ok-button';
/**
* Base class for all dialogs.
*/
export default class BaseDialog extends BasePageObject {
/**
* Clicks on the X (close) button.
*/
clickCloseButton(): Promise<void> {
return this.participant.driver.$(`#${CLOSE_BUTTON}`).click();
}
/**
* Clicks on the ok button.
*/
clickOkButton(): Promise<void> {
return this.participant.driver.$(`#${OK_BUTTON}`).click();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,213 @@
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.
*/
collapse() {
const collapseElem = this.participant.driver.$(
`div[data-testid="${this.id}"]`);
return 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.$(`button[title="${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.$(`button=${ADD_BREAKOUT_ROOM}`);
await addBreakoutButton.waitForDisplayed();
await addBreakoutButton.click();
}
/**
* Returns all breakout rooms.
*/
async getRooms(): Promise<BreakoutRoom[]> {
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.$(`button=${LEAVE_ROOM_LABEL}`);
await leaveButton.isClickable();
await leaveButton.click();
}
/**
* Auto assign participants to breakout rooms.
*/
async autoAssignToBreakoutRooms() {
const button = this.participant.driver.$(`button=${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.$(`div=${roomName}`);
await sendButton.scrollIntoView();
await sendButton.waitForClickable();
await sendButton.click();
}
}

View File

@@ -0,0 +1,207 @@
import BasePageObject from './BasePageObject';
/**
* Chat panel elements.
*/
export default class ChatPanel extends BasePageObject {
/**
* Is chat panel open.
*/
isOpen() {
return this.participant.driver.$('#sideToolbarContainer').isExisting();
}
/**
* Presses the "chat" keyboard shortcut which opens or closes the chat
* panel.
*/
async pressShortcut() {
await this.participant.driver.$('body').click();
await this.participant.driver.keys([ 'c' ]);
}
async sendMessage(message: string) {
if (!await this.isOpen()) {
await this.pressShortcut();
}
if (!await this.isOpen()) {
throw new Error('Chat panel failed to open');
}
const inputField = this.participant.driver.$('#chat-input');
await inputField.click();
await this.participant.driver.keys(`${message}\n`);
}
/**
* Opens the polls tab in the chat panel.
*/
async openPollsTab() {
await this.participant.driver.$('#polls-tab').click();
}
/**
* Checks whether the polls tab is visible.
*/
async isPollsTabVisible() {
return this.participant.driver.$('#polls-tab-panel').isDisplayed();
}
async clickCreatePollButton() {
await this.participant.driver.$('button=Create a poll').click();
}
/**
* Waits for the new poll input to be visible.
*/
async waitForNewPollInput() {
await this.participant.driver.$(
'#polls-create-input')
.waitForExist({
timeout: 2000,
timeoutMsg: 'New poll not created'
});
}
/**
* Waits for the option input to be visible.
* @param index
*/
async waitForOptionInput(index: number) {
await this.participant.driver.$(
`#polls-answer-input-${index}`)
.waitForExist({
timeout: 1000,
timeoutMsg: `Answer input ${index} not created`
});
}
/**
* Waits for the option input to be non-existing.
* @param index
*/
async waitForOptionInputNonExisting(index: number) {
await this.participant.driver.$(
`#polls-answer-input-${index}`)
.waitForExist({
reverse: true,
timeout: 2000,
timeoutMsg: `Answer input ${index} still exists`
});
}
/**
* Clicks the "Add option" button.
*/
async clickAddOptionButton() {
await this.participant.driver.$('button=Add option').click();
}
/**
* Clicks the "Remove option" button.
* @param index
*/
async clickRemoveOptionButton(index: number) {
await this.participant.driver.$(`[data-testid="remove-polls-answer-input-${index}"]`).click();
}
/**
* Fills in the poll question.
* @param question
*/
async fillPollQuestion(question: string) {
const input = await this.participant.driver.$('#polls-create-input');
await input.click();
await this.participant.driver.keys(question);
}
/**
* Fills in the poll option.
* @param index
* @param option
*/
async fillPollOption(index: number, option: string) {
const input = await this.participant.driver.$(`#polls-answer-input-${index}`);
await input.click();
await this.participant.driver.keys(option);
}
/**
* Gets the poll option.
* @param index
*/
async getOption(index: number) {
return this.participant.driver.$(`#polls-answer-input-${index}`).getValue();
}
/**
* Clicks the "Save" button.
*/
async clickSavePollButton() {
await this.participant.driver.$('button=Save').click();
}
/**
* Clicks the "Edit" button.
*/
async clickEditPollButton() {
await this.participant.driver.$('button=Edit').click();
}
/**
* Clicks the "Skip" button.
*/
async clickSkipPollButton() {
await this.participant.driver.$('button=Skip').click();
}
/**
* Clicks the "Send" button.
*/
async clickSendPollButton() {
await this.participant.driver.$('button=Send').click();
}
/**
* Waits for the "Send" button to be visible.
*/
async waitForSendButton() {
await this.participant.driver.$('button=Send').waitForExist({
timeout: 1000,
timeoutMsg: 'Send button not visible'
});
}
/**
* Votes for the given option in the given poll.
* @param pollId
* @param index
*/
async voteForOption(pollId: string, index: number) {
await this.participant.driver.execute(
(id, ix) => document.getElementById(`poll-answer-checkbox-${id}-${ix}`)?.click(),
pollId, index);
await this.participant.driver.$('button=Submit').click();
}
/**
* Checks whether the given poll is visible.
* @param pollId
*/
async isPollVisible(pollId: string) {
return this.participant.driver.$(`#poll-${pollId}`).isDisplayed();
}
/**
* Gets the result text for the given option in the given poll.
* @param pollId
* @param optionIndex
*/
async getResult(pollId: string, optionIndex: number) {
return await this.participant.driver.$(`#poll-result-${pollId}-${optionIndex}`).getText();
}
}

View File

@@ -0,0 +1,172 @@
import BasePageObject from './BasePageObject';
const FILE_SHARING_TAB_ID = 'file_sharing-tab';
const FILE_SHARING_PANEL_ID = 'file_sharing-tab-panel';
const UPLOAD_BUTTON_LABEL = 'Share file';
const DOWNLOAD_BUTTON_LABEL = 'Download';
const REMOVE_BUTTON_LABEL = 'Remove';
/**
* Page object for the file sharing panel.
*/
export default class FileSharingPanel extends BasePageObject {
/**
* Opens the chat sidebar and navigates to the file sharing tab.
*/
async open(): Promise<void> {
if (!await this.participant.driver.$('#sideToolbarContainer').isExisting()) {
await this.participant.getToolbar().clickChatButton();
}
await this.participant.driver.$(`#${FILE_SHARING_TAB_ID}`).click();
}
/**
* Returns whether the file sharing panel is the currently active tab.
*/
async isActive(): Promise<boolean> {
return this.participant.execute(() => {
// @ts-ignore
const state = APP?.store?.getState?.();
return state?.['features/chat']?.focusedTab === 'file_sharing-tab';
});
}
/**
* Returns whether the upload button is enabled (not disabled).
*/
async isUploadButtonEnabled(): Promise<boolean> {
const btn = this.participant.driver.$(`[aria-label="${UPLOAD_BUTTON_LABEL}"]`);
return !await btn.getAttribute('disabled');
}
/**
* Uploads a file via the hidden file input.
*
* @param {string} localFilePath - Local path to the file to upload.
*/
async uploadFile(localFilePath: string): Promise<void> {
const remotePath = await this.participant.driver.uploadFile(localFilePath);
const input = this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} input[type="file"]`);
await input.addValue(remotePath);
}
/**
* Waits until a file with the given name appears in the file list.
*
* @param {string} fileName - The file name to wait for.
* @param {number} timeout - Timeout in milliseconds.
*/
async waitForFile(fileName: string, timeout = 15_000): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`)
.waitForExist({ timeout, timeoutMsg: `File "${fileName}" did not appear within ${timeout}ms` });
}
/**
* Waits until a file with the given name disappears from the file list.
*
* @param {string} fileName - The file name to wait for.
* @param {number} timeout - Timeout in milliseconds.
*/
async waitForFileGone(fileName: string, timeout = 15_000): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`)
.waitForExist({ timeout, reverse: true, timeoutMsg: `File "${fileName}" still present after ${timeout}ms` });
}
/**
* Returns whether a file with the given name exists in the file list.
*
* @param {string} fileName - The file name to check.
*/
async hasFile(fileName: string): Promise<boolean> {
return this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`).isExisting();
}
/**
* Clicks the download button for the given file. Hovers over the file item first to make action buttons visible.
*
* @param {string} fileName - The file name to download.
*/
async downloadFile(fileName: string): Promise<void> {
await this.hoverOverFileItem(fileName);
await this.participant.driver
.$(`button[aria-label="${DOWNLOAD_BUTTON_LABEL} ${fileName}"]`)
.click();
}
/**
* Clicks the remove button for the given file. Hovers over the file item first to make action buttons visible.
*
* @param {string} fileName - The file name to remove.
*/
async removeFile(fileName: string): Promise<void> {
await this.hoverOverFileItem(fileName);
await this.participant.driver
.$(`button[aria-label="${REMOVE_BUTTON_LABEL} ${fileName}"]`)
.click();
}
/**
* Returns whether the remove button exists in the DOM for the given file.
* The remove button is only rendered when the participant has the 'file-upload' JWT feature.
*
* @param {string} fileName - The file name.
*/
canRemoveFile(fileName: string) {
return this.participant.driver
.$(`button[aria-label="${REMOVE_BUTTON_LABEL} ${fileName}"]`)
.isExisting();
}
/**
* Returns whether the download button exists in the DOM for the given file.
*
* @param {string} fileName - The file name.
*/
canDownloadFile(fileName: string) {
return this.participant.driver
.$(`button[aria-label="${DOWNLOAD_BUTTON_LABEL} ${fileName}"]`)
.isExisting();
}
/**
* Simulates dragging a file into the conference area and dispatches drag events.
* A dragenter event sets the React isDragging state, then after a short delay a
* dragover event triggers the file sharing tab to open.
*/
async simulateDragIntoConference(): Promise<void> {
await this.participant.driver.executeAsync((done: () => void) => {
const el = document.querySelector('[data-testid="conference-drag-zone"]') ?? document.body;
el.dispatchEvent(new DragEvent('dragenter', {
bubbles: true,
cancelable: true
}));
setTimeout(() => {
el.dispatchEvent(new DragEvent('dragover', {
bubbles: true,
cancelable: true
}));
done();
}, 200);
});
}
/**
* Returns whether the chat sidebar is currently open.
*/
isChatOpen() {
return this.participant.driver.$('#sideToolbarContainer').isExisting();
}
/**
* Hovers over the file item element to make its action buttons visible.
*
* @param {string} fileName - The file name whose item should be hovered.
*/
private async hoverOverFileItem(fileName: string): Promise<void> {
await this.participant.driver.$(`#${FILE_SHARING_PANEL_ID} [title="${fileName}"]`).moveTo();
}
}

View File

@@ -0,0 +1,390 @@
import { Participant } from '../helpers/Participant';
import BaseDialog from './BaseDialog';
import BasePageObject from './BasePageObject';
const LOCAL_VIDEO_XPATH = '//span[@id="localVideoContainer"]';
const LOCAL_VIDEO_MENU_TRIGGER = '#local-video-menu-trigger';
const LOCAL_USER_CONTROLS = 'button[title="Local user controls"]';
const HIDE_SELF_VIEW_BUTTON_XPATH = '//div[contains(@class, "popover")]//div[@id="hideselfviewButton"]';
/**
* Filmstrip elements.
*/
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
* {@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<void>}
*/
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
let id;
if (testee === this.participant) {
id = 'localVideoContainer';
} else {
id = `participant_${await testee.getEndpointId()}`;
}
const mutedIconXPath
= `//span[@id='${id}']//span[contains(@id, 'audioMuted')]//*[local-name()='svg' and @id='mic-disabled']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 5_000,
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name}`
});
}
/**
* Returns the remote display name for an endpoint.
* @param endpointId The endpoint id.
*/
async getRemoteDisplayName(endpointId: string) {
const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}_name"]`);
await remoteDisplayName.moveTo();
return await remoteDisplayName.getText();
}
/**
* Returns the remote video id of a participant with endpointID.
* @param endpointId
*/
async getRemoteVideoId(endpointId: string) {
await this.participant.driver.$(`span[id="participant_${endpointId}"]`).waitForExist();
return await this.participant.execute(eId =>
document.evaluate(`//span[@id="participant_${eId}"]//video`,
document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue?.srcObject?.id, endpointId);
}
/**
* Returns the local video id.
*/
getLocalVideoId() {
return this.participant.execute(
'return document.getElementById("localVideo_container").srcObject.id');
}
/**
* Pins a participant by clicking on their thumbnail.
* @param participant The participant.
*/
async pinParticipant(participant: Participant) {
if (participant === this.participant) {
// when looking up the element and clicking it, it doesn't work if we do it twice in a row (oneOnOne.spec)
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
} else {
const epId = await participant.getEndpointId();
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
}
const endpointID = await participant.getEndpointId();
await this.participant.waitForParticipantOnLargeVideo(endpointID);
}
/**
* Unpins a participant by clicking on their thumbnail.
* @param participant
*/
async unpinParticipant(participant: Participant) {
const epId = await participant.getEndpointId();
if (participant === this.participant) {
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
} else {
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
}
await this.participant.driver.$(`//div[ @id="pin-indicator-${epId}" ]`).waitForDisplayed({
timeout: 2_000,
timeoutMsg: `${this.participant.name} did not unpin ${participant.name}`,
reverse: true
});
}
/**
* Gets avatar SRC attribute for the one displayed on small video thumbnail.
* @param endpointId
*/
async getAvatar(endpointId: string) {
const elem = this.participant.driver.$(
`//span[@id='participant_${endpointId}']//img[contains(@class,'userAvatar')]`);
return await elem.isExisting() ? await elem.getAttribute('src') : null;
}
/**
* Grants moderator rights to a participant.
* @param participant
*/
async grantModerator(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'grantmoderatorlink', true);
}
/**
* Clicks on the link in the remote participant actions menu.
* @param participantId
* @param linkClassname
* @param dialogConfirm
* @private
*/
private async clickOnRemoteMenuLink(participantId: string, linkClassname: string, dialogConfirm: boolean) {
await this.participant.driver.$(`//span[@id='participant_${participantId}']`).moveTo();
await this.participant.driver.$(
`//span[@id='participant_${participantId
}']//span[@id='remotevideomenu']//div[@id='remote-video-menu-trigger']`).moveTo();
const popoverElement = this.participant.driver.$(
`//div[contains(@class, 'popover')]//div[contains(@class, '${linkClassname}')]`);
await popoverElement.waitForExist();
await popoverElement.waitForDisplayed();
await popoverElement.click();
if (dialogConfirm) {
await new BaseDialog(this.participant).clickOkButton();
}
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutelink', false);
}
/**
* Mutes the video of a participant.
* @param participant
*/
async muteVideo(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutevideolink', true);
}
/**
* Kicks a participant.
* @param participantId
*/
kickParticipant(participantId: string) {
return this.clickOnRemoteMenuLink(participantId, 'kicklink', true);
}
/**
* Hover over local video.
*/
hoverOverLocalVideo() {
return this.participant.driver.$(LOCAL_VIDEO_MENU_TRIGGER).moveTo();
}
/**
* Clicks on the hide self view button from local video.
*/
async hideSelfView() {
// open local video menu
await this.hoverOverLocalVideo();
await this.participant.driver.$(LOCAL_USER_CONTROLS).moveTo();
// click Hide self view button
const hideSelfViewButton = this.participant.driver.$(HIDE_SELF_VIEW_BUTTON_XPATH);
await hideSelfViewButton.waitForExist();
await hideSelfViewButton.waitForClickable();
await hideSelfViewButton.click();
}
/**
* Checks whether the local self view is displayed or not.
*/
assertSelfViewIsHidden(hidden: boolean) {
return this.participant.driver.$(LOCAL_VIDEO_XPATH).waitForDisplayed({
reverse: hidden,
timeout: 5000,
timeoutMsg: `Local video thumbnail is${hidden ? '' : ' not'} displayed for ${this.participant.name}`
});
}
/**
* Toggles the filmstrip.
*/
async toggle() {
const toggleButton = this.participant.driver.$('#toggleFilmstripButton');
await toggleButton.moveTo();
await toggleButton.waitForDisplayed();
await toggleButton.click();
}
/**
* Asserts that the filmstrip itself (the whole container, including the self view) is visible or not
* (it is hidden for example when collapsed via the toggle button).
*
* @param visible Whether the filmstrip is expected to be visible.
*/
assertVisible(visible = true) {
return this.participant.driver.waitUntil(
() => this.participant.execute(
expected => {
const filmstrip = document.querySelector('.filmstrip');
return Boolean(filmstrip) && filmstrip.classList.contains('hidden') !== expected;
}, visible),
{
timeout: 5_000,
timeoutMsg: `The filmstrip is${visible ? ' not' : ''} visible for ${this.participant.name}`
}
);
}
/**
* Asserts that the remote videos are hidden or not.
* @param reverse
*/
assertRemoteVideosHidden(reverse = false) {
return this.participant.driver.waitUntil(
async () =>
await this.participant.driver.$$('//div[@id="remoteVideos" and contains(@class, "hidden")]').length > 0,
{
timeout: 10_000, // 10 seconds
timeoutMsg: `Timeout waiting fore remote videos to be hidden: ${!reverse}.`
}
);
}
/**
* Counts the displayed remote video thumbnails.
*/
async countVisibleThumbnails() {
return (await this.participant.driver.$$('//div[@id="remoteVideos"]//span[contains(@class,"videocontainer")]')
.filter(thumbnail => thumbnail.isDisplayed())).length;
}
/**
* Check if remote videos in filmstrip are visible.
*
* @param isDisplayed whether or not filmstrip remote videos should be visible
*/
verifyRemoteVideosDisplay(isDisplayed: boolean) {
return this.participant.driver.$('//div[contains(@class, "remote-videos")]/div').waitForDisplayed({
timeout: 5_000,
reverse: !isDisplayed,
});
}
/**
* Checks for visible gaps in the filmstrip thumbnails.
* This detects if there are any missing thumbnails or excessive spacing between consecutive visible thumbnails.
*
* @returns Returns true if gaps are detected, false otherwise.
*/
async hasGapsInFilmstrip(): Promise<boolean> {
return await this.participant.execute(() => {
// Get all visible thumbnail containers in the filmstrip
const thumbnails = Array.from(
document.querySelectorAll('#remoteVideos span.videocontainer')
).filter((thumb: any) => {
const style = window.getComputedStyle(thumb);
const rect = thumb.getBoundingClientRect();
// Check if element is visible and has dimensions
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
});
if (thumbnails.length < 2) {
// Can't have gaps with less than 2 thumbnails
return false;
}
// Get positions and calculated margins of all visible thumbnails
const positions = thumbnails.map((thumb: any) => {
const rect = thumb.getBoundingClientRect();
const style = window.getComputedStyle(thumb);
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
marginTop: parseFloat(style.marginTop) || 0,
marginBottom: parseFloat(style.marginBottom) || 0,
marginLeft: parseFloat(style.marginLeft) || 0,
marginRight: parseFloat(style.marginRight) || 0
};
});
// Calculate expected spacing between thumbnails based on first two
const firstGap = positions.length >= 2
? Math.abs(positions[1].top - positions[0].top) !== 0
? positions[1].top - positions[0].bottom // vertical
: positions[1].left - positions[0].right // horizontal
: 0;
// Check if filmstrip is vertical or horizontal
const isVertical = Math.abs(positions[1].top - positions[0].top) > Math.abs(positions[1].left - positions[0].left);
if (isVertical) {
// For vertical filmstrip, check vertical spacing consistency
for (let i = 0; i < positions.length - 1; i++) {
const current = positions[i];
const next = positions[i + 1];
const gap = next.top - current.bottom;
// Compare against the first gap with some tolerance
// Flag if gap is more than 2x the expected spacing
if (gap > Math.max(firstGap * 2, current.height * 0.3)) {
return true;
}
}
} else {
// For horizontal filmstrip, check horizontal spacing consistency
for (let i = 0; i < positions.length - 1; i++) {
const current = positions[i];
const next = positions[i + 1];
const gap = next.left - current.right;
// Compare against the first gap with some tolerance
// Flag if gap is more than 2x the expected spacing
if (gap > Math.max(firstGap * 2, current.width * 0.3)) {
return true;
}
}
}
return false;
});
}
/**
* Asserts that there are no gaps in the filmstrip.
* This is useful for detecting layout issues where thumbnails might be missing or mispositioned.
*
* @param reverse - If true, asserts that gaps should exist. Default false.
*/
async assertNoGapsInFilmstrip(reverse = false): Promise<void> {
const hasGaps = await this.hasGapsInFilmstrip();
const expectedResult = reverse ? true : false;
if (hasGaps !== expectedResult) {
throw new Error(
`Expected filmstrip to ${reverse ? 'have' : 'not have'} gaps, but ${
hasGaps ? 'gaps were detected' : 'no gaps were found'
}`
);
}
}
}

View File

@@ -0,0 +1,139 @@
import { LOG_PREFIX } from '../helpers/browserLogger';
import BasePageObject from './BasePageObject';
/**
* The Iframe API and helpers from iframeAPITest.html
*/
export default class IframeAPI extends BasePageObject {
/**
* Returns the json object from the iframeAPI helper.
* @param event
*/
getEventResult(event: string): Promise<any> {
return this.participant.execute(
eventName => {
const result = window.jitsiAPI.test[eventName];
if (!result) {
return false;
}
return result;
}, event);
}
/**
* Clears the history of an event.
* @param event
*/
clearEventResults(event: string) {
return this.participant.execute(
eventName => {
window.jitsiAPI.test[eventName] = undefined;
}, event);
}
/**
* Adds an event listener to the iframeAPI.
* @param eventName The event name.
*/
addEventListener(eventName: string) {
return this.participant.execute(
(event, prefix) => {
// we want to add it once as we use static .test[event] to store the last event
if (window.jitsiAPI.listenerCount(event) > 0) {
return;
}
console.log(`${new Date().toISOString()} ${prefix}iframeAPI - Adding listener for event: ${event}`);
window.jitsiAPI.addListener(event, evt => {
console.log(
`${new Date().toISOString()} ${prefix}iframeAPI - Received ${event} event: ${JSON.stringify(evt)}`);
window.jitsiAPI.test[event] = evt;
});
}, eventName, LOG_PREFIX);
}
/**
* Returns an array of available rooms and details of it.
*/
getRoomsInfo() {
return this.participant.execute(() => window.jitsiAPI.getRoomsInfo());
}
/**
* Returns the number of participants in the conference.
*/
getNumberOfParticipants() {
return this.participant.execute(() => window.jitsiAPI.getNumberOfParticipants());
}
/**
* Executes command using iframeAPI.
* @param command The command.
* @param args The arguments.
*/
executeCommand(command: string, ...args: any[]) {
return this.participant.execute(
(commandName, commandArgs) =>
window.jitsiAPI.executeCommand(commandName, ...commandArgs)
, command, args);
}
/**
* Returns the current state of the participant's pane.
*/
isParticipantsPaneOpen() {
return this.participant.execute(() => window.jitsiAPI.isParticipantsPaneOpen());
}
/**
* Removes the embedded Jitsi Meet conference.
*/
dispose() {
return this.participant.execute(() => window.jitsiAPI.dispose());
}
/**
* Invite the given participant to the meeting via PSTN.
*/
invitePhone(value: string) {
return this.participant.execute(v => window.jitsiAPI.invite([ {
type: 'phone',
number: v
} ]), value);
}
/**
* Invite the given participant to the meeting via sip (sip jibri).
*/
inviteSIP(value: string) {
return this.participant.execute(v => window.jitsiAPI.invite([ {
type: 'sip',
address: v
} ]), value);
}
/**
* Starts a file recording or streaming session.
* @param options
*/
startRecording(options: any) {
return this.participant.execute(o => window.jitsiAPI.startRecording(o), options);
}
/**
* Stop a file recording or streaming session.
* @param mode
*/
stopRecording(mode: string) {
return this.participant.execute(m => window.jitsiAPI.stopRecording(m), mode);
}
/**
* Returns the live-streaming url.
*/
async getLivestreamUrl() {
return this.participant.execute(() => window.jitsiAPI.getLivestreamUrl());
}
}

View File

@@ -0,0 +1,104 @@
import BaseDialog from './BaseDialog';
const CONFERENCE_ID = 'conference-id';
const CONFERENCE_URL = 'invite-more-dialog-conference-url';
const DIALOG_CONTAINER = 'invite-more-dialog';
const MORE_NUMBERS = 'more-numbers';
const PHONE_NUMBER = 'phone-number';
/**
* Represents the invite dialog in a particular participant.
*/
export default class InviteDialog extends BaseDialog {
/**
* Checks if the dialog is open.
*/
isOpen() {
return this.participant.driver.$(`.${DIALOG_CONTAINER}`).isExisting();
}
/**
* Open the invite dialog, if the info dialog is closed.
*/
async open() {
if (await this.isOpen()) {
return;
}
await this.participant.getParticipantsPane().clickInvite();
}
/**
* Returns the PIN for the conference.
*/
async getPinNumber() {
await this.open();
return (await this.getValueAfterColon(CONFERENCE_ID)).replace(/[# ]/g, '');
}
/**
* Private helper to get values after colons. The invite dialog lists conference specific information
* after a label, followed by a colon.
*
* @param className
* @private
*/
private async getValueAfterColon(className: string) {
const elem = this.participant.driver.$(`.${className}`);
await elem.waitForExist({ timeout: 5000 });
const fullText = await elem.getText();
await this.participant.log(`Extracted text in invite dialog: ${fullText}`);
return fullText.split(':')[1].trim();
}
/**
* Returns the meeting url displayed in the dialog.
*/
async getMeetingURL() {
const elem = this.participant.driver.$(`.${CONFERENCE_URL}`);
await elem.waitForExist({
timeout: 5000
});
return (await elem.getText())?.trim();
}
/**
* Waits for the dialog to be open or closed.
* @param reverse
*/
async waitTillOpen(reverse = false) {
await this.participant.driver.waitUntil(
/* eslint-disable no-extra-parens */
async () => (reverse ? !await this.isOpen() : await this.isOpen()),
{
timeout: 2_000,
timeoutMsg: `invite dialog did not ${reverse ? 'close' : 'open'}`
}
);
}
/**
* Gets the string that contains the dial in number for the current conference.
*/
getDialInNumber() {
return this.getValueAfterColon(PHONE_NUMBER);
}
/**
* Clicks the link to open a page to show all available dial in numbers.
*/
async openDialInNumbersPage() {
const moreNumbers = this.participant.driver.$(`.${MORE_NUMBERS}`);
await moreNumbers.waitForExist();
await moreNumbers.waitForClickable();
await moreNumbers.click();
}
}

View File

@@ -0,0 +1,80 @@
import BasePageObject from './BasePageObject';
/**
* The large video.
*/
export default class LargeVideo extends BasePageObject {
/**
* Returns the elapsed time at which video has been playing.
*
* @return {number} - The current play time of the video element.
*/
async getPlaytime() {
return this.participant.driver.$('#largeVideo').getProperty('currentTime');
}
/**
* Waits 5s for the large video to switch to passed endpoint id.
*
* @param {string} endpointId - The endpoint.
* @returns {Promise<void>}
*/
waitForSwitchTo(endpointId: string): Promise<void> {
return this.participant.driver.waitUntil(async () => endpointId === await this.getResource(), {
timeout: 5_000,
timeoutMsg: `expected large video to switch to ${endpointId} for 5s`
});
}
/**
* Gets avatar SRC attribute for the one displayed on large video.
*/
async getAvatar() {
const avatar = this.participant.driver.$('//img[@id="dominantSpeakerAvatar"]');
return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
}
/**
* Returns resource part of the JID of the user who is currently displayed in the large video area.
*/
getResource() {
return this.participant.execute(() => APP?.UI?.getLargeVideoID());
}
/**
* Returns the source of the large video currently shown.
*/
getId() {
return this.participant.execute(() => document.getElementById('largeVideo')?.srcObject?.id);
}
/**
* Checks if the large video is playing or not.
*
* @returns {Promise<void>}
*/
assertPlaying() {
let lastTime: number;
return this.participant.driver.waitUntil(async () => {
const currentTime = parseFloat(await this.getPlaytime());
if (typeof lastTime === 'undefined') {
lastTime = currentTime;
}
if (currentTime > lastTime) {
return true;
}
lastTime = currentTime;
return false;
}, {
timeout: 5_500,
interval: 500,
timeoutMsg:
`Expected large video for participant ${this.participant.name} to play but it didn't for more than 5s`
});
}
}

View File

@@ -0,0 +1,30 @@
import PreMeetingScreen from './PreMeetingScreen';
const DISPLAY_NAME_TEST_ID = 'lobby.nameField';
const JOIN_BUTTON_TEST_ID = 'lobby.knockButton';
/**
* Page object for the Lobby screen.
*/
export default class LobbyScreen extends PreMeetingScreen {
/**
* Returns the join button element.
*/
getJoinButton(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${JOIN_BUTTON_TEST_ID}"]`);
}
/**
* Returns the display name input element.
*/
getDisplayNameInput(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${DISPLAY_NAME_TEST_ID}"]`);
}
/**
* Waits for lobby screen to load.
*/
waitForLoading(): Promise<void> {
return this.participant.driver.$('.lobby-screen').waitForDisplayed({ timeout: 6000 });
}
}

View File

@@ -0,0 +1,296 @@
import BasePageObject from './BasePageObject';
const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute';
const AV_MODERATION_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectTitle';
const AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectVideoTitle';
const JOIN_MULTIPLE_TEST_ID = 'notify.connectedThreePlusMembers';
const JOIN_ONE_TEST_ID = 'notify.connectedOneMember';
const JOIN_TWO_TEST_ID = 'notify.connectedTwoMembers';
const LOBBY_ACCESS_DENIED_TEST_ID = 'lobby.joinRejectedMessage';
const LOBBY_ENABLED_TEST_ID = 'lobby.notificationLobbyEnabled';
const LOBBY_KNOCKING_PARTICIPANT_NOTIFICATION_XPATH
= '//div[@data-testid="notify.participantWantsToJoin"]/div/div/span';
const LOBBY_NOTIFICATIONS_TITLE_TEST_ID = 'lobby.notificationTitle';
const LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID = 'lobby.notificationLobbyAccessDenied';
const LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID = 'lobby.notificationLobbyAccessGranted';
const LOBBY_PARTICIPANT_ADMIT_TEST_ID = 'participantsPane.actions.admit';
const LOBBY_PARTICIPANT_REJECT_TEST_ID = 'participantsPane.actions.reject';
const RAISE_HAND_NOTIFICATION_ID = 'notify.raisedHand';
const REENABLE_SELF_VIEW_CLOSE_NOTIFICATION = 'notify.selfViewTitle-dismiss';
const REENABLE_SELF_VIEW_NOTIFICATION_ID = 'notify.selfViewTitle';
const YOU_ARE_MUTED_TEST_ID = 'notify.mutedTitle';
export const MAX_USERS_TEST_ID = 'dialog.maxUsersLimitReached';
export const TOKEN_AUTH_FAILED_TEST_ID = 'dialog.tokenAuthFailed';
export const TOKEN_AUTH_FAILED_TITLE_TEST_ID = 'dialog.tokenAuthFailedTitle';
/**
* Gathers all notifications logic in the UI and obtaining those.
*/
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.
*/
async waitForRaisedHandNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${RAISE_HAND_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* The notification on participants page when the moderator asks to unmute.
*/
async waitForAskToUnmuteNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${ASK_TO_UNMUTE_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Closes the muted notification.
*/
async closeAVModerationMutedNotification(skipNonExisting = false) {
return this.closeNotification(AV_MODERATION_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the video muted notification.
*/
async closeAVModerationVideoMutedNotification(skipNonExisting = false) {
return this.closeNotification(AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the ask to unmute notification.
*/
async closeAskToUnmuteNotification(skipNonExisting = false) {
return this.closeNotification(ASK_TO_UNMUTE_NOTIFICATION_ID, skipNonExisting);
}
/**
* Dismisses any join notifications.
*/
dismissAnyJoinNotification() {
return Promise.allSettled(
[ `${JOIN_ONE_TEST_ID}-dismiss`, `${JOIN_TWO_TEST_ID}-dismiss`, `${JOIN_MULTIPLE_TEST_ID}-dismiss` ]
.map(id => this.participant.driver.$(`#${id}"]`).click()));
}
/**
* Waits for the self view notification to be displayed.
*/
async waitForReEnableSelfViewNotification() {
const el
= this.participant.driver.$(`div[data-testid="${REENABLE_SELF_VIEW_NOTIFICATION_ID}"]`);
await el.waitForExist({ timeout: 2000 });
await el.waitForDisplayed();
}
/**
* Closes the self view notification.
*/
closeReEnableSelfViewNotification() {
return this.participant.driver.$(`div[data-testid="${REENABLE_SELF_VIEW_CLOSE_NOTIFICATION}"]`).click();
}
/**
* The notification on participants page when Lobby is being enabled or disabled.
*/
getLobbyEnabledText() {
return this.waitForNotificationText(LOBBY_ENABLED_TEST_ID);
}
/**
* Closes a specific lobby notification.
* @param testId
* @param skipNonExisting
* @private
*/
private async closeNotification(testId: string, skipNonExisting = false) {
const closeButton = this.participant.driver.$(`[data-testid="${testId}"] #close-notification`);
try {
if (skipNonExisting && !await closeButton.isExisting()) {
return Promise.resolve();
}
await closeButton.moveTo();
await closeButton.click();
} catch (e) {
console.error(`Error closing notification ${testId}`, e);
if (!skipNonExisting) {
throw e;
}
}
}
/**
* Closes a specific lobby notification.
* @param testId
* @private
*/
private async closeLobbyNotification(testId: string) {
const notification = this.participant.driver.$(`[data-testid="${testId}"]`);
await notification.waitForExist();
await notification.waitForStable();
const closeButton
= this.participant.driver.$(`[data-testid="${LOBBY_NOTIFICATIONS_TITLE_TEST_ID}"]`)
.$('#close-notification');
await closeButton.moveTo();
await closeButton.click();
}
/**
* Closes the notification.
*/
closeLobbyEnabled() {
return this.closeLobbyNotification(LOBBY_ENABLED_TEST_ID);
}
/**
* Returns the name of the knocking participant (the only one) that is displayed on the notification.
*/
async getKnockingParticipantName() {
const knockingParticipantNotification
= this.participant.driver.$('//div[@data-testid="notify.participantWantsToJoin"]/div/div/span');
await knockingParticipantNotification.waitForDisplayed({
timeout: 15000,
timeoutMsg: 'Knocking participant notification not displayed'
});
return await knockingParticipantNotification.getText();
}
/**
* Admits the last knocking participant (it is the only one).
*/
async allowLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_ADMIT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification that someone's access was approved.
*/
getLobbyParticipantAccessGranted() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessGranted() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Waits until a notifications with the given testId is found and returns its text.
* @return the notification text.
*/
private async waitForNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
await notificationElement.waitForExist({ timeout: 2_000 });
return await notificationElement.getText();
}
/**
* Gets the text from the notification with the given testId, if the notification exists (otherwise returns
* undefined).
* @param testId
* @return the notification text.
*/
async getNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
if (await notificationElement.isExisting()) {
return await notificationElement.getText();
}
}
/**
* Rejects the last knocking participant (it is the only one).
*/
async rejectLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_REJECT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification test that someone's access was denied.
*/
getLobbyParticipantAccessDenied() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessDenied() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Waits for the notification for access denied for entering the lobby is shown.
*/
async waitForLobbyAccessDeniedNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${LOBBY_ACCESS_DENIED_TEST_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Will wait 3 seconds for the knocking participants to disappear and return true or will return false.
* @return <tt>true</tt> if the knocking participants list was not displayed.
*/
waitForHideOfKnockingParticipants() {
return this.participant.driver.$(LOBBY_KNOCKING_PARTICIPANT_NOTIFICATION_XPATH)
.waitForDisplayed({
timeout: 3000,
reverse: true
});
}
/**
* Closes local notification, for the participant that was denied.
*/
async closeLocalLobbyAccessDenied() {
await this.participant.driver.$('[data-testid="lobby.joinRejectedMessage"').waitForExist();
const dismissButton
= this.participant.driver.$('[data-testid="lobby.joinRejectedTitle-dismiss"]');
await dismissButton.moveTo();
await dismissButton.click();
}
/**
* Closes the `you are muted` notification.
*/
async closeYouAreMutedNotification() {
return this.closeNotification(YOU_ARE_MUTED_TEST_ID, true);
}
}

View File

@@ -0,0 +1,308 @@
import { ChainablePromiseElement } from 'webdriverio';
import { Participant } from '../helpers/Participant';
import AVModerationMenu from './AVModerationMenu';
import BasePageObject from './BasePageObject';
/**
* ID of the closed/hidden participants pane
*/
const PARTICIPANTS_PANE = 'participants-pane';
const INVITE = 'Invite someone';
/**
* Represents the participants pane from the UI.
*/
export default class ParticipantsPane extends BasePageObject {
/**
* Gets the audio video moderation menu.
*/
getAVModerationMenu() {
return new AVModerationMenu(this.participant);
}
/**
* Checks if the pane is open.
*/
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();
const pane = this.participant.driver.$(`#${PARTICIPANTS_PANE}`);
await pane.waitForExist();
await pane.waitForStable();
await 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 });
}
/**
* 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<void>}
*/
async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
const isOpen = await this.isOpen();
if (!isOpen) {
await this.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} at ${
this.participant.name} side.`
});
if (!isOpen) {
await this.close();
}
}
/**
* 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<void>}
*/
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
const isOpen = await this.isOpen();
if (!isOpen) {
await this.open();
}
const id = `participant-item-${await testee.getEndpointId()}`;
const mutedIconXPath
= `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='audioMuted']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name} at ${
this.participant.name} side.`
});
if (!isOpen) {
await this.close();
}
}
/**
* Clicks the context menu button in the participants pane.
*/
async clickContextMenuButton() {
if (!await this.isOpen()) {
await this.open();
}
const menu = this.participant.driver.$('#participants-pane-context-menu');
await menu.waitForDisplayed();
await menu.click();
}
/**
* Trys to click allow video button.
* @param participantToUnmute
*/
async allowVideo(participantToUnmute: Participant) {
if (!await this.isOpen()) {
await this.open();
}
const participantId = await participantToUnmute.getEndpointId();
await this.selectParticipant(participantToUnmute);
await this.openParticipantContextMenu(participantToUnmute);
const unmuteButton = this.participant.driver
.$(`[data-testid="unmute-video-${participantId}"]`);
await unmuteButton.waitForExist();
await unmuteButton.click();
}
/**
* Trys to click ask to unmute button.
* @param participantToUnmute
* @param fromContextMenu
*/
async askToUnmute(participantToUnmute: Participant, fromContextMenu: boolean) {
if (!await this.isOpen()) {
await this.open();
}
await this.participant.getNotifications().dismissAnyJoinNotification();
const participantId = await participantToUnmute.getEndpointId();
await this.selectParticipant(participantToUnmute);
if (fromContextMenu) {
await this.openParticipantContextMenu(participantToUnmute);
}
const unmuteButton = this.participant.driver
.$(`[data-testid="unmute-audio-${participantId}"]`);
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();
// The button is only revealed (moved into view) while its participant row is actually hovered.
// waitForDisplayed/waitForStable above do not guarantee that, so wait for real interactability
// right before clicking, retrying the hover if a re-render dropped it in the meantime.
await meetingParticipantMoreOptions.waitForClickable();
await meetingParticipantMoreOptions.click();
}
/**
* Clicks the invite button.
*/
async clickInvite() {
if (!await this.isOpen()) {
await this.open();
}
const inviteButton = this.participant.driver.$(`button=${INVITE}`);
await inviteButton.waitForDisplayed();
await inviteButton.click();
}
/**
* Find the participant by name.
* @param name - The name to look for.
* @private
*/
private async findLobbyParticipantByName(name: string): Promise<ChainablePromiseElement> {
return this.participant.driver.$$('//div[@id="lobby-list"]//div[starts-with(@id, "participant-item-")]')
.find(async participant => (await participant.getText()).includes(name));
}
/**
* Tries to click on the approve button and fails if it cannot be clicked.
* @param participantNameToAdmit - the name of the participant to admit.
*/
async admitLobbyParticipant(participantNameToAdmit: string) {
const participantToAdmit = await this.findLobbyParticipantByName(participantNameToAdmit);
await participantToAdmit.moveTo();
const participantIdToAdmit = (await participantToAdmit.getAttribute('id'))
.substring('participant-item-'.length);
const admitButton = this.participant.driver
.$(`[data-testid="admit-${participantIdToAdmit}"]`);
await admitButton.waitForExist();
await admitButton.click();
}
/**
* Tries to click on the reject button and fails if it cannot be clicked.
* @param participantNameToReject - the name of the participant for this {@link ParticipantsPane} to reject.
*/
async rejectLobbyParticipant(participantNameToReject: string) {
const participantToReject
= await this.findLobbyParticipantByName(participantNameToReject);
await participantToReject.moveTo();
const participantIdToReject = (await participantToReject.getAttribute('id'))
.substring('participant-item-'.length);
const moreOptionsButton
= this.participant.driver.$(`button[title="More moderation options ${participantNameToReject}"]`);
await moreOptionsButton.click();
const rejectButton = this.participant.driver
.$(`[data-testid="reject-${participantIdToReject}"]`);
await rejectButton.waitForExist();
await rejectButton.click();
}
/**
* Waits for the mute audio action of a participant to be available.
* @param participant
*/
async waitForMuteAudioAction(participant: Participant) {
if (!await this.isOpen()) {
await this.open();
}
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).waitForDisplayed();
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).click();
}
}

View File

@@ -0,0 +1,51 @@
import BaseDialog from './BaseDialog';
const INPUT_KEY_XPATH = '//input[@name="lockKey"]';
/**
* Represents the password dialog in a particular participant.
*/
export default class PasswordDialog extends BaseDialog {
/**
* Waiting for the dialog to appear.
*/
async waitForDialog() {
const input = this.participant.driver.$(INPUT_KEY_XPATH);
await input.waitForExist({
timeout: 5000,
timeoutMsg: 'Password dialog not found'
});
await input.waitForDisplayed();
await input.waitForStable();
}
async isOpen() {
const input = this.participant.driver.$(INPUT_KEY_XPATH);
try {
await input.isExisting();
return await input.isDisplayed();
} catch (e) {
return false;
}
}
/**
* Sets a password and submits the dialog.
* @param password
*/
async submitPassword(password: string) {
const passwordInput = this.participant.driver.$(INPUT_KEY_XPATH);
await passwordInput.waitForExist();
await passwordInput.waitForStable();
await passwordInput.click();
await passwordInput.clearValue();
await this.participant.driver.keys(password);
await this.clickOkButton();
}
}

View File

@@ -0,0 +1,55 @@
import PreMeetingScreen from './PreMeetingScreen';
const DISPLAY_NAME_ID = 'premeeting-name-input';
const ERROR_ON_JOIN = 'prejoin.errorMessage';
const JOIN_BUTTON_TEST_ID = 'prejoin.joinMeeting';
const JOIN_WITHOUT_AUDIO = 'prejoin.joinWithoutAudio';
const OPTIONS_BUTTON = 'prejoin.joinOptions';
/**
* Page object for the PreJoin screen.
*/
export default class PreJoinScreen extends PreMeetingScreen {
/**
* Returns the join button element.
*/
getJoinButton(): ChainablePromiseElement {
return this.participant.driver.$(`[data-testid="${JOIN_BUTTON_TEST_ID}"]`);
}
/**
* Returns the display name input element.
*/
getDisplayNameInput(): ChainablePromiseElement {
return this.participant.driver.$(`#${DISPLAY_NAME_ID}`);
}
/**
* Waits for pre join screen to load.
*/
waitForLoading(): Promise<void> {
return this.participant.driver.$('[data-testid="prejoin.screen"]')
.waitForDisplayed({ timeout: 3000 });
}
/**
* Returns the error message displayed on the prejoin screen.
*/
getErrorOnJoin() {
return this.participant.driver.$(`[data-testid="${ERROR_ON_JOIN}"]`);
}
/**
* Returns the join without audio button element.
*/
getJoinWithoutAudioButton() {
return this.participant.driver.$(`[data-testid="${JOIN_WITHOUT_AUDIO}"]`);
}
/**
* Returns the join options button element.
*/
getJoinOptions() {
return this.participant.driver.$(`[data-testid="${OPTIONS_BUTTON}"]`);
}
}

View File

@@ -0,0 +1,90 @@
import BasePageObject from './BasePageObject';
const PASSWORD_BUTTON_TEST_ID = 'lobby.enterPasswordButton';
/**
* Page object for the PreMeeting screen, common stuff between pre-join and lobby screens.
*/
export default abstract class PreMeetingScreen extends BasePageObject {
/**
* Waits for pre join or lobby screen to load.
*/
abstract waitForLoading(): Promise<void>;
/**
* Returns the display name input element.
*/
abstract getDisplayNameInput(): WebdriverIO.Element;
/**
* Returns the join button element.
*/
abstract getJoinButton(): WebdriverIO.Element;
/**
* Interacts with the view to enter a display name.
*/
async enterDisplayName(displayName: string) {
const displayNameInput = this.getDisplayNameInput();
await displayNameInput.click();
// element.clear does not always work, make sure we delete the content
await displayNameInput.clearValue();
await this.participant.driver.keys(displayName);
}
/**
* Checks internally whether lobby room is joined.
*
* @returns {Promise<boolean>}
*/
waitToJoinLobby(): Promise<boolean> {
return this.participant.driver.waitUntil(
() => this.isLobbyRoomJoined(),
{
timeout: 15_000, // 15 seconds
timeoutMsg: `Timeout waiting to join lobby for ${this.participant.name}`
}
);
}
/**
* Checks internally whether lobby room is joined.
*/
isLobbyRoomJoined() {
return this.participant.execute(
() => APP?.conference?._room?.room?.getLobby()?.lobbyRoom?.joined === true);
}
/**
* Returns the password button element.
*/
getPasswordButton() {
return this.participant.driver.$(`[data-testid="${PASSWORD_BUTTON_TEST_ID}"]`);
}
/**
* Interacts with the view to enter a password.
*/
async enterPassword(password: string) {
const passwordButton = this.getPasswordButton();
await passwordButton.moveTo();
await passwordButton.click();
const passwordInput = this.participant.driver.$('[data-testid="lobby.password"]');
await passwordInput.waitForDisplayed();
await passwordInput.click();
await passwordInput.clearValue();
await this.participant.driver.keys(password);
const joinButton = this.participant.driver.$('[data-testid="lobby.passwordJoinButton"]');
await joinButton.waitForDisplayed();
await joinButton.click();
}
}

View File

@@ -0,0 +1,342 @@
import BasePageObject from './BasePageObject';
const DIALOG_TITLE = '#dialog-title';
const CLOSE_BUTTON = '#modal-header-close-button';
const START_RECORDING = '[data-testid="recordingDialog.startRecording"]';
const STOP_RECORDING = '[data-testid="recordingDialog.stopRecording"]';
const START_TRANSCRIPTION = '[data-testid="recordingDialog.startTranscription"]';
const STOP_TRANSCRIPTION = '[data-testid="recordingDialog.stopTranscription"]';
const START_BOTH = '[data-testid="recordingDialog.startBoth"]';
const STOP_BOTH = '[data-testid="recordingDialog.stopBoth"]';
const RECORDING_OPTIONS_HEADER = '#recording-options';
const RECORDING_OPTIONS_BODY = '#recording-options-content';
const TRANSCRIPTION_OPTIONS_HEADER = '#transcription-options';
const SERVICE_SELECT = '#recording-service-select';
const SERVICE_SELECT_MENU = '#recording-service-select-menu';
const LANGUAGE_SELECT = '#transcription-language-select';
const LANGUAGE_SELECT_MENU = '#transcription-language-select-menu';
const FOLLOW_ME_RECORDER_SWITCH = '#recording-switch-follow-me';
const FILE_SHARING_SWITCH = '#recording-switch-share';
const LOCAL_RECORDING_ONLY_SELF_SWITCH = '#recording-switch-myself';
/**
* Page object for the unified Recording & Transcription dialog: two sections
* (audio & video recording, transcription), each with its own immediate
* start/stop button and a collapsible options accordion, plus footer buttons
* acting on both services at once.
*/
export default class RecordingTranscriptionDialog extends BasePageObject {
/**
* Waits for the dialog to be displayed.
*/
async waitForDisplay(): Promise<void> {
await this.participant.driver.$(DIALOG_TITLE).waitForExist({
timeout: 5000,
timeoutMsg: 'Recording & Transcription dialog did not appear'
});
}
/**
* Returns whether the dialog is currently open.
*/
async isDisplayed(): Promise<boolean> {
return this.participant.driver.$(DIALOG_TITLE).isExisting();
}
/**
* Returns the dialog title text.
*/
getTitle(): Promise<string> {
return this.participant.driver.$(DIALOG_TITLE).getText();
}
/**
* Whether the recording section currently offers a Start button, i.e.
* recording is not running.
*/
hasStartRecordingButton(): Promise<boolean> {
return this.participant.driver.$(START_RECORDING).isExisting();
}
/**
* Whether the recording section currently offers a Stop button, i.e.
* recording is running.
*/
hasStopRecordingButton(): Promise<boolean> {
return this.participant.driver.$(STOP_RECORDING).isExisting();
}
/**
* Whether the transcription section currently offers a Start button.
*/
hasStartTranscriptionButton(): Promise<boolean> {
return this.participant.driver.$(START_TRANSCRIPTION).isExisting();
}
/**
* Whether the transcription section currently offers a Stop button.
*/
hasStopTranscriptionButton(): Promise<boolean> {
return this.participant.driver.$(STOP_TRANSCRIPTION).isExisting();
}
/**
* Whether the footer shows the "Start both" button.
*/
hasStartBothButton(): Promise<boolean> {
return this.participant.driver.$(START_BOTH).isExisting();
}
/**
* Whether the footer shows the "Stop both" button.
*/
hasStopBothButton(): Promise<boolean> {
return this.participant.driver.$(STOP_BOTH).isExisting();
}
/**
* Whether the recording section Start button is enabled (a usable storage
* service is selected).
*/
isStartRecordingEnabled(): Promise<boolean> {
return this.participant.driver.$(START_RECORDING).isEnabled();
}
/**
* Whether the footer "Start both" button is enabled.
*/
isStartBothEnabled(): Promise<boolean> {
return this.participant.driver.$(START_BOTH).isEnabled();
}
/**
* Starts the audio & video recording. The action applies immediately and
* closes the dialog.
*/
async startRecording(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start recording');
return this.participant.driver.$(START_RECORDING).click();
}
/**
* Stops the audio & video recording. The action applies immediately and
* closes the dialog.
*/
async stopRecording(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop recording');
return this.participant.driver.$(STOP_RECORDING).click();
}
/**
* Starts the transcription. The action applies immediately and closes the
* dialog.
*/
async startTranscription(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start transcription');
return this.participant.driver.$(START_TRANSCRIPTION).click();
}
/**
* Stops the transcription. The action applies immediately and closes the
* dialog.
*/
async stopTranscription(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop transcription');
return this.participant.driver.$(STOP_TRANSCRIPTION).click();
}
/**
* Starts every service which is not running yet.
*/
async startBoth(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking start both');
return this.participant.driver.$(START_BOTH).click();
}
/**
* Stops every running service.
*/
async stopBoth(): Promise<void> {
await this.participant.log('RecordingTranscriptionDialog: clicking stop both');
return this.participant.driver.$(STOP_BOTH).click();
}
/**
* Whether the recording options accordion exists.
*/
hasRecordingOptions(): Promise<boolean> {
return this.participant.driver.$(RECORDING_OPTIONS_HEADER).isExisting();
}
/**
* Whether the transcription options accordion exists.
*/
hasTranscriptionOptions(): Promise<boolean> {
return this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER).isExisting();
}
/**
* Whether the recording options accordion is expanded.
*/
async isRecordingOptionsExpanded(): Promise<boolean> {
return await this.participant.driver.$(RECORDING_OPTIONS_HEADER)
.getAttribute('aria-expanded') === 'true';
}
/**
* Whether the transcription options accordion is expanded.
*/
async isTranscriptionOptionsExpanded(): Promise<boolean> {
return await this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER)
.getAttribute('aria-expanded') === 'true';
}
/**
* Expands/collapses the recording options accordion.
*/
toggleRecordingOptions(): Promise<void> {
return this.participant.driver.$(RECORDING_OPTIONS_HEADER).click();
}
/**
* Expands/collapses the transcription options accordion.
*/
toggleTranscriptionOptions(): Promise<void> {
return this.participant.driver.$(TRANSCRIPTION_OPTIONS_HEADER).click();
}
/**
* Whether the storage service dropdown is visible (recording options
* expanded).
*/
hasServiceSelect(): Promise<boolean> {
return this.participant.driver.$(SERVICE_SELECT).isExisting();
}
/**
* Whether the transcription language dropdown is visible (transcription
* options expanded).
*/
hasLanguageSelect(): Promise<boolean> {
return this.participant.driver.$(LANGUAGE_SELECT).isExisting();
}
/**
* The currently selected storage service, as displayed on the dropdown
* trigger.
*/
getSelectedService(): Promise<string> {
return this.participant.driver.$(SERVICE_SELECT).getText();
}
/**
* The currently selected transcription language, as displayed on the
* dropdown trigger.
*/
getSelectedLanguage(): Promise<string> {
return this.participant.driver.$(LANGUAGE_SELECT).getText();
}
/**
* Picks a storage service by its visible label (e.g. "Recording service",
* "Dropbox", "Local recording"). The recording options accordion must be
* expanded.
*/
selectService(label: string): Promise<void> {
return this._selectDropdownOption(SERVICE_SELECT, SERVICE_SELECT_MENU, label);
}
/**
* Picks a transcription language by its visible label (e.g. "English").
* The transcription options accordion must be expanded.
*/
selectLanguage(label: string): Promise<void> {
return this._selectDropdownOption(LANGUAGE_SELECT, LANGUAGE_SELECT_MENU, label);
}
/**
* Whether the "Recorder follows me" switch is shown (moderator with a
* cloud based service selected).
*/
hasFollowMeRecorderSwitch(): Promise<boolean> {
return this.participant.driver.$(FOLLOW_ME_RECORDER_SWITCH).isExisting();
}
/**
* Whether the "Recorder follows me" switch is enabled (it locks while a
* recording is in progress).
*/
isFollowMeRecorderEnabled(): Promise<boolean> {
return this.participant.driver.$(FOLLOW_ME_RECORDER_SWITCH).isEnabled();
}
/**
* Whether the "Share the recording link" switch is shown (Jitsi
* recording service selected and sharing is enabled server-side).
*/
hasFileSharingSwitch(): Promise<boolean> {
return this.participant.driver.$(FILE_SHARING_SWITCH).isExisting();
}
/**
* Whether the "Record only my audio and video streams" switch is shown
* (local recording selected).
*/
hasLocalRecordingOnlySelfSwitch(): Promise<boolean> {
return this.participant.driver.$(LOCAL_RECORDING_ONLY_SELF_SWITCH).isExisting();
}
/**
* Toggles the "Record only my audio and video streams" switch.
*/
toggleLocalRecordingOnlySelfSwitch(): Promise<void> {
return this.participant.driver.$(LOCAL_RECORDING_ONLY_SELF_SWITCH).click();
}
/**
* The text content of the (expanded) recording options accordion body,
* e.g. to check for the local-recording warning texts.
*/
getRecordingOptionsBodyText(): Promise<string> {
return this.participant.driver.$(RECORDING_OPTIONS_BODY).getText();
}
/**
* Clicks the close (X) button to dismiss the dialog.
*/
cancel(): Promise<void> {
return this.participant.driver.$(CLOSE_BUTTON).click();
}
/**
* Opens the given dropdown and picks the option with the given visible
* label.
*/
private async _selectDropdownOption(
triggerSelector: string, menuSelector: string, optionLabel: string): Promise<void> {
const driver = this.participant.driver;
await driver.$(triggerSelector).click();
await driver.$(menuSelector).waitForExist({
timeout: 3000,
timeoutMsg: 'Dropdown menu did not open'
});
const option = driver.$(menuSelector).$(`[role="menuitem"][aria-label="${optionLabel}"]`);
await option.waitForExist({
timeout: 3000,
timeoutMsg: `Dropdown option "${optionLabel}" not found`
});
await option.click();
}
}

View File

@@ -0,0 +1,134 @@
import BaseDialog from './BaseDialog';
const ADD_PASSWORD_LINK = 'add-password';
const ADD_PASSWORD_FIELD = 'info-password-input';
const DIALOG_CONTAINER = 'security-dialog';
const LOCAL_LOCK = 'info-password-local';
const REMOTE_LOCK = 'info-password-remote';
const REMOVE_PASSWORD = 'remove-password';
/**
* Page object for the security dialog.
*/
export default class SecurityDialog extends BaseDialog {
/**
* Waits for the settings dialog to be visible.
*/
waitForDisplay() {
return this.participant.driver.$(`.${DIALOG_CONTAINER}`).waitForDisplayed();
}
/**
* Returns the switch that can be used to detect lobby state or change lobby state.
* @private
*/
private getLobbySwitch() {
return this.participant.driver.$('#lobby-section-switch');
}
/**
* Returns is the lobby enabled.
*/
isLobbyEnabled() {
return this.getLobbySwitch().isSelected();
}
/**
* Toggles the lobby option from the security dialog.
*/
async toggleLobby() {
const lobbySwitch = this.getLobbySwitch();
await lobbySwitch.moveTo();
await lobbySwitch.click();
}
/**
* Checks whether lobby section is present in the UI.
*/
isLobbySectionPresent() {
return this.getLobbySwitch().isExisting();
}
/**
* Waits for the lobby to be enabled or disabled.
* @param reverse
*/
waitForLobbyEnabled(reverse = false) {
const lobbySwitch = this.getLobbySwitch();
return this.participant.driver.waitUntil(
async () => await lobbySwitch.isSelected() !== reverse,
{
timeout: 5_000, // 30 seconds
timeoutMsg: `Timeout waiting for lobby being ${reverse ? 'disabled' : 'enabled'} for ${
this.participant.name}.`
}
);
}
/**
* Checks if the current conference is locked with a locally set password.
*
* @return {@code true} if the conference is displayed as locked locally in
* the security dialog, {@code false} otherwise.
*/
private isLockedLocally() {
return this.participant.driver.$(`.${LOCAL_LOCK}`).isExisting();
}
/**
* Checks if the current conference is locked with a locally set password.
*
* @return {@code true} if the conference is displayed as locked remotely
* in the security dialog, {@code false} otherwise.
*/
private isLockedRemotely() {
return this.participant.driver.$(`.${REMOTE_LOCK}`).isExisting();
}
/**
* Checks if the current conference is locked based on the security dialog's
* display state.
*
* @return {@code true} if the conference is displayed as locked in the
* security dialog, {@code false} otherwise.
*/
async isLocked() {
return await this.isLockedLocally() || await this.isLockedRemotely();
}
/**
* Sets a password on the current conference to lock it.
*
* @param password - The password to use to lock the conference.
*/
async addPassword(password: string) {
const addPasswordLink = this.participant.driver.$(`.${ADD_PASSWORD_LINK}`);
await addPasswordLink.waitForClickable();
await addPasswordLink.click();
const passwordEntry = this.participant.driver.$(`#${ADD_PASSWORD_FIELD}`);
await passwordEntry.waitForDisplayed();
await passwordEntry.click();
await this.participant.driver.keys(password);
await this.participant.driver.$('button=Add').click();
}
/**
* Removes the password from the current conference through the security dialog, if a password is set.
*/
async removePassword() {
if (!await this.isLocked()) {
return;
}
const removePassword = this.participant.driver.$(`.${REMOVE_PASSWORD}`);
await removePassword.waitForClickable();
await removePassword.click();
}
}

View File

@@ -0,0 +1,197 @@
import BaseDialog from './BaseDialog';
const EMAIL_FIELD = '#setEmail';
const CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX = '//input[@name="enable-ctrl-alt-reaction-shortcuts"]';
const FOLLOW_ME_CHECKBOX = '//input[@name="follow-me"]';
const HIDE_SELF_VIEW_CHECKBOX = '//input[@name="hide-self-view"]';
const SETTINGS_DIALOG_CONTENT = '.settings-pane';
const START_AUDIO_MUTED_CHECKBOX = '//input[@name="start-audio-muted"]';
const START_VIDEO_MUTED_CHECKBOX = '//input[@name="start-video-muted"]';
const X_PATH_MODERATOR_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Moderator"]';
const X_PATH_MORE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="General"]';
const X_PATH_PROFILE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Profile"]';
const X_PATH_SHORTCUTS_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Shortcuts"]';
/**
* The settings dialog.
*/
export default class SettingsDialog extends BaseDialog {
/**
* Waits for the settings dialog to be visible.
*/
waitForDisplay() {
return this.participant.driver.$(SETTINGS_DIALOG_CONTENT).waitForDisplayed();
}
/**
* Displays a specific tab in the settings dialog.
* @param xpath
* @private
*/
private async openTab(xpath: string) {
const elem = this.participant.driver.$(xpath);
await elem.waitForClickable();
await elem.click();
}
/**
* Selects the Profile tab to be displayed.
*/
openProfileTab() {
return this.openTab(X_PATH_PROFILE_TAB);
}
/**
* Selects the More tab to be displayed.
*/
openMoreTab() {
return this.openTab(X_PATH_MORE_TAB);
}
/**
* Selects the moderator tab to be displayed.
*/
openModeratorTab() {
return this.openTab(X_PATH_MODERATOR_TAB);
}
/**
* Selects the Shortcuts tab to be displayed.
*/
openShortcutsTab() {
return this.openTab(X_PATH_SHORTCUTS_TAB);
}
/**
* Returns whether the Firefox-only Ctrl+Alt reaction shortcut setting exists.
*/
hasCtrlAltReactionShortcutsSetting() {
return this.participant.driver.$(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX).isExisting();
}
/**
* Returns whether Ctrl+Alt reaction shortcut aliases are enabled.
*/
isCtrlAltReactionShortcutsEnabled() {
return this.participant.driver.$(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX).isSelected();
}
/**
* Enables or disables Ctrl+Alt reaction shortcut aliases.
*
* @param enable - Whether the aliases should be enabled.
*/
setCtrlAltReactionShortcutsEnabled(enable: boolean) {
return this.setCheckbox(CTRL_ALT_REACTION_SHORTCUTS_CHECKBOX, enable);
}
/**
* Returns the shortcut key labels currently displayed on the Shortcuts tab.
*/
async getShortcutKeyLabels() {
const elements = await this.participant.driver.$$('//ul/li/span[last()]/span');
return elements.map(element => element.getText());
}
/**
* Enters the passed in email into the email field.
* @param email
*/
async setEmail(email: string) {
await this.openProfileTab();
await this.participant.driver.$(EMAIL_FIELD).setValue(email);
}
/**
* Returns the participant's email displayed in the settings dialog.
*/
async getEmail() {
await this.openProfileTab();
return await this.participant.driver.$(EMAIL_FIELD).getValue();
}
/**
* Clicks the OK button on the settings dialog to close the dialog and save any changes made.
*/
submit() {
return this.clickOkButton();
}
/**
* Sets the start audio muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartAudioMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_AUDIO_MUTED_CHECKBOX, enable);
}
/**
* Sets the start video muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartVideoMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_VIDEO_MUTED_CHECKBOX, enable);
}
/**
* Sets the state checked/selected of a checkbox in the settings dialog.
*/
async setHideSelfView(hideSelfView: boolean) {
await this.openMoreTab();
await this.setCheckbox(HIDE_SELF_VIEW_CHECKBOX, hideSelfView);
}
/**
* Sets the follow me feature to enabled/disabled.
* @param enable
*/
async setFollowMe(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(FOLLOW_ME_CHECKBOX, enable);
}
/**
* Returns true if the follow me checkbox is displayed in the settings dialog.
*/
async isFollowMeDisplayed() {
const elem = this.participant.driver.$(X_PATH_MODERATOR_TAB);
if (!await elem.isExisting()) {
return false;
}
await this.openModeratorTab();
return await this.participant.driver.$$(FOLLOW_ME_CHECKBOX).length > 0;
}
/**
* Sets the state of a checkbox.
* @param selector
* @param enable
* @private
*/
private async setCheckbox(selector: string, enable: boolean) {
const checkbox = this.participant.driver.$(selector);
await checkbox.waitForExist();
if (enable !== await checkbox.isSelected()) {
// we show a div with svg and text after the input and those elements grab the click
// so we need to click on the parent element
await this.participant.driver.$(`${selector}//ancestor::div[1]`).click();
}
}
}

View 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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,139 @@
import BaseDialog from './BaseDialog';
const VB_DIALOG_CONTAINER = '#virtual-background-dialog';
const NONE_THUMBNAIL = '[aria-label="Remove background"]';
const SLIGHT_BLUR_THUMBNAIL = '[aria-label="Half Blur"]';
const BLUR_THUMBNAIL = '[aria-label="Blur"]';
/**
* Page object for the Virtual Background tab inside the settings dialog.
*/
export default class VirtualBackgroundDialog extends BaseDialog {
/**
* Waits for the virtual background dialog container to be displayed.
*/
async waitForDisplay(): Promise<void> {
await this.participant.driver.$(VB_DIALOG_CONTAINER).waitForDisplayed({
timeout: 5000,
timeoutMsg: 'Virtual background dialog not displayed'
});
}
/**
* Returns true if the dialog container is currently displayed.
*/
isDisplayed(): Promise<boolean> {
return this.participant.driver.$(VB_DIALOG_CONTAINER).isDisplayed();
}
/**
* Clicks a thumbnail and waits for it to become aria-checked.
* Ensures React has finished re-rendering before the next click, preventing
* rapid clicks from being swallowed during reconciliation.
*
* @param {string} selector - CSS selector for the thumbnail.
* @param {string} label - Human-readable label for logging.
*/
private async clickAndWaitChecked(selector: string, label: string): Promise<void> {
await this.participant.log(`VirtualBackgroundDialog: clicking ${label} thumbnail`);
const el = this.participant.driver.$(selector);
await el.waitForClickable({ timeout: 3000, timeoutMsg: `${label} thumbnail not clickable` });
// Selecting an effect makes the preview (re)load the segmentation model/wasm, which
// saturates the main thread. Two flaky failure modes follow from that:
// 1. A click issued while React is mid-reconciliation (e.g. during rapid switching) is
// dropped — the onClick handler never fires and the thumbnail never becomes checked.
// 2. The first selection of a blur effect triggers a cold model/worker init that blocks
// the main thread for several seconds, delaying the aria-checked re-render (and the
// attribute reads themselves) well past a short timeout.
// Re-issue the click on every poll until aria-checked confirms it landed (covers 1), and
// allow a generous timeout for the cold-start re-render under load (covers 2). The poll
// interval is kept large so a still-pending selection is not needlessly re-triggered.
await this.participant.driver.waitUntil(
async () => {
if (await el.getAttribute('aria-checked') === 'true') {
return true;
}
await el.click();
return false;
},
{
timeout: 15000,
interval: 1500,
timeoutMsg: `${label} thumbnail did not become checked after click`
}
);
}
/**
* Clicks the "Remove background" (None) thumbnail.
*/
clickNone(): Promise<void> {
return this.clickAndWaitChecked(NONE_THUMBNAIL, 'None');
}
/**
* Clicks the "Half Blur" (slight blur) thumbnail.
*/
clickSlightBlur(): Promise<void> {
return this.clickAndWaitChecked(SLIGHT_BLUR_THUMBNAIL, 'Slight Blur');
}
/**
* Clicks the "Blur" (full blur) thumbnail.
*/
clickBlur(): Promise<void> {
return this.clickAndWaitChecked(BLUR_THUMBNAIL, 'Blur');
}
/**
* Returns true if the given thumbnail element has aria-checked="true" (selected in the dialog).
*
* @param {string} selector - CSS selector for the thumbnail element.
*/
async isThumbnailChecked(selector: string): Promise<boolean> {
const el = this.participant.driver.$(selector);
await el.waitForExist({ timeout: 2000 });
return (await el.getAttribute('aria-checked')) === 'true';
}
/**
* Returns true if the None thumbnail is currently selected in the dialog.
*/
isNoneChecked(): Promise<boolean> {
return this.isThumbnailChecked(NONE_THUMBNAIL);
}
/**
* Returns true if the Slight Blur thumbnail is currently selected in the dialog.
*/
isSlightBlurChecked(): Promise<boolean> {
return this.isThumbnailChecked(SLIGHT_BLUR_THUMBNAIL);
}
/**
* Returns true if the Blur thumbnail is currently selected in the dialog.
*/
isBlurChecked(): Promise<boolean> {
return this.isThumbnailChecked(BLUR_THUMBNAIL);
}
/**
* Clicks OK to apply the selected background and close the dialog.
*/
confirm(): Promise<void> {
return this.clickOkButton();
}
/**
* Clicks the X button to cancel without applying any changes.
*/
cancel(): Promise<void> {
return this.clickCloseButton();
}
}

View File

@@ -0,0 +1,63 @@
import BasePageObject from './BasePageObject';
/**
* Page object for the visitors elements in moderator and visitor page.
*/
export default class Visitors extends BasePageObject {
/**
* Returns the visitors dialog element if any.
*/
hasVisitorsDialog() {
return this.participant.driver.$('div=Joining meeting');
}
/**
* Returns the visitors count shown in the conference info (subject) area.
*/
getVisitorsCount() {
return this.participant.driver.$('#visitorsCountLabel').getText();
}
/**
* Returns the visitors count shown in the Participants pane.
*/
async getVisitorsHeaderFromParticipantsPane() {
const participantsPane = this.participant.getParticipantsPane();
const isOpen = await participantsPane.isOpen();
if (!isOpen) {
await participantsPane.open();
}
return this.participant.driver.$('#visitor-list-header').getText();
}
/**
* Whether the visitors queue UI is shown.
*/
isVisitorsQueueUIShown() {
return this.participant.driver.$('#visitors-waiting-queue').isDisplayed();
}
/**
* Returns the name of the knocking participant (the only one) that is displayed on the notification.
*/
async getWaitingVisitorsInQueue() {
const goLiveNotification
= this.participant.driver.$('//div[@data-testid="notify.waitingVisitors"]');
await goLiveNotification.waitForDisplayed({
timeout: 3000,
timeoutMsg: 'Go live notification not displayed'
});
return await goLiveNotification.getText();
}
/**
* Clicks the go live button in the visitors notification.
*/
async goLive() {
return this.participant.driver.$('//button[@data-testid="participantsPane.actions.goLive"]').click();
}
}