init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

18
tests/.eslintrc.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
'extends': [
'../.eslintrc.js'
],
'overrides': [
{
'files': [ '*.ts', '*.tsx' ],
extends: [ '@jitsi/eslint-config/typescript' ],
parserOptions: {
sourceType: 'module',
project: [ './tests/tsconfig.json' ]
},
rules: {
'@typescript-eslint/no-require-imports': 0
}
}
]
};

77
tests/env.example Normal file
View File

@@ -0,0 +1,77 @@
# Ignore certificate errors (self-signed certificates)
#ALLOW_INSECURE_CERTS=true
# The base url that will be used for the test (default will be using "https://alpha.jitsi.net")
# If there is a tenant in the URL it must end with a slash (e.g. "https://alpha.jitsi.net/sometenant/")
#BASE_URL=
# Whether to use beta for the first participants
#BROWSER_CHROME_BETA=false
#BROWSER_FF_BETA=false
# A rest URL to be used by dial-in tests to invite jigasi to the conference
#DIAL_IN_REST_URL=
# A destination number to dialout, that auto answers and sends media
#DIAL_OUT_URL=
# The grid host url (https://mygrid.com/wd/hub)
#GRID_HOST_URL=
# Whether to run the browser in headless mode
#HEADLESS=false
# The tenant used when executing the iframeAPI tests, will override any tenant from BASE_URL if any
#IFRAME_TENANT=
# Configure properties for jaas-specific tests (specs/jaas). Note that some of the iFrame tests can also
# be used to test JaaS, they are configured separately via IFRAME_TENANT, JWT_KID, JWT_PRIVATE_KEY_PATH.
# Make sure this tenant is configured to not allow guests.
# The key ID
#JAAS_KID=
# The path to the private key used for generating JWT token (.pk) for jaas-specific tests
#JAAS_PRIVATE_KEY_PATH=
# The JaaS tenant, e.g. vpaas-magic-cookie-abcdabcd1234567890
#JAAS_TENANT=
# Enable debug logging. Note this includes private information from .env.
#JITSI_DEBUG=true
# An access token to use to create meetings (used for the first participant)
#JWT_ACCESS_TOKEN=
# The kid to use in the token for non-jaas-specific tests (though it could be a jaas key).
#JWT_KID=
# The path to the private key used for generating JWT token (.pk) for non-jaas-specific tests (though it could be a
# jaas key).
#JWT_PRIVATE_KEY_PATH=
# The count of workers that execute the tests in parallel
# MAX_INSTANCES=1
# To be able to match a domain to a specific address
# The format is "MAP example.com 1.2.3.4"
#RESOLVER_RULES=
# Room name prefix to use when creating new room names
#ROOM_NAME_PREFIX=
# Room name suffix to use when creating new room names
#ROOM_NAME_SUFFIX=
# A destination number to dialout, that auto answer and sends media audio and video
#SIP_JIBRI_DIAL_OUT_URL=
# The path to the browser video capture file
#VIDEO_CAPTURE_FILE=tests/resources/FourPeople_1280x720_30.y4m
# A shared secret to authenticate the webhook proxy connection
#WEBHOOKS_PROXY_SHARED_SECRET=
# The address of the webhooks proxy used to test the webhooks feature (e.g. wss://your.service)
#WEBHOOKS_PROXY_URL=
# A stream key abd broadcast ID that can be used by the tests to stream to YouTube
#YTUBE_TEST_STREAM_KEY=
#YTUBE_TEST_BROADCAST_ID=

5
tests/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { IContext } from './helpers/types';
declare global {
const ctx: IContext;
}

View File

@@ -0,0 +1,973 @@
/* 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 <tt>this.driver.execute</tt> 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<ReturnValue, InnerArguments extends any[]>(
script: string | ((...innerArgs: InnerArguments) => ReturnValue),
...args: InnerArguments): Promise<ReturnValue> {
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<string>} The endpoint ID.
*/
async getEndpointId(): Promise<string> {
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<void>}
*/
async joinConference(options: IParticipantJoinOptions): Promise<Participant> {
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<void>}
* @private
*/
private async postLoadProcess(): Promise<void> {
const driver = this.driver;
const parallel = [];
parallel.push(this.execute((name, sessionId, prefix) => {
APP?.UI?.dockToolbar(true);
// disable keyframe animations (.fadeIn and .fadeOut classes)
$('<style>.notransition * { '
+ 'animation-duration: 0s !important; -webkit-animation-duration: 0s !important; transition:none; '
+ '} </style>') // @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<boolean>}
*/
async waitForPageToLoad(): Promise<boolean> {
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<void> {
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<void>}
*/
async waitToJoinMUC(): Promise<void> {
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<boolean>}
*/
waitForIceConnected(): Promise<boolean> {
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<boolean>}
*/
waitForP2PIceConnected(): Promise<boolean> {
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<boolean>}
*/
async waitForSendReceiveData(timeout = 15_000, msg?: string): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean>}
*/
async waitForRemoteStreams(number: number): Promise<boolean> {
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<boolean>}
*/
waitForParticipants(number: number, msg?: string): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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 - <tt>true</tt> to wait for audio muted status or <tt>false</tt> to wait for the participant to
* unmute.
*/
async waitForAudioMuted(testee: Participant, muted: boolean): Promise<void> {
// 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<boolean> {
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<boolean> {
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<boolean> {
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<string> {
if (!this._dialInPin) {
const dialInPin = await this.getInviteDialog().getPinNumber();
await this.getInviteDialog().clickCloseButton();
this._dialInPin = dialInPin;
}
return this._dialInPin;
}
}

View File

@@ -0,0 +1,133 @@
/**
* An interface that tests can export (as a TEST_PROPERTIES property) to define what they require.
*/
export type ITestProperties = {
/** The test uses the iFrame API. */
useIFrameApi: boolean;
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
useJaas: boolean;
/** The test requires the webhook proxy. */
useWebhookProxy: boolean;
usesBrowsers?: string[];
};
const defaultProperties: ITestProperties = {
useIFrameApi: false,
useWebhookProxy: false,
useJaas: false,
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
};
function getDefaultProperties(filename: string): ITestProperties {
const properties = { ...defaultProperties };
properties.usesBrowsers = getDefaultBrowsers(filename);
return properties;
}
function getDefaultBrowsers(filename: string): string[] {
if (filename.includes('/alone/')) {
return [ 'p1' ];
}
if (filename.includes('/2way/')) {
return [ 'p1', 'p2' ];
}
if (filename.includes('/3way/')) {
return [ 'p1', 'p2', 'p3' ];
}
if (filename.includes('/4way/')) {
return [ 'p1', 'p2', 'p3', 'p4' ];
}
// Tests outside /alone/, /2way/, /3way/, /4way/ will default to p1 only.
return [ 'p1' ];
}
/**
* Maps a test filename to its registered properties.
*/
export const testProperties: Record<string, ITestProperties> = {};
/**
* Set properties for a test file. This was needed because I couldn't find a hook that executes with describe() before
* the code in wdio.conf.ts's before() hook. The intention is for tests to execute this directly. The properties don't
* change dynamically.
*
* @param filename the absolute path to the test file
* @param properties the properties to set for the test file, defaults will be applied for missing properties
*/
export function setTestProperties(filename: string, properties: Partial<ITestProperties>): void {
if (testProperties[filename]) {
console.warn(`Test properties for ${filename} are already set. Overwriting.`);
}
testProperties[filename] = { ...getDefaultProperties(filename), ...properties };
}
let testFilesLoaded = false;
/**
* Loads test files to populate the testProperties registry. This function:
* 1. Mocks test framework globals to prevent test registration
* 2. require()s each file to trigger setTestProperties calls
* 3. Restores original test framework functions
*
* @param files - Array of file names to load
*/
export function loadTestFiles(files: string[]): void {
if (testFilesLoaded) {
return;
}
// Temporarily override test functions to prevent tests registering at this stage. We only want TestProperties to be
// loaded.
const originalTestFunctions: Record<string, any> = {};
const testGlobals = [ 'describe', 'it', 'test', 'expect', 'beforeEach', 'afterEach', 'before', 'after', 'beforeAll',
'afterAll', 'suite', 'setup', 'teardown' ];
testGlobals.forEach(fn => {
originalTestFunctions[fn] = (global as any)[fn];
(global as any)[fn] = () => {
// do nothing
};
});
try {
// Load all spec files to trigger setTestProperties calls
files.forEach(file => {
try {
require(file);
if (!testProperties[file]) {
// If no properties were set, apply defaults
setTestProperties(file, getDefaultProperties(file));
}
} catch (error) {
console.warn(`Warning: Could not analyze ${file}:`, (error as Error).message);
}
});
testFilesLoaded = true;
} finally {
// Restore original functions
testGlobals.forEach(fn => {
if (originalTestFunctions[fn] !== undefined) {
(global as any)[fn] = originalTestFunctions[fn];
} else {
delete (global as any)[fn];
}
});
// Clear require cache for analyzed files so they can be loaded fresh by WebDriverIO
files.forEach(file => {
delete require.cache[file];
});
}
}
/**
* @param testFilePath - The absolute path to the test file
* @returns Promise<ITestProperties> - The test properties with defaults applied
*/
export async function getTestProperties(testFilePath: string): Promise<ITestProperties> {
return testProperties[testFilePath] || getDefaultProperties(testFilePath);
}

View File

@@ -0,0 +1,44 @@
/**
* Global configuration that the tests are run with. Loaded from environment variables.
*/
export const config = {
/** Enable debug logging. Note this includes private information from .env */
debug: Boolean(process.env.JITSI_DEBUG?.trim()),
iframe: {
customerId: process.env.IFRAME_TENANT?.trim()?.replace('vpaas-magic-cookie-', ''),
tenant: process.env.IFRAME_TENANT?.trim(),
/** Whether the configuration specifies a JaaS account for the iFrame API tests. */
usesJaas: Boolean(process.env.JWT_PRIVATE_KEY_PATH && process.env.JWT_KID?.startsWith('vpaas-magic-cookie-')),
},
jaas: {
/** Whether the configuration for JaaS specific tests is enabled. */
enabled: Boolean(process.env.JAAS_TENANT && process.env.JAAS_PRIVATE_KEY_PATH && process.env.JAAS_KID),
/** The JaaS key ID, used to sign the tokens. */
kid: process.env.JAAS_KID?.trim(),
/** The path to the JaaS private key, used to sign JaaS tokens. */
privateKeyPath: process.env.JAAS_PRIVATE_KEY_PATH?.trim(),
/** The JaaS tenant (vpaas-magic-cookie-<ID>) . */
tenant: process.env.JAAS_TENANT?.trim(),
},
jwt: {
kid: process.env.JWT_KID?.trim(),
/** A pre-configured token used by some tests. */
preconfiguredToken: process.env.JWT_ACCESS_TOKEN?.trim(),
privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH?.trim()
},
roomName: {
/** Optional prefix for room names used for tests. */
prefix: process.env.ROOM_NAME_PREFIX?.trim(),
/** Optional suffix for room names used for tests. */
suffix: process.env.ROOM_NAME_SUFFIX?.trim()
},
webhooksProxy: {
enabled: Boolean(process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET),
sharedSecret: process.env.WEBHOOKS_PROXY_SHARED_SECRET?.trim(),
url: process.env.WEBHOOKS_PROXY_URL?.trim(),
}
};
if (config.debug) {
console.log('TestsConfig:', JSON.stringify(config, null, 2));
}

View File

@@ -0,0 +1,194 @@
import fs from 'node:fs';
import WebSocket from 'ws';
/**
* Uses the webhook proxy service to proxy events to the testing clients.
*/
export default class WebhookProxy {
private readonly url;
private readonly secret;
private logFile;
private ws: WebSocket | undefined;
private cache = new Map();
private listeners = new Map();
private consumers = new Map();
private _defaultMeetingSettings: object | undefined;
/**
* Initializes the webhook proxy.
* @param url
* @param secret
* @param logFile
*/
constructor(url: string, secret: string, logFile: string) {
this.url = url;
this.secret = secret;
this.logFile = logFile;
}
/**
* Connects.
*/
connect() {
this.ws = new WebSocket(this.url, {
headers: {
Authorization: this.secret
}
});
this.ws.on('error', console.error);
this.ws.on('open', function open() {
console.log('WebhookProxy connected');
});
this.ws.on('message', (data: any) => {
const msg = JSON.parse(data.toString());
this.logInfo(`${msg.eventType} event: ${JSON.stringify(msg)}`);
if (msg.eventType) {
let processed = false;
if (this.consumers.has(msg.eventType)) {
this.consumers.get(msg.eventType)(msg);
this.consumers.delete(msg.eventType);
processed = true;
} else {
this.cache.set(msg.eventType, msg);
}
if (this.listeners.has(msg.eventType)) {
this.listeners.get(msg.eventType)(msg);
processed = true;
}
if (!processed && msg.eventType === 'SETTINGS_PROVISIONING') {
// just in case to not be empty
let response: any = { someField: 'someValue' };
if (this._defaultMeetingSettings) {
response = this._defaultMeetingSettings;
}
this.ws?.send(JSON.stringify(response));
}
}
});
}
/**
* Adds event consumer. Consumers receive the event single time and we remove them from the list of consumers.
* @param eventType
* @param callback
*/
addConsumer(eventType: string, callback: (deventata: any) => void) {
if (this.cache.has(eventType)) {
callback(this.cache.get(eventType));
this.cache.delete(eventType);
return;
}
this.consumers.set(eventType, callback);
}
/**
* Clear any stored event.
*/
clearCache() {
this.logInfo('cache cleared');
this.cache.clear();
}
/**
* Waits for the event to be received.
* @param eventType
* @param timeout
*/
async waitForEvent(eventType: string, timeout = 120000): Promise<any> {
// we create the error here so we have a meaningful stack trace
const error = new Error(`Timeout waiting for event:${eventType}`);
return new Promise((resolve, reject) => {
const waiter = setTimeout(() => {
this.logInfo(error.message);
return reject(error);
}, timeout);
this.addConsumer(eventType, event => {
clearTimeout(waiter);
resolve(event);
});
});
}
/**
* Adds a listener for the event type.
* @param eventType
* @param callback
*/
addListener(eventType: string, callback: (data: any) => void) {
this.listeners.set(eventType, callback);
}
/**
* Adds a listener for the event type.
* @param eventType
*/
removeListener(eventType: string) {
this.listeners.delete(eventType);
}
/**
* Disconnects the webhook proxy.
*/
disconnect() {
if (this.ws) {
this.ws.close();
console.log('WebhookProxy disconnected');
this.ws = undefined;
this.logInfo('disconnected');
}
}
/**
* Logs a message in the logfile.
*
* @param {string} message - The message to add.
* @returns {void}
*/
logInfo(message: string) {
try {
// @ts-ignore
fs.appendFileSync(this.logFile, `${new Date().toISOString()} ${message}\n`);
} catch (err) {
console.error(err);
}
}
/**
* Sets the settings provider.
* @param value
*/
set defaultMeetingSettings(value: {
autoAudioRecording?: boolean;
autoTranscriptions?: boolean;
autoVideoRecording?: boolean;
lobbyEnabled?: boolean;
lobbyType?: 'WAIT_FOR_APPROVAL' | 'WAIT_FOR_MODERATOR';
maxOccupants?: number;
outboundPhoneNo?: string;
participantsSoftLimit?: number;
passcode?: string;
transcriberType?: 'GOOGLE' | 'ORACLE_CLOUD_AI_SPEECH' | 'EGHT_WHISPER';
visitorsEnabled?: boolean;
visitorsLive?: boolean;
}) {
this._defaultMeetingSettings = value;
}
}

View File

@@ -0,0 +1,67 @@
import fs from 'node:fs';
/**
* A prefix to use for all messages we add to the console log.
*/
export const LOG_PREFIX = '[MeetTest] ';
/**
* Initialize logger for a driver.
*
* @param {WebdriverIO.Browser} driver - The driver.
* @param {string} fileName - The name of the file.
* @param {string} folder - The folder to save the file.
* @returns {void}
*/
export function initLogger(driver: WebdriverIO.Browser, fileName: string, folder: string) {
// @ts-ignore
driver.logFile = `${folder}/${fileName}.log`;
driver.sessionSubscribe({ events: [ 'log.entryAdded' ] });
driver.on('log.entryAdded', (entry: any) => {
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, `${entry.text}\n`);
} catch (err) {
console.error(err);
}
});
}
/**
* Returns the content of the log file.
*
* @param {WebdriverIO.Browser} driver - The driver which log file is requested.
* @returns {string} The content of the log file.
*/
export function getLogs(driver: WebdriverIO.Browser) {
// @ts-ignore
if (!driver.logFile) {
return;
}
// @ts-ignore
return fs.readFileSync(driver.logFile, 'utf8');
}
/**
* Logs a message in the logfile.
*
* @param {WebdriverIO.Browser} driver - The participant in which log file to write.
* @param {string} message - The message to add.
* @returns {void}
*/
export function logInfo(driver: WebdriverIO.Browser, message: string) {
// @ts-ignore
if (!driver.logFile) {
return;
}
try {
// @ts-ignore
fs.appendFileSync(driver.logFile, `${new Date().toISOString()} ${LOG_PREFIX} ${message}\n`);
} catch (err) {
console.error(err);
}
}

View File

@@ -0,0 +1,342 @@
import { P1, P2, P3, P4, Participant } from './Participant';
import { config } from './TestsConfig';
import { generateToken } from './token';
import { IJoinOptions, IParticipantOptions } from './types';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
/**
* Ensure that there is on participant.
* Ensure that the first participant is moderator if there is such an option.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureOneParticipant(options?: IJoinOptions): Promise<void> {
const participantOps = { name: P1 } as IParticipantOptions;
if (!options?.skipFirstModerator) {
const jwtPrivateKeyPath = config.jwt.privateKeyPath;
// we prioritize the access token when iframe is not used and private key is set,
// otherwise if private key is not specified we use the access token if set
if (config.jwt.preconfiguredToken
&& ((jwtPrivateKeyPath && !ctx.testProperties.useIFrameApi && !options?.preferGenerateToken)
|| !jwtPrivateKeyPath)) {
participantOps.token = { jwt: config.jwt.preconfiguredToken };
} else if (jwtPrivateKeyPath) {
participantOps.token = generateToken({
...options?.tokenOptions,
displayName: participantOps.name,
moderator: true
});
}
}
// make sure the first participant is moderator, if supported by deployment
await joinParticipant(participantOps, options);
}
/**
* Ensure that there are three participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureThreeParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
]);
}
/**
* Creates the first participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export function joinFirstParticipant(options: IJoinOptions = { }): Promise<void> {
return ensureOneParticipant(options);
}
/**
* Creates the second participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinSecondParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P2 }, options);
}
/**
* Creates the third participant instance or prepares one for re-joining.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<Participant>}
*/
export function joinThirdParticipant(options?: IJoinOptions): Promise<Participant> {
return joinParticipant({ name: P3 }, options);
}
/**
* Ensure that there are four participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureFourParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// these need to be all, so we get the error when one fails
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
]);
}
/**
* Ensure that there are two participants.
*
* @param {IJoinOptions} options - The options to join.
*/
export async function ensureTwoParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
const participantOptions = { name: P2 } as IParticipantOptions;
if (options?.preferGenerateToken) {
participantOptions.token = generateToken({
...options?.tokenOptions,
displayName: participantOptions.name,
});
}
await joinParticipant({
...participantOptions,
name: P2
}, options);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1))
]);
}
/**
* Creates a new participant instance, or returns an existing one if it is already joined.
* @param participantOptions - The participant options, with required name set.
* @param {boolean} options - Join options.
* @param reuse whether to reuse an existing participant instance if one is available.
* @returns {Promise<Participant>} - The participant instance.
*/
async function joinParticipant( // eslint-disable-line max-params
participantOptions: IParticipantOptions,
options?: IJoinOptions
): Promise<Participant> {
participantOptions.iFrameApi = ctx.testProperties.useIFrameApi;
// @ts-ignore
const p = ctx[participantOptions.name] as Participant;
if (p) {
if (ctx.testProperties.useIFrameApi) {
await p.switchInPage();
}
if (await p.isInMuc()) {
return p;
}
if (ctx.testProperties.useIFrameApi) {
// when loading url make sure we are on the top page context or strange errors may occur
await p.switchToAPI();
}
// Change the page so we can reload same url if we need to, base.html is supposed to be empty or close to empty
await p.driver.url('/base.html');
}
const newParticipant = new Participant(participantOptions);
// set the new participant instance
// @ts-ignore
ctx[participantOptions.name] = newParticipant;
let forceTenant = options?.forceTenant;
if (options?.preferGenerateToken && !ctx.testProperties.useIFrameApi
&& config.iframe.usesJaas && config.iframe.tenant) {
forceTenant = config.iframe.tenant;
}
return await newParticipant.joinConference({
...options,
forceTenant,
roomName: options?.roomName || ctx.roomName,
});
}
/**
* Toggles the mute state of a specific Meet conference participant and verifies that a specific other Meet
* conference participants sees a specific mute state for the former.
*
* @param {Participant} testee - The {@code Participant} which represents the Meet conference participant whose
* mute state is to be toggled.
* @param {Participant} observer - The {@code Participant} which represents the Meet conference participant to verify
* the mute state of {@code testee}.
* @returns {Promise<void>}
*/
export async function muteAudioAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickAudioMuteButton();
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
}
/**
* Unmute audio, checks if the local UI has been updated accordingly and then does the verification from
* the other observer participant perspective.
* @param testee
* @param observer
*/
export async function unmuteAudioAndCheck(testee: Participant, observer: Participant) {
await testee.getNotifications().closeAskToUnmuteNotification(true);
await testee.getNotifications().closeAVModerationMutedNotification(true);
await testee.getToolbar().clickAudioUnmuteButton();
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
}
/**
* Stop the video on testee and check on observer.
* @param testee
* @param observer
*/
export async function unmuteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickVideoUnmuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
}
/**
* Starts the video on testee and check on observer.
* @param testee
* @param observer
*/
export async function muteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickVideoMuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
}
/**
* Parse a JID string.
* @param str the string to parse.
*/
export function parseJid(str: string): {
domain: string;
node: string;
resource: string | undefined;
} {
const parts = str.split('@');
const domainParts = parts[1].split('/');
return {
node: parts[0],
domain: domainParts[0],
resource: domainParts.length > 0 ? domainParts[1] : undefined
};
}
/**
* Check the subject of the participant.
* @param participant
* @param subject
*/
export async function checkSubject(participant: Participant, subject: string) {
const localTile = participant.driver.$(SUBJECT_XPATH);
await localTile.waitForExist();
await localTile.moveTo();
const txt = await localTile.getText();
expect(txt.startsWith(subject)).toBe(true);
}
/**
* Check if a screensharing tile is displayed on the observer.
* Expects there was already a video by this participant and screen sharing will be the second video `-v1`.
*/
export async function checkForScreensharingTile(sharer: Participant, observer: Participant, reverse = false) {
await observer.driver.$(`//span[@id='participant_${await sharer.getEndpointId()}-v1']`).waitForDisplayed({
timeout: 3_000,
reverse
});
}
/**
* Hangs up all participants (p1, p2, p3 and p4)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup(), ctx.p3?.hangup(), ctx.p4?.hangup() ]
.map(p => p ?? Promise.resolve()));
}

134
tests/helpers/token.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { config } from './TestsConfig';
export type ITokenOptions = {
displayName?: string;
/**
* The duration for which the token is valid, e.g. "1h" for one hour.
*/
exp?: string;
/**
* The key ID to use for the token.
* If not provided, the kid configured with environment variables will be used (see env.example).
*/
keyId?: string;
/**
* The path to the private key file used to sign the token.
* If not provided, the path configured with environment variables will be used (see env.example).
*/
keyPath?: string;
/**
* Whether to set the 'moderator' flag.
*/
moderator?: boolean;
/**
* The room for which the token is valid, or '*'. Defaults to '*'.
*/
room?: string;
sub?: string;
/**
* Whether to set the 'visitor' flag.
*/
visitor?: boolean;
};
export type IToken = {
/**
* The JWT headers, for easy reference.
*/
headers?: any;
/**
* The signed JWT.
*/
jwt: string;
/**
* The options used to generate the token.
*/
options?: ITokenOptions;
/**
* The token's payload, for easy reference.
*/
payload?: any;
};
export function generatePayload(options: ITokenOptions): any {
const payload = {
'aud': 'jitsi',
'iss': 'chat',
'sub': options?.sub || '',
'context': {
'user': {
'name': options.displayName,
'id': uuidv4(),
'avatar': 'https://avatars0.githubusercontent.com/u/3671647',
'email': 'john.doe@jitsi.org'
},
'group': uuidv4(),
'features': {
'outbound-call': 'true',
'transcription': 'true',
'recording': 'true',
'sip-outbound-call': true,
'livestreaming': true
},
},
'room': options.room || '*'
};
if (options.moderator) {
// @ts-ignore
payload.context.user.moderator = true;
} else if (options.visitor) {
// @ts-ignore
payload.context.user.role = 'visitor';
}
return payload;
}
/**
* Generate a signed token.
*/
export function generateToken(options: ITokenOptions): IToken {
const keyId = options.keyId || config.jwt.kid;
const keyPath = options.keyPath || config.jwt.privateKeyPath;
const headers = {
algorithm: 'RS256',
noTimestamp: true,
expiresIn: options.exp || '24h',
keyid: keyId,
};
if (!keyId) {
throw new Error('No keyId provided (JWT_KID is not set?)');
}
if (!keyPath) {
throw new Error('No keyPath provided (JWT_PRIVATE_KEY_PATH is not set?)');
}
const key = fs.readFileSync(keyPath);
const payload = generatePayload({
...options,
displayName: options?.displayName || '',
sub: keyId.substring(0, keyId.indexOf('/'))
});
return {
headers,
// @ts-ignore
jwt: jwt.sign(payload, key, headers),
options,
payload
};
}
/**
* Generated a signed token and return just the JWT string.
*/
export function generateJwt(options: ITokenOptions): string {
return generateToken(options).jwt;
}

