This commit is contained in:
973
tests/helpers/Participant.ts
Normal file
973
tests/helpers/Participant.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
133
tests/helpers/TestProperties.ts
Normal file
133
tests/helpers/TestProperties.ts
Normal 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);
|
||||
}
|
||||
44
tests/helpers/TestsConfig.ts
Normal file
44
tests/helpers/TestsConfig.ts
Normal 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));
|
||||
}
|
||||
194
tests/helpers/WebhookProxy.ts
Normal file
194
tests/helpers/WebhookProxy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
tests/helpers/browserLogger.ts
Normal file
67
tests/helpers/browserLogger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
342
tests/helpers/participants.ts
Normal file
342
tests/helpers/participants.ts
Normal 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
134
tests/helpers/token.ts
Normal 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
123
tests/helpers/types.ts
Normal 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
54
tests/helpers/utils.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user