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