123
tests/helpers/types.ts Normal file
View File

@@ -0,0 +1,123 @@
import { IConfig } from '../../react/features/base/config/configType';
import type { Participant } from './Participant';
import { ITestProperties } from './TestProperties';
import type WebhookProxy from './WebhookProxy';
import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The up-to-four browser instances provided by the framework. These can be initialized using
* ensureOneParticipant, ensureTwoParticipants, etc. from participants.ts.
**/
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
* A flag that tests can set, which signals to the framework that the (rest of the) test suite should be skipped.
*/
skipSuiteTests: boolean;
/**
* Test properties provided by the test file via TestProperties.setTestProperties. Used by the framework to
* set up the context appropriately.
**/
testProperties: ITestProperties;
times: any;
/**
* A WebhooksProxy instance generated by the framework and available for tests to use, if configured.
* Note that this is only configured for roomName, if a test wishes to use a different room name it can set up
* a WebhooksProxy instance itself.
*/
webhooksProxy: WebhookProxy;
};
export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Must be 'p1', 'p2', 'p3', or 'p4'. */
name: string;
/** An optional token to use. */
token?: IToken;
};
/**
* Options for joinConference.
*/
export type IParticipantJoinOptions = {
/**
* Config overwrites to use.
*/
configOverwrite?: IConfig;
/**
* An optional tenant to use. If provided the URL is prepended with /$forceTenant
*/
forceTenant?: string;
/** The name of the room to join */
roomName: string;
/**
* Whether to skip setting display name.
*/
skipDisplayName?: boolean;
/**
* Whether to skip waiting for the participant to join the room. Cases like lobby where we do not succeed to join
* based on the logic of the test.
*/
skipWaitToJoin?: boolean;
};
export type IJoinOptions = {
/**
* Config overwrites to pass to IParticipantJoinOptions.
*/
configOverwrite?: IConfig;
/**
* An optional tenant to use. If provided the URL is prepended with /$forceTenant
*/
forceTenant?: string;
/**
* When joining the first participant and jwt singing material is available and a provided token
* is available, prefer generating a new token for the first participant.
*/
preferGenerateToken?: boolean;
/**
* To be able to override the ctx generated room name. If missing the one from the context will be used.
*/
roomName?: string;
/**
*The skip display name setting to pass to IParticipantJoinOptions.
*/
skipDisplayName?: boolean;
/**
* Whether to skip setting the moderator role for the first participant (whether to use jwt for it).
*/
skipFirstModerator?: boolean;
/**
* Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
*/
skipInMeetingChecks?: boolean;
/**
* The skip waiting for the participant to join the room setting to pass to IParticipantJoinOptions.
*/
skipWaitToJoin?: boolean;
/**
* Options used when generating a token.
*/
tokenOptions?: ITokenOptions;
};

54
tests/helpers/utils.ts Normal file
View File

@@ -0,0 +1,54 @@
import { config as testsConfig } from './TestsConfig';
const https = require('https');
export function generateRoomName(testName: string) {
// XXX why chose between 1 and 40 and then always pad with an extra 0?
const rand = (Math.floor(Math.random() * 40) + 1).toString().padStart(3, '0');
let roomName = `${testName}-${rand}`;
if (testsConfig.roomName.prefix) {
roomName = `${testsConfig.roomName.prefix}_${roomName}`;
}
if (testsConfig.roomName.suffix) {
roomName += `_${testsConfig.roomName.suffix}`;
}
return roomName.toLowerCase();
}
/**
* Fetches JSON data from a given URL.
* @param {string} url - The URL to fetch data from.
* @returns {Promise<Object>} - A promise that resolves to the parsed JSON object.
*/
export async function fetchJson(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let data = '';
// Handle HTTP errors
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP Status Code: ${res.statusCode}`));
}
// Collect data chunks
res.on('data', chunk => {
data += chunk;
});
// Parse JSON when the response ends
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (err) {
reject(new Error('Invalid JSON response'));
}
});
}).on('error', err => {
reject(err);
});
});
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { Participant } from '../helpers/Participant';
/**
* Represents the base page object.
* All page object has the current participant (holding the driver/browser session).
*/
export default class BasePageObject {
participant: Participant;
/**
* Represents the base page object.
*/
constructor(participant: Participant) {
this.participant = participant;
}
}

View File

@@ -0,0 +1,212 @@
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.$(`aria/${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.$(`aria/${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.$(`aria/${LEAVE_ROOM_LABEL}`);
await leaveButton.isClickable();
await leaveButton.click();
}
/**
* Auto assign participants to breakout rooms.
*/
async autoAssignToBreakoutRooms() {
const button = this.participant.driver.$(`aria/${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.$(`aria/${roomName}`);
await sendButton.waitForClickable();
await sendButton.click();
}
}

View File

@@ -0,0 +1,22 @@
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' ]);
}
}

View File

@@ -0,0 +1,276 @@
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 = 'aria/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) {
const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}"]`);
await remoteDisplayName.moveTo();
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) {
let videoIdToSwitchTo;
if (participant === this.participant) {
videoIdToSwitchTo = await this.getLocalVideoId();
// 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();
videoIdToSwitchTo = await this.getRemoteVideoId(epId);
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
}
await this.participant.driver.waitUntil(
async () => await this.participant.getLargeVideo().getId() === videoIdToSwitchTo,
{
timeout: 3_000,
timeoutMsg: `${this.participant.name} did not switch the large video to ${
participant.name}`
}
);
}
/**
* 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 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,
});
}
}

View File

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

View File

@@ -0,0 +1,102 @@
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();
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();
return (await elem.getText())?.trim();
}
/**
* Waits for the dialog to be open or closed.
* @param reverse
*/
async waitTillOpen(reverse = false) {
await this.participant.driver.waitUntil(
/* eslint-disable no-extra-parens */
async () => (reverse ? !await this.isOpen() : await this.isOpen()),
{
timeout: 2_000,
timeoutMsg: `invite dialog did not ${reverse ? 'close' : 'open'}`
}
);
}
/**
* Gets the string that contains the dial in number for the current conference.
*/
getDialInNumber() {
return this.getValueAfterColon(PHONE_NUMBER);
}
/**
* Clicks the link to open a page to show all available dial in numbers.
*/
async openDialInNumbersPage() {
const moreNumbers = this.participant.driver.$(`.${MORE_NUMBERS}`);
await moreNumbers.waitForExist();
await moreNumbers.waitForClickable();
await moreNumbers.click();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,285 @@
import BasePageObject from './BasePageObject';
const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute';
const AV_MODERATION_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectTitle';
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 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 notification = this.participant.driver.$(`[data-testid="${testId}"]`);
if (skipNonExisting && !await notification.isExisting()) {
return Promise.resolve();
}
await notification.waitForExist();
await notification.waitForStable();
const closeButton = notification.$('#close-notification');
await closeButton.moveTo();
await closeButton.click();
}
/**
* 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: 3000,
timeoutMsg: 'Knocking participant notification not displayed'
});
return await knockingParticipantNotification.getText();
}
/**
* Admits the last knocking participant (it is the only one).
*/
async allowLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_ADMIT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification that someone's access was approved.
*/
getLobbyParticipantAccessGranted() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessGranted() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_GRANTED_TEST_ID);
}
/**
* Waits until a notifications with the given testId is found and returns its text.
* @return the notification text.
*/
private async waitForNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
await notificationElement.waitForExist({ timeout: 2_000 });
return await notificationElement.getText();
}
/**
* Gets the text from the notification with the given testId, if the notification exists (otherwise returns
* undefined).
* @param testId
* @return the notification text.
*/
async getNotificationText(testId: string) {
const notificationElement = this.participant.driver.$(`[data-testid="${testId}"]`);
if (await notificationElement.isExisting()) {
return await notificationElement.getText();
}
}
/**
* Rejects the last knocking participant (it is the only one).
*/
async rejectLobbyParticipant() {
const admitButton
= this.participant.driver.$(`[data-testid="${LOBBY_PARTICIPANT_REJECT_TEST_ID}"]`);
await admitButton.waitForExist();
await admitButton.waitForClickable();
await admitButton.click();
}
/**
* The notification test that someone's access was denied.
*/
getLobbyParticipantAccessDenied() {
return this.waitForNotificationText(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Closes the notification.
*/
closeLobbyParticipantAccessDenied() {
return this.closeLobbyNotification(LOBBY_PARTICIPANT_ACCESS_DENIED_TEST_ID);
}
/**
* Waits for the notification for access denied for entering the lobby is shown.
*/
async waitForLobbyAccessDeniedNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${LOBBY_ACCESS_DENIED_TEST_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Will wait 3 seconds for the knocking participants to disappear and return true or will return false.
* @return <tt>true</tt> if the knocking participants list was not displayed.
*/
waitForHideOfKnockingParticipants() {
return this.participant.driver.$(LOBBY_KNOCKING_PARTICIPANT_NOTIFICATION_XPATH)
.waitForDisplayed({
timeout: 3000,
reverse: true
});
}
/**
* Closes local notification, for the participant that was denied.
*/
async closeLocalLobbyAccessDenied() {
await this.participant.driver.$('[data-testid="lobby.joinRejectedMessage"').waitForExist();
const dismissButton
= this.participant.driver.$('[data-testid="lobby.joinRejectedTitle-dismiss"]');
await dismissButton.moveTo();
await dismissButton.click();
}
/**
* Closes the `you are muted` notification.
*/
async closeYouAreMutedNotification() {
return this.closeNotification(YOU_ARE_MUTED_TEST_ID, true);
}
}

View File

@@ -0,0 +1,287 @@
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();
await meetingParticipantMoreOptions.click();
}
/**
* Clicks the invite button.
*/
async clickInvite() {
if (!await this.isOpen()) {
await this.open();
}
const inviteButton = this.participant.driver.$(`aria/${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.$(`aria/More moderation options ${participantNameToReject}`);
await moreOptionsButton.click();
const rejectButton = this.participant.driver
.$(`[data-testid="reject-${participantIdToReject}"]`);
await rejectButton.waitForExist();
await rejectButton.click();
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).click();
}
}

View File

@@ -0,0 +1,50 @@
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.click();
await passwordInput.clearValue();
await this.participant.driver.keys(password);
await this.clickOkButton();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
import BaseDialog from './BaseDialog';
const EMAIL_FIELD = '#setEmail';
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"]';
/**
* 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);
}
/**
* Enters the passed in email into the email field.
* @param email
*/
async setEmail(email: string) {
await this.openProfileTab();
await this.participant.driver.$(EMAIL_FIELD).setValue(email);
}
/**
* Returns the participant's email displayed in the settings dialog.
*/
async getEmail() {
await this.openProfileTab();
return await this.participant.driver.$(EMAIL_FIELD).getValue();
}
/**
* Clicks the OK button on the settings dialog to close the dialog and save any changes made.
*/
submit() {
return this.clickOkButton();
}
/**
* Sets the start audio muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartAudioMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_AUDIO_MUTED_CHECKBOX, enable);
}
/**
* Sets the start video muted feature to enabled/disabled.
* @param {boolean} enable - true for enabled and false for disabled.
* @returns {Promise<void>}
*/
async setStartVideoMuted(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(START_VIDEO_MUTED_CHECKBOX, enable);
}
/**
* Sets the state checked/selected of a checkbox in the settings dialog.
*/
async setHideSelfView(hideSelfView: boolean) {
await this.openMoreTab();
await this.setCheckbox(HIDE_SELF_VIEW_CHECKBOX, hideSelfView);
}
/**
* Sets the follow me feature to enabled/disabled.
* @param enable
*/
async setFollowMe(enable: boolean) {
await this.openModeratorTab();
await this.setCheckbox(FOLLOW_ME_CHECKBOX, enable);
}
/**
* Returns true if the follow me checkbox is displayed in the settings dialog.
*/
async isFollowMeDisplayed() {
const elem = this.participant.driver.$(X_PATH_MODERATOR_TAB);
if (!await elem.isExisting()) {
return false;
}
await this.openModeratorTab();
return await this.participant.driver.$$(FOLLOW_ME_CHECKBOX).length > 0;
}
/**
* Sets the state of a checkbox.
* @param selector
* @param enable
* @private
*/
private async setCheckbox(selector: string, enable: boolean) {
const checkbox = this.participant.driver.$(selector);
await checkbox.waitForExist();
if (enable !== await checkbox.isSelected()) {
// we show a div with svg and text after the input and those elements grab the click
// so we need to click on the parent element
await this.participant.driver.$(`${selector}//ancestor::div[1]`).click();
}
}
}

View File

@@ -0,0 +1,313 @@
import BasePageObject from './BasePageObject';
const AUDIO_MUTE = 'Mute microphone';
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 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 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/${accessibilityCSSSelector}`);
}
/**
* The audio mute button.
*/
get audioMuteBtn() {
return this.getButton(AUDIO_MUTE);
}
/**
* The audio unmute button.
*/
get audioUnMuteBtn() {
return this.getButton(AUDIO_UNMUTE);
}
/**
* Clicks audio mute button.
*
* @returns {Promise<void>}
*/
clickAudioMuteButton(): Promise<void> {
this.participant.log('Clicking on: Audio Mute Button');
return this.audioMuteBtn.click();
}
/**
* Clicks audio unmute button.
*
* @returns {Promise<void>}
*/
clickAudioUnmuteButton(): Promise<void> {
this.participant.log('Clicking on: Audio Unmute Button');
return this.audioUnMuteBtn.click();
}
/**
* 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>}
*/
clickVideoMuteButton(): Promise<void> {
this.participant.log('Clicking on: Video Mute Button');
return this.videoMuteBtn.click();
}
/**
* Clicks video unmute button.
*
* @returns {Promise<void>}
*/
clickVideoUnmuteButton(): Promise<void> {
this.participant.log('Clicking on: Video Unmute Button');
return this.videoUnMuteBtn.click();
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
clickCloseParticipantsPaneButton(): Promise<void> {
this.participant.log('Clicking on: Close Participants pane Button');
return this.getButton(CLOSE_PARTICIPANTS_PANE).click();
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
clickParticipantsPaneButton(): Promise<void> {
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.
*/
clickRaiseHandButton(): Promise<void> {
this.participant.log('Clicking on: Raise hand Button');
return this.getButton(RAISE_HAND).click();
}
/**
* Clicks on the chat button that opens chat panel.
*/
clickChatButton(): Promise<void> {
this.participant.log('Clicking on: Chat Button');
return this.getButton(CHAT).click();
}
/**
* Clicks on the chat button that closes chat panel.
*/
clickCloseChatButton(): Promise<void> {
this.participant.log('Clicking on: Close Chat Button');
return this.getButton(CLOSE_CHAT).click();
}
/**
* Clicks on the desktop sharing button that starts desktop sharing.
*/
clickDesktopSharingButton() {
return this.getButton(DESKTOP).click();
}
/**
* Clicks on the desktop sharing button to stop it.
*/
clickStopDesktopSharingButton() {
return this.getButton(STOP_DESKTOP).click();
}
/**
* 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.
*/
clickHangupButton(): Promise<void> {
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);
}
/**
* Ensure the overflow menu is open and clicks on a specified button.
* @param accessibilityLabel The accessibility label of the button to be clicked.
* @private
*/
private async clickButtonInOverflowMenu(accessibilityLabel: 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();
this.participant.log(`Clicking on: ${accessibilityLabel}`);
await this.getButton(accessibilityLabel).click();
await this.closeOverflowMenu();
}
/**
* Checks if the overflow menu is open and visible.
* @private
*/
private async isOverflowMenuOpen() {
return await this.participant.driver.$$(`aria/${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;
}
await this.clickOverflowButton();
await this.waitForOverFlowMenu(true);
}
/**
* Ensures the overflow menu is not displayed.
* @private
*/
async closeOverflowMenu() {
if (!await this.isOverflowMenuOpen()) {
return;
}
await this.clickOverflowButton();
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,
timeout: 3000,
timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}`
});
}
/**
* Gets the participant's avatar image element located in the toolbar.
*/
async getProfileImage() {
await this.openOverflowMenu();
const elem = this.participant.driver.$(`[aria-label^="${PROFILE}"] img`);
return await elem.isExisting() ? await elem.getAttribute('src') : null;
}
}

View File

@@ -0,0 +1,42 @@
import { Key } from 'webdriverio';
import BaseDialog from './BaseDialog';
const VIDEO_QUALITY_SLIDER_CLASS = 'custom-slider';
/**
* The video quality dialog.
*/
export default class VideoQualityDialog extends BaseDialog {
/**
* Opens the video quality dialog and sets the video quality to the minimum or maximum definition.
* @param audioOnly - Whether to set the video quality to audio only (minimum).
* @private
*/
async setVideoQuality(audioOnly: boolean) {
await this.participant.getToolbar().clickVideoQualityButton();
const videoQualitySlider = this.participant.driver.$(`.${VIDEO_QUALITY_SLIDER_CLASS}`);
const audioOnlySliderValue = parseInt(await videoQualitySlider.getAttribute('min'), 10);
const maxDefinitionSliderValue = parseInt(await videoQualitySlider.getAttribute('max'), 10);
const activeValue = parseInt(await videoQualitySlider.getAttribute('value'), 10);
const targetValue = audioOnly ? audioOnlySliderValue : maxDefinitionSliderValue;
const distanceToTargetValue = targetValue - activeValue;
const keyDirection = distanceToTargetValue > 0 ? Key.ArrowRight : Key.ArrowLeft;
// we need to click the element to activate it so it will receive the keys
await videoQualitySlider.click();
// Move the slider to the target value.
for (let i = 0; i < Math.abs(distanceToTargetValue); i++) {
await this.participant.driver.keys(keyDirection);
}
// Close the video quality dialog.
await this.clickCloseButton();
}
}

View File

@@ -0,0 +1,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.$('aria/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();
}
}

Binary file not shown.

View File

@@ -0,0 +1,9 @@
fakeAudioStream.wav created from [1].
Speaker: The Jacobin
Derivative of [2]
Licensed under Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0)[3]
[1]: https://en.wikipedia.org/wiki/File:Helium.ogg
[2]: https://en.wikipedia.org/wiki/Helium?oldid=302049858
[3]: https://creativecommons.org/licenses/by-sa/3.0/deed.en

View File

@@ -0,0 +1,133 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>iframe API test</title>
</head>
<body>
<script>
/**
* Ported from https://github.com/jitsi/jitsi-meet-torture/blob/master/src/test/resources/files/iframeAPITest.html
*/
const blacklist = [ '__proto__', 'constructor', 'prototype' ];
const paramStr = document.location.hash;
const params = {};
const paramParts = paramStr?.substring(1).split('&') || [];
paramParts.forEach(part => {
const param = part.split('=');
const key = param[0];
if (!key || key.split('.').some(k => blacklist.includes(k))) {
return;
}
let value;
try {
value = param[1];
const decoded = decodeURIComponent(value).replace(/\\&/, '&')
.replace(/[\u2018\u2019]/g, '\'')
.replace(/[\u201C\u201D]/g, '"');
value = decoded === 'undefined' || decoded === '' ? undefined : JSON.parse(decoded);
} catch (e) {
console.error(`Failed to parse URL parameter value: ${String(value)}`, e);
return;
}
params[key] = value;
});
const json = {
config: {},
interfaceConfig: {}
};
function log(msg) {
console.log(`${new Date().toISOString()} [MeetTestIFrame] ${msg}`);
}
for (const param of Object.keys(params)) {
let base = json;
const names = param.split('.');
const last = names.pop() ?? '';
for (const name of names) {
base = base[name] = base[name] || {};
}
base[last] = params[param];
}
const { config, domain, interfaceConfig, jwt, password, room:roomName, userInfo: uInfoObj } = json;
let tenant = json.tenant || '';
let userInfo;
if (uInfoObj) {
if (uInfoObj.length > 0) {
userInfo = JSON.parse(uInfoObj);
} else if (Object.keys(uInfoObj).length) {
userInfo = uInfoObj;
}
}
if (tenant.length > 0) {
tenant = tenant + '/';
}
const options = {
jwt,
roomName: `${tenant}${roomName}`,
configOverwrite: config,
interfaceConfigOverwrite: interfaceConfig,
userInfo,
onload: function () {
log(`iframeAPI.onload`);
// we use this to save data from api to be accessible to tests
window.jitsiAPI.test = {};
window.jitsiAPI.addEventListener('participantRoleChanged', function(event) {
log(`participantRoleChanged: ${JSON.stringify(event)} myEndpointId:${window.jitsiAPI.test.myEndpointId}`);
if (event.role === "moderator" && event.id === window.jitsiAPI.test.myEndpointId) {
window.jitsiAPI.test.isModerator = true;
}
window.jitsiAPI.test['participantRoleChanged'] = event;
});
window.jitsiAPI.addEventListener('audioAvailabilityChanged', function(event) {
log(`audioAvailabilityChanged: ${JSON.stringify(event)}`);
window.jitsiAPI.test.audioAvailabilityChanged = event;
});
window.jitsiAPI.addEventListener('videoAvailabilityChanged', function(event) {
log(`videoAvailabilityChanged: ${JSON.stringify(event)}`);
window.jitsiAPI.test.videoAvailabilityChanged = event;
});
window.jitsiAPI.addEventListener('videoConferenceJoined', function(event) {
log(`videoConferenceJoined: ${JSON.stringify(event)}`);
window.jitsiAPI.test.videoConferenceJoined = event;
window.jitsiAPI.test.myEndpointId = event.id;
});
if (password && password.length > 0) {
// join a protected channel with the password supplied
window.jitsiAPI.on('passwordRequired', function ()
{
window.jitsiAPI.executeCommand('password', password);
});
}
}
};
const externalAPIScript = document.createElement('script');
externalAPIScript.src = `https://${domain}/${tenant}external_api.js`;
externalAPIScript.type = "text/javascript";
externalAPIScript.onload = function(){
log(`externalAPIScript.onload`);
window.jitsiAPI = new JitsiMeetExternalAPI(domain, options);
}
document.getElementsByTagName('head')[0].appendChild(externalAPIScript);
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
import { ensureTwoParticipants } from '../../helpers/participants';
describe('Audio only', () => {
it('joining the meeting', () => ensureTwoParticipants());
/**
* Enables audio only mode for p1 and verifies that the other participant sees participant1 as video muted.
*/
it('set and check', () => setAudioOnlyAndCheck(true));
/**
* Verifies that participant1 sees avatars for itself and other participants.
*/
it('avatars check', async () => {
const { p1 } = ctx;
await p1.driver.$('//div[@id="dominantSpeaker"]').waitForDisplayed();
// Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed.
await p1.assertThumbnailShowsAvatar(p1);
});
/**
* Disables audio only mode and verifies that both participants see p1 as not video muted.
*/
it('disable and check', () => setAudioOnlyAndCheck(false));
/**
* Mutes video on participant1, toggles audio-only twice and then verifies if both participants see participant1
* as video muted.
*/
it('mute video, set twice and check muted', async () => {
const { p1 } = ctx;
// Mute video on participant1.
await p1.getToolbar().clickVideoMuteButton();
await verifyVideoMute(true);
// Enable audio-only mode.
await setAudioOnlyAndCheck(true);
// Disable audio-only mode.
await p1.getVideoQualityDialog().setVideoQuality(false);
// p1 should stay muted since it was muted before audio-only was enabled.
await verifyVideoMute(true);
});
it('unmute video and check not muted', async () => {
// Unmute video on participant1.
await ctx.p1.getToolbar().clickVideoUnmuteButton();
await verifyVideoMute(false);
});
});
/**
* Toggles the audio only state of a p1 participant and verifies participant sees the audio only label and that
* p2 participant sees a video mute state for the former.
* @param enable
*/
async function setAudioOnlyAndCheck(enable: boolean) {
const { p1 } = ctx;
await p1.getVideoQualityDialog().setVideoQuality(enable);
await verifyVideoMute(enable);
await p1.driver.$('//div[@id="videoResolutionLabel"][contains(@class, "audio-only")]')
.waitForDisplayed({ reverse: !enable });
}
/**
* Verifies that p1 and p2 see p1 as video muted or not.
* @param muted
*/
async function verifyVideoMute(muted: boolean) {
const { p1, p2 } = ctx;
// Verify the observer sees the testee in the desired muted state.
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted);
// Verify the testee sees itself in the desired muted state.
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, !muted);
}

