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

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