Compare commits

...

26 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
38 changed files with 992 additions and 221 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

@@ -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.",

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

@@ -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,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

@@ -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

@@ -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';
@@ -230,17 +231,9 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
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;
}
@@ -256,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;
}
@@ -289,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

@@ -157,7 +157,14 @@ function start_av_moderation(room, mediaType, occupant)
room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = array{ internal_room_jid_match_rewrite(occupant.nick) };
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
@@ -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') or ends_with(room_occupant.nick, '/focus') 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

@@ -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,7 @@ local function table_compare(old_table, new_table)
end
end
return removed, added
return removed, added, modified
end
local function table_equals(t1, t2)
@@ -646,9 +649,9 @@ local function table_equals(t1, t2)
return t1 == nil;
end
local removed, added = table_compare(t1, t2);
local removed, added, modified = table_compare(t1, t2);
return next(removed) == nil and next(added) == nil
return next(removed) == nil and next(added) == nil and next(modified) == nil
end
-- Splits a string using delimiter

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() {