View File

@@ -0,0 +1,41 @@
import { ensureTwoParticipants } from '../../helpers/participants';
describe('DisplayName', () => {
it('joining the meeting', () => ensureTwoParticipants({ skipDisplayName: true }));
it('check change', async () => {
const { p1, p2 } = ctx;
// default remote display name
const defaultDisplayName = await p1.execute(() => config.defaultRemoteDisplayName);
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
// Checks whether default display names are set and shown, when both sides still miss the display name.
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EndpointId)).toBe(defaultDisplayName);
expect(await p2.getFilmstrip().getRemoteDisplayName(p1EndpointId)).toBe(defaultDisplayName);
const randomName = `Name${Math.trunc(Math.random() * 1_000_000)}`;
await p2.setLocalDisplayName(randomName);
expect(await p2.getLocalDisplayName()).toBe(randomName);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EndpointId)).toBe(randomName);
});
it('check persistence', async () => {
const { p2 } = ctx;
const randomName = `Name${Math.trunc(Math.random() * 1_000_000)}`;
await p2.setLocalDisplayName(randomName);
expect(await p2.getLocalDisplayName()).toBe(randomName);
await p2.hangup();
await ensureTwoParticipants({
skipDisplayName: true
});
expect(await p2.getLocalDisplayName()).toBe(randomName);
});
});

View File

@@ -0,0 +1,20 @@
import { ensureTwoParticipants } from '../../helpers/participants';
describe('End Conference', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('hangup call and check', async () => {
const { p1 } = ctx;
const url = await p1.driver.getUrl();
await p1.getToolbar().clickHangupButton();
await p1.driver.waitUntil(
async () => await p1.driver.getUrl() !== url,
{
timeout: 5000,
timeoutMsg: 'p1 did not navigate away from the conference'
}
);
});
});

View File

@@ -0,0 +1,52 @@
import process from 'node:process';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
import { cleanup, isDialInEnabled, waitForAudioFromDialInParticipant } from '../helpers/DialIn';
describe('Fake Dial-In', () => {
it('join participant', async () => {
// we execute fake dial in only if the real dial in is not enabled
// check rest url is not configured
if (process.env.DIAL_IN_REST_URL) {
ctx.skipSuiteTests = true;
return;
}
await ensureOneParticipant();
// check dial-in is enabled, so skip
if (await isDialInEnabled(ctx.p1)) {
ctx.skipSuiteTests = true;
}
});
it('open invite dialog', async () => {
await ctx.p1.getInviteDialog().open();
await ctx.p1.getInviteDialog().clickCloseButton();
});
it('invite second participant', async () => {
if (!await ctx.p1.isInMuc()) {
// local participant did not join abort
return;
}
await ensureTwoParticipants();
});
it('wait for audio from second participant', async () => {
const { p1 } = ctx;
if (!await p1.isInMuc()) {
// local participant did not join abort
return;
}
await waitForAudioFromDialInParticipant(p1);
await cleanup(p1);
});
});

View File

@@ -0,0 +1,42 @@
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
describe('Grant moderator', () => {
it('joining the meeting', async () => {
await ensureOneParticipant();
if (await ctx.p1.execute(() => typeof APP.conference._room.grantOwner !== 'function')) {
ctx.skipSuiteTests = true;
return;
}
await ensureTwoParticipants();
});
it('grant moderator and validate', async () => {
const { p1, p2 } = ctx;
if (!await p1.isModerator()) {
ctx.skipSuiteTests = true;
return;
}
if (await p2.isModerator()) {
ctx.skipSuiteTests = true;
return;
}
await p1.getFilmstrip().grantModerator(p2);
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 did not become moderator'
}
);
});
});

View File

@@ -0,0 +1,44 @@
import { ensureTwoParticipants } from '../../helpers/participants';
describe('Kick', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants();
if (!await ctx.p1.isModerator()) {
ctx.skipSuiteTests = true;
}
});
it('kick and check', () => kickParticipant2AndCheck());
it('kick p2p and check', async () => {
await ensureTwoParticipants({
configOverwrite: {
p2p: {
enabled: true
}
}
});
await kickParticipant2AndCheck();
});
});
/**
* Kicks the second participant and checks that the participant is removed from the conference and that dialog is open.
*/
async function kickParticipant2AndCheck() {
const { p1, p2 } = ctx;
await p1.getFilmstrip().kickParticipant(await p2.getEndpointId());
await p1.waitForParticipants(0);
// check that the kicked participant sees the kick reason dialog
// let's wait for this to appear at least 2 seconds
await p2.driver.waitUntil(
async () => p2.isLeaveReasonDialogOpen(), {
timeout: 2000,
timeoutMsg: 'No leave reason dialog shown for p2'
});
}

View File

@@ -0,0 +1,189 @@
import { ensureOneParticipant, ensureTwoParticipants, joinSecondParticipant } from '../../helpers/participants';
import type SecurityDialog from '../../pageobjects/SecurityDialog';
let roomKey: string;
/**
* 1. Lock the room (make sure the image changes to locked)
* 2. Join with a second browser/tab
* 3. Make sure we are required to enter a password.
* (Also make sure the padlock is locked)
* 4. Enter wrong password, make sure we are not joined in the room
* 5. Unlock the room (Make sure the padlock is unlocked)
* 6. Join again and make sure we are not asked for a password and that
* the padlock is unlocked.
*/
describe('Lock Room', () => {
it('joining the meeting', () => ensureOneParticipant());
it('locks the room', () => participant1LockRoom());
it('enter participant in locked room', async () => {
// first enter wrong pin then correct one
await joinSecondParticipant({
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const { p2 } = ctx;
// wait for password prompt
const p2PasswordDialog = p2.getPasswordDialog();
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give sometime to the password prompt to disappear and send the password
await p2.driver.pause(500);
// wait for password prompt
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(roomKey);
await p2.waitToJoinMUC();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, true);
});
it('unlock room', async () => {
// Unlock room. Check whether room is still locked. Click remove and check whether it is unlocked.
await ctx.p2.hangup();
await participant1UnlockRoom();
});
it('enter participant in unlocked room', async () => {
// Just enter the room and check that is not locked.
// if we fail to unlock the room this one will detect it
// as participant will fail joining
await ensureTwoParticipants();
const { p2 } = ctx;
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, false);
await p2SecurityDialog.clickCloseButton();
});
it('update locked state while participants in room', async () => {
// Both participants are in unlocked room, lock it and see whether the
// change is reflected on the second participant icon.
await participant1LockRoom();
const { p2 } = ctx;
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, true);
await participant1UnlockRoom();
await waitForRoomLockState(p2SecurityDialog, false);
});
it('unlock after participant enter wrong password', async () => {
// P1 locks the room. Participant tries to enter using wrong password.
// P1 unlocks the room and Participant submits the password prompt with no password entered and
// should enter of unlocked room.
await ctx.p2.hangup();
await participant1LockRoom();
await joinSecondParticipant({
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const { p2 } = ctx;
// wait for password prompt
const p2PasswordDialog = p2.getPasswordDialog();
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give sometime to the password prompt to disappear and send the password
await p2.driver.pause(500);
// wait for password prompt
await p2PasswordDialog.waitForDialog();
await participant1UnlockRoom();
await p2PasswordDialog.clickOkButton();
await p2.waitToJoinMUC();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, false);
});
});
/**
* Participant1 locks the room.
*/
async function participant1LockRoom() {
roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
const { p1 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await waitForRoomLockState(p1SecurityDialog, false);
await p1SecurityDialog.addPassword(roomKey);
await p1SecurityDialog.clickCloseButton();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await waitForRoomLockState(p1SecurityDialog, true);
await p1SecurityDialog.clickCloseButton();
}
/**
* Participant1 unlocks the room.
*/
async function participant1UnlockRoom() {
const { p1 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await p1SecurityDialog.removePassword();
await waitForRoomLockState(p1SecurityDialog, false);
await p1SecurityDialog.clickCloseButton();
}
/**
* Waits for the room to be locked or unlocked.
* @param securityDialog
* @param locked
*/
function waitForRoomLockState(securityDialog: SecurityDialog, locked: boolean) {
return securityDialog.participant.driver.waitUntil(
async () => await securityDialog.isLocked() === locked,
{
timeout: 3_000, // 3 seconds
timeoutMsg: `Timeout waiting for the room to unlock for ${securityDialog.participant.name}.`
}
);
}

View File

@@ -0,0 +1,138 @@
import type { Participant } from '../../helpers/Participant';
import {
checkForScreensharingTile,
ensureOneParticipant,
ensureTwoParticipants,
joinSecondParticipant,
muteAudioAndCheck,
unmuteAudioAndCheck,
unmuteVideoAndCheck
} from '../../helpers/participants';
describe('Mute', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('mute p1 and check', () => toggleMuteAndCheck(ctx.p1, ctx.p2, true));
it('unmute p1 and check', () => toggleMuteAndCheck(ctx.p1, ctx.p2, false));
it('mute p2 and check', () => toggleMuteAndCheck(ctx.p2, ctx.p1, true));
it('unmute p2 and check', () => toggleMuteAndCheck(ctx.p2, ctx.p1, false));
it('p1 mutes p2 and check', async () => {
const { p1, p2 } = ctx;
if (!await p1.isModerator()) {
return;
}
await p1.getFilmstrip().muteAudio(p2);
// and now check whether second participant is muted
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
});
it('p2 unmute after p1 mute and check', async () => {
const { p1, p2 } = ctx;
await unmuteAudioAndCheck(p2, p1);
});
it('p1 mutes before p2 joins', async () => {
await ctx.p2.hangup();
const { p1 } = ctx;
await p1.getToolbar().clickAudioMuteButton();
await ensureTwoParticipants();
const { p2 } = ctx;
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1);
await toggleMuteAndCheck(p1, p2, false);
});
it('mute before join and screen share after in p2p', () => muteP1BeforeP2JoinsAndScreenshare(true));
it('mute before join and screen share after with jvb', () => muteP1BeforeP2JoinsAndScreenshare(false));
});
/**
* Toggles the mute state of a specific Meet conference participant and
* verifies that a specific other Meet conference participants sees a
* specific mute state for the former.
* @param testee The participant whose mute state is to be toggled.
* @param observer The participant to verify the mute state of {@code testee}.
* @param muted the mute state of {@code testee} expected to be observed by {@code observer}.
*/
async function toggleMuteAndCheck(
testee: Participant,
observer: Participant,
muted: boolean) {
if (muted) {
await muteAudioAndCheck(testee, observer);
} else {
await unmuteAudioAndCheck(testee, observer);
}
}
/**
* Video mutes participant1 before participant2 joins and checks if participant1 can share or unmute video
* and that media is being received on participant2 in both the cases.
*
* @param p2p whether to enable p2p or not.
*/
async function muteP1BeforeP2JoinsAndScreenshare(p2p: boolean) {
await Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup() ]);
await ensureOneParticipant({
configOverwrite: {
p2p: {
enabled: p2p
}
}
});
const { p1 } = ctx;
await p1.getToolbar().clickVideoMuteButton();
await joinSecondParticipant({
configOverwrite: {
p2p: {
enabled: p2p
}
}
});
const { p2 } = ctx;
if (p2p) {
await p2.waitForP2PIceConnected();
} else {
await p2.waitForIceConnected();
}
await p2.waitForSendMedia();
// Check if p1 appears video muted on p2.
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
// Start desktop share.
await p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p2);
// we need to pass the id of the fake participant we use for the screensharing
await p2.waitForRemoteVideo(`${await p1.getEndpointId()}-v1`);
// Stop desktop share and unmute video and check for video again.
await p1.getToolbar().clickStopDesktopSharingButton();
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await unmuteVideoAndCheck(p1, p2);
await p2.waitForRemoteVideo(await p1.getEndpointId());
}

View File

@@ -0,0 +1,125 @@
import { ensureOneParticipant, joinFirstParticipant, joinSecondParticipant } from '../../helpers/participants';
describe('PreJoin', () => {
it('display name required', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
},
requireDisplayName: true
},
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const p1PreJoinScreen = ctx.p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
await joinButton.click();
const error = p1PreJoinScreen.getErrorOnJoin();
await error.waitForDisplayed();
await ctx.p1.hangup();
});
it('without lobby', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const p1PreJoinScreen = ctx.p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
await ctx.p1.hangup();
});
it('without audio', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const { p1 } = ctx;
const p1PreJoinScreen = p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
await p1PreJoinScreen.getJoinOptions().click();
const joinWithoutAudioBtn = p1PreJoinScreen.getJoinWithoutAudioButton();
await joinWithoutAudioBtn.waitForClickable();
await joinWithoutAudioBtn.click();
await p1.waitToJoinMUC();
await p1.driver.$('//div[contains(@class, "audio-preview")]//div[contains(@class, "toolbox-icon") '
+ 'and contains(@class, "toggled") and contains(@class, "disabled")]')
.waitForDisplayed();
await ctx.p1.hangup();
});
it('with lobby', async () => {
await ensureOneParticipant();
const { p1 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLobbyEnabled()).toBe(false);
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled();
await joinSecondParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const p1PreJoinScreen = ctx.p2.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
});
});

View File

@@ -0,0 +1,68 @@
import type { Participant } from '../../helpers/Participant';
import { ensureTwoParticipants } from '../../helpers/participants';
describe('Self view', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('hide from menu', async () => {
const { p1 } = ctx;
await checkSelfViewHidden(p1, false);
await p1.getFilmstrip().hideSelfView();
await checkSelfViewHidden(p1, true, true);
await p1.getToolbar().clickEnterTileViewButton();
await checkSelfViewHidden(p1, true);
});
it('show from settings', async () => {
const { p1 } = ctx;
await toggleSelfViewFromSettings(p1, false);
await checkSelfViewHidden(p1, false);
});
it('hide from settings', async () => {
const { p1 } = ctx;
await toggleSelfViewFromSettings(p1, true);
await checkSelfViewHidden(p1, true, true);
});
it('check in alone meeting', async () => {
const { p1, p2 } = ctx;
await checkSelfViewHidden(p1, true);
await p2.hangup();
await checkSelfViewHidden(p1, true);
});
});
/**
* Toggles the self view option from the settings dialog.
*/
async function toggleSelfViewFromSettings(participant: Participant, hide: boolean) {
await participant.getToolbar().clickSettingsButton();
const settings = participant.getSettingsDialog();
await settings.waitForDisplay();
await settings.setHideSelfView(hide);
await settings.submit();
}
/**
* Checks whether the local self view is displayed or not.
*/
async function checkSelfViewHidden(participant: Participant, hidden: boolean, checkNotification = false) {
if (checkNotification) {
await participant.getNotifications().waitForReEnableSelfViewNotification();
await participant.getNotifications().closeReEnableSelfViewNotification();
}
await participant.getFilmstrip().assertSelfViewIsHidden(hidden);
}

View File

@@ -0,0 +1,29 @@
import type { Participant } from '../../helpers/Participant';
import { ensureTwoParticipants } from '../../helpers/participants';
describe('Single port', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('test', async () => {
const { p1, p2 } = ctx;
const port1 = await getRemotePort(p1);
const port2 = await getRemotePort(p2);
expect(Number.isInteger(port1)).toBe(true);
expect(Number.isInteger(port2)).toBe(true);
expect(port1).toBe(port2);
});
});
/**
* Get the remote port of the participant.
* @param participant
*/
async function getRemotePort(participant: Participant) {
const data = await participant.execute(() => APP?.conference?.getStats()?.transport[0]?.ip);
const parts = data.split(':');
return parts.length > 1 ? parseInt(parts[1], 10) : '';
}

View File

@@ -0,0 +1,41 @@
import { ensureTwoParticipants, muteVideoAndCheck, unmuteVideoAndCheck } from '../../helpers/participants';
describe('Stop video', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('stop video and check', () => muteVideoAndCheck(ctx.p1, ctx.p2));
it('start video and check', () => unmuteVideoAndCheck(ctx.p1, ctx.p2));
it('start video and check stream', async () => {
await muteVideoAndCheck(ctx.p1, ctx.p2);
// now participant2 should be on large video
const largeVideoId = await ctx.p1.getLargeVideo().getId();
await unmuteVideoAndCheck(ctx.p1, ctx.p2);
// check if video stream from second participant is still on large video
expect(largeVideoId).toBe(await ctx.p1.getLargeVideo().getId());
});
it('stop video on participant and check', () => muteVideoAndCheck(ctx.p2, ctx.p1));
it('start video on participant and check', () => unmuteVideoAndCheck(ctx.p2, ctx.p1));
it('stop video on before second joins', async () => {
await ctx.p2.hangup();
const { p1 } = ctx;
await p1.getToolbar().clickVideoMuteButton();
await ensureTwoParticipants();
const { p2 } = ctx;
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await unmuteVideoAndCheck(p1, p2);
});
});

View File

@@ -0,0 +1,32 @@
import type { Participant } from '../../helpers/Participant';
import { ensureTwoParticipants } from '../../helpers/participants';
const MY_TEST_SUBJECT = 'My Test Subject';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
describe('Subject', () => {
it('joining the meeting', () => ensureTwoParticipants({
configOverwrite: {
subject: MY_TEST_SUBJECT
}
}));
it('check', async () => {
await checkSubject(ctx.p1, MY_TEST_SUBJECT);
await checkSubject(ctx.p2, MY_TEST_SUBJECT);
});
});
/**
* Check was subject set.
*
* @param participant
* @param subject
*/
async function checkSubject(participant: Participant, subject: string) {
const localTile = participant.driver.$(SUBJECT_XPATH);
await localTile.moveTo();
expect(await localTile.getText()).toBe(subject);
}

View File

@@ -0,0 +1,39 @@
import { ensureTwoParticipants } from '../../helpers/participants';
describe('SwitchVideo', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('p1 click on local', () => ctx.p1.getFilmstrip().pinParticipant(ctx.p1));
it('p1 click on remote', async () => {
await closeToolbarMenu();
const { p1, p2 } = ctx;
await p1.getFilmstrip().pinParticipant(p2);
});
it('p1 unpin remote', () => ctx.p1.getFilmstrip().unpinParticipant(ctx.p2));
it('p2 pin remote', () => ctx.p2.getFilmstrip().pinParticipant(ctx.p1));
it('p2 unpin remote', () => ctx.p2.getFilmstrip().unpinParticipant(ctx.p1));
it('p2 click on local', () => ctx.p2.getFilmstrip().pinParticipant(ctx.p2));
it('p2 click on remote', async () => {
await closeToolbarMenu();
const { p1, p2 } = ctx;
await p2.getFilmstrip().pinParticipant(p1);
});
});
/**
* Closes the overflow menu on both participants.
*/
async function closeToolbarMenu() {
await ctx.p1.getToolbar().closeOverflowMenu();
await ctx.p2.getToolbar().closeOverflowMenu();
}

View File

@@ -0,0 +1,26 @@
import type { Participant } from '../../helpers/Participant';
import { ensureTwoParticipants } from '../../helpers/participants';
describe('UDP', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('check', async () => {
const { p1, p2 } = ctx;
// just in case wait 1500, this is the interval we use for `config.pcStatsInterval`
await p1.driver.pause(1500);
expect(await getProtocol(p1)).toBe('udp');
expect(await getProtocol(p2)).toBe('udp');
});
});
/**
* Get the remote port of the participant.
* @param participant
*/
async function getProtocol(participant: Participant) {
const data = await participant.execute(() => APP?.conference?.getStats()?.transport[0]?.type);
return data.toLowerCase();
}

View File

@@ -0,0 +1,41 @@
import { multiremotebrowser } from '@wdio/globals';
import { config } from '../../helpers/TestsConfig';
import { ensureTwoParticipants } from '../../helpers/participants';
describe('URL Normalisation', () => {
it('joining the meeting', async () => {
// if we are running with token this becomes ugly to match the URL
if (config.jwt.preconfiguredToken) {
ctx.skipSuiteTests = true;
return;
}
// a hack to extract the baseUrl that the test will use
const baseUrl = multiremotebrowser.getInstance('p1').options.baseUrl;
if (!baseUrl) {
throw new Error('baseUrl is not set');
}
await ensureTwoParticipants({
forceTenant: 'tenant@example.com',
roomName: `${ctx.roomName}@example.com`
});
});
it('check', async () => {
const currentUrlStr = await ctx.p1.driver.getUrl();
const currentUrl = new URL(currentUrlStr);
const path = currentUrl.pathname;
const parts = path.split('/');
expect(parts[1]).toBe('tenantexample.com');
// @ts-ignore
expect(parts[2]).toBe(`${ctx.roomName}example.com`);
});
});

View File

@@ -0,0 +1,91 @@
import type { Participant } from '../../helpers/Participant';
import { ensureThreeParticipants, muteAudioAndCheck } from '../../helpers/participants';
describe('ActiveSpeaker', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await muteAudioAndCheck(p1, p2);
await muteAudioAndCheck(p2, p1);
await muteAudioAndCheck(p3, p1);
// participant1 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(p1, p2, p3);
// participant3 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(p3, p2, p1);
// participant2 becomes active speaker - check from participant1's perspective
await testActiveSpeaker(p2, p1, p3);
// check the displayed speakers, there should be only one speaker
await assertOneDominantSpeaker(p1);
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
});
});
/**
* Tries to make given participant an active speaker by unmuting it.
* Verifies from {@code participant2}'s perspective that the active speaker
* has been displayed on the large video area. Mutes him back.
*
* @param {Participant} activeSpeaker - <tt>Participant</tt> instance of the participant who will be tested as an
* active speaker.
* @param {Participant} otherParticipant1 - <tt>Participant</tt> of the participant who will be observing and verifying
* active speaker change.
* @param {Participant} otherParticipant2 - Used only to print some debugging info.
*/
async function testActiveSpeaker(
activeSpeaker: Participant, otherParticipant1: Participant, otherParticipant2: Participant) {
activeSpeaker.log(`Start testActiveSpeaker for participant: ${activeSpeaker.name}`);
const speakerEndpoint = await activeSpeaker.getEndpointId();
// just a debug print to go in logs
activeSpeaker.log('Unmuting in testActiveSpeaker');
// Unmute
await activeSpeaker.getToolbar().clickAudioUnmuteButton();
// just a debug print to go in logs
otherParticipant1.log(`Participant unmuted in testActiveSpeaker ${speakerEndpoint}`);
otherParticipant2.log(`Participant unmuted in testActiveSpeaker ${speakerEndpoint}`);
await activeSpeaker.getFilmstrip().assertAudioMuteIconIsDisplayed(activeSpeaker, true);
// Verify that the user is now an active speaker from otherParticipant1's perspective
const otherParticipant1Driver = otherParticipant1.driver;
await otherParticipant1Driver.waitUntil(
async () => await otherParticipant1.getLargeVideo().getResource() === speakerEndpoint,
{
timeout: 30_000, // 30 seconds
timeoutMsg: 'Active speaker not displayed on large video.'
});
// just a debug print to go in logs
activeSpeaker.log('Muting in testActiveSpeaker');
// Mute back again
await activeSpeaker.getToolbar().clickAudioMuteButton();
// just a debug print to go in logs
otherParticipant1.log(`Participant muted in testActiveSpeaker ${speakerEndpoint}`);
otherParticipant2.log(`Participant muted in testActiveSpeaker ${speakerEndpoint}`);
await otherParticipant1.getFilmstrip().assertAudioMuteIconIsDisplayed(activeSpeaker);
}
/**
* Asserts that the number of small videos with the dominant speaker
* indicator displayed equals 1.
*
* @param {Participant} participant - The participant to check.
*/
async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(
'//span[not(contains(@class, "tile-view"))]//span[contains(@class,"dominant-speaker")]').length).toBe(1);
}

