Compare commits

...

40 Commits

Author SHA1 Message Date
Boris Grozev
d1d52c7f54 Use separate key id/path for jaas (part2). 2025-08-01 07:57:56 -05:00
Boris Grozev
9714e99c4c squash: Fix token code in jaas tests. 2025-08-01 07:33:45 -05:00
Boris Grozev
b8408665f7 ref: Don't store token payload in global context. 2025-08-01 07:32:59 -05:00
Boris Grozev
1acdf1c384 ref: Store the token in Participant. 2025-08-01 07:32:50 -05:00
Boris Grozev
890597b2e7 ref: Extract token utils to a separate class. 2025-08-01 07:30:44 -05:00
Boris Grozev
100b5f8163 ref: Use local context for roomKey. 2025-08-01 07:30:17 -05:00
Boris Grozev
788a1c55ed Use separate config for jaas key id/path (partial). 2025-08-01 07:29:11 -05:00
Boris Grozev
b2898455e0 fix: Fix JAAS_ variable names and document in env.example. 2025-08-01 06:31:15 -05:00
Boris Grozev
c05277c1d6 Re-order alphabetically. 2025-08-01 06:23:03 -05:00
Boris Grozev
be12cdeeff squash: Remove comments. 2025-08-01 02:50:19 -05:00
Boris Grozev
171768faa6 squash: Linting. 2025-07-31 10:43:22 -05:00
Boris Grozev
b9e8981ecc squash: Remove unnecessart TODOs 2025-07-31 10:24:20 -05:00
Boris Grozev
31c17cec2d squash: Remove unnecessary cleanup code. 2025-07-31 10:05:09 -05:00
Boris Grozev
0465a9fdda fix: Fix isVisitor check. 2025-07-31 10:02:18 -05:00
Boris Grozev
b0491d7d2b test: Add a maxOccupants jaas test. 2025-07-31 09:59:25 -05:00
Boris Grozev
1659b978a9 ref: Store local context in a local variable. 2025-07-30 08:06:14 -05:00
Boris Grozev
59dc791362 test: Add more jaas tests. 2025-07-30 06:23:44 -05:00
Boris Grozev
863cbab6b0 test: Add tests for joining a JaaS MUC with different token options. 2025-07-28 05:29:03 -05:00
damencho
b050e5f5e8 fix: Fixes table equals missing param name. 2025-07-24 15:00:09 +03:00
damencho
bf8d83953b fix: Fixes table equals.
Was checking only for added or removed keys, but not for modified values.
2025-07-24 14:11:50 +03:00
Horatiu Muresan
f16bf466eb feat(external-api) Add camera capture function (#16238) 2025-07-23 17:22:48 +03:00
damencho
29ea811527 fix(av-moderation): Updates the whitelist with every moderator.
When a moderator joins or someone is granted moderation we update the whitelist for any media type for which moderation is enabled. The updated whitelist is sent to all the moderators including the newly joined or granted one.
2025-07-23 10:53:15 +03:00
Calin-Teodor
435d034fdb fix(toolbox/native): update SvgCssUri import 2025-07-23 10:50:59 +03:00
Calinteodor
419baa7ab7 feat(android): init RIMHs app before on create (#15887)
Initialise ReactInstanceManagerHolder during application startup, making it ready before onCreate() is called.
2025-07-22 13:05:54 +03:00
damencho
9eb7b7bb01 fix: Showing go-live notification.
Handle the case when a local participant becomes moderator after metadata is updated.
2025-07-22 11:19:59 +03:00
Hristo Terezov
19ee989cda fix(visitors): Add fallback display names for empty visitor names
Visitors with empty or undefined names now show the configured
defaultRemoteDisplayName or 'Fellow Jitster' as fallback, matching
the behavior of regular remote participants.
2025-07-22 07:27:52 +03:00
ltorje-8x8
ab1dcc5375 fix(go-live): unsubscribe from topics before closing if not done already (#16244) 2025-07-21 16:47:24 -05:00
damencho
3047b4c8c4 fix: Fixes updating local UI startMuted state. 2025-07-21 22:49:35 +03:00
damencho
2afce3d151 fix: Fixes restoring startmuted in av mod. 2025-07-21 16:37:23 +03:00
damencho
1cea9b1786 fix: Avoids sending two metadata updates.
When setting startMuted we are sending two metadata updates.
2025-07-21 16:37:23 +03:00
damencho
2b7299ae05 fix: Drops not needed default values when filtering. 2025-07-21 16:37:09 +03:00
damencho
4b50f13e96 fix: Filters stanza on cloned copy. 2025-07-21 16:37:09 +03:00
Saúl Ibarra Corretgé
c639acebcf fix(polls) more resilient parsing of payloads, take 2 2025-07-21 15:10:56 +02:00
Horatiu Muresan
1a34ed9a2d fix(i18n) Fix showing Afrikaans when set language is not found (#16245)
- fix translates sort
2025-07-17 15:14:52 +03:00
Hristo Terezov
0939e207eb fix(go-live): waiting not updated correctly.
We were comparing if the number of waiting participants have changed with the wrong property from the state - the number of visitors. The result was that we won't update the state when the new waiting value matches the number of visitors already in the state. Most of the times this will be 0 and we would never go to 0.
2025-07-15 20:54:12 -05:00
Hristo Terezov
8c3ea05ae6 fix(go-live): Disconnect on page close.
Currently we don't close the socket for the participants in the queue when the page is closed.
2025-07-15 18:32:21 -05:00
bgrozev
daf8a929b1 fix: Fix hideDisplayNameForAll. (#16239)
Remove filtering on the receive side, because:
1. It's not applied to visitors, and should be for the "all" case
2. We don't want to strip stats-id from stanzas sent to jicofo
2025-07-15 10:49:04 -05:00
bgrozev
2f3df2c66f fix: Fix setting whitelist when av_moderation is initially enabled. (#16235) 2025-07-14 18:32:51 -05:00
Mihaela Dumitru
d8d1f8331e fix(lang) add missing desktop sharing keys (#16234) 2025-07-14 18:08:41 -05:00
ltorje-8x8
0e69336f94 JIT-14750 Do not show names to visitors (#16231)
* JIT-14750 Do not show names to visitors

* apply review

* change name and email too

* fix: Fix filtering initial presence to vnodes.

* Also strip stats-id and identity.user.name.

* Move filtering logic to a util, filter all identity in main room

---------

Co-authored-by: Boris Grozev <boris@jitsi.org>
2025-07-14 16:00:25 -05:00
53 changed files with 1302 additions and 348 deletions

View File

@@ -49,6 +49,10 @@ public class JitsiInitializer implements Initializer<Boolean> {
// Register activity lifecycle handler for the orientation locker module.
((Application) context).registerActivityLifecycleCallbacks(OrientationActivityLifecycle.getInstance());
// Initialize ReactInstanceManager during application startup
// This ensures it's ready before any Activity onCreate is called
ReactInstanceManagerHolder.initReactInstanceManager((Application) context);
return true;
}

View File

@@ -102,6 +102,10 @@ public class JitsiMeetActivity extends AppCompatActivity
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ReactInstanceManager is now initialized by JitsiInitializer during application startup
// Just call onHostResume since the manager is already ready
JitsiMeetActivityDelegate.onHostResume(this);
setContentView(R.layout.activity_jitsi_meet);
this.jitsiView = findViewById(R.id.jitsiView);

View File

@@ -17,6 +17,7 @@
package org.jitsi.meet.sdk;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.os.Bundle;
import android.util.AttributeSet;
@@ -196,8 +197,6 @@ public class JitsiMeetView extends FrameLayout {
}
setBackgroundColor(BACKGROUND_COLOR);
ReactInstanceManagerHolder.initReactInstanceManager((Activity) context);
}
/**

View File

@@ -18,6 +18,7 @@ package org.jitsi.meet.sdk;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Application;
import androidx.annotation.Nullable;
@@ -207,9 +208,9 @@ class ReactInstanceManagerHolder {
* time. All {@code ReactRootView} instances will be tied to the one and
* only {@code ReactInstanceManager}.
*
* @param activity {@code Activity} current running Activity.
* @param app {@code Application}
*/
static void initReactInstanceManager(Activity activity) {
static void initReactInstanceManager(Application app) {
if (reactInstanceManager != null) {
return;
}
@@ -231,14 +232,14 @@ class ReactInstanceManagerHolder {
reactInstanceManager
= ReactInstanceManager.builder()
.setApplication(activity.getApplication())
.setCurrentActivity(activity)
.setApplication(app)
.setCurrentActivity(null)
.setBundleAssetName("index.android.bundle")
.setJSMainModulePath("index.android")
.setJavaScriptExecutorFactory(new HermesExecutorFactory())
.addPackages(getReactNativePackages())
.setUseDeveloperSupport(BuildConfig.DEBUG)
.setInitialLifecycleState(LifecycleState.RESUMED)
.setInitialLifecycleState(LifecycleState.BEFORE_CREATE)
.build();
}
}

View File

@@ -50,7 +50,8 @@ import {
commonUserJoinedHandling,
commonUserLeftHandling,
getConferenceOptions,
sendLocalParticipant
sendLocalParticipant,
updateTrackMuteState
} from './react/features/base/conference/functions';
import { getReplaceParticipant, getSsrcRewritingFeatureFlag } from './react/features/base/config/functions';
import { connect } from './react/features/base/connection/actions.web';
@@ -1663,8 +1664,12 @@ export default {
room.on(
JitsiConferenceEvents.START_MUTED_POLICY_CHANGED,
({ audio, video }) => {
APP.store.dispatch(
onStartMutedPolicyChanged(audio, video));
APP.store.dispatch(onStartMutedPolicyChanged(audio, video));
const state = APP.store.getState();
updateTrackMuteState(state, APP.store.dispatch, true);
updateTrackMuteState(state, APP.store.dispatch, false);
}
);

View File

@@ -300,6 +300,12 @@
"alreadySharedVideoTitle": "Only one shared video is allowed at a time",
"applicationWindow": "Application window",
"authenticationRequired": "Authentication required",
"cameraCaptureDialog": {
"description": "Take and send a picture using your mobile camera",
"ok": "Open camera",
"reject": "Not now",
"title": "Take a picture"
},
"cameraConstraintFailedError": "Your camera does not satisfy some of the required constraints.",
"cameraNotFoundError": "Camera was not found.",
"cameraNotSendingData": "We are unable to access your camera. Please check if another application is using this device, select another device from the settings menu or try to reload the application.",
@@ -375,6 +381,7 @@
"micTimeoutError": "Could not start audio source. Timeout occurred!",
"micUnknownError": "Cannot use microphone for an unknown reason.",
"moderationAudioLabel": "Allow attendees to unmute themselves",
"moderationDesktopLabel": "Allow non-moderators to share their screen",
"moderationVideoLabel": "Allow non-moderators to start their video",
"muteEveryoneDialog": "The participants can unmute themselves at any time.",
"muteEveryoneDialogModerationOn": "The participants can send a request to speak at any time.",
@@ -387,6 +394,9 @@
"muteEveryoneSelf": "yourself",
"muteEveryoneStartMuted": "Everyone starts muted from now on",
"muteEveryoneTitle": "Mute everyone?",
"muteEveryonesDesktopDialog": "The participants can share their screen at any time.",
"muteEveryonesDesktopDialogModerationOn": "The participants can send a request to share their screen at any time.",
"muteEveryonesDesktopTitle": "Stop everyone's screen share?",
"muteEveryonesVideoDialog": "The participants can turn on their video at any time.",
"muteEveryonesVideoDialogModerationOn": "The participants can send a request to turn on their video at any time.",
"muteEveryonesVideoDialogOk": "Disable",

View File

@@ -30,6 +30,7 @@ import { overwriteConfig } from '../../react/features/base/config/actions';
import { getWhitelistedJSON } from '../../react/features/base/config/functions.any';
import { toggleDialog } from '../../react/features/base/dialog/actions';
import { isSupportedBrowser } from '../../react/features/base/environment/environment';
import { isMobileBrowser } from '../../react/features/base/environment/utils';
import { parseJWTFromURLParams } from '../../react/features/base/jwt/functions';
import JitsiMeetJS, { JitsiRecordingConstants } from '../../react/features/base/lib-jitsi-meet';
import { MEDIA_TYPE, VIDEO_TYPE } from '../../react/features/base/media/constants';
@@ -113,7 +114,10 @@ import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../react/features/rec
import { getActiveSession, supportsLocalRecording } from '../../react/features/recording/functions';
import { startAudioScreenShareFlow, startScreenShareFlow } from '../../react/features/screen-share/actions';
import { isScreenAudioSupported } from '../../react/features/screen-share/functions';
import { toggleScreenshotCaptureSummary } from '../../react/features/screenshot-capture/actions';
import {
openCameraCaptureDialog,
toggleScreenshotCaptureSummary
} from '../../react/features/screenshot-capture/actions';
import { isScreenshotCaptureEnabled } from '../../react/features/screenshot-capture/functions';
import SettingsDialog from '../../react/features/settings/components/web/SettingsDialog';
import { SETTINGS_TABS } from '../../react/features/settings/constants';
@@ -940,6 +944,20 @@ function initCommands() {
});
});
break;
case 'capture-camera-picture' : {
const { cameraFacingMode, descriptionText, titleText } = request;
if (!isMobileBrowser()) {
logger.error('This feature is only supported on mobile');
return;
}
APP.store.dispatch(openCameraCaptureDialog(callback, { cameraFacingMode,
descriptionText,
titleText }));
break;
}
case 'deployment-info':
callback(APP.store.getState()['features/base/config'].deploymentInfo);
break;

View File

@@ -820,6 +820,27 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
});
}
/**
* Captures a picture through OS camera.
*
* @param {string} cameraFacingMode - The OS camera facing mode (environment/user).
* @param {string} descriptionText - The OS camera facing mode (environment/user).
* @param {string} titleText - The OS camera facing mode (environment/user).
* @returns {Promise<string>} - Resolves with a base64 encoded image data of the screenshot.
*/
captureCameraPicture(
cameraFacingMode,
descriptionText,
titleText
) {
return this._transport.sendRequest({
name: 'capture-camera-picture',
cameraFacingMode,
descriptionText,
titleText
});
}
/**
* Removes the listeners and removes the Jitsi Meet frame.
*

View File

@@ -83,7 +83,8 @@ import {
getConferenceState,
getCurrentConference,
getVisitorOptions,
sendLocalParticipant
sendLocalParticipant,
updateTrackMuteState
} from './functions';
import logger from './logger';
import { IConferenceMetadata, IJitsiConference } from './reducer';
@@ -186,6 +187,15 @@ function _addConferenceListeners(conference: IJitsiConference, dispatch: IStore[
(disableVideoMuteChange: boolean) => {
dispatch(setVideoUnmutePermissions(disableVideoMuteChange));
});
conference.on(
JitsiConferenceEvents.START_MUTED_POLICY_CHANGED,
({ audio, video }: { audio: boolean; video: boolean; }) => {
dispatch(onStartMutedPolicyChanged(audio, video));
updateTrackMuteState(state, dispatch, true);
updateTrackMuteState(state, dispatch, false);
}
);
// Dispatches into features/base/tracks follow:
@@ -1013,6 +1023,8 @@ export function setStartMutedPolicy(
audio: startAudioMuted,
video: startVideoMuted
});
dispatch(onStartMutedPolicyChanged(startAudioMuted, startVideoMuted));
};
}

View File

@@ -40,3 +40,8 @@ export const CONFERENCE_LEAVE_REASONS = {
SWITCH_ROOM: 'switch_room',
UNRECOVERABLE_ERROR: 'unrecoverable_error'
};
/**
* The ID of the notification that is shown when the user is muted by focus.
*/
export const START_MUTED_NOTIFICATION_ID = 'start-muted';

View File

@@ -3,9 +3,13 @@ import { upperFirst, words } from 'lodash-es';
import { getName } from '../../app/functions';
import { IReduxState, IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { determineTranscriptionLanguage } from '../../transcribing/functions';
import { IStateful } from '../app/types';
import { JitsiTrackErrors } from '../lib-jitsi-meet';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import {
participantJoined,
participantLeft
@@ -22,7 +26,8 @@ import { setObfuscatedRoom } from './actions';
import {
AVATAR_URL_COMMAND,
EMAIL_COMMAND,
JITSI_CONFERENCE_URL_KEY
JITSI_CONFERENCE_URL_KEY,
START_MUTED_NOTIFICATION_ID
} from './constants';
import logger from './logger';
import { IJitsiConference } from './reducer';
@@ -574,3 +579,42 @@ function safeStartCase(s = '') {
(result, word, index) => result + (index ? ' ' : '') + upperFirst(word)
, '');
}
/**
* Updates the mute state of the track based on the start muted policy.
*
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
* @param {Function} dispatch - Redux dispatch function.
* @param {boolean} isAudio - Whether the track is audio or video.
* @returns {void}
*/
export function updateTrackMuteState(stateful: IStateful, dispatch: IStore['dispatch'], isAudio: boolean) {
const state = toState(stateful);
const mutedPolicyKey = isAudio ? 'startAudioMutedPolicy' : 'startVideoMutedPolicy';
const mutedPolicyValue = state['features/base/conference'][mutedPolicyKey];
// Currently, the policy only supports force muting others, not unmuting them.
if (!mutedPolicyValue) {
return;
}
let muteStateUpdated = false;
const { muted } = isAudio ? state['features/base/media'].audio : state['features/base/media'].video;
if (isAudio && !Boolean(muted)) {
dispatch(setAudioMuted(mutedPolicyValue, true));
muteStateUpdated = true;
} else if (!isAudio && !Boolean(muted)) {
// TODO: Add a new authority for video mutism for the moderator case.
dispatch(setVideoMuted(mutedPolicyValue, VIDEO_MUTISM_AUTHORITY.USER, true));
muteStateUpdated = true;
}
if (muteStateUpdated) {
dispatch(showNotification({
titleKey: 'notify.mutedTitle',
descriptionKey: 'notify.muted',
uid: START_MUTED_NOTIFICATION_ID // use the same id, to make sure we show one notification
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
}

View File

@@ -72,7 +72,6 @@ import {
} from './functions';
import logger from './logger';
import { IConferenceMetadata } from './reducer';
import './subscriber';
/**
* Handler for before unload event.

View File

@@ -1,61 +0,0 @@
import { IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import StateListenerRegistry from '../redux/StateListenerRegistry';
let hasShownNotification = false;
/**
* Handles changes in the start muted policy for audio and video tracks in the meta data set for the conference.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/conference'].startAudioMutedPolicy,
/* listener */ (startAudioMutedPolicy, store) => {
_updateTrackMuteState(store, true);
});
StateListenerRegistry.register(
/* selector */ state => state['features/base/conference'].startVideoMutedPolicy,
/* listener */(startVideoMutedPolicy, store) => {
_updateTrackMuteState(store, false);
});
/**
* Updates the mute state of the track based on the start muted policy.
*
* @param {IStore} store - The redux store.
* @param {boolean} isAudio - Whether the track is audio or video.
* @returns {void}
*/
function _updateTrackMuteState(store: IStore, isAudio: boolean) {
const { dispatch, getState } = store;
const mutedPolicyKey = isAudio ? 'startAudioMutedPolicy' : 'startVideoMutedPolicy';
const mutedPolicyValue = getState()['features/base/conference'][mutedPolicyKey];
// Currently, the policy only supports force muting others, not unmuting them.
if (!mutedPolicyValue) {
return;
}
let muteStateUpdated = false;
const { muted } = isAudio ? getState()['features/base/media'].audio : getState()['features/base/media'].video;
if (isAudio && !Boolean(muted)) {
dispatch(setAudioMuted(mutedPolicyValue, true));
muteStateUpdated = true;
} else if (!isAudio && !Boolean(muted)) {
// TODO: Add a new authority for video mutism for the moderator case.
dispatch(setVideoMuted(mutedPolicyValue, VIDEO_MUTISM_AUTHORITY.USER, true));
muteStateUpdated = true;
}
if (!hasShownNotification && muteStateUpdated) {
hasShownNotification = true;
dispatch(showNotification({
titleKey: 'notify.mutedTitle',
descriptionKey: 'notify.muted'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
}

View File

@@ -3,7 +3,7 @@
*
* @enum {string}
*/
export const CAMERA_FACING_MODE = {
export const CAMERA_FACING_MODE: Record<string, string> = {
ENVIRONMENT: 'environment',
USER: 'user'
};

View File

@@ -6,6 +6,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
@@ -78,6 +79,7 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
const visitors = useSelector(getVisitorsList);
const featureEnabled = useSelector(isVisitorsListEnabled);
const shouldDisplayList = useSelector(shouldDisplayCurrentVisitorsList);
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
@@ -109,9 +111,11 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
return null;
}
const filtered = visitors.filter(v =>
normalizeAccents(v.name).toLowerCase().includes(normalizeAccents(searchString).toLowerCase())
);
const filtered = visitors.filter(v => {
const displayName = v.name || defaultRemoteDisplayName || 'Fellow Jitster';
return normalizeAccents(displayName).toLowerCase().includes(normalizeAccents(searchString).toLowerCase());
});
// ListItem height is 56px including padding so the item size
// for virtualization needs to match it exactly to avoid clipping.
@@ -125,7 +129,7 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { v.name }
displayName = { v.name || defaultRemoteDisplayName || 'Fellow Jitster' }
participantID = { v.id }
videoMediaState = { MEDIA_STATE.NONE } />
</div>

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/polls');

View File

@@ -16,6 +16,7 @@ import {
COMMAND_NEW_POLL,
COMMAND_OLD_POLLS
} from './constants';
import logger from './logger';
import { IAnswer, IPoll, IPollData } from './types';
/**
@@ -43,7 +44,16 @@ const parsePollData = (pollData: Partial<IPollData>): IPoll | null => {
const { id, senderId, question, answers } = pollData;
if (typeof id !== 'string' || typeof senderId !== 'string'
|| typeof question !== 'string' || !(answers instanceof Array)) {
|| typeof question !== 'string' || !(answers instanceof Array)) {
logger.error('Malformed poll data received:', pollData);
return null;
}
// Validate answers.
if (answers.some(answer => typeof answer !== 'string')) {
logger.error('Malformed answers data received:', answers);
return null;
}
@@ -173,7 +183,7 @@ function _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], get
const receivedAnswer: IAnswer = {
voterId,
pollId,
answers: answers.slice(0, MAX_ANSWERS)
answers: answers.slice(0, MAX_ANSWERS).map(Boolean)
};
dispatch(receiveAnswer(pollId, receivedAnswer));
@@ -188,7 +198,7 @@ function _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], get
const poll = parsePollData(pollData);
if (poll === null) {
console.warn('[features/polls] Invalid old poll data');
logger.warn('Malformed old poll data', pollData);
} else {
dispatch(receivePoll(pollData.id, poll, false));
}

View File

@@ -0,0 +1,154 @@
import { Theme } from '@mui/material';
import React, { useCallback, useRef } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect, useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { hideDialog } from '../base/dialog/actions';
import { translate } from '../base/i18n/functions';
import Label from '../base/label/components/web/Label';
import { CAMERA_FACING_MODE } from '../base/media/constants';
import Button from '../base/ui/components/web/Button';
import Dialog from '../base/ui/components/web/Dialog';
import { BUTTON_TYPES } from '../base/ui/constants.any';
import { ICameraCapturePayload } from './actionTypes';
const useStyles = makeStyles()((theme: Theme) => ({
container: {
display: 'flex',
height: '100%',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
gap: theme.spacing(3),
textAlign: 'center'
},
buttonsContainer: {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
width: '100%',
maxWidth: '100%'
},
hidden: {
display: 'none'
},
label: {
background: 'transparent',
margin: `${theme.spacing(3)} 0 ${theme.spacing(6)}`,
},
button: {
width: '100%',
height: '48px',
maxWidth: '400px'
}
}));
/**
* The type of {@link CameraCaptureDialog}'s React {@code Component} props.
*/
interface IProps extends WithTranslation {
/**
* Callback function on file input changed.
*/
callback: ({ error, dataURL }: { dataURL?: string; error?: string; }) => void;
/**
* The camera capture payload.
*/
componentProps: ICameraCapturePayload;
}
/**
* Implements the Camera capture dialog.
*
* @param {Object} props - The props of the component.
* @returns {React$Element}
*/
const CameraCaptureDialog = ({
callback,
componentProps,
t,
}: IProps) => {
const { cameraFacingMode = CAMERA_FACING_MODE.ENVIRONMENT,
descriptionText,
titleText } = componentProps;
const dispatch = useDispatch();
const { classes } = useStyles();
const inputRef = useRef<HTMLInputElement>(null);
const onCancel = useCallback(() => {
callback({
error: 'User canceled!'
});
dispatch(hideDialog());
}, []);
const onSubmit = useCallback(() => {
inputRef.current?.click();
}, []);
const onInputChange = useCallback(event => {
const reader = new FileReader();
const files = event.target.files;
if (!files?.[0]) {
callback({
error: 'No picture selected!'
});
return;
}
reader.onload = () => {
callback({ dataURL: reader.result as string });
dispatch(hideDialog());
};
reader.onerror = () => {
callback({ error: 'Failed generating base64 URL!' });
dispatch(hideDialog());
};
reader.readAsDataURL(files[0]);
}, []);
return (
<Dialog
cancel = {{ hidden: true }}
disableAutoHideOnSubmit = { true }
ok = {{ hidden: true }}
onCancel = { onCancel }
titleKey = { titleText || t('dialog.cameraCaptureDialog.title') }>
<div className = { classes.container }>
<Label
aria-label = { descriptionText || t('dialog.cameraCaptureDialog.description') }
className = { classes.label }
text = { descriptionText || t('dialog.cameraCaptureDialog.description') } />
<div className = { classes.buttonsContainer } >
<Button
accessibilityLabel = { t('dialog.cameraCaptureDialog.ok') }
className = { classes.button }
labelKey = { 'dialog.cameraCaptureDialog.ok' }
onClick = { onSubmit } />
<Button
accessibilityLabel = { t('dialog.cameraCaptureDialog.reject') }
className = { classes.button }
labelKey = { 'dialog.cameraCaptureDialog.reject' }
onClick = { onCancel }
type = { BUTTON_TYPES.TERTIARY } />
<input
accept = 'image/*'
capture = { cameraFacingMode }
className = { classes.hidden }
onChange = { onInputChange }
ref = { inputRef }
type = 'file' />
</div>
</div>
</Dialog>
);
};
export default translate(connect()(CameraCaptureDialog));

View File

@@ -7,3 +7,23 @@
*/
export const SET_SCREENSHOT_CAPTURE = 'SET_SCREENSHOT_CAPTURE';
/**
* The camera capture payload.
*/
export interface ICameraCapturePayload {
/**
* Selected camera on open.
*/
cameraFacingMode?: string;
/**
* Custom explanatory text to show.
*/
descriptionText?: string,
/**
* Custom dialog title text.
*/
titleText?: string
};

View File

@@ -1,7 +1,10 @@
import { IStore } from '../app/types';
import { openDialog } from '../base/dialog/actions';
import { isMobileBrowser } from '../base/environment/utils';
import { getLocalJitsiDesktopTrack } from '../base/tracks/functions';
import { SET_SCREENSHOT_CAPTURE } from './actionTypes';
import CameraCaptureDialog from './CameraCaptureDialog';
import { ICameraCapturePayload, SET_SCREENSHOT_CAPTURE } from './actionTypes';
import { createScreenshotCaptureSummary } from './functions';
import logger from './logger';
@@ -61,3 +64,23 @@ export function toggleScreenshotCaptureSummary(enabled: boolean) {
return Promise.resolve();
};
}
/**
* Opens {@code CameraCaptureDialog}.
*
* @param {Function} callback - The callback to execute on picture taken.
* @param {ICameraCapturePayload} componentProps - The camera capture payload.
* @returns {Function}
*/
export function openCameraCaptureDialog(callback: Function, componentProps: ICameraCapturePayload) {
return (dispatch: IStore['dispatch']) => {
if (!isMobileBrowser()) {
return;
}
dispatch(openDialog(CameraCaptureDialog, {
callback,
componentProps
}));
};
}

View File

@@ -91,6 +91,23 @@ export function getNotificationsMap(stateful: IStateful): { [key: string]: boole
}, {});
}
function normalizeCurrentLanguage(language: string) {
if (!language) {
return;
}
const [ country, lang ] = language.split('-');
const jitsiNormalized = `${country}${lang ?? ''}`;
if (LANGUAGES.includes(jitsiNormalized)) {
return jitsiNormalized;
}
if (LANGUAGES.includes(country)) {
return country;
}
}
/**
* Returns the properties for the "More" tab from settings dialog from Redux
* state.
@@ -102,7 +119,7 @@ export function getNotificationsMap(stateful: IStateful): { [key: string]: boole
export function getMoreTabProps(stateful: IStateful) {
const state = toState(stateful);
const stageFilmstripEnabled = isStageFilmstripEnabled(state);
const language = i18next.language || DEFAULT_LANGUAGE;
const language = normalizeCurrentLanguage(i18next.language) || DEFAULT_LANGUAGE;
const configuredTabs: string[] = interfaceConfig.SETTINGS_SECTIONS || [];
// when self view is controlled by the config we hide the settings

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Image, View, ViewStyle } from 'react-native';
import { SvgCssUri } from 'react-native-svg';
import { SvgCssUri } from 'react-native-svg/css';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';

View File

@@ -1,4 +1,4 @@
import { Client } from '@stomp/stompjs';
import { Client, StompSubscription } from '@stomp/stompjs';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
@@ -10,6 +10,9 @@ import { WebsocketClient } from './websocket-client';
export class VisitorsListWebsocketClient extends WebsocketClient {
private static client: VisitorsListWebsocketClient;
private _topicSubscription: StompSubscription | undefined;
private _queueSubscription: StompSubscription | undefined;
/**
* Creates a new instance of the VisitorsListWebsocketClient.
*
@@ -87,7 +90,7 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
const cachedDeltas: Array<{ n: string; r: string; s: string; }> = [];
// Subscribe first for deltas so we don't miss any while waiting for the initial list
this.stompClient.subscribe(topicEndpoint, deltaMessage => {
this._topicSubscription = this.stompClient.subscribe(topicEndpoint, deltaMessage => {
try {
const updates: Array<{ n: string; r: string; s: string; }> = JSON.parse(deltaMessage.body);
@@ -102,7 +105,7 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
});
// Subscribe for the initial list after topic subscription is active
const queueSubscription = this.stompClient.subscribe(queueEndpoint, message => {
this._queueSubscription = this.stompClient.subscribe(queueEndpoint, message => {
try {
const visitors: Array<{ n: string; r: string; }> = JSON.parse(message.body);
@@ -110,7 +113,11 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
initialReceived = true;
initialCallback(visitors);
queueSubscription.unsubscribe();
// Unsubscribe from queue after receiving initial list
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
this._queueSubscription = undefined;
}
if (cachedDeltas.length) {
deltaCallback(cachedDeltas);
@@ -124,4 +131,45 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
this.stompClient.activate();
}
/**
* Unsubscribes from both topic and queue subscriptions.
*
* @returns {void}
*/
override unsubscribe(): void {
if (this._topicSubscription) {
this._topicSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list topic');
this._topicSubscription = undefined;
}
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list queue');
this._queueSubscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first.
*
* @returns {Promise}
*/
override disconnect(): Promise<any> {
if (!this.stompClient) {
return Promise.resolve();
}
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.debug(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}
}

View File

@@ -9,6 +9,7 @@ import {
ENDPOINT_MESSAGE_RECEIVED,
UPDATE_CONFERENCE_METADATA
} from '../base/conference/actionTypes';
import { IConferenceMetadata } from '../base/conference/reducer';
import { SET_CONFIG } from '../base/config/actionTypes';
import { CONNECTION_FAILED } from '../base/connection/actionTypes';
import { connect, setPreferVisitor } from '../base/connection/actions';
@@ -176,7 +177,11 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
// let's subscribe for visitor waiting queue
const { room } = getState()['features/base/conference'];
const { disableBeforeUnloadHandlers = false } = getState()['features/base/config'];
const conferenceJid = `${room}@${hosts?.muc}`;
const beforeUnloadHandler = () => {
WebsocketClient.getInstance().disconnect();
};
WebsocketClient.getInstance()
.connect(`wss://${visitorsConfig?.queueService}/visitor/websocket`,
@@ -185,8 +190,12 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
if ('status' in msg && msg.status === 'live') {
logger.info('The conference is now live!');
WebsocketClient.getInstance().disconnect()
.then(() => {
window.removeEventListener(
disableBeforeUnloadHandlers ? 'unload' : 'beforeunload',
beforeUnloadHandler);
let delay = 0;
// now let's connect to meeting
@@ -213,20 +222,18 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
dispatch(setInVisitorsQueue(true));
});
/**
* Disconnecting the WebSocket client when the user closes the page.
*/
window.addEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', beforeUnloadHandler);
break;
}
case PARTICIPANT_UPDATED: {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
const { metadata } = getState()['features/base/conference'];
if (visitorsConfig?.queueService && isLocalParticipantModerator(getState)) {
const { metadata } = getState()['features/base/conference'];
if (metadata?.visitors?.live === false && !WebsocketClient.getInstance().isActive()) {
// when go live is available and false, we should subscribe
// to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
}
_handleQueueAndNotification(dispatch, getState, metadata);
break;
}
@@ -242,26 +249,8 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!visitorsConfig?.queueService) {
break;
}
if (isLocalParticipantModerator(getState)) {
if (metadata?.visitors?.live === false) {
if (!WebsocketClient.getInstance().isActive()) {
// if metadata go live changes to goLive false and local is moderator
// we should subscribe to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
_showNotLiveNotification(dispatch, getVisitorsInQueueCount(getState));
} else if (metadata?.visitors?.live) {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
WebsocketClient.getInstance().disconnect();
}
}
_handleQueueAndNotification(dispatch, getState, metadata);
break;
}
@@ -275,6 +264,38 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
return next(action);
});
/**
* Handles the queue connection and notification for visitors if needed.
*
* @param {IStore.dispatch} dispatch - The Redux dispatch function.
* @param {IStore.getState} getState - The Redux getState function.
* @param {IConferenceMetadata} metadata - The conference metadata.
* @returns {void}
*/
function _handleQueueAndNotification(
dispatch: IStore['dispatch'],
getState: IStore['getState'],
metadata: IConferenceMetadata | undefined): void {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!(visitorsConfig?.queueService && isLocalParticipantModerator(getState))) {
return;
}
if (metadata?.visitors?.live === false) {
if (!WebsocketClient.getInstance().isActive()) {
// if metadata go live changes to goLive false and local is moderator
// we should subscribe to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
_showNotLiveNotification(dispatch, getVisitorsInQueueCount(getState));
} else if (metadata?.visitors?.live) {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
WebsocketClient.getInstance().disconnect();
}
}
/**
* Shows a notification that the meeting is not live.
*

View File

@@ -63,7 +63,7 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
};
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
if (state.count === action.count) {
if (state.inQueueCount === action.count) {
return state;
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Client } from '@stomp/stompjs';
import { Client, StompSubscription } from '@stomp/stompjs';
import logger from './logger';
@@ -28,6 +28,8 @@ export class WebsocketClient {
private _connectCount = 0;
private _subscription: StompSubscription | undefined;
/**
* WebsocketClient getInstance.
*
@@ -100,7 +102,7 @@ export class WebsocketClient {
this._connectCount++;
connectCallback?.();
this.stompClient.subscribe(endpoint, message => {
this._subscription = this.stompClient.subscribe(endpoint, message => {
try {
callback(JSON.parse(message.body));
} catch (e) {
@@ -113,7 +115,21 @@ export class WebsocketClient {
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from the current subscription.
*
* @returns {void}
*/
unsubscribe(): void {
if (this._subscription) {
this._subscription.unsubscribe();
logger.debug('Unsubscribed from WebSocket topic');
this._subscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first if available.
*
* @returns {Promise}
*/
@@ -124,8 +140,11 @@ export class WebsocketClient {
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.info(`disconnected from: ${url}`);
logger.debug(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}

View File

@@ -157,7 +157,14 @@ function start_av_moderation(room, mediaType, occupant)
room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = array{};
room.av_moderation[mediaType] = array();
-- add all current moderators to the new whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.role == 'moderator' and not ends_with(room_occupant.nick, '/focus') then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
-- We want to set startMuted policy in metadata, in case of new participants are joining to respect
-- it, that will be enforced by jicofo
@@ -166,7 +173,7 @@ function start_av_moderation(room, mediaType, occupant)
-- We want to keep the previous value of startMuted for this mediaType if av moderation is disabled
-- to be able to restore
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
av_moderation_startMuted_restore = startMutedMetadata[mediaType];
av_moderation_startMuted_restore[mediaType] = startMutedMetadata[mediaType];
room.av_moderation_startMuted_restore = av_moderation_startMuted_restore;
startMutedMetadata[mediaType] = true;
@@ -262,6 +269,12 @@ function on_message(event)
-- send message to all occupants
notify_occupants_enable(nil, enabled, room, occupant.nick, mediaType);
if enabled then
-- inform all moderators for the newly created whitelist
notify_whitelist_change(nil, true, room, mediaType);
end
return true;
elseif moderation_command.attr.jidToWhitelist then
local occupant_jid = moderation_command.attr.jidToWhitelist;
@@ -357,9 +370,13 @@ function occupant_joined(event)
-- NOTE for some reason event.occupant.role is not reflecting the actual occupant role (when changed
-- from allowners module) but iterating over room occupants returns the correct role
for _, room_occupant in room:each_occupant() do
-- if moderator send the whitelist
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
notify_whitelist_change(room_occupant.jid, false, room);
-- if it is a moderator, send the whitelist to every moderator
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end
end
end
@@ -367,14 +384,30 @@ end
-- when a occupant was granted moderator we need to update him with the whitelist
function occupant_affiliation_changed(event)
local room = event.room;
if not room.av_moderation or is_healthcheck_room(room.jid) or is_admin(event.jid)
or event.affiliation ~= 'owner' then
return;
end
-- in any enabled media type add the new moderator to the whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.bare_jid == event.jid then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
end
end
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
if event.actor and event.affiliation == 'owner' and event.room.av_moderation then
local room = event.room;
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
notify_whitelist_change(occupant.jid, false, room);
if event.actor and event.affiliation == 'owner' then
-- notify all moderators for the new grant moderator and the change in whitelists
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end

View File

@@ -1,19 +1,23 @@
--- This module removes identity information from presence stanzas when the
--- hideDisplayNameForAll or hideDisplayNameForGuests options are enabled
--- for a room.
--- To be enabled under the main muc component
local filters = require 'util.filters';
local st = require 'util.stanza';
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_admin = util.is_admin;
local ends_with = util.ends_with;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local NICK_NS = 'http://jabber.org/protocol/nick';
-- we need to get the shared resource for joining moderators, as participants are marked as moderators
-- after joining which is after the filter for stanza/out, but we need to know will this participant be a moderator
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
--- Filter presence sent to non-moderator members of a room when the hideDisplayNameForGuests option is set.
function filter_stanza_out(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
@@ -21,48 +25,26 @@ function filter_stanza_out(stanza, session)
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
local shouldFilter = false;
if not room or room._data.hideDisplayNameForGuests ~= true then
return stanza;
end
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
if occupant then
if stanza.attr.from == internal_room_jid_match_rewrite(occupant.nick) then
-- we ignore self-presences, in this case and role will not be correct
return stanza;
end
if occupant.role ~= 'moderator' and not joining_moderator_participants[occupant.bare_jid] then
local st_clone = st.clone(stanza);
st_clone:remove_children('nick', NICK_NS);
return st_clone;
if room and (room._data.hideDisplayNameForGuests == true or room._data.hideDisplayNameForAll == true) then
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
-- don't touch self-presence
if occupant and stanza.attr.from ~= internal_room_jid_match_rewrite(occupant.nick) then
local isModerator = (occupant.role == 'moderator' or joining_moderator_participants[occupant.bare_jid]);
shouldFilter = room._data.hideDisplayNameForAll or not isModerator;
end
end
return stanza;
end
function filter_stanza_in(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
if shouldFilter then
return filter_identity_from_presence(stanza);
else
return stanza;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
-- if hideDisplayNameForAll we want to drop any display name from the presence stanza
if not room or room._data.hideDisplayNameForAll ~= true then
return stanza;
end
stanza:remove_children('nick', NICK_NS);
return stanza;
end
function filter_session(session)
filters.add_filter(session, 'stanzas/out', filter_stanza_out, -100);
filters.add_filter(session, 'stanzas/in', filter_stanza_in, -100);
end
function module.load()

View File

@@ -19,6 +19,7 @@ local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local table_add = util.table_add;
local table_equals = util.table_equals;
local MUC_NS = 'http://jabber.org/protocol/muc';
local COMPONENT_IDENTITY_TYPE = 'room_metadata';
@@ -188,12 +189,15 @@ function on_message(event)
jsonData.data = res;
end
room.jitsiMetadata[jsonData.key] = jsonData.data;
local old_value = room.jitsiMetadata[jsonData.key];
if not table_equals(old_value, jsonData.data) then
room.jitsiMetadata[jsonData.key] = jsonData.data;
broadcastMetadata(room);
broadcastMetadata(room);
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
end
return true;
end
@@ -238,12 +242,24 @@ function process_main_muc_loaded(main_muc, host_module)
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
startMutedMetadata.audio = startMuted.attr.audio == 'true';
startMutedMetadata.video = startMuted.attr.video == 'true';
local audioNewValue = startMuted.attr.audio == 'true';
local videoNewValue = startMuted.attr.video == 'true';
local send_update = false;
room.jitsiMetadata.startMuted = startMutedMetadata;
if startMutedMetadata.audio ~= audioNewValue then
startMutedMetadata.audio = audioNewValue;
send_update = true;
end
if startMutedMetadata.video ~= videoNewValue then
startMutedMetadata.video = videoNewValue;
send_update = true;
end
host_module:fire_event('room-metadata-changed', { room = room; });
if send_update then
room.jitsiMetadata.startMuted = startMutedMetadata;
host_module:fire_event('room-metadata-changed', { room = room; });
end
end);
end

View File

@@ -12,12 +12,16 @@ local st = require 'util.stanza';
local jid = require 'util.jid';
local new_id = require 'util.id'.medium;
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local is_admin = util.is_admin;
local presence_check_status = util.presence_check_status;
local process_host_module = util.process_host_module;
local is_transcriber_jigasi = util.is_transcriber_jigasi;
local json = require 'cjson.safe';
-- Debug flag
local DEBUG = false;
local MUC_NS = 'http://jabber.org/protocol/muc';
-- required parameter for custom muc component prefix, defaults to 'conference'
@@ -97,6 +101,25 @@ local function send_visitors_iq(conference_service, room, type)
module:send(visitors_iq);
end
-- Filter out identity information (nick name, email, etc) from a presence stanza,
-- if the hideDisplayNameForGuests option for the room is set (note that the
-- hideDisplayNameForAll option is implemented in a diffrent way and does not
-- require filtering here)
-- This is applied to presence of main room participants before it is sent out to
-- vnodes.
local function filter_stanza_nick_if_needed(stanza, room)
if not stanza or stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
return stanza;
end
-- if hideDisplayNameForGuests we want to drop any display name from the presence stanza
if room and (room._data.hideDisplayNameForGuests or room._data.hideDisplayNameForAll) then
return filter_identity_from_presence(stanza);
end
return stanza;
end
-- an event received from visitors component, which receives iqs from jicofo
local function connect_vnode(event)
local room, vnode = event.room, event.vnode;
@@ -123,7 +146,7 @@ local function connect_vnode(event)
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid) then
local fmuc_pr = st.clone(o:get_presence());
local fmuc_pr = filter_stanza_nick_if_needed(st.clone(o:get_presence()), room);
local user, _, res = jid.split(o.nick);
fmuc_pr.attr.to = jid.join(user, conference_service , res);
fmuc_pr.attr.from = o.jid;
@@ -206,7 +229,8 @@ end, 900);
process_host_module(main_muc_component_config, function(host_module, host)
-- detects presence change in a main participant and propagate it to the used visitor nodes
host_module:hook('muc-occupant-pre-change', function (event)
local room, stanza, occupant = event.room, event.stanza, event.dest_occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.dest_occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus and configured domains (used for jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
@@ -227,7 +251,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- when a main participant leaves inform the visitor nodes
host_module:hook('muc-occupant-left', function (event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- ignore configured domains (jibri and transcribers)
if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil
@@ -270,7 +295,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- detects new participants joining main room and sending them to the visitor nodes
host_module:hook('muc-occupant-joined', function (event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus, ignore configured domains (jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
@@ -294,7 +320,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
end);
-- forwards messages from main participants to vnodes
host_module:hook('muc-occupant-groupchat', function(event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter sending messages from transcribers/jibris to visitors
if not visitors_nodes[room.jid] then
@@ -314,7 +341,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- receiving messages from visitor nodes and forward them to local main participants
-- and forward them to the rest of visitor nodes
host_module:hook('muc-occupant-groupchat', function(event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
local occupant, room, stanzaEv = event.occupant, event.room, event.stanza;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
local to = stanza.attr.to;
local from = stanza.attr.from;
local from_vnode = jid.host(from);

View File

@@ -620,11 +620,14 @@ end
local function table_compare(old_table, new_table)
local removed = {}
local added = {}
local modified = {}
-- Find removed items (in old but not in new)
for id, _ in pairs(old_table) do
for id, value in pairs(old_table) do
if new_table[id] == nil then
table.insert(removed, id)
elseif new_table[id] ~= value then
table.insert(modified, id)
end
end
@@ -635,7 +638,20 @@ local function table_compare(old_table, new_table)
end
end
return removed, added
return removed, added, modified
end
local function table_equals(t1, t2)
if t1 == nil then
return t2 == nil;
end
if t2 == nil then
return t1 == nil;
end
local removed, added, modified = table_compare(t1, t2);
return next(removed) == nil and next(added) == nil and next(modified) == nil
end
-- Splits a string using delimiter
@@ -685,11 +701,35 @@ local function is_admin(_jid)
return false;
end
-- Filter out identity information (nick name, email, etc) from a presence stanza.
local function filter_identity_from_presence(orig_stanza)
local stanza = st.clone(orig_stanza);
stanza:remove_children('nick', 'http://jabber.org/protocol/nick');
stanza:remove_children('email');
stanza:remove_children('stats-id');
local identity = stanza:get_child('identity');
if identity then
local user = identity:get_child('user');
local name = identity:get_child('name');
if user then
user:remove_children('email');
user:remove_children('name');
end
if name then
name:remove_children('name'); -- Remove name with no namespace
end
end
return stanza;
end
return {
OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES;
INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES;
RECORDER_PREFIXES = RECORDER_PREFIXES;
extract_subdomain = extract_subdomain;
filter_identity_from_presence = filter_identity_from_presence;
is_admin = is_admin;
is_feature_allowed = is_feature_allowed;
is_jibri = is_jibri;
@@ -722,4 +762,5 @@ return {
table_compare = table_compare;
table_shallow_copy = table_shallow_copy;
table_find = table_find;
table_equals = table_equals;
};

View File

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

View File

@@ -24,6 +24,7 @@ import VideoQualityDialog from '../pageobjects/VideoQualityDialog';
import Visitors from '../pageobjects/Visitors';
import { LOG_PREFIX, logInfo } from './browserLogger';
import { IToken } from './token';
import { IContext, IJoinOptions } from './types';
export const P1 = 'p1';
@@ -49,7 +50,10 @@ export class Participant {
*/
private _name: string;
private _endpointId: string;
private _jwt?: string;
/**
* The token that this participant was initialized with.
*/
private _token?: IToken;
/**
* The default config to use when joining.
@@ -110,11 +114,11 @@ export class Participant {
* Creates a participant with given name.
*
* @param {string} name - The name of the participant.
* @param {string }jwt - The jwt if any.
* @param {string} token - The token if any.
*/
constructor(name: string, jwt?: string) {
constructor(name: string, token?: IToken) {
this._name = name;
this._jwt = jwt;
this._token = token;
}
/**
@@ -200,13 +204,13 @@ export class Participant {
};
}
if (ctx.iframeAPI) {
if (ctx.testProperties.useIFrameApi) {
config.room = 'iframeAPITest.html';
}
let url = urlObjectToString(config) || '';
if (ctx.iframeAPI) {
if (ctx.testProperties.useIFrameApi) {
const baseUrl = new URL(this.driver.options.baseUrl || '');
// @ts-ignore
@@ -219,8 +223,8 @@ export class Participant {
url = `${url}&tenant="${baseUrl.pathname.substring(1)}"`;
}
}
if (this._jwt) {
url = `${url}&jwt="${this._jwt}"`;
if (this._token?.jwt) {
url = `${url}&jwt="${this._token.jwt}"`;
}
if (options.baseUrl) {
@@ -231,7 +235,8 @@ export class Participant {
let urlToLoad = url.startsWith('/') ? url.substring(1) : url;
if (options.preferGenerateToken && !ctx.iframeAPI && ctx.isJaasAvailable() && process.env.IFRAME_TENANT) {
if (options.preferGenerateToken && !ctx.testProperties.useIFrameApi
&& process.env.JWT_KID?.startsWith('vpaas-magic-cookie-') && process.env.IFRAME_TENANT) {
// This to enables tests like invite, which can force using the jaas auth instead of the provided token
urlToLoad = `/${process.env.IFRAME_TENANT}/${urlToLoad}`;
}
@@ -241,7 +246,7 @@ export class Participant {
await this.waitForPageToLoad();
if (ctx.iframeAPI) {
if (ctx.testProperties.useIFrameApi) {
const mainFrame = this.driver.$('iframe');
await this.driver.switchFrame(mainFrame);
@@ -338,6 +343,10 @@ export class Participant {
&& 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.
*/
@@ -447,7 +456,7 @@ export class Participant {
}
/**
* Waits for number of participants.
* 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.
@@ -891,4 +900,11 @@ export class Participant {
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;
}
}

View File

@@ -0,0 +1,43 @@
/**
* 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;
};
const defaultProperties: ITestProperties = {
useIFrameApi: false,
useWebhookProxy: false,
useJaas: false
};
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] = { ...defaultProperties, ...properties };
}
/**
* @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] || { ...defaultProperties };
}

View File

@@ -1,9 +1,7 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import process from 'node:process';
import { v4 as uuidv4 } from 'uuid';
import { P1, P2, P3, P4, Participant } from './Participant';
import { IToken, generateToken } from './token';
import { IContext, IJoinOptions } from './types';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
@@ -171,7 +169,7 @@ async function _joinParticipant( // eslint-disable-line max-params
const p = ctx[name] as Participant;
if (p) {
if (ctx.iframeAPI) {
if (ctx.testProperties.useIFrameApi) {
await p.switchInPage();
}
@@ -179,7 +177,7 @@ async function _joinParticipant( // eslint-disable-line max-params
return;
}
if (ctx.iframeAPI) {
if (ctx.testProperties.useIFrameApi) {
// when loading url make sure we are on the top page context or strange errors may occur
await p.switchToAPI();
}
@@ -190,25 +188,33 @@ async function _joinParticipant( // eslint-disable-line max-params
// we want the participant instance re-recreated so we clear any kept state, like endpoint ID
}
let jwtToken;
let token: IToken = { jwt: '' };
if (name === P1) {
if (!options?.skipFirstModerator) {
// 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 (process.env.JWT_ACCESS_TOKEN
&& ((ctx.jwtPrivateKeyPath && !ctx.iframeAPI && !options?.preferGenerateToken)
&& ((ctx.jwtPrivateKeyPath && !ctx.testProperties.useIFrameApi && !options?.preferGenerateToken)
|| !ctx.jwtPrivateKeyPath)) {
jwtToken = process.env.JWT_ACCESS_TOKEN;
token = { jwt: process.env.JWT_ACCESS_TOKEN };
} else if (ctx.jwtPrivateKeyPath) {
jwtToken = getToken(ctx, name, options);
token = generateToken({
...options?.tokenOptions,
displayName: name,
});
}
}
} else if (name === P2) {
jwtToken = options?.preferGenerateToken ? getToken(ctx, P2, options) : undefined;
if (options?.preferGenerateToken) {
token = generateToken({
...options?.tokenOptions,
displayName: name,
});
}
}
const newParticipant = new Participant(name, jwtToken);
const newParticipant = new Participant(name, token);
// set the new participant instance
// @ts-ignore
@@ -283,64 +289,6 @@ export async function muteVideoAndCheck(testee: Participant, observer: Participa
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
}
/**
* Get a JWT token for a moderator.
*/
function getToken(ctx: IContext, displayName: string, options?: IJoinOptions) {
const keyid = process.env.JWT_KID;
const headers = {
algorithm: 'RS256',
noTimestamp: true,
expiresIn: '24h',
keyid
};
if (!keyid) {
console.error('JWT_KID is not set');
return;
}
const key = fs.readFileSync(ctx.jwtPrivateKeyPath);
const payload = {
'aud': 'jitsi',
'iss': 'chat',
'sub': keyid.substring(0, keyid.indexOf('/')),
'context': {
'user': {
'name': 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': '*'
};
// if the moderator is set, or options are missing, we assume moderator
if (options?.moderator || !options) {
// @ts-ignore
payload.context.user.moderator = true;
} else if (options.visitor) {
// @ts-ignore
payload.context.user.role = 'visitor';
}
ctx.data[`${displayName}-jwt-payload`] = payload;
// @ts-ignore
return jwt.sign(payload, key, headers);
}
/**
* Parse a JID string.
* @param str the string to parse.

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

@@ -0,0 +1,132 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
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, JWT_KID will be used from the environment variables.
*/
keyId?: string;
/**
* The path to the private key file used to sign the token.
* If not provided, JWT_PRIVATE_KEY_PATH will be used from the environment variables.
*/
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 || process.env.JWT_KID;
const keyPath = options.keyPath || process.env.JWT_PRIVATE_KEY_PATH;
const headers = {
algorithm: 'RS256',
noTimestamp: true,
expiresIn: options.exp || '24h',
keyid: keyId,
};
if (!keyId) {
throw new Error('JWT_KID is not set');
}
if (!keyPath) {
throw new Error('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;
}

View File

@@ -1,12 +1,16 @@
import { IConfig } from '../../react/features/base/config/configType';
import type { Participant } from './Participant';
import { ITestProperties } from './TestProperties';
import type WebhookProxy from './WebhookProxy';
import { ITokenOptions } from './token';
export type IContext = {
data: any;
iframeAPI: boolean;
isJaasAvailable: () => boolean;
/**
* Whether the configuration specifies a JaaS account for the iFrame API tests.
*/
iFrameUsesJaas: boolean;
jwtKid: string;
jwtPrivateKeyPath: string;
keepAlive: Array<any>;
@@ -16,6 +20,7 @@ export type IContext = {
p4: Participant;
roomName: string;
skipSuiteTests: boolean;
testProperties: ITestProperties;
times: any;
webhooksProxy: WebhookProxy;
};
@@ -37,11 +42,6 @@ export type IJoinOptions = {
*/
displayName?: string;
/**
* Whether to create a moderator token for joining.
*/
moderator?: boolean;
/**
* 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.
@@ -70,7 +70,7 @@ export type IJoinOptions = {
skipWaitToJoin?: boolean;
/**
* Whether to create a visitor token for joining.
* Options used when generating a token.
*/
visitor?: boolean;
tokenOptions?: ITokenOptions;
};

View File

@@ -1,9 +1,14 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
import { fetchJson } from '../../helpers/utils';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Chat', () => {
it('joining the meeting', async () => {

View File

@@ -1,13 +1,19 @@
import { isEqual } from 'lodash-es';
import { P1, P2, Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants, parseJid } from '../../helpers/participants';
import { IContext } from '../../helpers/types';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
/**
* Tests PARTICIPANT_LEFT webhook.
*/
async function checkParticipantLeftHook(ctx: IContext, p: Participant, reason: string, checkId = false) {
async function checkParticipantLeftHook(ctx: IContext, p: Participant, reason: string, checkId = false, conferenceJid: string) {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
@@ -28,14 +34,14 @@ async function checkParticipantLeftHook(ctx: IContext, p: Participant, reason: s
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.data.conferenceJid);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.disconnectReason).toBe(reason);
expect(event.data.isBreakout).toBe(false);
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
if (checkId) {
const jwtPayload = ctx.data[`${p.name}-jwt-payload`];
const jwtPayload = p.getToken()?.payload;
expect(event.data.id).toBe(jwtPayload?.context?.user?.id);
expect(event.data.group).toBe(jwtPayload?.context?.group);
@@ -45,6 +51,8 @@ async function checkParticipantLeftHook(ctx: IContext, p: Participant, reason: s
}
describe('Participants presence', () => {
let conferenceJid: string = '';
it('joining the meeting', async () => {
// ensure 2 participants one moderator and one guest, we will load both with iframeAPI
await ensureTwoParticipants(ctx);
@@ -113,7 +121,7 @@ describe('Participants presence', () => {
const { node, resource } = parseJid(roomsInfo.jid);
ctx.data.conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
const p1EpId = await p1.getEndpointId();
@@ -135,7 +143,7 @@ describe('Participants presence', () => {
} = await webhooksProxy.waitForEvent('ROOM_CREATED');
expect('ROOM_CREATED').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.data.conferenceJid);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
}
}
@@ -231,7 +239,7 @@ describe('Participants presence', () => {
const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0];
ctx.data.conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
@@ -248,7 +256,7 @@ describe('Participants presence', () => {
timeoutMsg: 'participantKickedOut event not received on p2 side'
});
await checkParticipantLeftHook(ctx, p2, 'kicked', true);
await checkParticipantLeftHook(ctx, p2, 'kicked', true, conferenceJid);
expect(eventP1).toBeDefined();
expect(eventP2).toBeDefined();
@@ -315,7 +323,7 @@ describe('Participants presence', () => {
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.data.conferenceJid);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
expect(event.data.moderator).toBe(false);
expect(event.data.name).toBe(await p2.getLocalDisplayName());
@@ -385,7 +393,7 @@ describe('Participants presence', () => {
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
await checkParticipantLeftHook(ctx, p2, 'left');
await checkParticipantLeftHook(ctx, p2, 'left', false, conferenceJid);
const eventReadyToCloseP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
@@ -396,7 +404,7 @@ describe('Participants presence', () => {
});
it('dispose conference', async () => {
const { data: { conferenceJid }, p1, roomName, webhooksProxy } = ctx;
const { p1, roomName, webhooksProxy } = ctx;
await p1.switchToAPI();
@@ -414,7 +422,7 @@ describe('Participants presence', () => {
expect(eventConferenceLeft).toBeDefined();
expect(eventConferenceLeft.roomName).toBe(roomName);
await checkParticipantLeftHook(ctx, p1, 'left', true);
await checkParticipantLeftHook(ctx, p1, 'left', true, conferenceJid);
if (webhooksProxy) {
// ROOM_DESTROYED webhook
// @ts-ignore

View File

@@ -1,9 +1,15 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import type WebhookProxy from '../../helpers/WebhookProxy';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Transcriptions', () => {
it('joining the meeting', async () => {
await ensureOneParticipant(ctx);

View File

@@ -1,5 +1,11 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Visitors', () => {
it('joining the meeting', async () => {
const { webhooksProxy } = ctx;
@@ -33,7 +39,7 @@ describe('Visitors', () => {
it('visitor joins', async () => {
await ensureTwoParticipants(ctx, {
preferGenerateToken: true,
visitor: true,
tokenOptions: { visitor: true },
skipInMeetingChecks: true
});
@@ -72,7 +78,7 @@ describe('Visitors', () => {
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
const jwtPayload = ctx.data[`${p2.name}-jwt-payload`];
const jwtPayload = p2.getToken()?.payload;
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.avatar).toBe(jwtPayload.context.user.avatar);

View File

@@ -1,7 +1,13 @@
import { expect } from '@wdio/globals';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Visitors', () => {
it('joining the meeting', async () => {
const { webhooksProxy } = ctx;
@@ -37,7 +43,7 @@ describe('Visitors', () => {
it('go live', async () => {
await ensureTwoParticipants(ctx, {
preferGenerateToken: true,
visitor: true,
tokenOptions: { visitor: true },
skipWaitToJoin: true,
skipInMeetingChecks: true
});

View File

@@ -1,6 +1,8 @@
import { ensureOneParticipant, ensureTwoParticipants, joinSecondParticipant } from '../../helpers/participants';
import type SecurityDialog from '../../pageobjects/SecurityDialog';
let roomKey: string;
/**
* 1. Lock the room (make sure the image changes to locked)
* 2. Join with a second browser/tab
@@ -29,14 +31,14 @@ describe('Lock Room', () => {
const p2PasswordDialog = p2.getPasswordDialog();
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${ctx.data.roomKey}1234`);
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give sometime to the password prompt to disappear and send the password
await p2.driver.pause(500);
// wait for password prompt
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(ctx.data.roomKey);
await p2PasswordDialog.submitPassword(roomKey);
await p2.waitToJoinMUC();
@@ -106,7 +108,7 @@ describe('Lock Room', () => {
const p2PasswordDialog = p2.getPasswordDialog();
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${ctx.data.roomKey}1234`);
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give sometime to the password prompt to disappear and send the password
await p2.driver.pause(500);
@@ -132,7 +134,7 @@ describe('Lock Room', () => {
* Participant1 locks the room.
*/
async function participant1LockRoom() {
ctx.data.roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
const { p1 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
@@ -142,7 +144,7 @@ async function participant1LockRoom() {
await waitForRoomLockState(p1SecurityDialog, false);
await p1SecurityDialog.addPassword(ctx.data.roomKey);
await p1SecurityDialog.addPassword(roomKey);
await p1SecurityDialog.clickCloseButton();

View File

@@ -77,8 +77,8 @@ describe('AVModeration', () => {
});
it('hangup and change moderator', async () => {
// no moderator switching if jaas is available
if (ctx.isJaasAvailable()) {
// no moderator switching if jaas is available.
if (ctx.iFrameUsesJaas) {
return;
}

View File

@@ -195,8 +195,8 @@ describe('Lobby', () => {
});
it('change of moderators in lobby', async () => {
// no moderator switching if jaas is available
if (ctx.isJaasAvailable()) {
// no moderator switching if jaas is available.
if (ctx.iFrameUsesJaas) {
return;
}
await hangupAllParticipants();
@@ -287,8 +287,8 @@ describe('Lobby', () => {
});
it('moderator leaves while lobby enabled', async () => {
// no moderator switching if jaas is available
if (ctx.isJaasAvailable()) {
// no moderator switching if jaas is available.
if (ctx.iFrameUsesJaas) {
return;
}
const { p1, p2, p3 } = ctx;

View File

@@ -1,4 +1,5 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
import {
cleanup,
@@ -8,6 +9,11 @@ import {
waitForAudioFromDialInParticipant
} from '../helpers/DialIn';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Invite iframeAPI', () => {
it('join participant', async () => {
await ensureOneParticipant(ctx);

View File

@@ -1,5 +1,11 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
useIFrameApi: true,
useWebhookProxy: true
});
describe('Recording', () => {
it('join participant', async () => {
await ensureOneParticipant(ctx);
@@ -180,7 +186,7 @@ async function testRecordingStopped(command: boolean) {
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED', 20000);
const jwtPayload = ctx.data[`${p1.name}-jwt-payload`];
const jwtPayload = p1.getToken()?.payload;
expect(recordingUploadedEvent.data.initiatorId).toBe(jwtPayload?.context?.user?.id);
expect(recordingUploadedEvent.data.participants.some(

View File

@@ -0,0 +1,51 @@
import { Participant } from '../../helpers/Participant';
import { IToken, ITokenOptions, generateToken } from '../../helpers/token';
/**
* Creates a new Participant and joins the MUC with the given name. The jaas-specific properties must be set as
* environment variables: JAAS_DOMAIN and JAAS_TENANT, JAAS_KID, JAAS_PRIVATE_KEY_PATH.
*
* @param roomName The name of the room to join, without the tenant.
* @param instanceId This is the "name" passed to the Participant, I think it's used to match against one of the
* pre-configured browser instances in wdio? It must be one of 'p1', 'p2', 'p3', or 'p4'. TODO: figure out how this
* should be used.
* @param token the token to use, if any.
*/
export async function joinMuc(roomName: string, instanceId: 'p1' | 'p2' | 'p3' | 'p4', token?: IToken) {
if (!process.env.JAAS_DOMAIN || !process.env.JAAS_TENANT) {
throw new Error('JAAS_DOMAIN and JAAS_TENANT environment variables must be set');
}
// TODO: this should re-use code from Participant (e.g. setting config).
let url = `https://${process.env.JAAS_DOMAIN}/${process.env.JAAS_TENANT}/${roomName}`;
if (token) {
url += `?jwt=${token.jwt}`;
}
url += '#config.prejoinConfig.enabled=false';
const newParticipant = new Participant(instanceId, token);
try {
await newParticipant.driver.setTimeout({ 'pageLoad': 30000 });
await newParticipant.driver.url(url);
await newParticipant.waitForPageToLoad();
await newParticipant.waitToJoinMUC();
} catch (error) {
}
return newParticipant;
}
export function generateJaasToken(options: ITokenOptions): IToken {
if (!process.env.JAAS_PRIVATE_KEY_PATH || !process.env.JAAS_KID) {
throw new Error('JAAS_PRIVATE_KEY_PATH and JAAS_KID environment variables must be set');
}
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
return generateToken({
...options,
keyId: options.keyId || process.env.JAAS_KID,
keyPath: options.keyPath || process.env.JAAS_PRIVATE_KEY_PATH,
});
}

View File

@@ -0,0 +1,63 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinMuc, generateJaasToken as t } from '../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
describe('XMPP login and MUC join test', () => {
it('with a valid token (wildcard room)', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ room: '*' }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a valid token (specific room)', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ room: ctx.roomName }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a token with bad signature', async () => {
const token = t({ room: ctx.roomName });
token.jwt = token.jwt + 'badSignature';
const p = await joinMuc(ctx.roomName, 'p1', token);
expect(Boolean(await p.isInMuc())).toBe(false);
});
it('with an expired token', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ exp: '-1m' }));
expect(Boolean(await p.isInMuc())).toBe(false);
});
it('with a token using the wrong key ID', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ keyId: 'invalid-key-id' }));
expect(Boolean(await p.isInMuc())).toBe(false);
});
it('with a token for a different room', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ room: ctx.roomName + 'different' }));
expect(Boolean(await p.isInMuc())).toBe(false);
});
it('with a moderator token', async () => {
const p = await joinMuc(ctx.roomName, 'p1', t({ moderator: true }));
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(true);
});
// it('without sending a conference-request', async () => {
// console.log('Joining a MUC without sending a conference-request');
// // TODO verify failure
// //expect(await joinMuc(ctx.roomName, 'p1', token)).toBe(true);
// });
});

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import path from 'node:path';
import process from 'node:process';
import pretty from 'pretty';
import { getTestProperties } from './helpers/TestProperties';
import WebhookProxy from './helpers/WebhookProxy';
import { getLogs, initLogger, logInfo } from './helpers/browserLogger';
import { IContext } from './helpers/types';
@@ -180,7 +181,11 @@ export const config: WebdriverIO.MultiremoteConfig = {
console.warn('We expect to run a single suite, but got more than one');
}
const testName = path.basename(specs[0]).replace('.spec.ts', '');
const testFilePath = specs[0].replace(/^file:\/\//, '');
const testName = path.relative('tests/specs', testFilePath)
.replace(/.spec.ts$/, '')
.replace(/\//g, '-');
const testProperties = await getTestProperties(testFilePath);
console.log(`Running test: ${testName} via worker: ${cid}`);
@@ -191,6 +196,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
times: {}
} as IContext;
globalAny.ctx.keepAlive = [];
globalAny.ctx.testProperties = testProperties;
await Promise.all(multiremotebrowser.instances.map(async (instance: string) => {
const bInstance = multiremotebrowser.getInstance(instance);
@@ -224,13 +230,12 @@ export const config: WebdriverIO.MultiremoteConfig = {
globalAny.ctx.roomName = globalAny.ctx.roomName.toLowerCase();
globalAny.ctx.jwtPrivateKeyPath = process.env.JWT_PRIVATE_KEY_PATH;
globalAny.ctx.jwtKid = process.env.JWT_KID;
globalAny.ctx.isJaasAvailable = () => globalAny.ctx.jwtKid?.startsWith('vpaas-magic-cookie-');
globalAny.ctx.iFrameUsesJaas = process.env.JWT_PRIVATE_KEY_PATH
&& process.env.JWT_KID?.startsWith('vpaas-magic-cookie-');
// If we are running the iFrameApi tests, we need to mark it as such and if needed to create the proxy
// and connect to it.
if (testName.startsWith('iFrameApi')) {
globalAny.ctx.iframeAPI = true;
if (testProperties.useWebhookProxy) {
if (!globalAny.ctx.webhooksProxy
&& process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET) {
globalAny.ctx.webhooksProxy = new WebhookProxy(
@@ -240,6 +245,20 @@ export const config: WebdriverIO.MultiremoteConfig = {
globalAny.ctx.webhooksProxy.connect();
}
}
if (testProperties.useWebhookProxy && !globalAny.ctx.webhooksProxy) {
console.warn(`WebhookProxy is not available, skipping ${testName}`);
globalAny.ctx.skipSuiteTests = true;
}
const isJaasConfigured = process.env.JAAS_DOMAIN && process.env.JAAS_TENANT
&& process.env.JAAS_PRIVATE_KEY_PATH && process.env.JAAS_KID;
if (testProperties.useJaas && !isJaasConfigured) {
console.warn(`JaaS is not configured, skipping ${testName}. `
+ 'Set JAAS_DOMAIN, JAAS_TENANT, JAAS_KID, and JAAS_PRIVATE_KEY_PATH to enable.');
globalAny.ctx.skipSuiteTests = true;
}
},
after() {