/* global APP $ */ import { multiremotebrowser } from '@wdio/globals'; import assert from 'assert'; import { Key } from 'webdriverio'; import { IConfig } from '../../react/features/base/config/configType'; import { urlObjectToString } from '../../react/features/base/util/uri'; import BreakoutRooms from '../pageobjects/BreakoutRooms'; import ChatPanel from '../pageobjects/ChatPanel'; import Filmstrip from '../pageobjects/Filmstrip'; import IframeAPI from '../pageobjects/IframeAPI'; import InviteDialog from '../pageobjects/InviteDialog'; import LargeVideo from '../pageobjects/LargeVideo'; import LobbyScreen from '../pageobjects/LobbyScreen'; import Notifications, { MAX_USERS_TEST_ID, TOKEN_AUTH_FAILED_TEST_ID, TOKEN_AUTH_FAILED_TITLE_TEST_ID } from '../pageobjects/Notifications'; import ParticipantsPane from '../pageobjects/ParticipantsPane'; import PasswordDialog from '../pageobjects/PasswordDialog'; import PreJoinScreen from '../pageobjects/PreJoinScreen'; import SecurityDialog from '../pageobjects/SecurityDialog'; import SettingsDialog from '../pageobjects/SettingsDialog'; import Toolbar from '../pageobjects/Toolbar'; import VideoQualityDialog from '../pageobjects/VideoQualityDialog'; import Visitors from '../pageobjects/Visitors'; import { config as testsConfig } from './TestsConfig'; import { LOG_PREFIX, logInfo } from './browserLogger'; import { IToken } from './token'; import { IParticipantJoinOptions, IParticipantOptions } from './types'; export const P1 = 'p1'; export const P2 = 'p2'; export const P3 = 'p3'; export const P4 = 'p4'; /** * Participant. */ export class Participant { /** * The current context. * * @private */ private _name: string; private _endpointId: string; /** * The token that this participant was initialized with. */ private _token?: IToken; /** * Cache the dial in pin code so that it doesn't have to be read from the UI. */ private _dialInPin?: string; private _iFrameApi: boolean = false; /** * The default config to use when joining. * * @private */ private config = { analytics: { disabled: true }, // if there is a video file to play, use deployment config, // otherwise use lower resolution to avoid high CPU usage constraints: process.env.VIDEO_CAPTURE_FILE ? undefined : { video: { height: { ideal: 360, max: 360, min: 180 }, // @ts-ignore width: { ideal: 640, max: 640, min: 320 }, frameRate: { max: 30 } } }, resolution: process.env.VIDEO_CAPTURE_FILE ? undefined : 360, requireDisplayName: false, testing: { testMode: true }, disableAP: true, disable1On1Mode: true, disableModeratorIndicator: true, enableTalkWhileMuted: false, gatherStats: true, p2p: { enabled: false, useStunTurn: false }, pcStatsInterval: 1500, prejoinConfig: { enabled: false }, toolbarConfig: { alwaysVisible: true } } as IConfig; /** * Creates a participant with given options. */ constructor(options: IParticipantOptions) { this._name = options.name; this._token = options.token; this._iFrameApi = options.iFrameApi || false; } /** * A wrapper for this.driver.execute that would catch errors, print them and throw them again. * * @param {string | ((...innerArgs: InnerArguments) => ReturnValue)} script - The script that will be executed. * @param {any[]} args - The rest of the arguments. * @returns {ReturnValue} - The result of the script. */ async execute( script: string | ((...innerArgs: InnerArguments) => ReturnValue), ...args: InnerArguments): Promise { try { // @ts-ignore return await this.driver.execute(script, ...args); } catch (error) { console.error('An error occurred while trying to execute a script: ', error); throw error; } } /** * Returns participant endpoint ID. * * @returns {Promise} The endpoint ID. */ async getEndpointId(): Promise { if (!this._endpointId) { this._endpointId = await this.execute(() => { // eslint-disable-line arrow-body-style return APP?.conference?.getMyUserId(); }); } return this._endpointId; } /** * The driver it uses. */ get driver() { return multiremotebrowser.getInstance(this._name); } /** * The name. */ get name() { return this._name; } /** * Adds a log to the participants log file. * * @param {string} message - The message to log. * @returns {void} */ log(message: string): void { logInfo(this.driver, message); } /** * Joins conference. * * @param {IParticipantJoinOptions} options - Options for joining. * @returns {Promise} */ async joinConference(options: IParticipantJoinOptions): Promise { const config = { room: options.roomName, configOverwrite: { ...this.config, ...options.configOverwrite || {} }, interfaceConfigOverwrite: { SHOW_CHROME_EXTENSION_BANNER: false } }; if (!options.skipDisplayName) { // @ts-ignore config.userInfo = { displayName: this._name }; } if (this._iFrameApi) { config.room = 'iframeAPITest.html'; } let url = urlObjectToString(config) || ''; if (this._iFrameApi) { const baseUrl = new URL(this.driver.options.baseUrl || ''); // @ts-ignore url = `${this.driver.iframePageBase}${url}&domain="${baseUrl.host}"&room="${options.roomName}"`; if (testsConfig.iframe.tenant) { url = `${url}&tenant="${testsConfig.iframe.tenant}"`; } else if (baseUrl.pathname.length > 1) { // remove leading slash url = `${url}&tenant="${baseUrl.pathname.substring(1)}"`; } } if (this._token?.jwt) { url = `${url}&jwt="${this._token.jwt}"`; } await this.driver.setTimeout({ 'pageLoad': 30000 }); // drop the leading '/' so we can use the tenant if any url = url.startsWith('/') ? url.substring(1) : url; if (options.forceTenant) { url = `/${options.forceTenant}/${url}`; } await this.driver.url(url); await this.waitForPageToLoad(); if (this._iFrameApi) { const mainFrame = this.driver.$('iframe'); await this.driver.switchFrame(mainFrame); } if (!options.skipWaitToJoin) { await this.waitForMucJoinedOrError(); } await this.postLoadProcess(); return this; } /** * Loads stuff after the page loads. * * @returns {Promise} * @private */ private async postLoadProcess(): Promise { const driver = this.driver; const parallel = []; parallel.push(this.execute((name, sessionId, prefix) => { APP?.UI?.dockToolbar(true); // disable keyframe animations (.fadeIn and .fadeOut classes) $('') // @ts-ignore .appendTo(document.head); // @ts-ignore $('body').toggleClass('notransition'); document.title = `${name}`; console.log(`${new Date().toISOString()} ${prefix} sessionId: ${sessionId}`); // disable the blur effect in firefox as it has some performance issues const blur = document.querySelector('.video_blurred_container'); if (blur) { // @ts-ignore document.querySelector('.video_blurred_container').style.display = 'none'; } }, this._name, driver.sessionId, LOG_PREFIX)); await Promise.all(parallel); } /** * Waits for the page to load. * * @returns {Promise} */ async waitForPageToLoad(): Promise { return this.driver.waitUntil( () => this.execute(() => { console.log(`${new Date().toISOString()} document.readyState: ${document.readyState}`); return document.readyState === 'complete'; }), { timeout: 30_000, // 30 seconds timeoutMsg: `Timeout waiting for Page Load Request to complete for ${this.name}.` } ); } /** * Waits for the tile view to display. */ async waitForTileViewDisplay(reverse = false) { await this.driver.$('//div[@id="videoconference_page" and contains(@class, "tile-view")]').waitForDisplayed({ reverse, timeout: 10_000, timeoutMsg: `Tile view did not display in 10s for ${this.name}` }); } /** * Checks if the participant is in the meeting. */ isInMuc() { return this.execute(() => typeof APP !== 'undefined' && APP.conference?.isJoined()); } /** * Waits until either the MUC is joined, or a password prompt is displayed, or an authentication failure * notification is displayed, or max users notification is displayed. */ async waitForMucJoinedOrError(): Promise { await this.driver.waitUntil(async () => { return await this.isInMuc() || await this.getPasswordDialog().isOpen() || await this.getNotifications().getNotificationText(MAX_USERS_TEST_ID) || await this.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID) || await this.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID); }, { timeout: 10_000, timeoutMsg: 'Timeout waiting for MUC joined or error.' }); } /** * Checks if the participant is a moderator in the meeting. */ async isModerator() { return await this.execute(() => typeof APP !== 'undefined' && APP.store?.getState()['features/base/participants']?.local?.role === 'moderator'); } async isVisitor() { return await this.execute(() => APP?.store?.getState()['features/visitors']?.iAmVisitor || false); } /** * Checks if the meeting supports breakout rooms. */ async isBreakoutRoomsSupported() { return await this.execute(() => typeof APP !== 'undefined' && APP.store?.getState()['features/base/conference'].conference?.getBreakoutRooms()?.isSupported()); } /** * Checks if the participant is in breakout room. */ async isInBreakoutRoom() { return await this.execute(() => typeof APP !== 'undefined' && APP.store?.getState()['features/base/conference'].conference?.getBreakoutRooms()?.isBreakoutRoom()); } /** * Waits to join the muc. * * @returns {Promise} */ async waitToJoinMUC(): Promise { return this.driver.waitUntil( () => this.isInMuc(), { timeout: 10_000, // 10 seconds timeoutMsg: `Timeout waiting to join muc for ${this.name}` } ); } /** * Waits for ICE to get connected. * * @returns {Promise} */ waitForIceConnected(): Promise { return this.driver.waitUntil(() => this.execute(() => APP?.conference?.getConnectionState() === 'connected'), { timeout: 15_000, timeoutMsg: `expected ICE to be connected for 15s for ${this.name}` }); } /** * Waits for ICE to get connected on the p2p connection. * * @returns {Promise} */ waitForP2PIceConnected(): Promise { return this.driver.waitUntil(() => this.execute(() => APP?.conference?.getP2PConnectionState() === 'connected'), { timeout: 15_000, timeoutMsg: `expected P2P ICE to be connected for 15s for ${this.name}` }); } /** * Waits until the conference stats show positive upload and download bitrate (independently). * * @returns {Promise} */ async waitForSendReceiveData(timeout = 15_000, msg?: string): Promise { const values = await Promise.all([ await this.waitForSendMedia(timeout, msg ? `${msg} (send)` : undefined), await this.waitForReceiveMedia(timeout, msg ? `${msg} (receive)` : undefined) ]); return values[0] && values[1]; } /** * Waits until the conference stats show positive upload bitrate. * @param timeout max time to wait in ms * @param timeoutMsg the message to log if the timeout is reached */ async waitForSendMedia( timeout = 15_000, timeoutMsg = `expected to send media in ${timeout / 1000}s for ${this.name}`): Promise { return this.driver.waitUntil(() => this.execute(() => { return APP?.conference?.getStats()?.bitrate?.upload > 0; }), { timeout, timeoutMsg }); } /** * Waits until the conference stats show positive upload bitrate. * @param timeout max time to wait in ms * @param timeoutMsg the message to log if the timeout is reached */ async waitForReceiveMedia( timeout = 15_000, timeoutMsg = `expected to receive media in ${timeout / 1000}s for ${this.name}`): Promise { return this.driver.waitUntil(() => this.execute(() => { return APP?.conference?.getStats()?.bitrate?.download > 0; }), { timeout, timeoutMsg }); } /** * Waits until there are at least [number] participants that have at least one track. * * @param {number} number - The number of remote streams to wait for. * @returns {Promise} */ async waitForRemoteStreams(number: number): Promise { return await this.driver.waitUntil(async () => await this.execute( count => (APP?.conference?.getNumberOfParticipantsWithTracks() ?? -1) >= count, number ), { timeout: 15_000, timeoutMsg: `expected number of remote streams:${number} in 15s for ${this.name}` }); } /** * Waits until the number of participants is exactly the given number. * * @param {number} number - The number of participant to wait for. * @param {string} msg - A custom message to use. * @returns {Promise} */ waitForParticipants(number: number, msg?: string): Promise { return this.driver.waitUntil( () => this.execute(count => (APP?.conference?.listMembers()?.length ?? -1) === count, number), { timeout: 15_000, timeoutMsg: msg || `not the expected participants ${number} in 15s for ${this.name}` }); } /** * Returns the chat panel for this participant. */ getChatPanel(): ChatPanel { return new ChatPanel(this); } /** * Returns the BreakoutRooms for this participant. * * @returns {BreakoutRooms} */ getBreakoutRooms(): BreakoutRooms { return new BreakoutRooms(this); } /** * Returns the toolbar for this participant. * * @returns {Toolbar} */ getToolbar(): Toolbar { return new Toolbar(this); } /** * Returns the filmstrip for this participant. * * @returns {Filmstrip} */ getFilmstrip(): Filmstrip { return new Filmstrip(this); } /** * Returns the invite dialog for this participant. * * @returns {InviteDialog} */ getInviteDialog(): InviteDialog { return new InviteDialog(this); } /** * Returns the notifications. */ getNotifications(): Notifications { return new Notifications(this); } /** * Returns the participants pane. * * @returns {ParticipantsPane} */ getParticipantsPane(): ParticipantsPane { return new ParticipantsPane(this); } /** * Returns the large video page object. * * @returns {LargeVideo} */ getLargeVideo(): LargeVideo { return new LargeVideo(this); } /** * Returns the videoQuality Dialog. * * @returns {VideoQualityDialog} */ getVideoQualityDialog(): VideoQualityDialog { return new VideoQualityDialog(this); } /** * Returns the security Dialog. * * @returns {SecurityDialog} */ getSecurityDialog(): SecurityDialog { return new SecurityDialog(this); } /** * Returns the settings Dialog. * * @returns {SettingsDialog} */ getSettingsDialog(): SettingsDialog { return new SettingsDialog(this); } /** * Returns the password dialog. */ getPasswordDialog(): PasswordDialog { return new PasswordDialog(this); } /** * Returns the prejoin screen. */ getPreJoinScreen(): PreJoinScreen { return new PreJoinScreen(this); } /** * Returns the lobby screen. */ getLobbyScreen(): LobbyScreen { return new LobbyScreen(this); } /** * Returns the Visitors page object. * * @returns {Visitors} */ getVisitors(): Visitors { return new Visitors(this); } /** * Switches to the iframe API context */ async switchToAPI() { await this.driver.switchFrame(null); } /** * Switches to the meeting page context. */ switchInPage() { const mainFrame = this.driver.$('iframe'); return this.driver.switchFrame(mainFrame); } /** * Returns the iframe API for this participant. */ getIframeAPI() { return new IframeAPI(this); } /** * Hangups the participant by leaving the page. base.html is an empty page on all deployments. */ async hangup() { const current = await this.driver.getUrl(); // already hangup if (current.endsWith('/base.html')) { return; } // do a hangup, to make sure unavailable presence is sent await this.execute(() => typeof APP !== 'undefined' && APP.conference?.hangup()); // let's give it some time to leave the muc, we redirect after hangup so we should wait for the // change of url await this.driver.waitUntil( async () => { const u = await this.driver.getUrl(); // trying to debug some failures of reporting not leaving, where we see the close page in screenshot console.log(`initialUrl: ${current} currentUrl: ${u}`); return current !== u; }, { timeout: 8000, timeoutMsg: `${this.name} did not leave the muc in 8s initialUrl: ${current}` } ); await this.driver.url('/base.html') // This was fixed in wdio v9.9.1, we can drop once we update to that version .catch(_ => {}); // eslint-disable-line @typescript-eslint/no-empty-function } /** * Returns the local display name element. * @private */ private async getLocalDisplayNameElement() { const localVideoContainer = this.driver.$('span[id="localVideoContainer"]'); await localVideoContainer.moveTo(); return localVideoContainer.$('span[id="localDisplayName"]'); } /** * Returns the local display name. */ async getLocalDisplayName() { return (await this.getLocalDisplayNameElement()).getText(); } /** * Sets the display name of the local participant. */ async setLocalDisplayName(displayName: string) { const localDisplayName = await this.getLocalDisplayNameElement(); await localDisplayName.click(); await this.driver.keys(displayName); await this.driver.keys(Key.Return); // just click somewhere to lose focus, to make sure editing has ended const localVideoContainer = this.driver.$('span[id="localVideoContainer"]'); await localVideoContainer.moveTo(); await localVideoContainer.click(); } /** * Gets avatar SRC attribute for the one displayed on local video thumbnail. */ async getLocalVideoAvatar() { const avatar = this.driver.$('//span[@id="localVideoContainer"]//img[contains(@class,"userAvatar")]'); return await avatar.isExisting() ? await avatar.getAttribute('src') : null; } /** * Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed. * There are 3 options for avatar: * - defaultAvatar: true - the default avatar (with grey figure) is used * - image: true - the avatar is an image set in the settings * - defaultAvatar: false, image: false - the avatar is produced from the initials of the display name */ async assertThumbnailShowsAvatar( participant: Participant, reverse = false, defaultAvatar = false, image = false): Promise { const id = participant === this ? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`; const xpath = defaultAvatar ? `//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]` : `//span[@id="${id}"]//${image ? 'img' : 'div'}[contains(@class,"userAvatar")]`; await this.driver.$(xpath).waitForDisplayed({ reverse, timeout: 2000, timeoutMsg: `Avatar is ${reverse ? '' : 'not'} displayed in the local thumbnail for ${participant.name}` }); await this.driver.$(`//span[@id="${id}"]//video`).waitForDisplayed({ reverse: !reverse, timeout: 2000, timeoutMsg: `Video is ${reverse ? 'not' : ''} displayed in the local thumbnail for ${participant.name}` }); } /** * Makes sure that the default avatar is used. */ async assertDefaultAvatarExist(participant: Participant): Promise { const id = participant === this ? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`; await this.driver.$( `//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]`) .waitForExist({ timeout: 2000, timeoutMsg: `Default avatar does not exist for ${participant.name}` }); } /** * Makes sure that the local video is displayed in the local thumbnail and that the avatar is not displayed. */ async asserLocalThumbnailShowsVideo(): Promise { await this.assertThumbnailShowsAvatar(this, true); } /** * Make sure a display name is visible on the stage. * @param value */ async assertDisplayNameVisibleOnStage(value: string) { const displayNameEl = this.driver.$('div[data-testid="stage-display-name"]'); expect(await displayNameEl.isDisplayed()).toBe(true); expect(await displayNameEl.getText()).toBe(value); } /** * Checks if the leave reason dialog is open. */ async isLeaveReasonDialogOpen() { return this.driver.$('div[data-testid="dialog.leaveReason"]').isDisplayed(); } /** * Returns the audio level for a participant. * * @param p * @return */ async getRemoteAudioLevel(p: Participant) { const jid = await p.getEndpointId(); return await this.execute(id => { const level = APP?.conference?.getPeerSSRCAudioLevel(id); return level ? level.toFixed(2) : null; }, jid); } /** * For the participant to have his audio muted/unmuted from given observer's * perspective. The method will fail the test if something goes wrong or * the audio muted status is different than the expected one. We wait up to * 3 seconds for the expected status to appear. * * @param testee - instance of the participant for whom we're checking the audio muted status. * @param muted - true to wait for audio muted status or false to wait for the participant to * unmute. */ async waitForAudioMuted(testee: Participant, muted: boolean): Promise { // Waits for the correct icon await this.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, !muted); // Extended timeout for 'unmuted' to make tests more resilient to // unexpected glitches. const timeout = muted ? 3_000 : 6_000; // Give it 3 seconds to not get any audio or to receive some // depending on "muted" argument try { await this.driver.waitUntil(async () => { const audioLevel = await this.getRemoteAudioLevel(testee); if (muted) { if (audioLevel !== null && audioLevel > 0.1) { console.log(`muted exiting on: ${audioLevel}`); return true; } return false; } // When testing for unmuted we wait for first sound if (audioLevel !== null && audioLevel > 0.1) { console.log(`unmuted exiting on: ${audioLevel}`); return true; } return false; }, { timeout }); // When testing for muted we don't want to have // the condition succeeded if (muted) { assert.fail(`There was some sound coming from muted: '${this.name}'`); } // else we're good for unmuted participant } catch (_timeoutE) { if (!muted) { assert.fail(`There was no sound from unmuted: '${this.name}'`); } // else we're good for muted participant } } /** * Checks if video is currently received for the given remote endpoint ID (there is a track, it's not muted, * and it's streaming status according to the connection-indicator is active). */ async isRemoteVideoReceived(endpointId: string): Promise { return this.execute(e => JitsiMeetJS.app.testing.isRemoteVideoReceived(e), endpointId); } /** * Checks if the remove video is displayed for the given remote endpoint ID. * @param endpointId */ async isRemoteVideoDisplayed(endpointId: string): Promise { return this.driver.$( `//span[@id="participant_${endpointId}" and contains(@class, "display-video")]`).isExisting(); } /** * Check if remote video for a specific remote endpoint is both received and displayed. * @param endpointId */ async isRemoteVideoReceivedAndDisplayed(endpointId: string): Promise { return await this.isRemoteVideoReceived(endpointId) && await this.isRemoteVideoDisplayed(endpointId); } /** * Waits for remote video state - receiving and displayed. * @param endpointId * @param reverse if true, waits for the remote video to NOT be received AND NOT displayed. */ async waitForRemoteVideo(endpointId: string, reverse = false) { if (reverse) { await this.driver.waitUntil(async () => !await this.isRemoteVideoReceived(endpointId) && !await this.isRemoteVideoDisplayed(endpointId), { timeout: 15_000, timeoutMsg: `expected remote video for ${endpointId} to not be received 15s by ${this.name}` }); } else { await this.driver.waitUntil(async () => await this.isRemoteVideoReceivedAndDisplayed(endpointId), { timeout: 15_000, timeoutMsg: `expected remote video for ${endpointId} to be received 15s by ${this.name}` }); } } /** * Waits for ninja icon to be displayed. * @param endpointId When no endpoint id is passed we check for any ninja icon. */ async waitForNinjaIcon(endpointId?: string) { if (endpointId) { await this.driver.$(`//span[@id='participant_${endpointId}']//span[@class='connection_ninja']`) .waitForDisplayed({ timeout: 15_000, timeoutMsg: `expected ninja icon for ${endpointId} to be displayed in 15s by ${this.name}` }); } else { await this.driver.$('//span[contains(@class,"videocontainer")]//span[contains(@class,"connection_ninja")]') .waitForDisplayed({ timeout: 5_000, timeoutMsg: `expected ninja icon to be displayed in 5s by ${this.name}` }); } } /** * Waits for dominant speaker icon to appear in remote video of a participant. * @param endpointId the endpoint ID of the participant whose dominant speaker icon status will be checked. */ waitForDominantSpeaker(endpointId: string) { return this.driver.$(`//span[@id="participant_${endpointId}" and contains(@class, "dominant-speaker")]`) .waitForDisplayed({ timeout: 5_000 }); } /** * Returns the token that this participant was initialized with. */ getToken(): IToken | undefined { return this._token; } /** * Gets the dial in pin for the conference. Reads it from the invite dialog if the pin hasn't been cached yet. */ async getDialInPin(): Promise { if (!this._dialInPin) { const dialInPin = await this.getInviteDialog().getPinNumber(); await this.getInviteDialog().clickCloseButton(); this._dialInPin = dialInPin; } return this._dialInPin; } }