View File

@@ -0,0 +1,297 @@
import { Participant } from '../../helpers/Participant';
import { config } from '../../helpers/TestsConfig';
import {
ensureOneParticipant,
ensureThreeParticipants, ensureTwoParticipants,
hangupAllParticipants,
unmuteAudioAndCheck,
unmuteVideoAndCheck
} from '../../helpers/participants';
describe('AVModeration', () => {
it('check for moderators', async () => {
// if all 3 participants are moderators, skip this test
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
if (!await p1.isModerator()
|| (await p1.isModerator() && await p2.isModerator() && await p3.isModerator())) {
ctx.skipSuiteTests = true;
}
});
it('check audio enable/disable', async () => {
const { p1, p3 } = ctx;
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await p1ParticipantsPane.close();
// Here we want to try unmuting and check that we are still muted.
await tryToAudioUnmuteAndCheck(p3, p1);
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStopAudioModeration();
await p1ParticipantsPane.close();
await unmuteAudioAndCheck(p3, p1);
});
it('check video enable/disable', async () => {
const { p1, p3 } = ctx;
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await p1ParticipantsPane.close();
// Here we want to try unmuting and check that we are still muted.
await tryToVideoUnmuteAndCheck(p3, p1);
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStopVideoModeration();
await p1ParticipantsPane.close();
await unmuteVideoAndCheck(p3, p1);
});
it('unmute by moderator', async () => {
const { p1, p2, p3 } = ctx;
await unmuteByModerator(p1, p3, true, true);
// moderation is stopped at this point, make sure participants 1 & 2 are also unmuted,
// participant3 was unmuted by unmuteByModerator
await unmuteAudioAndCheck(p2, p1);
await unmuteVideoAndCheck(p2, p1);
// make sure p1 is not muted after turning on and then off the AV moderation
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
});
it('hangup and change moderator', async () => {
// no moderator switching if jaas is available.
if (config.iframe.usesJaas) {
return;
}
await Promise.all([ ctx.p2.hangup(), ctx.p3.hangup() ]);
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await p2.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickAudioMuteButton();
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await p1ParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await p2.getToolbar().clickRaiseHandButton();
await p3.getToolbar().clickRaiseHandButton();
await p1.hangup();
// we don't use ensureThreeParticipants to avoid all meeting join checks
// all participants are muted and checks for media will fail
await ensureOneParticipant();
// After p1 re-joins either p2 or p3 is promoted to moderator. They should still be muted.
const isP2Moderator = await p2.isModerator();
const moderator = isP2Moderator ? p2 : p3;
const nonModerator = isP2Moderator ? p3 : p2;
const moderatorParticipantsPane = moderator.getParticipantsPane();
const nonModeratorParticipantsPane = nonModerator.getParticipantsPane();
await moderatorParticipantsPane.assertVideoMuteIconIsDisplayed(moderator);
await nonModeratorParticipantsPane.assertVideoMuteIconIsDisplayed(nonModerator);
await moderatorParticipantsPane.allowVideo(nonModerator);
await moderatorParticipantsPane.askToUnmute(nonModerator, false);
await nonModerator.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(nonModerator, p1);
await unmuteVideoAndCheck(nonModerator, p1);
await moderatorParticipantsPane.clickContextMenuButton();
await moderatorParticipantsPane.getAVModerationMenu().clickStopAudioModeration();
await moderatorParticipantsPane.getAVModerationMenu().clickStopVideoModeration();
});
it('grant moderator', async () => {
await hangupAllParticipants();
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await p1ParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await p1.getFilmstrip().grantModerator(p3);
await p3.driver.waitUntil(
() => p3.isModerator(), {
timeout: 5000,
timeoutMsg: `${p3.name} is not moderator`
});
await unmuteByModerator(p3, p2, false, true);
});
it('ask to unmute', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
// mute p2
await p2.getToolbar().clickAudioMuteButton();
// ask p2 to unmute
await p1.getParticipantsPane().askToUnmute(p2, true);
await p2.getNotifications().waitForAskToUnmuteNotification();
await p1.getParticipantsPane().close();
});
it('remove from whitelist', async () => {
const { p1, p2 } = ctx;
await unmuteByModerator(p1, p2, true, false);
// p1 mute audio on p2 and check
await p1.getFilmstrip().muteAudio(p2);
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
// we try to unmute and test it that it was still muted
await tryToAudioUnmuteAndCheck(p2, p1);
// stop video and check
await p1.getFilmstrip().muteVideo(p2);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await tryToVideoUnmuteAndCheck(p2, p1);
});
it('join moderated', async () => {
await hangupAllParticipants();
await ensureOneParticipant();
const p1ParticipantsPane = ctx.p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await p1ParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await p1ParticipantsPane.close();
// join with second participant and check
await ensureTwoParticipants({
skipInMeetingChecks: true
});
const { p1, p2 } = ctx;
await p2.getNotifications().closeYouAreMutedNotification();
await tryToAudioUnmuteAndCheck(p2, p1);
await tryToVideoUnmuteAndCheck(p2, p1);
// asked to unmute and check
await unmuteByModerator(p1, p2, false, false);
// mute and check
await p1.getFilmstrip().muteAudio(p2);
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await tryToAudioUnmuteAndCheck(p2, p1);
});
});
/**
* Checks a user can unmute after being asked by moderator.
* @param moderator - The participant that is moderator.
* @param participant - The participant being asked to unmute.
* @param turnOnModeration - if we want to turn on moderation before testing (when it is currently off).
* @param stopModeration - true if moderation to be stopped when done.
*/
async function unmuteByModerator(
moderator: Participant,
participant: Participant,
turnOnModeration: boolean,
stopModeration: boolean) {
const moderatorParticipantsPane = moderator.getParticipantsPane();
if (turnOnModeration) {
await moderatorParticipantsPane.clickContextMenuButton();
await moderatorParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await moderatorParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await moderatorParticipantsPane.close();
}
// raise hand to speak
await participant.getToolbar().clickRaiseHandButton();
await moderator.getNotifications().waitForRaisedHandNotification();
// ask participant to unmute
await moderatorParticipantsPane.allowVideo(participant);
await moderatorParticipantsPane.askToUnmute(participant, false);
await participant.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(participant, moderator);
await unmuteVideoAndCheck(participant, moderator);
if (stopModeration) {
await moderatorParticipantsPane.clickContextMenuButton();
await moderatorParticipantsPane.getAVModerationMenu().clickStopAudioModeration();
await moderatorParticipantsPane.getAVModerationMenu().clickStopVideoModeration();
await moderatorParticipantsPane.close();
}
}
/**
* In case of moderation, tries to audio unmute but stays muted.
* Checks locally and remotely that this is still the case.
* @param participant
* @param observer
*/
async function tryToAudioUnmuteAndCheck(participant: Participant, observer: Participant) {
// try to audio unmute and check
await participant.getToolbar().clickAudioUnmuteButton();
// Check local audio muted icon state
await participant.getFilmstrip().assertAudioMuteIconIsDisplayed(participant);
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(participant);
}
/**
* In case of moderation, tries to video unmute but stays muted.
* Checks locally and remotely that this is still the case.
* @param participant
* @param observer
*/
async function tryToVideoUnmuteAndCheck(participant: Participant, observer: Participant) {
// try to video unmute and check
await participant.getToolbar().clickVideoUnmuteButton();
// Check local audio muted icon state
await participant.getParticipantsPane().assertVideoMuteIconIsDisplayed(participant);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(participant);
}

View File

@@ -0,0 +1,212 @@
import {
ensureThreeParticipants,
ensureTwoParticipants,
unmuteVideoAndCheck
} from '../../helpers/participants';
const EMAIL = 'support@jitsi.org';
const HASH = '38f014e4b7dde0f64f8157d26a8c812e';
describe('Avatar', () => {
it('setup the meeting', () =>
ensureTwoParticipants({
skipDisplayName: true
})
);
it('change and check', async () => {
const { p1, p2 } = ctx;
// check default avatar for p1 on p2
await p2.assertDefaultAvatarExist(p1);
await p1.getToolbar().clickProfileButton();
const settings = p1.getSettingsDialog();
await settings.waitForDisplay();
await settings.setEmail(EMAIL);
await settings.submit();
// check if the local avatar in the toolbar menu has changed
await p1.driver.waitUntil(
async () => (await p1.getToolbar().getProfileImage())?.includes(HASH), {
timeout: 3000, // give more time for the initial download of the image
timeoutMsg: 'Avatar has not changed for p1'
});
// check if the avatar in the local thumbnail has changed
expect(await p1.getLocalVideoAvatar()).toContain(HASH);
const p1EndpointId = await p1.getEndpointId();
await p2.driver.waitUntil(
async () => (await p2.getFilmstrip().getAvatar(p1EndpointId))?.includes(HASH), {
timeout: 5000,
timeoutMsg: 'Avatar has not changed for p1 on p2'
});
// check if the avatar in the large video has changed
expect(await p2.getLargeVideo().getAvatar()).toContain(HASH);
// we check whether the default avatar of participant2 is displayed on both sides
await p1.assertDefaultAvatarExist(p2);
await p2.assertDefaultAvatarExist(p2);
// the problem on FF where we can send keys to the input field,
// and the m from the text can mute the call, check whether we are muted
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
});
it('when video muted', async () => {
const { p1 } = ctx;
await ctx.p2.hangup();
// Mute p1's video
await p1.getToolbar().clickVideoMuteButton();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p1.driver.waitUntil(
async () => (await p1.getLargeVideo().getAvatar())?.includes(HASH), {
timeout: 2000,
timeoutMsg: 'Avatar on large video did not change'
});
const p1LargeSrc = await p1.getLargeVideo().getAvatar();
const p1ThumbSrc = await p1.getLocalVideoAvatar();
// Check if avatar on large video is the same as on local thumbnail
expect(p1ThumbSrc).toBe(p1LargeSrc);
// Join p2
await ensureTwoParticipants({
skipDisplayName: true
});
const { p2 } = ctx;
// Verify that p1 is muted from the perspective of p2
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getFilmstrip().pinParticipant(p1);
// Check if p1's avatar is on large video now
await p2.driver.waitUntil(
async () => await p2.getLargeVideo().getAvatar() === p1LargeSrc, {
timeout: 2000,
timeoutMsg: 'Avatar on large video did not change'
});
// p1 pins p2's video
await p1.getFilmstrip().pinParticipant(p2);
// Check if avatar is displayed on p1's local video thumbnail
await p1.assertThumbnailShowsAvatar(p1, false, false, true);
// Unmute - now local avatar should be hidden and local video displayed
await unmuteVideoAndCheck(p1, p2);
await p1.asserLocalThumbnailShowsVideo();
// Now both p1 and p2 have video muted
await p1.getToolbar().clickVideoMuteButton();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getToolbar().clickVideoMuteButton();
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
// Start the third participant
await ensureThreeParticipants({
skipInMeetingChecks: true
});
const { p3 } = ctx;
// When the first participant is FF because of their audio mic feed it will never become dominant speaker
// and no audio track will be received by the third participant and video is muted,
// that's why we need to do a different check that expects any track just from p2
if (p1.driver.isFirefox) {
await Promise.all([ p2.waitForRemoteStreams(1), p3.waitForRemoteStreams(1) ]);
} else {
await Promise.all([ p2.waitForRemoteStreams(2), p3.waitForRemoteStreams(2) ]);
}
// Pin local video and verify avatars are displayed
await p3.getFilmstrip().pinParticipant(p3);
await p3.assertThumbnailShowsAvatar(p1, false, false, true);
await p3.assertThumbnailShowsAvatar(p2, false, true);
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
expect(await p3.getFilmstrip().getAvatar(p1EndpointId)).toBe(p1ThumbSrc);
// Click on p1's video
await p3.getFilmstrip().pinParticipant(p1);
// The avatar should be on large video and display name instead of an avatar, local video displayed
await p3.driver.waitUntil(
async () => await p3.getLargeVideo().getResource() === p1EndpointId, {
timeout: 2000,
timeoutMsg: `Large video did not switch to ${p1.name}`
});
await p3.assertDisplayNameVisibleOnStage(
await p3.getFilmstrip().getRemoteDisplayName(p1EndpointId));
// p2 has the default avatar
await p3.assertThumbnailShowsAvatar(p2, false, true);
await p3.assertThumbnailShowsAvatar(p3, true);
// Click on p2's video
await p3.getFilmstrip().pinParticipant(p2);
// The avatar should be on large video and display name instead of an avatar, local video displayed
await p3.driver.waitUntil(
async () => await p3.getLargeVideo().getResource() === p2EndpointId, {
timeout: 2000,
timeoutMsg: `Large video did not switch to ${p2.name}`
});
await p3.assertDisplayNameVisibleOnStage(
await p3.getFilmstrip().getRemoteDisplayName(p2EndpointId)
);
await p3.assertThumbnailShowsAvatar(p1, false, false, true);
await p3.assertThumbnailShowsAvatar(p3, true);
await p3.hangup();
// Unmute p1's and p2's videos
await unmuteVideoAndCheck(p1, p2);
});
it('email persistence', async () => {
let { p1 } = ctx;
if (p1.driver.isFirefox) {
// strangely this test when FF is involved, missing source mapping from jvb
// and fails with an error of: expected number of remote streams:1 in 15s for participant1
return;
}
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
await p1.hangup();
await ensureTwoParticipants({
skipDisplayName: true
});
p1 = ctx.p1;
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
});
});

View File

@@ -0,0 +1,481 @@
import type { ChainablePromiseElement } from 'webdriverio';
import type { Participant } from '../../helpers/Participant';
import {
checkSubject,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
const MAIN_ROOM_NAME = 'Main room';
const BREAKOUT_ROOMS_LIST_ID = 'breakout-rooms-list';
const LIST_ITEM_CONTAINER = 'list-item-container';
describe('BreakoutRooms', () => {
it('check support', async () => {
await ensureTwoParticipants();
if (!await ctx.p1.isBreakoutRoomsSupported()) {
ctx.skipSuiteTests = true;
}
});
it('add breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be no breakout rooms initially, list is sent with a small delay
await p1.driver.pause(2000);
expect(await p1BreakoutRooms.getRoomsCount()).toBe(0);
// add one breakout room
await p1BreakoutRooms.addBreakoutRoom();
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1, {
timeout: 3000,
timeoutMsg: 'No breakout room added for p1'
});
// second participant should also see one breakout room
await p2.driver.waitUntil(
async () => await p2.getBreakoutRooms().getRoomsCount() === 1, {
timeout: 3000,
timeoutMsg: 'No breakout room seen by p2'
});
});
it('join breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be one breakout room
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1, {
timeout: 3000,
timeoutMsg: 'No breakout room seen by p1'
});
const roomsList = await p1BreakoutRooms.getRooms();
expect(roomsList.length).toBe(1);
// join the room
await roomsList[0].joinRoom();
// the participant should see the main room as the only breakout room
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 1) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name === MAIN_ROOM_NAME;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not join breakout room'
});
// the second participant should see one participant in the breakout room
await p2.driver.waitUntil(
async () => {
const list = await p2.getBreakoutRooms().getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 3000,
timeoutMsg: 'P2 is not seeing p1 in the breakout room'
});
});
it('leave breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// leave room
await p1BreakoutRooms.leaveBreakoutRoom();
// there should be one breakout room and that should not be the main room
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 1) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name !== MAIN_ROOM_NAME;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not leave breakout room'
});
// the second participant should see no participants in the breakout room
await p2.driver.waitUntil(
async () => {
const list = await p2.getBreakoutRooms().getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 0;
}, {
timeout: 3000,
timeoutMsg: 'P2 is seeing p1 in the breakout room'
});
});
it('remove breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// remove the room
await (await p1BreakoutRooms.getRooms())[0].removeRoom();
// there should be no breakout rooms
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 0, {
timeout: 3000,
timeoutMsg: 'Breakout room was not removed for p1'
});
// the second participant should also see no breakout rooms
await p2.driver.waitUntil(
async () => await p2.getBreakoutRooms().getRoomsCount() === 0, {
timeout: 3000,
timeoutMsg: 'Breakout room was not removed for p2'
});
});
it('auto assign', async () => {
await ensureThreeParticipants();
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// create two rooms
await p1BreakoutRooms.addBreakoutRoom();
await p1BreakoutRooms.addBreakoutRoom();
// there should be two breakout rooms
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 2, {
timeout: 3000,
timeoutMsg: 'Breakout room was not created by p1'
});
// auto assign participants to rooms
await p1BreakoutRooms.autoAssignToBreakoutRooms();
// each room should have one participant
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not auto assigned participants to breakout rooms'
});
// the second participant should see one participant in the main room
const p2BreakoutRooms = p2.getBreakoutRooms();
await p2.driver.waitUntil(
async () => {
if (await p2BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p2BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1
&& (list[0].name === MAIN_ROOM_NAME || list[1].name === MAIN_ROOM_NAME);
}, {
timeout: 3000,
timeoutMsg: 'P2 is not seeing p1 in the main room'
});
});
it('close breakout room', async () => {
const { p1, p2, p3 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be two non-empty breakout rooms
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1;
}, {
timeout: 3000,
timeoutMsg: 'P1 is not seeing two breakout rooms'
});
// close the first room
await (await p1BreakoutRooms.getRooms())[0].closeRoom();
// there should be two rooms and first one should be empty
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 0 || list[1].participantCount === 0;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing an empty breakout room'
});
// there should be two participants in the main room, either p2 or p3 got moved to the main room
const checkParticipants = (p: Participant) =>
p.driver.waitUntil(
async () => {
const isInBreakoutRoom = await p.isInBreakoutRoom();
const breakoutRooms = p.getBreakoutRooms();
if (isInBreakoutRoom) {
if (await breakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await breakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list.every(r => { // eslint-disable-line arrow-body-style
return r.name === MAIN_ROOM_NAME ? r.participantCount === 2 : r.participantCount === 0;
});
}
if (await breakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await breakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount + list[1].participantCount === 1;
}, {
timeout: 3000,
timeoutMsg: `${p.name} is not seeing an empty breakout room and one with one participant`
});
await checkParticipants(p2);
await checkParticipants(p3);
});
it('send participants to breakout room', async () => {
await hangupAllParticipants();
// because the participants rejoin so fast, the meeting is not properly ended,
// so the previous breakout rooms would still be there.
// To avoid this issue we use a different meeting
// Respect room name suffix as it is important in multi-shard testing
ctx.roomName += `new-${ctx.roomName}`;
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be no breakout rooms
expect(await p1BreakoutRooms.getRoomsCount()).toBe(0);
// add one breakout room
await p1BreakoutRooms.addBreakoutRoom();
// there should be one empty room
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1
&& (await p1BreakoutRooms.getRooms())[0].participantCount === 0, {
timeout: 3000,
timeoutMsg: 'No breakout room added for p1'
});
// send the second participant to the first breakout room
await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, (await p1BreakoutRooms.getRooms())[0].name);
// there should be one room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing p2 in the breakout room'
});
});
it('collapse breakout room', async () => {
const { p1 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be one breakout room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 3000,
timeoutMsg: 'P1 is not seeing p2 in the breakout room'
});
// get id of the breakout room participant
const breakoutList = p1.driver.$(`#${BREAKOUT_ROOMS_LIST_ID}`);
const breakoutRoomItem = await breakoutList.$$(`.${LIST_ITEM_CONTAINER}`).find(
async el => {
const id = await el.getAttribute('id');
return id !== '' && id !== null;
}) as ChainablePromiseElement;
const pId = await breakoutRoomItem.getAttribute('id');
const breakoutParticipant = p1.driver.$(`//div[@id="${pId}"]`);
expect(await breakoutParticipant.isDisplayed()).toBe(true);
// collapse the first
await (await p1BreakoutRooms.getRooms())[0].collapse();
// the participant should not be visible
expect(await breakoutParticipant.isDisplayed()).toBe(false);
// the collapsed room should still have one participant
expect((await p1BreakoutRooms.getRooms())[0].participantCount).toBe(1);
});
it('rename breakout room', async () => {
const myNewRoomName = `breakout-${crypto.randomUUID()}`;
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// let's rename breakout room and see it in local and remote
await (await p1BreakoutRooms.getRooms())[0].renameRoom(myNewRoomName);
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name === myNewRoomName;
}, {
timeout: 3000,
timeoutMsg: 'The breakout room was not renamed for p1'
});
await checkSubject(p2, myNewRoomName);
const p2BreakoutRooms = p2.getBreakoutRooms();
// leave room
await p2BreakoutRooms.leaveBreakoutRoom();
// there should be one empty room
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 0;
}, {
timeout: 3000,
timeoutMsg: 'The breakout room not found or not empty for p1'
});
await p2.driver.waitUntil(
async () => {
const list = await p2BreakoutRooms.getRooms();
return list?.length === 1;
}, {
timeout: 3000,
timeoutMsg: 'The breakout room not seen by p2'
});
expect((await p2BreakoutRooms.getRooms())[0].name).toBe(myNewRoomName);
// send the second participant to the first breakout room
await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, myNewRoomName);
// there should be one room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'The breakout room was not rename for p1'
});
await checkSubject(p2, myNewRoomName);
});
});

View File

@@ -0,0 +1,128 @@
import {
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
describe('Codec selection', () => {
it('asymmetric codecs', async () => {
await ensureOneParticipant({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP9', 'VP8', 'AV1' ]
}
}
});
await ensureTwoParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP8', 'VP9', 'AV1' ]
}
}
});
const { p1, p2 } = ctx;
// Check if media is playing on both endpoints.
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// Check if p1 is sending VP9 and p2 is sending VP8 as per their codec preferences.
// Except on Firefox because it doesn't support VP9 encode.
if (p1.driver.isFirefox) {
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
} else {
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
}
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
});
it('asymmetric codecs with AV1', async () => {
await ensureThreeParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'AV1', 'VP9', 'VP8' ]
}
}
});
const { p1, p2, p3 } = ctx;
// Check if media is playing on p3.
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
const majorVersion = parseInt(p1.driver.capabilities.browserVersion || '0', 10);
// Check if p1 is encoding in VP9, p2 in VP8 and p3 in AV1 as per their codec preferences.
// Except on Firefox because it doesn't support VP9 encode.
if (p1.driver.isFirefox) {
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
} else {
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
}
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
// If there is a Firefox ep in the call, all other eps will switch to VP9.
if (p1.driver.isFirefox && majorVersion < 136) {
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
} else {
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingAv1())).toBe(true);
}
});
it('codec switch over', async () => {
await hangupAllParticipants();
await ensureTwoParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP9', 'VP8', 'AV1' ]
}
}
});
const { p1, p2 } = ctx;
// Disable this test on Firefox because it doesn't support VP9 encode.
if (p1.driver.isFirefox) {
return;
}
// Check if p1 and p2 are encoding in VP9 which is the default codec.
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9())).toBe(true);
await ensureThreeParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP8' ]
}
}
});
const { p3 } = ctx;
// Check if all three participants are encoding in VP8 now.
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp8())).toBe(true);
await p3.hangup();
// Check of p1 and p2 have switched to VP9.
await p1.driver.waitUntil(
() => p1.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9()),
{
timeout: 10000,
timeoutMsg: 'p1 did not switch back to VP9'
}
);
await p2.driver.waitUntil(
() => p2.execute(() => JitsiMeetJS.app.testing.isLocalCameraEncodingVp9()),
{
timeout: 10000,
timeoutMsg: 'p1 did not switch back to VP9'
}
);
});
});

View File

@@ -0,0 +1,93 @@
import { ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
describe('Follow Me', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants();
const { p1 } = ctx;
await p1.getToolbar().clickSettingsButton();
const settings = p1.getSettingsDialog();
await settings.waitForDisplay();
await settings.setFollowMe(true);
await settings.submit();
});
it('follow me checkbox visible only for moderators', async () => {
const { p2 } = ctx;
if (!await p2.isModerator()) {
await p2.getToolbar().clickSettingsButton();
const settings = p2.getSettingsDialog();
await settings.waitForDisplay();
expect(await settings.isFollowMeDisplayed()).toBe(false);
await settings.clickCloseButton();
}
});
it('filmstrip commands', async () => {
const { p1, p2 } = ctx;
const p1Filmstrip = p1.getFilmstrip();
const p2Filmstrip = p2.getFilmstrip();
await p1Filmstrip.toggle();
await p1Filmstrip.assertRemoteVideosHidden();
await p2Filmstrip.assertRemoteVideosHidden();
});
it('tile view', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await p1.waitForTileViewDisplay();
await p1.getToolbar().clickExitTileViewButton();
await Promise.all([
p1.waitForTileViewDisplay(true),
p2.waitForTileViewDisplay(true),
p3.waitForTileViewDisplay(true)
]);
await p1.getToolbar().clickEnterTileViewButton();
await Promise.all([
p1.waitForTileViewDisplay(),
p2.waitForTileViewDisplay(),
p3.waitForTileViewDisplay()
]);
});
it('next on stage', async () => {
const { p1, p2, p3 } = ctx;
await p1.getFilmstrip().pinParticipant(p2);
const p2Filmstrip = p2.getFilmstrip();
const localVideoId = await p2Filmstrip.getLocalVideoId();
await p2.driver.waitUntil(
async () => await localVideoId === await p2.getLargeVideo().getId(),
{
timeout: 5_000,
timeoutMsg: 'The pinned participant is not displayed on stage for p2'
});
const p2VideoIdOnp3 = await p3.getFilmstrip().getRemoteVideoId(await p2.getEndpointId());
await p3.driver.waitUntil(
async () => p2VideoIdOnp3 === await p3.getLargeVideo().getId(),
{
timeout: 5_000,
timeoutMsg: 'The pinned participant is not displayed on stage for p3'
});
});
});

View File

@@ -0,0 +1,503 @@
import { P1, P3, Participant } from '../../helpers/Participant';
import { config } from '../../helpers/TestsConfig';
import {
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import type { IJoinOptions } from '../../helpers/types';
import type PreMeetingScreen from '../../pageobjects/PreMeetingScreen';
describe('Lobby', () => {
it('joining the meeting', async () => {
await ensureOneParticipant();
if (!await ctx.p1.execute(() => APP.conference._room.isLobbySupported())) {
ctx.skipSuiteTests = true;
}
});
it('enable', async () => {
await ensureTwoParticipants();
await enableLobby();
});
it('entering in lobby and approve', async () => {
const { p1, p2 } = ctx;
await enterLobby(p1, true);
const { p3 } = ctx;
await p1.getNotifications().allowLobbyParticipant();
const notificationText = await p2.getNotifications().getLobbyParticipantAccessGranted();
expect(notificationText.includes(P1)).toBe(true);
expect(notificationText.includes(P3)).toBe(true);
await p2.getNotifications().closeLobbyParticipantAccessGranted();
// ensure 3 participants in the call will check for the third one that muc is joined, ice connected,
// media is being receiving and there are two remote streams
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForSendReceiveData();
await p3.waitForRemoteStreams(2);
// now check third one display name in the room, is the one set in the prejoin screen
const name = await p1.getFilmstrip().getRemoteDisplayName(await p3.getEndpointId());
expect(name).toBe(P3);
await p3.hangup();
});
it('entering in lobby and deny', async () => {
const { p1, p2 } = ctx;
// the first time tests is executed we need to enter display name,
// for next execution that will be locally stored
await enterLobby(p1, false);
// moderator rejects access
await p1.getNotifications().rejectLobbyParticipant();
// deny notification on 2nd participant
const notificationText = await p2.getNotifications().getLobbyParticipantAccessDenied();
expect(notificationText.includes(P1)).toBe(true);
expect(notificationText.includes(P3)).toBe(true);
await p2.getNotifications().closeLobbyParticipantAccessDenied();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
await p3.hangup();
});
it('approve from participants pane', async () => {
const { p1 } = ctx;
const knockingParticipant = await enterLobby(p1, false);
// moderator allows access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.admitLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// ensure 3 participants in the call will check for the third one that muc is joined, ice connected,
// media is being receiving and there are two remote streams
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForSendReceiveData();
await p3.waitForRemoteStreams(2);
// now check third one display name in the room, is the one set in the prejoin screen
// now check third one display name in the room, is the one set in the prejoin screen
const name = await p1.getFilmstrip().getRemoteDisplayName(await p3.getEndpointId());
expect(name).toBe(P3);
await p3.hangup();
});
it('reject from participants pane', async () => {
const { p1 } = ctx;
const knockingParticipant = await enterLobby(p1, false);
// moderator rejects access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.rejectLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
// The third participant should see a warning that his access to the room was denied
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
// check Lobby room not left
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
await p3.hangup();
});
it('lobby user leave', async () => {
const { p1 } = ctx;
await enterLobby(p1, false);
await ctx.p3.hangup();
// check that moderator (participant 1) no longer sees notification about participant in lobby
await p1.getNotifications().waitForHideOfKnockingParticipants();
});
it('conference ended in lobby', async () => {
const { p1, p2 } = ctx;
await enterLobby(p1, false);
await p1.hangup();
await p2.hangup();
const { p3 } = ctx;
await p3.driver.$('.dialog.leaveReason').isExisting();
await p3.driver.waitUntil(
async () => !await p3.getLobbyScreen().isLobbyRoomJoined(),
{
timeout: 2000,
timeoutMsg: 'p2 did not leave lobby'
}
);
await p3.hangup();
});
it('disable while participant in lobby', async () => {
await ensureTwoParticipants();
const { p1 } = ctx;
await enableLobby();
await enterLobby(p1);
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled(true);
const { p3 } = ctx;
await p3.waitToJoinMUC();
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
});
it('change of moderators in lobby', async () => {
// no moderator switching if jaas is available.
if (config.iframe.usesJaas) {
return;
}
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
// hanging up the first one, which is moderator and second one should be
await p1.hangup();
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 is not moderator after p1 leaves'
}
);
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await p2SecurityDialog.toggleLobby();
await p2SecurityDialog.waitForLobbyEnabled();
// here the important check is whether the moderator sees the knocking participant
await enterLobby(p2, false);
});
it('shared password', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1 } = ctx;
await enableLobby();
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLocked()).toBe(false);
const roomPasscode = String(Math.trunc(Math.random() * 1_000_000));
await p1SecurityDialog.addPassword(roomPasscode);
await p1.driver.waitUntil(
() => p1SecurityDialog.isLocked(),
{
timeout: 2000,
timeoutMsg: 'room did not lock for p1'
}
);
await enterLobby(p1, false);
const { p3 } = ctx;
// now fill in password
const lobbyScreen = p3.getLobbyScreen();
await lobbyScreen.enterPassword(roomPasscode);
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForSendReceiveData();
});
it('enable with more than two participants', async () => {
await hangupAllParticipants();
await ensureThreeParticipants();
await enableLobby();
// we need to check remote participants as isInMuc has not changed its value as
// the bug is triggered by presence with status 322 which is not handled correctly
const { p1, p2, p3 } = ctx;
await p1.waitForRemoteStreams(2);
await p2.waitForRemoteStreams(2);
await p3.waitForRemoteStreams(2);
});
it('moderator leaves while lobby enabled', async () => {
// no moderator switching if jaas is available.
if (config.iframe.usesJaas) {
return;
}
const { p1, p2, p3 } = ctx;
await p3.hangup();
await p1.hangup();
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 is not moderator after p1 leaves'
}
);
const lobbyScreen = p2.getLobbyScreen();
expect(await lobbyScreen.isLobbyRoomJoined()).toBe(true);
});
it('reject and approve in pre-join', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
await enableLobby();
const { p1 } = ctx;
const knockingParticipant = await enterLobby(p1, true, true);
// moderator rejects access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.rejectLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
// The third participant should see a warning that his access to the room was denied
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
// check Lobby room left
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
// try again entering the lobby with the third one and approve it
// check that everything is fine in the meeting
await p3.getNotifications().closeLocalLobbyAccessDenied();
// let's retry to enter the lobby and approve this time
const lobbyScreen = p3.getPreJoinScreen();
// click join button
await lobbyScreen.getJoinButton().click();
await lobbyScreen.waitToJoinLobby();
// check that moderator (participant 1) sees notification about participant in lobby
const name = await p1.getNotifications().getKnockingParticipantName();
expect(name).toBe(P3);
expect(await lobbyScreen.isLobbyRoomJoined()).toBe(true);
await p1ParticipantsPane.open();
await p1ParticipantsPane.admitLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
await p3.waitForParticipants(2);
await p3.waitForRemoteStreams(2);
expect(await p3.getFilmstrip().countVisibleThumbnails()).toBe(3);
});
});
/**
* Enable lobby and check that it is enabled.
*/
async function enableLobby() {
const { p1, p2 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLobbyEnabled()).toBe(false);
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled();
expect((await p2.getNotifications().getLobbyEnabledText()).includes(p1.name)).toBe(true);
await p2.getNotifications().closeLobbyEnabled();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
// lobby is visible to moderators only, this depends on whether deployment is all moderators or not
if (await p2.isModerator()) {
await p2SecurityDialog.waitForLobbyEnabled();
} else {
expect(await p2SecurityDialog.isLobbySectionPresent()).toBe(false);
}
// let's close the security dialog, or we will not be able to click
// on popups for allow/deny participants
await p1SecurityDialog.clickCloseButton();
await p2SecurityDialog.clickCloseButton();
}
/**
* Expects that lobby is enabled for the room we will try to join.
* Lobby UI is shown, enter display name and join.
* Checks Lobby UI and also that when joining the moderator sees corresponding notifications.
*
* @param participant The participant that is moderator in the meeting.
* @param enterDisplayName whether to enter display name. We need to enter display name only the first time when
* a participant sees the lobby screen, next time visiting the page display name will be pre-filled
* from local storage.
* @param usePreJoin
* @return the participant name knocking.
*/
async function enterLobby(participant: Participant, enterDisplayName = false, usePreJoin = false) {
const options: IJoinOptions = { };
if (usePreJoin) {
options.configOverwrite = {
prejoinConfig: {
enabled: true
}
};
}
await ensureThreeParticipants({
...options,
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const { p3 } = ctx;
let screen: PreMeetingScreen;
// WebParticipant participant3 = getParticipant3();
// ParentPreMeetingScreen lobbyScreen;
if (usePreJoin) {
screen = p3.getPreJoinScreen();
} else {
screen = p3.getLobbyScreen();
}
// participant 3 should be now on pre-join screen
await screen.waitForLoading();
const displayNameInput = screen.getDisplayNameInput();
// check display name is visible
expect(await displayNameInput.isExisting()).toBe(true);
expect(await displayNameInput.isDisplayed()).toBe(true);
const joinButton = screen.getJoinButton();
expect(await joinButton.isExisting()).toBe(true);
if (enterDisplayName) {
let classes = await joinButton.getAttribute('class');
if (!usePreJoin) {
// check join button is disabled
expect(classes.includes('disabled')).toBe(true);
}
// TODO check that password is hidden as the room does not have password
// this check needs to be added once the functionality exists
// enter display name
await screen.enterDisplayName(P3);
// check join button is enabled
classes = await joinButton.getAttribute('class');
expect(classes.includes('disabled')).toBe(false);
}
// click join button
await screen.getJoinButton().click();
await screen.waitToJoinLobby();
// check no join button
await p3.driver.waitUntil(
async () => !await joinButton.isExisting() || !await joinButton.isDisplayed() || !await joinButton.isEnabled(),
{
timeout: 2_000,
timeoutMsg: 'Join button is still available for p3'
});
// new screen, is password button shown
const passwordButton = screen.getPasswordButton();
expect(await passwordButton.isExisting()).toBe(true);
expect(await passwordButton.isEnabled()).toBe(true);
// check that moderator (participant 1) sees notification about participant in lobby
const name = await participant.getNotifications().getKnockingParticipantName();
expect(name).toBe(P3);
expect(await screen.isLobbyRoomJoined()).toBe(true);
return name;
}

View File

@@ -0,0 +1,81 @@
import type { Participant } from '../../helpers/Participant';
import {
ensureThreeParticipants,
ensureTwoParticipants
} from '../../helpers/participants';
const ONE_ON_ONE_CONFIG_OVERRIDES = {
configOverwrite: {
disable1On1Mode: false,
toolbarConfig: {
timeout: 500,
alwaysVisible: false
}
}
};
describe('OneOnOne', () => {
it('filmstrip hidden in 1on1', async () => {
await ensureTwoParticipants(ONE_ON_ONE_CONFIG_OVERRIDES);
const { p1, p2 } = ctx;
await configureToolbarsToHideQuickly(p1);
await configureToolbarsToHideQuickly(p2);
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
await p2.getFilmstrip().verifyRemoteVideosDisplay(false);
});
it('filmstrip visible with more than 2', async () => {
await ensureThreeParticipants(ONE_ON_ONE_CONFIG_OVERRIDES);
const { p1, p2, p3 } = ctx;
await configureToolbarsToHideQuickly(p3);
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
await p2.getFilmstrip().verifyRemoteVideosDisplay(true);
await p3.getFilmstrip().verifyRemoteVideosDisplay(true);
});
it('filmstrip display when returning to 1on1', async () => {
const { p1, p2, p3 } = ctx;
await p2.getFilmstrip().pinParticipant(p2);
await p3.hangup();
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
await p2.getFilmstrip().verifyRemoteVideosDisplay(true);
});
it('filmstrip visible on self view focus', async () => {
const { p1 } = ctx;
await p1.getFilmstrip().pinParticipant(p1);
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
await p1.getFilmstrip().unpinParticipant(p1);
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
});
it('filmstrip hover show videos', async () => {
const { p1 } = ctx;
await p1.getFilmstrip().hoverOverLocalVideo();
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
});
});
/**
* Hangs up all participants (p1, p2 and p3)
* @returns {Promise<void>}
*/
function configureToolbarsToHideQuickly(participant: Participant): Promise<void> {
return participant.execute(() => {
APP.UI.dockToolbar(false);
APP.UI.showToolbar(250);
});
}

View File

@@ -0,0 +1,290 @@
import {
checkForScreensharingTile,
ensureOneParticipant,
ensureTwoParticipants,
hangupAllParticipants,
joinSecondParticipant,
joinThirdParticipant,
unmuteVideoAndCheck
} from '../../helpers/participants';
describe('StartMuted', () => {
it('checkboxes test', async () => {
const options = {
configOverwrite: {
p2p: {
enabled: true
},
testing: {
testMode: true,
debugAudioLevels: true
}
} };
await ensureOneParticipant(options);
const { p1 } = ctx;
const p1EndpointId = await p1.getEndpointId();
await p1.getToolbar().clickSettingsButton();
const settingsDialog = p1.getSettingsDialog();
await settingsDialog.waitForDisplay();
await settingsDialog.setStartAudioMuted(true);
await settingsDialog.setStartVideoMuted(true);
await settingsDialog.submit();
// Check that p1 doesn't get muted.
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await joinSecondParticipant({
...options,
skipInMeetingChecks: true
});
// Enable screenshare on p1.
p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p1);
const { p2 } = ctx;
const p2EndpointId = await p2.getEndpointId();
await p2.waitForIceConnected();
await p2.waitForReceiveMedia();
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p1.waitForAudioMuted(p2, true);
await p1.waitForRemoteVideo(p2EndpointId, true);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await p2.waitForAudioMuted(p1, false);
await p2.waitForRemoteVideo(p1EndpointId, false);
// Check if a remote screenshare tile is created on p2.
await checkForScreensharingTile(p1, p2);
// Enable video on p2 and check if p2 appears unmuted on p1.
await Promise.all([
p2.getToolbar().clickAudioUnmuteButton(), p2.getToolbar().clickVideoUnmuteButton()
]);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await p1.waitForAudioMuted(p2, false);
await p1.waitForRemoteVideo(p2EndpointId, false);
// Add a third participant and check p3 is able to receive audio and video from p2.
await joinThirdParticipant({
...options,
skipInMeetingChecks: true
});
const { p3 } = ctx;
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await checkForScreensharingTile(p1, p3);
});
it('config options test', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
testing: {
testMode: true,
debugAudioLevels: true
},
startAudioMuted: 2,
startVideoMuted: 2
}
};
await ensureOneParticipant(options);
await joinSecondParticipant({
...options,
skipInMeetingChecks: true
});
const { p2 } = ctx;
await p2.waitForIceConnected();
await p2.waitForReceiveMedia();
await joinThirdParticipant({
...options,
skipInMeetingChecks: true
});
const { p3 } = ctx;
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
const { p1 } = ctx;
const p2ID = await p2.getEndpointId();
p1.log(`Start configOptionsTest, second participant: ${p2ID}`);
// Participant 3 should be muted, 1 and 2 unmuted.
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p3);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await Promise.all([
p1.waitForAudioMuted(p3, true),
p2.waitForAudioMuted(p3, true)
]);
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
// Unmute and see if the audio works
await p3.getToolbar().clickAudioUnmuteButton();
p1.log('configOptionsTest, unmuted third participant');
await p1.waitForAudioMuted(p3, false /* unmuted */);
});
it('startWithVideoMuted=true can unmute', async () => {
// Maybe disable if there is FF or Safari participant.
await hangupAllParticipants();
// Explicitly enable P2P due to a regression with unmute not updating
// large video while in P2P.
const options = {
configOverwrite: {
p2p: {
enabled: true
},
startWithVideoMuted: true
}
};
await ensureTwoParticipants(options);
const { p1, p2 } = ctx;
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await Promise.all([
p1.getLargeVideo().waitForSwitchTo(await p2.getEndpointId()),
p2.getLargeVideo().waitForSwitchTo(await p1.getEndpointId())
]);
await unmuteVideoAndCheck(p2, p1);
await p1.getLargeVideo().assertPlaying();
});
it('startWithAudioMuted=true can unmute', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
startWithAudioMuted: true,
testing: {
testMode: true,
debugAudioLevels: true
}
}
};
await ensureTwoParticipants(options);
const { p1, p2 } = ctx;
await Promise.all([ p1.waitForAudioMuted(p2, true), p2.waitForAudioMuted(p1, true) ]);
await p1.getToolbar().clickAudioUnmuteButton();
await Promise.all([ p1.waitForAudioMuted(p2, true), p2.waitForAudioMuted(p1, false) ]);
});
it('startWithAudioVideoMuted=true can unmute', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true,
p2p: {
enabled: true
}
}
};
await ensureOneParticipant(options);
await joinSecondParticipant({
configOverwrite: {
testing: {
testMode: true,
debugAudioLevels: true
},
p2p: {
enabled: true
}
},
skipInMeetingChecks: true
});
const { p1, p2 } = ctx;
await p2.waitForIceConnected();
await p2.waitForSendMedia();
await p2.waitForAudioMuted(p1, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
// Unmute p1's both audio and video and check on p2.
await p1.getToolbar().clickAudioUnmuteButton();
await p2.waitForAudioMuted(p1, false);
await unmuteVideoAndCheck(p1, p2);
await p2.getLargeVideo().assertPlaying();
});
it('test p2p JVB switch and switch back', async () => {
const { p1, p2 } = ctx;
// Mute p2's video just before p3 joins.
await p2.getToolbar().clickVideoMuteButton();
await joinThirdParticipant({
configOverwrite: {
p2p: {
enabled: true
}
}
});
const { p3 } = ctx;
// Unmute p2 and check if its video is being received by p1 and p3.
await unmuteVideoAndCheck(p2, p3);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
// Mute p2's video just before p3 leaves.
await p2.getToolbar().clickVideoMuteButton();
await p3.hangup();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getToolbar().clickVideoUnmuteButton();
// Check if p2's video is playing on p1.
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await p1.getLargeVideo().assertPlaying();
});
});

View File

@@ -0,0 +1,113 @@
import { ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
/**
* The CSS selector for local video when outside of tile view. It should
* be in a container separate from remote videos so remote videos can
* scroll while local video stays docked.
*/
const FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR = '#filmstripLocalVideo #localVideoContainer';
/**
* The CSS selector for local video tile view is enabled. It should display
* at the end of all the other remote videos, as the last tile.
*/
const TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR = '.remote-videos #localVideoContainer';
describe('TileView', () => {
it('joining the meeting', () => ensureTwoParticipants());
// TODO: implements etherpad check
it('pinning exits', async () => {
await enterTileView();
const { p1, p2 } = ctx;
await p1.getFilmstrip().pinParticipant(p2);
await p1.waitForTileViewDisplay(true);
});
it('local video display', async () => {
await enterTileView();
const { p1 } = ctx;
await p1.driver.$(TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({ timeout: 3000 });
await p1.driver.$(FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({
timeout: 3000,
reverse: true
});
});
it('can exit', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickExitTileViewButton();
await p1.waitForTileViewDisplay(true);
});
it('local video display independently from remote', async () => {
const { p1 } = ctx;
await p1.driver.$(TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({
timeout: 3000,
reverse: true
});
await p1.driver.$(FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({ timeout: 3000 });
});
it('lastN', async () => {
const { p1, p2 } = ctx;
if (p1.driver.isFirefox) {
// Firefox does not support external audio file as input.
// Not testing as second participant cannot be dominant speaker.
return;
}
await p2.getToolbar().clickAudioMuteButton();
await ensureThreeParticipants({
configOverwrite: {
channelLastN: 1,
startWithAudioMuted: true
}
});
const { p3 } = ctx;
// one inactive icon should appear in few seconds
await p3.waitForNinjaIcon();
const p1EpId = await p1.getEndpointId();
await p3.waitForRemoteVideo(p1EpId);
const p2EpId = await p2.getEndpointId();
await p3.waitForNinjaIcon(p2EpId);
// no video for participant 2
await p3.waitForRemoteVideo(p2EpId, true);
// mute audio for participant 1
await p1.getToolbar().clickAudioMuteButton();
// unmute audio for participant 2
await p2.getToolbar().clickAudioUnmuteButton();
await p3.waitForDominantSpeaker(p2EpId);
// check video of participant 2 should be received
await p3.waitForRemoteVideo(p2EpId);
});
});
/**
* Attempts to enter tile view and verifies tile view has been entered.
*/
async function enterTileView() {
await ctx.p1.getToolbar().clickEnterTileViewButton();
await ctx.p1.waitForTileViewDisplay();
}

View File

@@ -0,0 +1,316 @@
import { SET_AUDIO_ONLY } from '../../../react/features/base/audio-only/actionTypes';
import {
checkForScreensharingTile,
ensureFourParticipants,
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
describe('Desktop sharing', () => {
it('start', async () => {
await ensureTwoParticipants({
configOverwrite: {
p2p: {
enabled: true
}
}
});
const { p1, p2 } = ctx;
await p2.getToolbar().clickDesktopSharingButton();
// Check if a remote screen share tile is created on p1.
await checkForScreensharingTile(p2, p1);
// Check if a local screen share tile is created on p2.
await checkForScreensharingTile(p2, p2);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
it('stop', async () => {
const { p1, p2 } = ctx;
await p2.getToolbar().clickStopDesktopSharingButton();
// Check if the local screen share thumbnail disappears on p2.
await checkForScreensharingTile(p2, p2, true);
// Check if the remote screen share thumbnail disappears on p1.
await checkForScreensharingTile(p1, p2, true);
});
/**
* Ensures screen share is still visible when the call switches from p2p to jvb connection.
*/
it('p2p to jvb switch', async () => {
await ctx.p2.getToolbar().clickDesktopSharingButton();
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
// Check if a remote screen share tile is created on all participants.
await checkForScreensharingTile(p2, p1);
await checkForScreensharingTile(p2, p2);
await checkForScreensharingTile(p2, p2);
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
/**
* Ensure screen share is still visible when the call switches from jvb to p2p and back.
*/
it('p2p to jvb switch and back', async () => {
const { p1, p2, p3 } = ctx;
await p3.hangup();
// Check if a remote screen share tile is created on p1 and p2 after switching back to p2p.
await checkForScreensharingTile(p2, p1);
await checkForScreensharingTile(p2, p2);
// The video should be playing.
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// Start desktop share on p1.
await p1.getToolbar().clickDesktopSharingButton();
// Check if a new tile for p1's screen share is created on both p1 and p2.
await checkForScreensharingTile(p1, p1);
await checkForScreensharingTile(p1, p2);
await ensureThreeParticipants();
await checkForScreensharingTile(p1, p3);
await checkForScreensharingTile(p2, p3);
// The large video should be playing on p3.
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
/**
* Ensure that screen share is still visible in jvb connection when share is toggled while the users are
* in p2p mode, i.e., share is restarted when user is in p2p mode and then the call switches over to jvb mode.
*/
it('stop screen sharing and back', async () => {
const { p1, p2, p3 } = ctx;
// Stop share on both p1 and p2.
await p1.getToolbar().clickStopDesktopSharingButton();
await p2.getToolbar().clickStopDesktopSharingButton();
await p3.hangup();
// Start share on both p1 and p2.
await p1.getToolbar().clickDesktopSharingButton();
await p2.getToolbar().clickDesktopSharingButton();
// Check if p1 and p2 can see each other's shares in p2p.
await checkForScreensharingTile(p1, p2);
await checkForScreensharingTile(p2, p1);
// Add p3 back to the conference and check if p1 and p2's shares are visible on p3.
await ensureThreeParticipants();
await checkForScreensharingTile(p1, p3);
await checkForScreensharingTile(p2, p3);
// The large video should be playing on p3.
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
/**
* Ensures screen share is visible when a muted screen share track is added to the conference, i.e.,
* users starts and stops the share before anyone else joins the call.
* The call switches to jvb and then back to p2p.
*/
it('screen sharing toggle before others join', async () => {
await hangupAllParticipants();
await ensureOneParticipant({
configOverwrite: {
p2p: {
enabled: true
}
}
});
const { p1 } = ctx;
// p1 starts share when alone in the call.
await p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p1);
// p1 stops share.
await p1.getToolbar().clickStopDesktopSharingButton();
// Call switches to jvb.
await ensureThreeParticipants();
const { p2, p3 } = ctx;
// p1 starts share again when call switches to jvb.
await p1.getToolbar().clickDesktopSharingButton();
// Check p2 and p3 are able to see p1's share.
await checkForScreensharingTile(p1, p2);
await checkForScreensharingTile(p1, p3);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// p3 leaves the call.
await p3.hangup();
// Make sure p2 see's p1's share after the call switches back to p2p.
await checkForScreensharingTile(p1, p2);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// p2 starts share when in p2p.
await p2.getToolbar().clickDesktopSharingButton();
// Makes sure p2's share is visible on p1.
await checkForScreensharingTile(p2, p1);
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
/**
* A case where a non-dominant speaker is sharing screen for a participant in low bandwidth mode
* where only a screen share can be received. A bug fixed in jvb 0c5dd91b where the video was not received.
*/
it('audio only and non dominant screen share', async () => {
await hangupAllParticipants();
await ensureOneParticipant();
const { p1 } = ctx;
// a workaround to directly set audio only mode without going through the rest of the settings in the UI
await p1.execute(type => {
APP?.store?.dispatch({
type,
audioOnly: true
});
APP?.conference?.onToggleAudioOnly();
}, SET_AUDIO_ONLY);
await p1.getToolbar().clickAudioMuteButton();
await ensureThreeParticipants({
skipInMeetingChecks: true
});
const { p2, p3 } = ctx;
await p3.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
// the video should be playing
await p1.driver.waitUntil(() => p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived()), {
timeout: 5_000,
timeoutMsg: 'expected remote screen share to be on large'
});
});
/**
* A case where first participant is muted (a&v) and enters low bandwidth mode,
* the second one is audio muted only and the one sharing (the third) is dominant speaker.
* A problem fixed in jitsi-meet 3657c19e and d6ab0a72.
*/
it('audio only and dominant screen share', async () => {
await hangupAllParticipants();
await ensureOneParticipant({
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true
}
});
const { p1 } = ctx;
// a workaround to directly set audio only mode without going through the rest of the settings in the UI
await p1.execute(type => {
APP?.store?.dispatch({
type,
audioOnly: true
});
APP?.conference?.onToggleAudioOnly();
}, SET_AUDIO_ONLY);
await ensureTwoParticipants({
configOverwrite: {
startWithAudioMuted: true
},
skipInMeetingChecks: true
});
await ensureThreeParticipants({
skipInMeetingChecks: true
});
const { p2, p3 } = ctx;
await p3.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
// The desktop sharing participant should be on large
expect(await p1.getLargeVideo().getResource()).toBe(`${await p3.getEndpointId()}-v1`);
// the video should be playing
await p1.driver.waitUntil(() => p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived()), {
timeout: 5_000,
timeoutMsg: 'expected remote screen share to be on large'
});
});
/**
* Test screensharing with lastN. We add p4 with lastN=2 and verify that it receives the expected streams.
*/
it('with lastN', async () => {
await hangupAllParticipants();
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await p3.getToolbar().clickDesktopSharingButton();
await p1.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickAudioMuteButton();
await ensureFourParticipants({
configOverwrite: {
channelLastN: 2,
startWithAudioMuted: true
}
});
const { p4 } = ctx;
// We now have p1, p2, p3, p4.
// p3 is screensharing.
// p1, p3, p4 are audio muted, so p2 should eventually become dominant speaker.
// Participants should display p3 on-stage because it is screensharing.
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
await checkForScreensharingTile(p3, p4);
// And the video should be playing
expect(await p4.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
// p4 has lastN=2 and has selected p3. With p2 being dominant speaker p4 should eventually
// see video for [p3, p2] and p1 as ninja.
await p4.waitForNinjaIcon(p1EndpointId);
await p4.waitForRemoteVideo(p2EndpointId);
// Let's switch and check, muting participant 2 and unmuting 1 will leave participant 1 as dominant
await p1.getToolbar().clickAudioUnmuteButton();
await p2.getToolbar().clickAudioMuteButton();
// Participant4 should eventually see video for [p3, p1] and p2 as a ninja.
await p4.waitForNinjaIcon(p2EndpointId);
await p4.waitForRemoteVideo(p1EndpointId);
});
});

View File

@@ -0,0 +1,73 @@
import { ensureFourParticipants, ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
describe('lastN', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true,
channelLastN: 1
},
skipInMeetingChecks: true
});
await ensureThreeParticipants({
configOverwrite: {
channelLastN: 1
},
skipInMeetingChecks: true,
});
});
it('checks', async () => {
const { p3 } = ctx;
const p3Toolbar = p3.getToolbar();
await p3.waitForSendMedia();
await ctx.p1.waitForRemoteVideo(await p3.getEndpointId());
// Mute audio on participant3.
await p3Toolbar.clickAudioMuteButton();
await ensureFourParticipants({
configOverwrite: {
channelLastN: 1
},
skipInMeetingChecks: true
});
const { p1, p2, p4 } = ctx;
await p4.waitForSendReceiveData();
// Mute audio on p4 and unmute p3.
await p4.getToolbar().clickAudioMuteButton();
await p3Toolbar.clickAudioUnmuteButton();
const p4EndpointId = await p4.getEndpointId();
const p3EndpointId = await p3.getEndpointId();
// Check if p1 starts receiving video from p3 and p4 shows up as ninja.
await p1.waitForNinjaIcon(p4EndpointId);
await p1.waitForRemoteVideo(p3EndpointId);
// At this point, mute video of p3 and others should be receiving p4's video.
// Mute p1's video
await p3Toolbar.clickVideoMuteButton();
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await p1.waitForRemoteVideo(p4EndpointId);
// Unmute p3's video and others should switch to receiving p3's video.
await p3Toolbar.clickVideoUnmuteButton();
await p1.waitForRemoteVideo(p3EndpointId);
await p1.waitForNinjaIcon(p4EndpointId);
// Mute p3's audio and unmute p2's audio. Other endpoints should continue to receive video from p3
// even though p2 is the dominant speaker.
await p3Toolbar.clickAudioMuteButton();
await p2.getToolbar().clickAudioUnmuteButton();
await p1.waitForRemoteVideo(p3EndpointId);
});
});

View File

@@ -0,0 +1,33 @@
import { ensureOneParticipant } from '../../helpers/participants';
describe('Chat Panel', () => {
it('join participant', () => ensureOneParticipant());
it('start closed', async () => {
expect(await ctx.p1.getChatPanel().isOpen()).toBe(false);
});
it('open', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickChatButton();
expect(await p1.getChatPanel().isOpen()).toBe(true);
});
it('use shortcut to close', async () => {
const chatPanel = ctx.p1.getChatPanel();
await chatPanel.pressShortcut();
expect(await chatPanel.isOpen()).toBe(false);
});
it('use shortcut to open', async () => {
const chatPanel = ctx.p1.getChatPanel();
await chatPanel.pressShortcut();
expect(await chatPanel.isOpen()).toBe(true);
});
it('use button to open', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickCloseChatButton();
expect(await p1.getChatPanel().isOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,59 @@
import process from 'node:process';
import { ensureOneParticipant } from '../../helpers/participants';
import { cleanup, dialIn, isDialInEnabled, waitForAudioFromDialInParticipant } from '../helpers/DialIn';
describe('Dial-In', () => {
it('join participant', async () => {
// check rest url is configured
if (!process.env.DIAL_IN_REST_URL) {
ctx.skipSuiteTests = true;
return;
}
await ensureOneParticipant({ preferGenerateToken: true });
// check dial-in is enabled
if (!await isDialInEnabled(ctx.p1)) {
ctx.skipSuiteTests = true;
}
});
it('retrieve pin', async () => {
let dialInPin: string;
try {
dialInPin = await ctx.p1.getDialInPin();
} catch (e) {
console.error('dial-in.test.no-pin');
ctx.skipSuiteTests = true;
throw e;
}
if (dialInPin.length === 0) {
console.error('dial-in.test.no-pin');
ctx.skipSuiteTests = true;
throw new Error('no pin');
}
expect(dialInPin.length >= 8).toBe(true);
});
it('invite dial-in participant', async () => {
await dialIn(ctx.p1);
});
it('wait for audio from dial-in participant', async () => {
const { p1 } = ctx;
if (!await p1.isInMuc()) {
// local participant did not join abort
return;
}
await waitForAudioFromDialInParticipant(p1);
await cleanup(p1);
});
});

View File

@@ -0,0 +1,85 @@
import { ensureOneParticipant } from '../../helpers/participants';
import { isDialInEnabled } from '../helpers/DialIn';
describe('Invite', () => {
it('join participant', () => ensureOneParticipant({ preferGenerateToken: true }));
it('url displayed', async () => {
const { p1 } = ctx;
const inviteDialog = p1.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
const driverUrl = await p1.driver.getUrl();
expect(driverUrl.includes(await inviteDialog.getMeetingURL())).toBe(true);
await inviteDialog.clickCloseButton();
await inviteDialog.waitTillOpen(true);
});
it('dial-in displayed', async () => {
const { p1 } = ctx;
if (!await isDialInEnabled(p1)) {
return;
}
const inviteDialog = p1.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
expect((await inviteDialog.getDialInNumber()).length > 0).toBe(true);
expect((await inviteDialog.getPinNumber()).length > 0).toBe(true);
});
it('view more numbers', async () => {
const { p1 } = ctx;
if (!await isDialInEnabled(p1)) {
return;
}
const inviteDialog = p1.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
const windows = await p1.driver.getWindowHandles();
expect(windows.length).toBe(1);
const meetingWindow = windows[0];
const displayedNumber = await inviteDialog.getDialInNumber();
const displayedPin = await inviteDialog.getPinNumber();
await inviteDialog.openDialInNumbersPage();
const newWindow = (await p1.driver.getWindowHandles()).filter(w => w !== meetingWindow);
expect(newWindow.length).toBe(1);
const moreNumbersWindow = newWindow[0];
await p1.driver.switchWindow(moreNumbersWindow);
await browser.pause(10000);
await p1.driver.$('.dial-in-numbers-list').waitForExist();
const conferenceIdMessage = p1.driver.$('//div[contains(@class, "pinLabel")]');
expect((await conferenceIdMessage.getText()).replace(/ /g, '').includes(displayedPin)).toBe(true);
const numbers = p1.driver.$$('.dial-in-number');
const nums = await numbers.filter(
async el => (await el.getText()).trim() === displayedNumber);
expect(nums.length).toBe(1);
});
});

View File

@@ -0,0 +1,46 @@
import { ensureOneParticipant } from '../../helpers/participants';
/**
* Tests that the digits only password feature works.
*
* 1. Lock the room with a string (shouldn't work)
* 2. Lock the room with a valid numeric password (should work)
*/
describe('Lock Room with Digits only', () => {
it('join participant', () => ensureOneParticipant({
configOverwrite: {
roomPasswordNumberOfDigits: 5
}
}));
it('lock room with digits only', async () => {
const { p1 } = ctx;
expect(await p1.execute(
() => APP.store.getState()['features/base/config'].roomPasswordNumberOfDigits === 5)).toBe(true);
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLocked()).toBe(false);
// Set a non-numeric password.
await p1SecurityDialog.addPassword('AAAAA');
expect(await p1SecurityDialog.isLocked()).toBe(false);
await p1SecurityDialog.clickCloseButton();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await p1SecurityDialog.addPassword('12345');
await p1SecurityDialog.clickCloseButton();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLocked()).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import { ensureOneParticipant } from '../../helpers/participants';
describe('Video Layout', () => {
it('join participant', () => ensureOneParticipant());
it('check', async () => {
const { p1 } = ctx;
const innerWidth = parseInt(await p1.execute('return window.innerWidth'), 10);
const innerHeight = parseInt(await p1.execute('return window.innerHeight'), 10);
const largeVideo = p1.driver.$('//div[@id="largeVideoContainer"]');
const filmstrip = p1.driver.$('//div[contains(@class, "filmstrip")]');
let filmstripWidth;
if (!await filmstrip.isExisting() || !await filmstrip.isDisplayed()) {
filmstripWidth = 0;
} else {
filmstripWidth = await filmstrip.getSize('width');
}
const largeVideoSize = await largeVideo.getSize();
expect((largeVideoSize.width === (innerWidth - filmstripWidth)) || (largeVideoSize.height === innerHeight))
.toBe(true);
});
});

View File

@@ -0,0 +1,90 @@
import https from 'node:https';
import process from 'node:process';
import type { Participant } from '../../helpers/Participant';
/**
* Helper functions for dial-in related operations.
* To be able to create a fake dial-in test that will run most of the logic for the real dial-in test.
*/
/**
* Waits for the audio from the dial-in participant.
* @param participant
*/
export async function waitForAudioFromDialInParticipant(participant: Participant) {
// waits 15 seconds for the participant to join
await participant.waitForParticipants(1, `dial-in.test.jigasi.participant.no.join.for:${
ctx.times.restAPIExecutionTS + 15_000} ms.`);
const joinedTS = performance.now();
console.log(`dial-in.test.jigasi.participant.join.after:${joinedTS - ctx.times.restAPIExecutionTS}`);
await participant.waitForIceConnected();
await participant.waitForRemoteStreams(1);
await participant.waitForSendReceiveData(20_000, 'dial-in.test.jigasi.participant.no.audio.after.join');
console.log(`dial-in.test.jigasi.participant.received.audio.after.join:${performance.now() - joinedTS} ms.`);
}
/**
* Cleans up the dial-in participant by kicking it if the local participant is a moderator.
* @param participant
*/
export async function cleanup(participant: Participant) {
// cleanup
if (await participant.isModerator()) {
const jigasiEndpointId = await participant.execute(() => APP?.conference?.listMembers()[0].getId());
await participant.getFilmstrip().kickParticipant(jigasiEndpointId);
}
}
/**
* Checks if the dial-in is enabled.
* @param participant
*/
export async function isDialInEnabled(participant: Participant) {
return await participant.execute(() => Boolean(
config.dialInConfCodeUrl && config.dialInNumbersUrl && config.hosts?.muc));
}
/**
* Sends a request to the REST API to dial in the participant using the provided pin.
* @param participant
*/
export async function dialIn(participant: Participant) {
if (!await participant.isInMuc()) {
// local participant did not join abort
return;
}
const dialInPin = await participant.getDialInPin();
const restUrl = process.env.DIAL_IN_REST_URL?.replace('{0}', dialInPin);
// we have already checked in the first test that DIAL_IN_REST_URL exist so restUrl cannot be ''
const responseData: string = await new Promise((resolve, reject) => {
https.get(restUrl || '', res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
ctx.times.restAPIExecutionTS = performance.now();
resolve(data);
});
}).on('error', err => {
console.error('dial-in.test.restAPI.request.fail');
console.error(err);
reject(err);
});
});
console.log(`dial-in.test.call_session_history_id:${JSON.parse(responseData).call_session_history_id}`);
console.log(`API response:${responseData}`);
}

View File

@@ -0,0 +1,62 @@
import { Participant } from '../../helpers/Participant';
import { config } from '../../helpers/TestsConfig';
import { IToken, ITokenOptions, generateToken } from '../../helpers/token';
import { IParticipantJoinOptions } from '../../helpers/types';
export function generateJaasToken(options: ITokenOptions): IToken {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
return generateToken({
...options,
keyId: options.keyId || config.jaas.kid,
keyPath: options.keyPath || config.jaas.privateKeyPath
});
}
/**
* Creates a new Participant and joins the MUC with the given options. The jaas-specific properties must be set as
* environment variables (see env.example and TestsConfig.ts). If no room name is specified, the default room name
* from the context is used.
*
* @param instanceId This is the "name" passed to the Participant, I think it's used to match against one of the
* pre-configured browser instances in wdio? It must be one of 'p1', 'p2', 'p3', or 'p4'. TODO: figure out how this
* should be used.
* @param token the token to use, if any.
* @param joinOptions options to use when joining the MUC.
* @returns {Promise<Participant>} The Participant that has joined the MUC.
*/
export async function joinMuc(
instanceId: 'p1' | 'p2' | 'p3' | 'p4',
token?: IToken,
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
if (!config.jaas.enabled) {
throw new Error('JaaS is not configured.');
}
// @ts-ignore
const p = ctx[instanceId] as Participant;
if (p) {
// Load a blank page to make sure the page is reloaded (in case the new participant uses the same URL). Using
// 'about:blank' was causing problems in the past, if we notice any issues we can change to "base.html".
await p.driver.url('about:blank');
}
const newParticipant = new Participant({
name: instanceId,
token
});
// @ts-ignore
ctx[instanceId] = newParticipant;
return await newParticipant.joinConference({
...joinOptions,
forceTenant: config.jaas.tenant,
roomName: joinOptions?.roomName || ctx.roomName,
});
}

View File

@@ -0,0 +1,198 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
import { fetchJson } from '../../helpers/utils';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Chat', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants();
const { p1, p2 } = ctx;
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled
ctx.skipSuiteTests = true;
return;
}
// let's populate endpoint ids
await Promise.all([
p1.getEndpointId(),
p2.getEndpointId()
]);
});
it('send message', async () => {
const { p1, p2 } = ctx;
await p1.switchToAPI();
await p2.switchToAPI();
await p2.getIframeAPI().addEventListener('chatUpdated');
await p2.getIframeAPI().addEventListener('incomingMessage');
await p1.getIframeAPI().addEventListener('outgoingMessage');
const testMessage = 'Hello world';
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: false,
unreadCount: 1
});
const incomingMessageEvent: {
from: string;
message: string;
nick: string;
privateMessage: boolean;
} = await p2.getIframeAPI().getEventResult('incomingMessage');
expect(incomingMessageEvent).toEqual({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: false
});
const outgoingMessageEvent: {
message: string;
privateMessage: boolean;
} = await p1.getIframeAPI().getEventResult('outgoingMessage');
expect(outgoingMessageEvent).toEqual({
message: testMessage,
privateMessage: false
});
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('toggle chat', async () => {
const { p1, p2 } = ctx;
await p2.getIframeAPI().executeCommand('toggleChat');
await testSendGroupMessageWithChatOpen(p1, p2);
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('private chat', async () => {
const { p1, p2 } = ctx;
const testMessage = 'Hello private world!';
const p2Id = await p2.getEndpointId();
const p1Id = await p1.getEndpointId();
await p1.getIframeAPI().executeCommand('initiatePrivateChat', p2Id);
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage, p2Id);
const incomingMessageEvent = await p2.driver.waitUntil(
() => p2.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toEqual({
from: p1Id,
message: testMessage,
nick: p1.name,
privateMessage: true
});
expect(await p1.getIframeAPI().getEventResult('outgoingMessage')).toEqual({
message: testMessage,
privateMessage: true
});
await p1.getIframeAPI().executeCommand('cancelPrivateChat');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
await testSendGroupMessageWithChatOpen(p1, p2);
});
it('chat upload chat', async () => {
const { p1, p2, webhooksProxy } = ctx;
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
if (webhooksProxy) {
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('CHAT_UPLOADED');
expect('CHAT_UPLOADED').toBe(event.eventType);
expect(event.data.preAuthenticatedLink).toBeDefined();
const uploadedChat: any = await fetchJson(event.data.preAuthenticatedLink);
expect(uploadedChat.messageType).toBe('CHAT');
expect(uploadedChat.messages).toBeDefined();
expect(uploadedChat.messages.length).toBe(3);
}
});
});
/**
* Test sending a group message with the chat open.
* @param p1
* @param p2
*/
async function testSendGroupMessageWithChatOpen(p1: Participant, p2: Participant) {
const testMessage = 'Hello world again';
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: true,
unreadCount: 0
});
const incomingMessageEvent = await p2.driver.waitUntil(
() => p2.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toEqual({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: false
});
}

View File

@@ -0,0 +1,214 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { ensureOneParticipant } from '../../helpers/participants';
import {
cleanup,
dialIn,
isDialInEnabled,
waitForAudioFromDialInParticipant
} from '../helpers/DialIn';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
const customerId = testsConfig.iframe.customerId;
describe('Invite iframeAPI', () => {
let dialInDisabled: boolean;
let dialOutDisabled: boolean;
let sipJibriDisabled: boolean;
it('join participant', async () => {
await ensureOneParticipant();
const { p1 } = ctx;
// check for dial-in dial-out sip-jibri maybe
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled
ctx.skipSuiteTests = true;
return;
}
dialOutDisabled = Boolean(!await p1.execute(() => config.dialOutAuthUrl));
sipJibriDisabled = Boolean(!await p1.execute(() => config.inviteServiceUrl));
// check dial-in is enabled
if (!await isDialInEnabled(ctx.p1) || !process.env.DIAL_IN_REST_URL) {
dialInDisabled = true;
}
});
it('dial-in', async () => {
if (dialInDisabled) {
return;
}
const { p1 } = ctx;
const dialInPin = await p1.getDialInPin();
expect(dialInPin.length >= 8).toBe(true);
await dialIn(p1);
if (!await p1.isInMuc()) {
// local participant did not join abort
return;
}
await waitForAudioFromDialInParticipant(p1);
await checkDialEvents(p1, 'in', 'DIAL_IN_STARTED', 'DIAL_IN_ENDED');
});
it('dial-out', async () => {
if (dialOutDisabled || !process.env.DIAL_OUT_URL) {
return;
}
const { p1 } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().invitePhone(process.env.DIAL_OUT_URL);
await p1.switchInPage();
await p1.waitForParticipants(1);
await waitForAudioFromDialInParticipant(p1);
await checkDialEvents(p1, 'out', 'DIAL_OUT_STARTED', 'DIAL_OUT_ENDED');
});
it('sip jibri', async () => {
if (sipJibriDisabled || !process.env.SIP_JIBRI_DIAL_OUT_URL) {
return;
}
const { p1 } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().inviteSIP(process.env.SIP_JIBRI_DIAL_OUT_URL);
await p1.switchInPage();
await p1.waitForParticipants(1);
await waitForAudioFromDialInParticipant(p1);
const { webhooksProxy } = ctx;
if (webhooksProxy) {
const sipCallOutStartedEvent: {
customerId: string;
data: {
participantFullJid: string;
participantId: string;
participantJid: string;
sipAddress: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_STARTED');
expect('SIP_CALL_OUT_STARTED').toBe(sipCallOutStartedEvent.eventType);
expect(sipCallOutStartedEvent.data.sipAddress).toBe(`sip:${process.env.SIP_JIBRI_DIAL_OUT_URL}`);
expect(sipCallOutStartedEvent.customerId).toBe(customerId);
const participantId = sipCallOutStartedEvent.data.participantId;
const participantJid = sipCallOutStartedEvent.data.participantJid;
const participantFullJid = sipCallOutStartedEvent.data.participantFullJid;
await cleanup(p1);
const sipCallOutEndedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_ENDED');
expect('SIP_CALL_OUT_ENDED').toBe(sipCallOutEndedEvent.eventType);
expect(sipCallOutEndedEvent.customerId).toBe(customerId);
expect(sipCallOutEndedEvent.data.participantFullJid).toBe(participantFullJid);
expect(sipCallOutEndedEvent.data.participantId).toBe(participantId);
expect(sipCallOutEndedEvent.data.participantJid).toBe(participantJid);
} else {
await cleanup(p1);
}
});
});
/**
* Checks the dial events for a participant and clean up at the end.
* @param participant
* @param startedEventName
* @param endedEventName
* @param direction
*/
async function checkDialEvents(participant: Participant, direction: string, startedEventName: string, endedEventName: string) {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
const dialInStartedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(startedEventName);
expect(startedEventName).toBe(dialInStartedEvent.eventType);
expect(dialInStartedEvent.data.direction).toBe(direction);
expect(dialInStartedEvent.customerId).toBe(customerId);
const participantId = dialInStartedEvent.data.participantId;
const participantJid = dialInStartedEvent.data.participantJid;
const participantFullJid = dialInStartedEvent.data.participantFullJid;
const usageEvent: {
customerId: string;
data: any;
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect('USAGE').toBe(usageEvent.eventType);
expect(usageEvent.customerId).toBe(customerId);
expect(usageEvent.data.some((el: any) =>
el.participantId === participantId && el.callDirection === direction)).toBe(true);
await cleanup(participant);
const dialInEndedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(endedEventName);
expect(endedEventName).toBe(dialInEndedEvent.eventType);
expect(dialInEndedEvent.customerId).toBe(customerId);
expect(dialInEndedEvent.data.participantFullJid).toBe(participantFullJid);
expect(dialInEndedEvent.data.participantId).toBe(participantId);
expect(dialInEndedEvent.data.participantJid).toBe(participantJid);
} else {
await cleanup(participant);
}
}

View File

@@ -0,0 +1,477 @@
import { isEqual } from 'lodash-es';
import { P1, P2, Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { ensureTwoParticipants, parseJid } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
/**
* Tests PARTICIPANT_LEFT webhook.
*/
async function checkParticipantLeftHook(p: Participant, reason: string, checkId = false, conferenceJid: string) {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
// PARTICIPANT_LEFT webhook
// @ts-ignore
const event: {
customerId: string;
data: {
conference: string;
disconnectReason: string;
group: string;
id: string;
isBreakout: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(event.eventType);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.disconnectReason).toBe(reason);
expect(event.data.isBreakout).toBe(false);
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
if (checkId) {
const jwtPayload = p.getToken()?.payload;
expect(event.data.id).toBe(jwtPayload?.context?.user?.id);
expect(event.data.group).toBe(jwtPayload?.context?.group);
expect(event.customerId).toBe(testsConfig.iframe.customerId);
}
}
}
describe('Participants presence', () => {
let conferenceJid: string = '';
it('joining the meeting', async () => {
// ensure 2 participants one moderator and one guest, we will load both with iframeAPI
await ensureTwoParticipants();
const { p1, p2, webhooksProxy } = ctx;
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled
ctx.skipSuiteTests = true;
return;
}
// let's populate endpoint ids
await Promise.all([
p1.getEndpointId(),
p2.getEndpointId()
]);
await p1.switchToAPI();
await p2.switchToAPI();
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p2.getIframeAPI().getEventResult('isModerator')).toBe(false);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
if (webhooksProxy) {
// USAGE webhook
// @ts-ignore
const event: {
data: [
{ participantId: string; }
];
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect('USAGE').toBe(event.eventType);
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
expect(event.data.filter(d => d.participantId === p1EpId
|| d.participantId === p2EpId).length).toBe(2);
}
});
it('participants info',
async () => {
const { p1, roomName, webhooksProxy } = ctx;
const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0];
expect(roomsInfo).toBeDefined();
expect(roomsInfo.isMainRoom).toBe(true);
expect(roomsInfo.id).toBeDefined();
const { node: roomNode } = parseJid(roomsInfo.id);
expect(roomNode).toBe(roomName);
const { node, resource } = parseJid(roomsInfo.jid);
conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
const p1EpId = await p1.getEndpointId();
expect(node).toBe(roomName);
expect(resource).toBe(p1EpId);
expect(roomsInfo.participants.length).toBe(2);
expect(await p1.getIframeAPI().getNumberOfParticipants()).toBe(2);
if (webhooksProxy) {
// ROOM_CREATED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_CREATED');
expect('ROOM_CREATED').toBe(event.eventType);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
}
}
);
it('participants pane', async () => {
const { p1 } = ctx;
await p1.switchToAPI();
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
await p1.getIframeAPI().addEventListener('participantsPaneToggled');
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', true);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(true);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(true);
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', false);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(false);
});
it('grant moderator', async () => {
const { p1, p2, webhooksProxy } = ctx;
const p2EpId = await p2.getEndpointId();
await p1.getIframeAPI().clearEventResults('participantRoleChanged');
await p2.getIframeAPI().clearEventResults('participantRoleChanged');
await p1.getIframeAPI().executeCommand('grantModerator', p2EpId);
await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('isModerator'), {
timeout: 3000,
timeoutMsg: 'Moderator role not granted'
});
type RoleChangedEvent = {
id: string;
role: string;
};
const event1: RoleChangedEvent = await p1.driver.waitUntil(
() => p1.getIframeAPI().getEventResult('participantRoleChanged'), {
timeout: 3000,
timeoutMsg: 'Role was not update on p1 side'
});
expect(event1?.id).toBe(p2EpId);
expect(event1?.role).toBe('moderator');
const event2: RoleChangedEvent = await p2.driver.waitUntil(
() => p2.getIframeAPI().getEventResult('participantRoleChanged'), {
timeout: 3000,
timeoutMsg: 'Role was not update on p2 side'
});
expect(event2?.id).toBe(p2EpId);
expect(event2?.role).toBe('moderator');
if (webhooksProxy) {
// ROLE_CHANGED webhook
// @ts-ignore
const event: {
data: {
grantedBy: {
participantId: string;
};
grantedTo: {
participantId: string;
};
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROLE_CHANGED');
expect('ROLE_CHANGED').toBe(event.eventType);
expect(event.data.role).toBe('moderator');
expect(event.data.grantedBy.participantId).toBe(await p1.getEndpointId());
expect(event.data.grantedTo.participantId).toBe(await p2.getEndpointId());
}
});
it('kick participant', async () => {
// we want to join second participant with token, so we can check info in webhook
await ctx.p2.getIframeAPI().addEventListener('videoConferenceLeft');
await ctx.p2.switchToAPI();
await ctx.p2.getIframeAPI().executeCommand('hangup');
await ctx.p2.driver.waitUntil(() =>
ctx.p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
await ensureTwoParticipants({
preferGenerateToken: true
});
const { p1, p2, roomName, webhooksProxy } = ctx;
webhooksProxy?.clearCache();
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const p1DisplayName = await p1.getLocalDisplayName();
const p2DisplayName = await p2.getLocalDisplayName();
await p1.switchToAPI();
await p2.switchToAPI();
const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0];
conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
const eventP1 = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p1 side'
});
const eventP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p2 side'
});
await checkParticipantLeftHook(p2, 'kicked', true, conferenceJid);
expect(eventP1).toBeDefined();
expect(eventP2).toBeDefined();
expect(isEqual(eventP1, {
kicked: {
id: p2EpId,
local: false,
name: p2DisplayName
},
kicker: {
id: p1EpId,
local: true,
name: p1DisplayName
}
})).toBe(true);
expect(isEqual(eventP2, {
kicked: {
id: 'local',
local: true,
name: p2DisplayName
},
kicker: {
id: p1EpId,
name: p1DisplayName
}
})).toBe(true);
const eventConferenceLeftP2 = await p2.driver.waitUntil(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
});
it('join after kick', async () => {
const { p1, webhooksProxy } = ctx;
await p1.getIframeAPI().addEventListener('participantJoined');
await p1.getIframeAPI().addEventListener('participantMenuButtonClick');
webhooksProxy?.clearCache();
// join again
await ensureTwoParticipants();
const { p2 } = ctx;
if (webhooksProxy) {
// PARTICIPANT_JOINED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
moderator: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
expect(event.data.moderator).toBe(false);
expect(event.data.name).toBe(await p2.getLocalDisplayName());
expect(event.data.participantId).toBe(await p2.getEndpointId());
expect(event.data.name).toBe(p2.name);
}
await p1.switchToAPI();
const event = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('participantJoined'), {
timeout: 2000,
timeoutMsg: 'participantJoined not received'
});
const p2DisplayName = await p2.getLocalDisplayName();
expect(event).toBeDefined();
expect(event.id).toBe(await p2.getEndpointId());
expect(event.displayName).toBe(p2DisplayName);
expect(event.formattedDisplayName).toBe(p2DisplayName);
});
it('overwrite names', async () => {
const { p1, p2 } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const newP1Name = P1;
const newP2Name = P2;
const newNames: ({ id: string; name: string; })[] = [ {
id: p2EpId,
name: newP2Name
}, {
id: p1EpId,
name: newP1Name
} ];
await p1.getIframeAPI().executeCommand('overwriteNames', newNames);
await p1.switchInPage();
expect(await p1.getLocalDisplayName()).toBe(newP1Name);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EpId)).toBe(newP2Name);
});
it('hangup', async () => {
const { p1, p2, roomName } = ctx;
await p1.switchToAPI();
await p2.switchToAPI();
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('readyToClose');
await p2.getIframeAPI().executeCommand('hangup');
const eventConferenceLeftP2 = await p2.driver.waitUntil(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
await checkParticipantLeftHook(p2, 'left', false, conferenceJid);
const eventReadyToCloseP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToCloseP2).toBeDefined();
});
it('dispose conference', async () => {
const { p1, roomName, webhooksProxy } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().clearEventResults('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('readyToClose');
await p1.getIframeAPI().executeCommand('hangup');
const eventConferenceLeft = await p1.driver.waitUntil(() =>
p1.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeft).toBeDefined();
expect(eventConferenceLeft.roomName).toBe(roomName);
await checkParticipantLeftHook(p1, 'left', true, conferenceJid);
if (webhooksProxy) {
// ROOM_DESTROYED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_DESTROYED');
expect('ROOM_DESTROYED').toBe(event.eventType);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
}
const eventReadyToClose = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToClose).toBeDefined();
// dispose
await p1.getIframeAPI().dispose();
// check there is no iframe on the page
await p1.driver.$('iframe').waitForExist({
reverse: true,
timeout: 2000,
timeoutMsg: 'iframe is still on the page'
});
});
});

View File

@@ -0,0 +1,207 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
const { tenant, customerId } = testsConfig.iframe;
describe('Recording', () => {
let recordingDisabled: boolean;
let liveStreamingDisabled: boolean;
it('join participant', async () => {
await ensureOneParticipant();
const { p1 } = ctx;
// check for dial-in dial-out sip-jibri maybe
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled
ctx.skipSuiteTests = true;
return;
}
recordingDisabled = Boolean(!await p1.execute(() => config.recordingService?.enabled));
liveStreamingDisabled = Boolean(!await p1.execute(() => config.liveStreaming?.enabled))
|| !process.env.YTUBE_TEST_STREAM_KEY;
});
it('start/stop function', async () => {
if (recordingDisabled) {
return;
}
await testRecordingStarted(true);
await testRecordingStopped(true);
// to avoid limits
await ctx.p1.driver.pause(30000);
});
it('start/stop command', async () => {
if (recordingDisabled) {
return;
}
await testRecordingStarted(false);
await testRecordingStopped(false);
// to avoid limits
await ctx.p1.driver.pause(30000);
});
it('start/stop Livestreaming command', async () => {
if (liveStreamingDisabled) {
return;
}
const { p1, webhooksProxy } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().addEventListener('recordingStatusChanged');
await p1.getIframeAPI().executeCommand('startRecording', {
youtubeBroadcastID: process.env.YTUBE_TEST_BROADCAST_ID,
mode: 'stream',
youtubeStreamKey: process.env.YTUBE_TEST_STREAM_KEY
});
if (webhooksProxy) {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_STARTED');
expect('LIVE_STREAM_STARTED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
}
const statusEvent = (await p1.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(statusEvent.mode).toBe('stream');
expect(statusEvent.on).toBe(true);
if (process.env.YTUBE_TEST_BROADCAST_ID) {
const liveStreamUrl = await p1.getIframeAPI().getLivestreamUrl();
expect(liveStreamUrl.livestreamUrl).toBeDefined();
}
await p1.getIframeAPI().executeCommand('stopRecording', 'stream');
if (webhooksProxy) {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_ENDED');
expect('LIVE_STREAM_ENDED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
}
const stoppedStatusEvent = (await p1.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(stoppedStatusEvent.mode).toBe('stream');
expect(stoppedStatusEvent.on).toBe(false);
});
});
/**
* Checks if the recording is started.
* @param command
*/
async function testRecordingStarted(command: boolean) {
const { p1, webhooksProxy } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().addEventListener('recordingStatusChanged');
await p1.getIframeAPI().addEventListener('recordingLinkAvailable');
if (command) {
await p1.getIframeAPI().executeCommand('startRecording', {
mode: 'file'
});
} else {
await p1.getIframeAPI().startRecording({
mode: 'file'
});
}
if (webhooksProxy) {
const recordingEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_STARTED');
expect('RECORDING_STARTED').toBe(recordingEvent.eventType);
expect(recordingEvent.customerId).toBe(customerId);
webhooksProxy?.clearCache();
}
const statusEvent = (await p1.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(statusEvent.mode).toBe('file');
expect(statusEvent.on).toBe(true);
const linkEvent = (await p1.getIframeAPI().getEventResult('recordingLinkAvailable'));
expect(linkEvent.link.startsWith('https://')).toBe(true);
expect(linkEvent.link.includes(tenant)).toBe(true);
expect(linkEvent.ttl > 0).toBe(true);
}
/**
* Checks if the recording is stopped.
* @param command
*/
async function testRecordingStopped(command: boolean) {
const { p1, webhooksProxy } = ctx;
await p1.switchToAPI();
if (command) {
await p1.getIframeAPI().executeCommand('stopRecording', 'file');
} else {
await p1.getIframeAPI().stopRecording('file');
}
if (webhooksProxy) {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_ENDED');
expect('RECORDING_ENDED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
const recordingUploadedEvent: {
customerId: string;
data: {
initiatorId: string;
participants: Array<string>;
};
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED');
const jwtPayload = p1.getToken()?.payload;
expect(recordingUploadedEvent.data.initiatorId).toBe(jwtPayload?.context?.user?.id);
expect(recordingUploadedEvent.data.participants.some(
// @ts-ignore
e => e.id === jwtPayload?.context?.user?.id)).toBe(true);
webhooksProxy?.clearCache();
}
const statusEvent = (await p1.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(statusEvent.mode).toBe('file');
expect(statusEvent.on).toBe(false);
await p1.getIframeAPI().clearEventResults('recordingStatusChanged');
}

View File

@@ -0,0 +1,237 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import type WebhookProxy from '../../helpers/WebhookProxy';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Transcriptions', () => {
it('joining the meeting', async () => {
await ensureOneParticipant();
const { p1 } = ctx;
if (await p1.execute(() => config.disableIframeAPI || !config.transcription?.enabled)) {
// skip the test if iframeAPI or transcriptions are disabled
ctx.skipSuiteTests = true;
return;
}
await p1.switchToAPI();
await ensureTwoParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p2 } = ctx;
// let's populate endpoint ids
await Promise.all([
p1.getEndpointId(),
p2.getEndpointId()
]);
await p1.switchToAPI();
await p2.switchToAPI();
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
it('toggle subtitles', async () => {
const { p1, p2, webhooksProxy } = ctx;
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await checkReceivingChunks(p1, p2, webhooksProxy);
await p1.getIframeAPI().executeCommand('toggleSubtitles');
// give it some time to process
await p1.driver.pause(5000);
});
it('set subtitles on and off', async () => {
const { p1, p2, webhooksProxy } = ctx;
// we need to clear results or the last one will be used, form the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await checkReceivingChunks(p1, p2, webhooksProxy);
await p1.getIframeAPI().executeCommand('setSubtitles', false);
// give it some time to process
await p1.driver.pause(5000);
});
it('start/stop transcriptions via recording', async () => {
const { p1, p2, webhooksProxy } = ctx;
// we need to clear results or the last one will be used, form the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
let allTranscriptionStatusChanged: Promise<any>[] = [];
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1 side'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2 side'
}));
let result = await Promise.allSettled(allTranscriptionStatusChanged);
expect(result.length).toBe(2);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(true);
});
await checkReceivingChunks(p1, p2, webhooksProxy);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
allTranscriptionStatusChanged = [];
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1 side'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2 side'
}));
result = await Promise.allSettled(allTranscriptionStatusChanged);
expect(result.length).toBe(2);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(false);
});
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
if (webhooksProxy) {
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect('TRANSCRIPTION_UPLOADED').toBe(event.eventType);
expect(event.data.preAuthenticatedLink).toBeDefined();
}
});
});
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy) {
const allTranscripts: Promise<any>[] = [];
allTranscripts.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p1 side'
}));
allTranscripts.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
}));
if (webhooksProxy) {
// TRANSCRIPTION_CHUNK_RECEIVED webhook
allTranscripts.push((async () => {
const event: {
data: {
final: string;
language: string;
messageID: string;
participant: {
id: string;
name: string;
};
stable: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_CHUNK_RECEIVED');
expect('TRANSCRIPTION_CHUNK_RECEIVED').toBe(event.eventType);
event.data.stable = event.data.final;
return event;
})());
}
const result = await Promise.allSettled(allTranscripts);
expect(result.length).toBeGreaterThan(0);
// @ts-ignore
const firstEntryData = result[0].value.data;
const stable = firstEntryData.stable || firstEntryData.final;
const language = firstEntryData.language;
const messageID = firstEntryData.messageID;
const p1Id = await p1.getEndpointId();
result.map(r => {
// @ts-ignore
const v = r.value;
expect(v).toBeDefined();
return v.data;
}).forEach(tr => {
const checkTranscripts = stable.includes(tr.stable || tr.final) || (tr.stable || tr.final).includes(stable);
if (!checkTranscripts) {
console.log('received events', JSON.stringify(result));
}
expect(checkTranscripts).toBe(true);
expect(tr.language).toBe(language);
expect(tr.messageID).toBe(messageID);
expect(tr.participant.id).toBe(p1Id);
expect(tr.participant.name).toBe(p1.name);
});
}

View File

@@ -0,0 +1,125 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Visitors', () => {
it('joining the meeting', async () => {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true
};
}
await ensureOneParticipant();
const { p1 } = ctx;
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled or visitors are not supported
ctx.skipSuiteTests = true;
return;
}
await p1.driver.waitUntil(() => p1.execute(() => APP.conference._room.isVisitorsSupported()), {
timeout: 2000
}).then(async () => {
await p1.switchToAPI();
}).catch(() => {
ctx.skipSuiteTests = true;
});
});
it('visitor joins', async () => {
await ensureTwoParticipants({
preferGenerateToken: true,
tokenOptions: { visitor: true },
skipInMeetingChecks: true
});
const { p1, p2, webhooksProxy } = ctx;
await p2.waitForReceiveMedia(15_000, 'Visitor is not receiving media');
await p2.waitForRemoteStreams(1);
const p2Visitors = p2.getVisitors();
const p1Visitors = p1.getVisitors();
await p2.driver.waitUntil(() => p2Visitors.hasVisitorsDialog(), {
timeout: 5000,
timeoutMsg: 'Missing visitors dialog'
});
expect((await p1Visitors.getVisitorsCount()).trim()).toBe('1');
expect((await p1Visitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
if (webhooksProxy) {
// PARTICIPANT_JOINED webhook
// @ts-ignore
const event: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
const jwtPayload = p2.getToken()?.payload;
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.avatar).toBe(jwtPayload.context.user.avatar);
expect(event.data.email).toBe(jwtPayload.context.user.email);
expect(event.data.id).toBe(jwtPayload.context.user.id);
expect(event.data.group).toBe(jwtPayload.context.group);
expect(event.data.name).toBe(p2.name);
expect(event.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(event.data.name).toBe(p2.name);
expect(event.data.role).toBe('visitor');
expect(event.customerId).toBe(testsConfig.iframe.customerId);
await p2.switchToAPI();
await p2.getIframeAPI().executeCommand('hangup');
// PARTICIPANT_LEFT webhook
// @ts-ignore
const eventLeft: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(eventLeft.eventType);
expect(eventLeft.data.avatar).toBe(jwtPayload.context.user.avatar);
expect(eventLeft.data.email).toBe(jwtPayload.context.user.email);
expect(eventLeft.data.id).toBe(jwtPayload.context.user.id);
expect(eventLeft.data.group).toBe(jwtPayload.context.group);
expect(eventLeft.data.name).toBe(p2.name);
expect(eventLeft.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(eventLeft.data.name).toBe(p2.name);
expect(eventLeft.data.role).toBe('visitor');
expect(eventLeft.customerId).toBe(testsConfig.iframe.customerId);
}
});
});

View File

@@ -0,0 +1,81 @@
import { expect } from '@wdio/globals';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Visitors', () => {
it('joining the meeting', async () => {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: false
};
}
await ensureOneParticipant();
const { p1 } = ctx;
if (await p1.execute(() => config.disableIframeAPI)) {
// skip the test if iframeAPI is disabled or visitors are not supported
ctx.skipSuiteTests = true;
return;
}
await p1.driver.waitUntil(() => p1.execute(() => APP.conference._room.isVisitorsSupported()), {
timeout: 2000
}).then(async () => {
await p1.switchToAPI();
}).catch(() => {
ctx.skipSuiteTests = true;
});
});
it('go live', async () => {
await ensureTwoParticipants({
preferGenerateToken: true,
tokenOptions: { visitor: true },
skipWaitToJoin: true,
skipInMeetingChecks: true
});
const { p1, p2 } = ctx;
const p2Visitors = p2.getVisitors();
const p1Visitors = p1.getVisitors();
await p2.driver.waitUntil(async () => p2Visitors.isVisitorsQueueUIShown(), {
timeout: 5000,
timeoutMsg: 'Missing visitors queue UI'
});
await p1.driver.waitUntil(async () => await p1Visitors.getWaitingVisitorsInQueue()
=== 'Viewers waiting in queue: 1', {
timeout: 15000,
timeoutMsg: 'Missing visitors queue count in UI'
});
await p1Visitors.goLive();
await p2.waitToJoinMUC();
await p2.waitForReceiveMedia(15000, 'Visitor is not receiving media');
await p2.waitForRemoteStreams(1);
await p2.driver.waitUntil(() => p2Visitors.hasVisitorsDialog(), {
timeout: 5000,
timeoutMsg: 'Missing visitors dialog'
});
expect((await p1Visitors.getVisitorsCount()).trim()).toBe('1');
expect((await p1Visitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
});
});

View File

@@ -0,0 +1,101 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { TOKEN_AUTH_FAILED_TEST_ID, TOKEN_AUTH_FAILED_TITLE_TEST_ID } from '../../pageobjects/Notifications';
import { joinMuc, generateJaasToken as t } from '../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
describe('XMPP login and MUC join test', () => {
it('with a valid token (wildcard room)', async () => {
console.log('Joining a MUC with a valid token (wildcard room)');
const p = await joinMuc('p1', t({ room: '*' }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a valid token (specific room)', async () => {
console.log('Joining a MUC with a valid token (specific room)');
const p = await joinMuc('p1', t({ room: ctx.roomName }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a token with bad signature', async () => {
console.log('Joining a MUC with a token with bad signature');
const token = t({ room: ctx.roomName });
token.jwt = token.jwt + 'badSignature';
const p = await joinMuc('p1', token);
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID)
|| await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with an expired token', async () => {
console.log('Joining a MUC with an expired token');
const p = await joinMuc('p1', t({ exp: '-1m' }));
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('Token is expired');
});
it('with a token using the wrong key ID', async () => {
console.log('Joining a MUC with a token using the wrong key ID');
const p = await joinMuc('p1', t({ keyId: 'invalid-key-id' }));
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a token for a different room', async () => {
console.log('Joining a MUC with a token for a different room');
const p = await joinMuc('p1', t({ room: ctx.roomName + 'different' }));
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a moderator token', async () => {
console.log('Joining a MUC with a moderator token');
const p = await joinMuc('p1', t({ moderator: true }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(true);
});
// This is dependent on jaas account configuration. All tests under jaas/ expect that "unauthenticated access" is
// disabled.
it('without a token', async () => {
console.log('Joining a MUC without a token');
const p = await joinMuc('p1');
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
// it('without sending a conference-request', async () => {
// console.log('Joining a MUC without sending a conference-request');
// // TODO verify failure
// //expect(await joinMuc(ctx.roomName, 'p1', token)).toBe(true);
// });
});

View File

@@ -0,0 +1,31 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('MaxOccupants limit enforcement', () => {
it('test maxOccupants limit', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
maxOccupants: 2
};
const p1 = await joinMuc('p1', t({ room: ctx.roomName }));
const p2 = await joinMuc('p2', t({ room: ctx.roomName }));
expect(await p1.isInMuc()).toBe(true);
expect(await p2.isInMuc()).toBe(true);
// Third participant should be rejected (exceeding maxOccupants), even if it's a moderator
let p3 = await joinMuc('p3', t({ room: ctx.roomName, moderator: true }));
expect(Boolean(await p3.isInMuc())).toBe(false);
await p1.hangup();
p3 = await joinMuc('p3', t({ room: ctx.roomName }));
expect(await p3.isInMuc()).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { IToken } from '../../helpers/token';
import { joinMuc, generateJaasToken as t } from '../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2' ]
});
const passcode = '1234';
describe('Setting passcode through settings provisioning', () => {
it('With a valid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: passcode,
visitorsEnabled: true
};
// We want to keep the room from getting destroyed, because the visitors queue has a timeout and causes
// problems. We could use different rooms instead, but the webhooksProxy is only configured for the default room.
await joinWithPassword('p1', t({ room: ctx.roomName }));
await joinWithPassword('p2', t({ room: ctx.roomName, moderator: true }));
await joinWithPassword('p2', t({ room: ctx.roomName, visitor: true }));
});
});
/**
* Join a password-protected room. Assert that a password is required, that a wrong password does not work, and that
* the correct password does work.
*/
async function joinWithPassword(instanceId: string, token: IToken) {
// @ts-ignore
const p = await joinMuc(instanceId, token, ctx.roomName);
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword('wrong password');
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword(passcode);
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
}

View File

@@ -0,0 +1,25 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true
});
// This test is separate from passcode.spec.ts, because it needs to use a different room name, and webhooksProxy is only
// setup for the default room name.
describe('Setting passcode through settings provisioning', () => {
it('With an invalid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: 'passcode-must-be-digits-only'
};
const p = await joinMuc('p1', t({ room: ctx.roomName }), ctx.roomName);
// The settings provisioning contains an invalid passcode, the expected result is that the room is not
// configured to require a passcode.
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,50 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Visitors triggered by reaching participantsSoftLimit', () => {
it('test participantsSoftLimit', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
participantsSoftLimit: 2,
visitorsEnabled: true
};
/// XXX the "name" of the participant MUST match one of the "capabilities" defined in wdio. It's not a "participant", it's an instance configuration!
const m = await joinMuc(
'p1',
t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
);
expect(await m.isInMuc()).toBe(true);
expect(await m.isModerator()).toBe(true);
expect(await m.isVisitor()).toBe(false);
console.log('Moderator joined');
// Joining with a participant token before participantSoftLimit has been reached
const p = await joinMuc(
'p2',
t({ room: ctx.roomName, displayName: 'Parti Cipant' })
);
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
expect(await p.isVisitor()).toBe(false);
console.log('Participant joined');
// Joining with a participant token after participantSoftLimit has been reached
const v = await joinMuc(
'p3',
t({ room: ctx.roomName, displayName: 'Visi Tor' })
);
expect(await v.isInMuc()).toBe(true);
expect(await v.isModerator()).toBe(false);
expect(await v.isVisitor()).toBe(true);
console.log('Visitor joined');
});
});

View File

@@ -0,0 +1,61 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
});
/**
* This is a case which fails if jitsi-videobridge doesn't properly forward PLIs from visitors.
*/
describe('Visitor receiving video from a single remote participant', () => {
it('joining the meeting', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: true,
};
// Force a connection via JVB.
const configOverwrite = {
p2p: {
enabled: false
}
};
const sender = await joinMuc(
'p1',
t({ room: ctx.roomName, displayName: 'Sender', moderator: true }), {
configOverwrite
}
);
const senderEndpointId = await sender.getEndpointId();
const testVisitor = async function(instanceId: 'p1' | 'p2' | 'p3' | 'p4') {
const visitor = await joinMuc(
instanceId,
t({ room: ctx.roomName, displayName: 'Visitor', visitor: true }), {
configOverwrite
}
);
await visitor.waitForIceConnected();
const iceConnected = performance.now();
await visitor.driver.waitUntil(
() => visitor.isRemoteVideoReceivedAndDisplayed(senderEndpointId), {
timeout: 10_000,
timeoutMsg: `Visitor (${instanceId}) is not receiving video from the sender`
});
const duration = performance.now() - iceConnected;
console.log(`Video displayed after ${duration} ms after ICE connected (${instanceId})`);
};
await testVisitor('p2');
await testVisitor('p3');
await testVisitor('p4');
});
});

View File

@@ -0,0 +1,58 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Visitors triggered by visitor tokens', () => {
it('test visitor tokens', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true
};
const m = await joinMuc(
'p1',
t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
);
expect(await m.isInMuc()).toBe(true);
expect(await m.isModerator()).toBe(true);
expect(await m.isVisitor()).toBe(false);
console.log('Moderator joined');
// Joining with a participant token before any visitors
const p = await joinMuc(
'p2',
t({ room: ctx.roomName, displayName: 'Parti Cipant' })
);
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
expect(await p.isVisitor()).toBe(false);
console.log('Participant joined');
// Joining with a visitor token
const v = await joinMuc(
'p3',
t({ room: ctx.roomName, displayName: 'Visi Tor', visitor: true })
);
expect(await v.isInMuc()).toBe(true);
expect(await v.isModerator()).toBe(false);
expect(await v.isVisitor()).toBe(true);
console.log('Visitor joined');
// Joining with a participant token after visitors...:mindblown:
const v2 = await joinMuc(
'p2',
t({ room: ctx.roomName, displayName: 'Visi Tor 2' }));
expect(await v2.isInMuc()).toBe(true);
expect(await v2.isModerator()).toBe(false);
expect(await v2.isVisitor()).toBe(true);
console.log('Visitor2 joined');
});
});

409
tests/wdio.conf.ts Normal file
View File

@@ -0,0 +1,409 @@
import AllureReporter from '@wdio/allure-reporter';
import { multiremotebrowser } from '@wdio/globals';
import { Buffer } from 'buffer';
import { glob } from 'glob';
import path from 'node:path';
import process from 'node:process';
import pretty from 'pretty';
import { getTestProperties, loadTestFiles } from './helpers/TestProperties';
import { config as testsConfig } from './helpers/TestsConfig';
import WebhookProxy from './helpers/WebhookProxy';
import { getLogs, initLogger, logInfo } from './helpers/browserLogger';
import { IContext } from './helpers/types';
import { generateRoomName } from './helpers/utils';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const allure = require('allure-commandline');
// This is deprecated without alternative (https://github.com/nodejs/node/issues/32483)
// we need it to be able to reuse jitsi-meet code in tests
require.extensions['.web.ts'] = require.extensions['.ts'];
const chromeArgs = [
'--allow-insecure-localhost',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--disable-plugins',
'--mute-audio',
'--disable-infobars',
'--autoplay-policy=no-user-gesture-required',
'--auto-select-desktop-capture-source=Your Entire screen',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
// Avoids - "You are checking for animations on an inactive tab, animations do not run for inactive tabs"
// when executing waitForStable()
'--disable-renderer-backgrounding',
'--use-file-for-fake-audio-capture=tests/resources/fakeAudioStream.wav'
];
if (process.env.RESOLVER_RULES) {
chromeArgs.push(`--host-resolver-rules=${process.env.RESOLVER_RULES}`);
}
if (process.env.ALLOW_INSECURE_CERTS === 'true') {
chromeArgs.push('--ignore-certificate-errors');
}
if (process.env.HEADLESS === 'true') {
chromeArgs.push('--headless');
chromeArgs.push('--window-size=1280,1024');
}
if (process.env.VIDEO_CAPTURE_FILE) {
chromeArgs.push(`--use-file-for-fake-video-capture=${process.env.VIDEO_CAPTURE_FILE}`);
}
const chromePreferences = {
'intl.accept_languages': 'en-US'
};
const specs = [
'specs/**/*.spec.ts'
];
/**
* Analyzes test files at config construction time to determine browser requirements
* and generate capabilities with appropriate exclusions.
*/
function generateCapabilitiesFromSpecs(): Record<string, any> {
const allSpecFiles: string[] = [];
const browsers = [ 'p1', 'p2', 'p3', 'p4' ];
for (const pattern of specs) {
const matches = glob.sync(pattern, { cwd: path.join(__dirname) });
allSpecFiles.push(...matches.map(f => path.resolve(__dirname, f)));
}
// Load test files to populate the testProperties registry
loadTestFiles(allSpecFiles);
// Import TestProperties to access the populated registry
const { testProperties } = require('./helpers/TestProperties');
// Determine which browsers need which exclusions
const browserExclusions: Record<string, Set<string>> = {
p1: new Set(),
p2: new Set(),
p3: new Set(),
p4: new Set()
};
for (const file of allSpecFiles) {
const props = testProperties[file];
const relativeFile = path.relative(__dirname, file);
// If a test doesn't use a particular browser, add it to exclusions for that browser
if (props?.usesBrowsers) {
browsers.forEach(browser => {
if (!props.usesBrowsers!.includes(browser)) {
browserExclusions[browser].add(relativeFile);
}
});
}
}
return Object.fromEntries(
browsers.map(browser => [
browser,
{
capabilities: {
browserName: 'chrome',
...(browser === 'p1' && process.env.BROWSER_CHROME_BETA ? { browserVersion: 'beta' } : {}),
'goog:chromeOptions': {
args: chromeArgs,
prefs: chromePreferences
},
'wdio:exclude': Array.from(browserExclusions[browser] || [])
}
}
])
);
}
const capabilities = generateCapabilitiesFromSpecs();
const TEST_RESULTS_DIR = 'test-results';
const keepAlive: Array<any> = [];
export const config: WebdriverIO.MultiremoteConfig = {
runner: 'local',
specs,
maxInstances: parseInt(process.env.MAX_INSTANCES || '1', 10), // if changing check onWorkerStart logic
baseUrl: process.env.BASE_URL || 'https://alpha.jitsi.net/torture/',
tsConfigPath: './tsconfig.json',
// Default timeout for all waitForXXX commands.
waitforTimeout: 1000,
// Default timeout in milliseconds for request
// if browser driver or grid doesn't send response
connectionRetryTimeout: 15_000,
// Default request retries count
connectionRetryCount: 3,
framework: 'mocha',
mochaOpts: {
timeout: 180_000
},
capabilities,
// Level of logging verbosity: trace | debug | info | warn | error | silent
logLevel: 'trace',
logLevels: {
webdriver: 'info'
},
// Set directory to store all logs into
outputDir: TEST_RESULTS_DIR,
reporters: [
[ 'junit', {
outputDir: TEST_RESULTS_DIR,
outputFileFormat(options) { // optional
return `results-${options.cid}.xml`;
}
} ],
[ 'allure', {
// addConsoleLogs: true,
outputDir: `${TEST_RESULTS_DIR}/allure-results`,
disableWebdriverStepsReporting: true,
disableWebdriverScreenshotsReporting: true,
useCucumberStepReporter: false
} ]
],
// =====
// Hooks
// =====
/**
* Gets executed before test execution begins. At this point you can access to all global
* variables like `browser`. It is the perfect place to define custom commands.
* We have overriden this function in beforeSession to be able to pass cid as first param.
*
* @returns {Promise<void>}
*/
async before(cid, _, files) {
if (files.length !== 1) {
console.warn('We expect to run a single suite, but got more than one');
}
const testFilePath = files[0].replace(/^file:\/\//, '');
const testName = path.relative('tests/specs', testFilePath)
.replace(/.spec.ts$/, '')
.replace(/\//g, '-');
const testProperties = await getTestProperties(testFilePath);
console.log(`Running test: ${testName} via worker: ${cid}`);
const globalAny: any = global;
globalAny.ctx = {
times: {}
} as IContext;
globalAny.ctx.testProperties = testProperties;
if (testProperties.useJaas && !testsConfig.jaas.enabled) {
console.warn(`JaaS is not configured, skipping ${testName}.`);
globalAny.ctx.skipSuiteTests = true;
return;
}
await Promise.all(multiremotebrowser.instances.map(async (instance: string) => {
const bInstance = multiremotebrowser.getInstance(instance);
// @ts-ignore
initLogger(bInstance, `${instance}-${cid}-${testName}`, TEST_RESULTS_DIR);
// setup keepalive
keepAlive.push(setInterval(async () => {
await bInstance.execute(() => console.log(`${new Date().toISOString()} keep-alive`));
}, 20_000));
if (bInstance.isFirefox) {
return;
}
const rpath = await bInstance.uploadFile('tests/resources/iframeAPITest.html');
// @ts-ignore
bInstance.iframePageBase = `file://${path.dirname(rpath)}`;
}));
globalAny.ctx.roomName = generateRoomName(testName);
console.log(`Using room name: ${globalAny.ctx.roomName}`);
// If we are running the iFrameApi tests, we need to mark it as such and if needed to create the proxy
// and connect to it.
if (testProperties.useWebhookProxy && testsConfig.webhooksProxy.enabled && !globalAny.ctx.webhooksProxy) {
// Note this prevents iframe and jaas test from running together.
const tenant = testsConfig.jaas.enabled ? testsConfig.jaas.tenant : testsConfig.iframe.tenant;
globalAny.ctx.webhooksProxy = new WebhookProxy(
`${testsConfig.webhooksProxy.url}?tenant=${tenant}&room=${globalAny.ctx.roomName}`,
testsConfig.webhooksProxy.sharedSecret!,
`${TEST_RESULTS_DIR}/webhooks-${cid}-${testName}.log`);
globalAny.ctx.webhooksProxy.connect();
}
if (testProperties.useWebhookProxy && !globalAny.ctx.webhooksProxy) {
console.warn(`WebhookProxy is not available, skipping ${testName}`);
globalAny.ctx.skipSuiteTests = true;
}
},
after() {
const { ctx }: any = global;
ctx?.webhooksProxy?.disconnect();
keepAlive.forEach(clearInterval);
},
beforeSession(c, capabilities_, specs_, cid) {
const originalBefore = c.before;
if (!originalBefore || !Array.isArray(originalBefore) || originalBefore.length !== 1) {
console.warn('No before hook found or more than one found, skipping');
return;
}
if (originalBefore) {
c.before = [ async function(...args) {
// Call original with cid as first param, followed by original args
// @ts-ignore
return await originalBefore[0].call(c, cid, ...args);
} ];
}
},
/**
* Gets executed before the suite starts (in Mocha/Jasmine only).
*
* @param {Object} suite - Suite details.
*/
beforeSuite(suite) {
multiremotebrowser.instances.forEach((instance: string) => {
logInfo(multiremotebrowser.getInstance(instance),
`---=== Begin ${suite.file.substring(suite.file.lastIndexOf('/') + 1)} ===---`);
});
},
/**
* Function to be executed before a test (in Mocha/Jasmine only).
*
* @param {Object} test - Test object.
* @param {Object} context - The context object.
*/
beforeTest(test, context) {
if (ctx.skipSuiteTests) {
context.skip();
return;
}
multiremotebrowser.instances.forEach((instance: string) => {
logInfo(multiremotebrowser.getInstance(instance), `---=== Start test ${test.title} ===---`);
});
},
/**
* Function to be executed after a test (in Mocha/Jasmine only).
*
* @param {Object} test - Test object.
* @param {Object} context - Scope object the test was executed with.
* @param {Error} error - Error object in case the test fails, otherwise `undefined`.
* @returns {Promise<void>}
*/
async afterTest(test, context, { error }) {
multiremotebrowser.instances.forEach((instance: string) =>
logInfo(multiremotebrowser.getInstance(instance), `---=== End test ${test.title} ===---`));
if (error) {
const allProcessing: Promise<any>[] = [];
multiremotebrowser.instances.forEach((instance: string) => {
const bInstance = multiremotebrowser.getInstance(instance);
allProcessing.push(bInstance.takeScreenshot().then(shot => {
AllureReporter.addAttachment(
`Screenshot-${instance}`,
Buffer.from(shot, 'base64'),
'image/png');
}));
// @ts-ignore
allProcessing.push(bInstance.execute(() => typeof APP !== 'undefined' && APP.connection?.getLogs())
.then(logs =>
logs && AllureReporter.addAttachment(
`debug-logs-${instance}`,
JSON.stringify(logs, null, ' '),
'text/plain'))
.catch(e => console.error('Failed grabbing debug logs', e)));
AllureReporter.addAttachment(`console-logs-${instance}`, getLogs(bInstance) || '', 'text/plain');
allProcessing.push(bInstance.getPageSource().then(source => {
AllureReporter.addAttachment(`html-source-${instance}`, pretty(source), 'text/plain');
}));
});
await Promise.allSettled(allProcessing);
}
},
/**
* Hook that gets executed after the suite has ended (in Mocha/Jasmine only).
*
* @param {Object} suite - Suite details.
* @returns {Promise<void>}
*/
afterSuite(suite) {
multiremotebrowser.instances.forEach((instance: string) => {
logInfo(multiremotebrowser.getInstance(instance),
`---=== End ${suite.file.substring(suite.file.lastIndexOf('/') + 1)} ===---`);
});
},
/**
* Gets executed after all workers have shut down and the process is about to exit.
* An error thrown in the `onComplete` hook will result in the test run failing.
*
* @returns {Promise<void>}
*/
onComplete() {
const reportError = new Error('Could not generate Allure report');
const generation = allure([
'generate', `${TEST_RESULTS_DIR}/allure-results`,
'--clean', '--single-file',
'--report-dir', `${TEST_RESULTS_DIR}/allure-report`
]);
return new Promise<void>((resolve, reject) => {
const generationTimeout = setTimeout(
() => reject(reportError),
5000);
// @ts-ignore
generation.on('exit', eCode => {
clearTimeout(generationTimeout);
if (eCode !== 0) {
return reject(reportError);
}
console.log('Allure report successfully generated');
resolve();
});
});
}
} as WebdriverIO.MultiremoteConfig;

11
tests/wdio.dev.conf.ts Normal file
View File

@@ -0,0 +1,11 @@
// wdio.dev.conf.ts
// extends the main configuration file for the development environment (make dev)
// it will connect to the webpack-dev-server running locally on port 8080
import { merge } from 'lodash-es';
// @ts-ignore
import { config as defaultConfig } from './wdio.conf.ts';
export const config = merge(defaultConfig, {
baseUrl: 'https://127.0.0.1:8080/torture'
}, { clone: false });

View File

@@ -0,0 +1,56 @@
// wdio.firefox.conf.ts
// extends the main configuration file changing first participant to be Firefox
import { merge } from 'lodash-es';
import process from 'node:process';
// @ts-ignore
import { config as defaultConfig } from './wdio.conf.ts';
const ffArgs = [];
const ffPreferences = {
'intl.accept_languages': 'en-US',
'media.navigator.permission.disabled': true,
'media.navigator.streams.fake': true,
'media.autoplay.default': 0
};
if (process.env.HEADLESS === 'true') {
ffArgs.push('--headless');
}
const mergedConfig = merge(defaultConfig, {
exclude: [
'specs/iframe/*.spec.ts', // FF does not support uploading files (uploadFile)
// FF does not support setting a file as mic input, no dominant speaker events
'specs/3way/activeSpeaker.spec.ts',
'specs/3way/startMuted.spec.ts', // bad audio levels
'specs/4way/desktopSharing.spec.ts',
'specs/4way/lastN.spec.ts',
// when unmuting a participant, we see the presence in debug logs immediately,
// but for 15 seconds it is not received/processed by the client
// (also the menu disappears after clicking one of the moderation options, does not happen manually)
'specs/3way/audioVideoModeration.spec.ts'
],
capabilities: {
p1: {
capabilities: {
browserName: 'firefox',
browserVersion: process.env.BROWSER_FF_BETA ? 'beta' : undefined,
'moz:firefoxOptions': {
args: ffArgs,
prefs: ffPreferences
},
acceptInsecureCerts: process.env.ALLOW_INSECURE_CERTS === 'true'
}
}
}
}, { clone: false });
// Remove the chrome options from the first participant
// @ts-ignore
mergedConfig.capabilities.p1.capabilities['goog:chromeOptions'] = undefined;
export const config = mergedConfig;

40
tests/wdio.grid.conf.ts Normal file
View File

@@ -0,0 +1,40 @@
// wdio.grid.conf.ts
// extends the main configuration file to add the selenium grid address
import { URL } from 'url';
// @ts-ignore
import { config as defaultConfig } from './wdio.conf.ts';
const gridUrl = new URL(process.env.GRID_HOST_URL as string);
const protocol = gridUrl.protocol.replace(':', '');
const mergedConfig = {
...defaultConfig,
protocol,
hostname: gridUrl.hostname,
port: gridUrl.port ? parseInt(gridUrl.port, 10) // Convert port to number
: protocol === 'http' ? 80 : 443,
path: gridUrl.pathname
};
mergedConfig.capabilities.p1.capabilities['goog:chromeOptions'].args
= updateRemoteResource(mergedConfig.capabilities.p1.capabilities['goog:chromeOptions'].args);
mergedConfig.capabilities.p2.capabilities['goog:chromeOptions'].args
= updateRemoteResource(mergedConfig.capabilities.p2.capabilities['goog:chromeOptions'].args);
mergedConfig.capabilities.p3.capabilities['goog:chromeOptions'].args
= updateRemoteResource(mergedConfig.capabilities.p3.capabilities['goog:chromeOptions'].args);
mergedConfig.capabilities.p4.capabilities['goog:chromeOptions'].args
= updateRemoteResource(mergedConfig.capabilities.p4.capabilities['goog:chromeOptions'].args);
export const config = mergedConfig;
/**
* Updates the array of arguments for the Chrome browser to use a remote resource for fake audio capture.
* @param arr
*/
function updateRemoteResource(arr: string[]): string[] {
// eslint-disable-next-line no-confusing-arrow
return arr.map((item: string) => item.startsWith('--use-file-for-fake-audio-capture=')
? `--use-file-for-fake-audio-capture=${process.env.REMOTE_RESOURCE_PATH}/fakeAudioStream.wav` : item
);
}

View File

@@ -0,0 +1,18 @@
// wdio.grid.conf.ts
// extends the main configuration file to add the selenium grid address
import { URL } from 'url';
// @ts-ignore
import { config as defaultConfig } from './wdio.firefox.conf.ts';
const gridUrl = new URL(process.env.GRID_HOST_URL as string);
const protocol = gridUrl.protocol.replace(':', '');
export const config = {
...defaultConfig,
protocol,
hostname: gridUrl.hostname,
port: gridUrl.port ? parseInt(gridUrl.port, 10) // Convert port to number
: protocol === 'http' ? 80 : 443,
path: gridUrl.pathname
};