Compare commits

...

30 Commits
8288 ... 8317

Author SHA1 Message Date
Saúl Ibarra Corretgé
3d30489cc4 chore(deps) update TypeScript 2024-12-19 15:04:16 +01:00
Дамян Минков
7288a6dce4 feat(tests): AVModeration tests. (#15408)
* feat(tests): Adds option to skip suite.

* fix(tests): Rename context to ctx to avoid clashing mocha's one.

* feat(tests): Moves room name generation in hooks.

Move also the proxy connection in the hooks.

* fix(tests): Avatar checks when using a token.

Token has its avatar so we skip the token for avatar tests.

* feat(tests): Renames avatars to drop Test from name.

* feat(tests): Updates dependencies.

* feat(tests): Fix end test log.

* feat(tests): AVModeration tests.
2024-12-19 07:12:14 -06:00
Hristo Terezov
697c41bffa fix(config-whitelist): remove p2p.stunServers 2024-12-18 18:39:22 -06:00
teamogy-team
0361830122 fix(lang) update Czech translation 2024-12-18 12:17:11 +01:00
damencho
6bb984acc8 fix(polls): Returns an error on duplicate poll. 2024-12-17 16:44:12 -06:00
damencho
938d112f6b fix(visitors): Fix a check that can result missing main participants. 2024-12-17 10:59:26 -06:00
Дамян Минков
d8cca36181 fix(shared-video): Removes disable pointer for non moderators. (#15399)
* fix(shared-video): Removes disable pointer for non moderators.

It fixes an issue where people can see ads from YouTube, allowing them to click the Skip Ad button. If you by mistake pause, the next time sync will unpuase it.

* squash: Remove unused.
2024-12-17 06:20:09 -06:00
Jaya Allamsetty
c9add0a9ef ref(tracks) Remove gUM option that is no longer supported 2024-12-16 22:41:54 -05:00
Saúl Ibarra Corretgé
aabc50da60 fix(rnsdk) specify the RN version we support
Our SDK is only really tested with a single RN version, make sure to put
that in peerDependencies.

In addition, drop the peer dependency on @react-native/metro-config
since our project doesn't use it directly.
2024-12-16 17:53:14 +02:00
Calin-Teodor
4aa4382d2b dep(@react-native-clipboard/clipboard): update to 1.14.3 2024-12-16 10:48:32 +02:00
Calin-Teodor
6bf1b46609 chore(react-native): update CocoaPods 2024-12-16 10:47:54 +02:00
shooding
9d7237dc24 fix(transcript) fix matching languages with variants
* fixtranscript results from whisper may have json.language="zh-TW" also requesting transcriptions language could be "zh-CN". Use _getPrimaryLanguageCode func to compare only the main lang code.

* fix: lint issue
2024-12-16 09:02:57 +01:00
Saúl Ibarra Corretgé
3745c19857 feat(overlay) drop permissions overlay
It covers the entire meeting and it's not helpful. A different version
of the same concept might be implemented using less intrusive UX, like a
modal.
2024-12-13 10:55:10 -06:00
damencho
cd6419d826 feat(ga): Adds a syntax check for config.js. 2024-12-13 10:34:39 -06:00
Saúl Ibarra Corretgé
a05e8fd2ef feat(build) use Node 22, the new LTS 2024-12-13 15:55:58 +01:00
Mejans
239e5743dd fix(lang) update Occitan locale (#15389) 2024-12-13 09:35:57 +01:00
Robert Oanta
01a731ab4d feat(prosody): extend jigasi kick endpoint to work for any participant (#15387)
* feat(prosody): extend jigasi kick endpoint to work for any participant

* apply review

* squash: Fix UI when there is no actor of the kick.

---------

Co-authored-by: damencho <damencho@jitsi.org>
2024-12-12 11:03:26 -06:00
Edgars Voroboks
3d56538c69 fix(lang): Update Latvian language translation 2024-12-12 10:52:59 -06:00
Дамян Минков
2dc135b80f feat(tests): Adds avatar test. (#15382)
* feat(tests): Adds join options.

* fix(tests): Fix opening tests by default with tenant.

* fix(tests): Renames a method.

* fix(tests): Moves a method from filmstrip to participants pane.

* fix(tests): Adds ok button to base dialog.

* fix(tests): Adds missing checks for using iframe API.

* feat(tests): Prettify the result html on error.

* fix(tests): Fixes checking when not in room.

* fix(tests): Adds profile button to toolbar.

* fix(tests): Adds avatar test.

* fix(tests): Fix all execute methods and await.

* fix(tests): Fix avatar checks.
2024-12-12 08:29:15 -06:00
simon
4e81d4461b fix(lang) update German translation 2024-12-12 13:52:46 +01:00
Mihaela Dumitru
68b16d7bc8 fix(config) hide ui label for the recording start notification and send event (#15385) 2024-12-12 11:31:38 +02:00
Hristo Terezov
45f6550d30 fix(configWhitelist): Remove hiddenDomain and restrict dropbox options to key only. (#15381) 2024-12-11 13:08:12 -06:00
Calin-Teodor
5c65446ef4 feat(toolbox/native): update CustomOptionButton props 2024-12-11 17:44:01 +02:00
Saúl Ibarra Corretgé
eee58b2945 fix(whiteboard) fix disabling button 2024-12-11 13:30:21 +01:00
Jaya Allamsetty
c7cfb5218d fix(config): Add missing config options 2024-12-10 16:34:53 -05:00
Дамян Минков
b9017176a8 feat(tests): Grid, FF and iframeAPI tests. (#15372)
* fix(tests): Fix include/excludes of tests based on participant count.

* feat(tests): Define context globally.

The context is being created on every new suite in before hook.

* feat(tests): Drop extra char in room name.

* feat(tests): Adds an option to load iframeAPI.

* feat(tests): Adds some ts types.

* fix(tests): Fix iframeAPI helper with events received too early.

* fix(tests): Fix iframeAPI helper detecting own role changed.

* feat(tests): Adds run script to start tests with local dev server.

* feat(tests): Adds participants iframeAPI tests.

* feat(tests): Updates wdio dependencies.

* feat: Adds grid config.

* feat: Simplify iframeAPI.

Drop URL params.

* feat: Adds tenant to iframeAPI.

* feat: Adds firefox target.

Certain tests are disable as not supported on FF. Missing upload file function for iframeAPI helper. Missing option to set audio file as a mic source.

* fix: Fix using tenant from baseUrl.

* feat: Adds audio only tests.

* feat: Adds option to generate tokens for the moderator.

* feat: Adds option to test and webhooks.

* fix: Improve error stack trace on error.

* fix: Address comments.

* fix: Fix test exclusion for FF.

* squash: Revert the strophe change and add a comment.
2024-12-10 11:22:44 -06:00
Hristo Terezov
cad37306d4 feat(dynamic-branding): New options for URLs in config
Adds new options that are cloning URL options from the config.
2024-12-10 09:46:04 -06:00
Saúl Ibarra Corretgé
83192435e7 feat(dynamic-branding) support setting transcription language
It will be used as the default if specified.
2024-12-09 15:51:49 +01:00
Дамян Минков
f85d0e6469 feat(always-on-top): Updates buttons for visitors. (#15369)
* feat(always-on-top): Updates buttons for visitors.

* squash: rename listener.

* squash: Adds visitor to the conference joined event.

* squash: fix comments and lint.

* squash: fix comments.
2024-12-06 12:28:29 -06:00
Calin-Teodor
3834f1e99c feat(base/tracks): removed pip limitation while screensharing 2024-12-06 14:31:20 +02:00
80 changed files with 5862 additions and 1616 deletions

View File

@@ -53,6 +53,8 @@ jobs:
npm -v
- run: npm install
- run: make
- name: Check config.js syntax
run: node config.js
android-rn-bundle-build:
name: Build mobile bundle (Android)
runs-on: macos-15

2
.nvmrc
View File

@@ -1 +1 @@
20
22

View File

@@ -155,7 +155,6 @@ import {
NOTIFICATION_TIMEOUT_TYPE
} from './react/features/notifications/constants';
import { isModerationNotificationDisplayed } from './react/features/notifications/functions';
import { mediaPermissionPromptVisibilityChanged } from './react/features/overlay/actions';
import { suspendDetected } from './react/features/power-monitor/actions';
import { initPrejoin, isPrejoinPageVisible } from './react/features/prejoin/functions';
import { disableReceiver, stopReceiver } from './react/features/remote-control/actions';
@@ -435,15 +434,6 @@ export default {
requestedVideo = true;
}
if (!config.disableInitialGUM) {
JitsiMeetJS.mediaDevices.addEventListener(
JitsiMediaDevicesEvents.PERMISSION_PROMPT_IS_SHOWN,
browserName =>
APP.store.dispatch(
mediaPermissionPromptVisibilityChanged(true, browserName))
);
}
let tryCreateLocalTracks = Promise.resolve([]);
// On Electron there is no permission prompt for granting permissions. That's why we don't need to
@@ -452,8 +442,7 @@ export default {
const timeout = browser.isElectron() ? 15000 : 60000;
const audioOptions = {
devices: [ MEDIA_TYPE.AUDIO ],
timeout,
firePermissionPromptIsShownEvent: true
timeout
};
// Spot uses the _desktopSharingSourceDevice config option to use an external video input device label as
@@ -488,8 +477,7 @@ export default {
} else if (requestedAudio || requestedVideo) {
tryCreateLocalTracks = APP.store.dispatch(createInitialAVTracks({
devices: initialDevices,
timeout,
firePermissionPromptIsShownEvent: true
timeout
}, recordTimeMetrics)).then(({ tracks, errors: pErrors }) => {
Object.assign(errors, pErrors);
@@ -497,15 +485,6 @@ export default {
});
}
// Hide the permissions prompt/overlay as soon as the tracks are created. Don't wait for the connection to
// be established, as in some cases like when auth is required, connection won't be established until the user
// inputs their credentials, but the dialog would be overshadowed by the overlay.
tryCreateLocalTracks.then(tracks => {
APP.store.dispatch(mediaPermissionPromptVisibilityChanged(false));
return tracks;
});
return {
tryCreateLocalTracks,
errors

View File

@@ -509,12 +509,17 @@ var config = {
// videoQuality: {
//
// // Provides a way to set the codec preference on desktop based endpoints.
// codecPreferenceOrder: [ 'VP9', 'VP8', 'H264' ],
// codecPreferenceOrder: [ 'VP9', 'VP8', 'H264', 'AV1' ],
//
// // Provides a way to set the codec for screenshare.
// screenshareCodec: 'AV1',
// mobileScreenshareCodec: 'VP8',
//
// // Enables the adaptive mode in the client that will make runtime adjustments to selected codecs and received
// // videos for a better user experience. This mode will kick in only when CPU overuse is reported in the
// // WebRTC statistics for the outbound video streams.
// enableAdaptiveMode: false,
//
// // Codec specific settings for scalability modes and max bitrates.
// av1: {
// maxBitratesVideo: {

View File

@@ -1726,7 +1726,7 @@ PODS:
- React
- RNCAsyncStorage (1.23.1):
- React-Core
- RNCClipboard (1.14.1):
- RNCClipboard (1.14.3):
- React-Core
- RNDefaultPreference (1.4.4):
- React-Core
@@ -2197,7 +2197,7 @@ SPEC CHECKSUMS:
ReactCommon: 6a952e50c2a4b694731d7682aaa6c79bc156e4ad
RNCalendarEvents: 7e65eb4a94f53c1744d1e275f7fafcfaa619f7a3
RNCAsyncStorage: 826b603ae9c0f88b5ac4e956801f755109fa4d5c
RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37
RNCClipboard: 2821ac938ef46f736a8de0c8814845dde2dcbdfb
RNDefaultPreference: 08bdb06cfa9188d5da97d4642dac745218d7fb31
RNDeviceInfo: 02ea8b23e2280fa18e00a06d7e62804d74028579
RNGestureHandler: 939f21fabf5d45a725c0bf175eb819dd25cf2e70
@@ -2211,4 +2211,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 7ed908101076ca2c595b633c34648bcfc7e98614
COCOAPODS: 1.16.1
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@@ -1388,7 +1388,7 @@
"tileViewToggle": "Kachelansicht ein-/ausschalten",
"toggleCamera": "Kamera wechseln",
"unmute": "Stummschaltung aufheben",
"videoSettings": "Kameraeinstellungen",
"videoSettings": "Kamera-Einstellungen",
"videomute": "Kamera stoppen",
"videomuteGUMPending": "Verbinde Ihre Kamera",
"videounmute": "Kamera einschalten"

View File

@@ -483,8 +483,8 @@
"transcribing": "Notiek transkripcija",
"unlockRoom": "Noņemt $t(lockRoomPassword)",
"user": "Lietotājs",
"userIdentifier": "Lietotāja identifikators",
"userPassword": "lietotāja parole",
"userIdentifier": "Lietotājvārds",
"userPassword": "Parole",
"verifyParticipantConfirm": "Sakrīt",
"verifyParticipantDismiss": "Nesakrīt",
"verifyParticipantQuestion": "EKSPERIMENTĀLS: pajautājiet dalībniekam {{participantName}}, vai viņš redz to pašu saturu tādā pašā secībā.",
@@ -641,6 +641,7 @@
"on": "Tiešraide ieslēgta",
"onBy": "{{name}} ieslēdza tiešraidi",
"pending": "Sākam tiešraidi…",
"policyError": "Jūs mēģinājāt pārāk ātri sākt tiešraides straumi. Lūdzu, vēlāk mēģiniet vēlreiz!",
"serviceName": "Tiešsaistes pakalpojums",
"sessionAlreadyActive": "Šī sesija jau tiek ierakstīta vai straumēta tiešraidē.",
"signIn": "Ierakstīties ar Google kontu",
@@ -739,6 +740,7 @@
"connectedOneMember": "{{name}} ir pievienojies sapulcei",
"connectedThreePlusMembers": "{{name}} un {{count}} citi ir pievienojušies sapulcei",
"connectedTwoMembers": "{{first}} un {{second}} ir pievienojušies sapulcei",
"connectionFailed": "Savienojums neizdevās. Lūdzu, vēlāk mēģiniet vēlreiz!",
"dataChannelClosed": "Video kvalitāte var būt traucēta",
"dataChannelClosedDescription": "Savienojuma kanāls nedarbojas, tāpēc video kvalitāte var būt ierobežota līdz zemākajam iestatījumam.",
"dataChannelClosedDescriptionWithAudio": "Savienojuma kanāls nedarbojas, tāpēc var rasties audio un video traucējumi.",
@@ -753,6 +755,9 @@
"gifsMenu": "GIPHY",
"groupTitle": "Paziņojumi",
"hostAskedUnmute": "Moderators vēlas, lai jūs runātu",
"invalidTenant": "Nederīgs tenants",
"invalidTenantHyphenDescription": "Jūsu izmantotais tenants nav derīgs (sākas vai beidzas ar '-').",
"invalidTenantLengthDescription": "Jūsu izmantotais tenants ir pārāk garš.",
"invitedOneMember": "{{displayName}} tika uzaicināts",
"invitedThreePlusMembers": "Uzaicināts {{name}} un {{count}} citi lietotāji",
"invitedTwoMembers": "{{first}} un {{second}} tika uzaicināti uz sapulci",
@@ -1051,6 +1056,7 @@
"onBy": "{{name}} ieslēdza ierakstu",
"onlyRecordSelf": "Ierakstīt tikai manas audio un video straumes",
"pending": "Gatavojas ierakstīt sapulci…",
"policyError": "Jūs mēģinājāt pārāk ātri sākt ierakstīšanu. Lūdzu, vēlāk mēģiniet vēlreiz!",
"recordAudioAndVideo": "Ierakstīt audio un video",
"recordTranscription": "Ierakstīt transkripciju",
"saveLocalRecording": "Ieraksta faila saglabāšana lokāli (beta)",
@@ -1246,6 +1252,7 @@
"lobbyButton": "Iespējot/atspējot vestibila režīmu",
"localRecording": "Lokālā ieraksta vadības rīki (iesl./izsl.)",
"lockRoom": "Telpas slēgšana ar paroli (iesl./izsl.)",
"love": "Sirds",
"lowerHand": "Nolaist roku",
"moreActions": "Papildus iestatījumu izvēlne (rādīt/nerādīt)",
"moreActionsMenu": "Papildus iestatījumu izvēlne",
@@ -1335,6 +1342,7 @@
"lobbyButtonEnable": "Iespējot vestibila režīmu",
"login": "Ierakstīties",
"logout": "Izrakstīties",
"love": "Sirds",
"lowerYourHand": "Nolaist roku",
"moreActions": "Vairāk rīcību",
"moreOptions": "Vairāk opciju",
@@ -1360,6 +1368,7 @@
"raiseYourHand": "Pacelt roku",
"reactionBoo": "Nosūtīt būū reakciju",
"reactionClap": "Nosūtīt aplausu reakciju",
"reactionHeart": "Nosūtīt sirds reakciju",
"reactionLaugh": "Nosūtīt smieklu reakciju",
"reactionLike": "Nosūtīt īkšķi augšup reakciju",
"reactionSilence": "Nosūtīt klusuma reakciju",

File diff suppressed because it is too large Load Diff

View File

@@ -334,6 +334,7 @@
"kickParticipantButton": "Kick",
"kickParticipantDialog": "Are you sure you want to kick this participant?",
"kickParticipantTitle": "Kick this participant?",
"kickSystemTitle": "Ouch! You were kicked out of the meeting",
"kickTitle": "Ouch! {{participantDisplayName}} kicked you out of the meeting",
"linkMeeting": "Link meeting",
"linkMeetingTitle": "Link meeting to Salesforce",

View File

@@ -398,6 +398,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this._participants = {};
this._myUserID = undefined;
this._onStageParticipant = undefined;
this._iAmvisitor = undefined;
this._setupListeners();
id++;
}
@@ -619,6 +620,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
email: data.email,
avatarURL: data.avatarURL
};
this._iAmvisitor = data.visitor;
}
// eslint-disable-next-line no-fallthrough
@@ -1168,6 +1170,15 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
});
}
/**
* Returns whether we have joined as visitor in a meeting.
*
* @returns {boolean} - Returns true if we have joined as visitor.
*/
isVisitor() {
return this._iAmvisitor;
}
/**
* Returns the avatar URL of a participant.
*

2499
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
"@microsoft/microsoft-graph-client": "3.0.1",
"@mui/material": "5.12.1",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-clipboard/clipboard": "1.14.1",
"@react-native-clipboard/clipboard": "1.14.3",
"@react-native-community/netinfo": "11.1.0",
"@react-native-community/slider": "4.4.3",
"@react-native-google-signin/google-signin": "10.1.0",
@@ -139,12 +139,14 @@
"@types/amplitude-js": "8.16.5",
"@types/audioworklet": "0.0.29",
"@types/dom-screen-wake-lock": "1.0.1",
"@types/jasmine": "5.1.4",
"@types/js-md5": "0.4.3",
"@types/jsonwebtoken": "9.0.7",
"@types/lodash-es": "4.17.12",
"@types/mocha": "10.0.10",
"@types/moment-duration-format": "2.2.6",
"@types/offscreencanvas": "2019.7.2",
"@types/pixelmatch": "5.2.5",
"@types/pretty": "2.0.3",
"@types/punycode": "2.1.0",
"@types/react": "17.0.14",
"@types/react-dom": "17.0.14",
@@ -160,12 +162,12 @@
"@types/zxcvbn": "4.4.1",
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",
"@wdio/allure-reporter": "9.2.14",
"@wdio/cli": "9.2.14",
"@wdio/globals": "9.2.14",
"@wdio/jasmine-framework": "9.2.14",
"@wdio/junit-reporter": "9.2.14",
"@wdio/local-runner": "9.2.15",
"@wdio/allure-reporter": "9.4.3",
"@wdio/cli": "9.4.3",
"@wdio/globals": "9.4.3",
"@wdio/junit-reporter": "9.4.3",
"@wdio/local-runner": "9.4.3",
"@wdio/mocha-framework": "9.4.3",
"babel-loader": "9.1.0",
"babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0",
@@ -178,16 +180,18 @@
"eslint-plugin-react-native": "4.0.0",
"eslint-plugin-typescript-sort-keys": "2.3.0",
"jetifier": "1.6.4",
"jsonwebtoken": "9.0.2",
"metro-react-native-babel-preset": "0.77.0",
"patch-package": "6.4.7",
"pretty": "2.0.0",
"process": "0.11.10",
"sass": "1.26.8",
"style-loader": "3.3.1",
"traverse": "0.6.6",
"ts-loader": "9.4.2",
"typescript": "5.0.4",
"typescript": "5.7.2",
"unorm": "1.6.0",
"webdriverio": "9.2.14",
"webdriverio": "9.4.3",
"webpack": "5.95.0",
"webpack-bundle-analyzer": "4.4.2",
"webpack-cli": "5.1.4",
@@ -197,7 +201,7 @@
"@xmldom/xmldom": "0.8.7"
},
"engines": {
"node": ">=20.0.0",
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"license": "Apache-2.0",
@@ -215,7 +219,10 @@
"tsc-test:web": "tsc --project tsconfig.web.json --listFilesOnly | grep -v node_modules | grep native",
"tsc-test:native": "tsc --project tsconfig.native.json --listFilesOnly | grep -v node_modules | grep web",
"start": "make dev",
"test": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts"
"test": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts",
"test-ff": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.firefox.conf.ts",
"test-dev": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.dev.conf.ts",
"test-grid": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.grid.conf.ts"
},
"resolutions": {
"@types/react": "17.0.14",

View File

@@ -58,7 +58,6 @@
"peerDependencies": {
"@amplitude/react-native": "0.0.0",
"@giphy/react-native-sdk": "0.0.0",
"@react-native/metro-config": "*",
"@react-native-async-storage/async-storage": "0.0.0",
"@react-native-clipboard/clipboard": "0.0.0",
"@react-native-community/netinfo": "0.0.0",

View File

@@ -1,9 +1,13 @@
const fs = require('fs');
const semver = require('semver');
const packageJSON = require('../package.json');
const SDKPackageJSON = require('./package.json');
// Skip checking these.
const skipDeps = [ 'react', 'react-native' ];
/**
* Merges the dependency versions from the root package.json with the dependencies of the SDK package.json.
*/
@@ -18,15 +22,21 @@ function mergeDependencyVersions() {
// Updates SDK peer dependencies.
for (const key in packageJSON.dependencies) {
if (SDKPackageJSON.peerDependencies.hasOwnProperty(key)) {
// Updates all peer dependencies except react and react-native.
if (key !== 'react' && key !== 'react-native') {
SDKPackageJSON.peerDependencies[key] = packageJSON.dependencies[key];
}
if (SDKPackageJSON.peerDependencies.hasOwnProperty(key) && !skipDeps.includes(key)) {
SDKPackageJSON.peerDependencies[key] = packageJSON.dependencies[key];
}
}
// Set RN peer dependency.
const rnVersion = semver.parse(packageJSON.dependencies['react-native']);
if (!rnVersion) {
throw new Error('failed to parse React Native version');
}
// In RN the "major" version is the Semver minor.
SDKPackageJSON.peerDependencies['react-native'] = `~0.${rnVersion.minor}.0`;
// Updates SDK overrides dependencies.
for (const key in packageJSON.overrides) {
if (SDKPackageJSON.overrides.hasOwnProperty(key)) {

View File

@@ -4,6 +4,8 @@ import AudioMuteButton from './AudioMuteButton';
import HangupButton from './HangupButton';
import VideoMuteButton from './VideoMuteButton';
const { api } = window.alwaysOnTop;
/**
* The type of the React {@code Component} props of {@link Toolbar}.
*/
@@ -25,12 +27,89 @@ interface IProps {
onMouseOver: (e?: React.MouseEvent) => void;
}
/**
* The type of the React {@code Component} state of {@link Toolbar}.
*/
interface IState {
/**
* Whether audio button to be shown or not.
*/
showAudioButton: boolean;
/**
* Whether video button to be shown or not.
*/
showVideoButton: boolean;
}
type Props = Partial<IProps>;
/**
* Represents the toolbar in the Always On Top window.
*
* @augments Component
*/
export default class Toolbar extends Component<IProps> {
export default class Toolbar extends Component<Props, IState> {
/**
* Initializes a new {@code Toolbar} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize the new {@code Toolbar} instance with.
*/
constructor(props: Props) {
super(props);
this.state = {
showAudioButton: true,
showVideoButton: true
};
this._videoConferenceJoinedListener = this._videoConferenceJoinedListener.bind(this);
}
/**
* Sets listens for changing meetings while showing the toolbar.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
api.on('videoConferenceJoined', this._videoConferenceJoinedListener);
this._videoConferenceJoinedListener();
}
/**
* Handles is visitor changes.
*
* @returns {void}
*/
_videoConferenceJoinedListener() {
// for electron clients that embed the api and are not updated
if (!api.isVisitor) {
console.warn('external API not updated');
return;
}
const isNotVisitor = !api.isVisitor();
this.setState({
showAudioButton: isNotVisitor,
showVideoButton: isNotVisitor
});
}
/**
* Removes all listeners.
*
* @inheritdoc
* @returns {void}
*/
componentWillUnmount() {
api.removeListener('videoConferenceJoined', this._videoConferenceJoinedListener);
}
/**
* Implements React's {@link Component#render()}.
*
@@ -49,8 +128,8 @@ export default class Toolbar extends Component<IProps> {
className = { `toolbox-content-items always-on-top-toolbox ${className}` }
onMouseOut = { onMouseOut }
onMouseOver = { onMouseOver }>
<AudioMuteButton />
<VideoMuteButton />
{ this.state.showAudioButton && <AudioMuteButton /> }
{ this.state.showVideoButton && <VideoMuteButton /> }
<HangupButton customClass = 'hangup-button' />
</div>
);

View File

@@ -38,7 +38,6 @@ import '../jaas/reducer';
import '../large-video/reducer';
import '../lobby/reducer';
import '../notifications/reducer';
import '../overlay/reducer';
import '../participants-pane/reducer';
import '../polls/reducer';
import '../polls-history/reducer';

View File

@@ -57,7 +57,6 @@ import { INoAudioSignalState } from '../no-audio-signal/reducer';
import { INoiseDetectionState } from '../noise-detection/reducer';
import { INoiseSuppressionState } from '../noise-suppression/reducer';
import { INotificationsState } from '../notifications/reducer';
import { IOverlayState } from '../overlay/reducer';
import { IParticipantsPaneState } from '../participants-pane/reducer';
import { IPollsState } from '../polls/reducer';
import { IPollsHistoryState } from '../polls-history/reducer';
@@ -147,7 +146,6 @@ export interface IReduxState {
'features/noise-detection': INoiseDetectionState;
'features/noise-suppression': INoiseSuppressionState;
'features/notifications': INotificationsState;
'features/overlay': IOverlayState;
'features/participants-pane': IParticipantsPaneState;
'features/polls': IPollsState;
'features/polls-history': IPollsHistoryState;

View File

@@ -208,6 +208,7 @@ export function getConferenceOptions(stateful: IStateful) {
const config = state['features/base/config'];
const { locationURL } = state['features/base/connection'];
const { defaultTranscriptionLanguage } = state['features/dynamic-branding'];
const { tenant } = state['features/base/jwt'];
const { email, name: nick } = getLocalParticipant(state) ?? {};
const options: any = { ...config };
@@ -229,7 +230,8 @@ export function getConferenceOptions(stateful: IStateful) {
}
options.applicationName = getName();
options.transcriptionLanguage = determineTranscriptionLanguage(options);
options.transcriptionLanguage
= defaultTranscriptionLanguage ?? determineTranscriptionLanguage(options);
// Disable analytics, if requested.
if (options.disableThirdPartyRequests) {

View File

@@ -404,6 +404,7 @@ export interface IConfig {
guestDialOutStatusUrl?: string;
guestDialOutUrl?: string;
helpCentreURL?: string;
hiddenDomain?: string;
hiddenPremeetingButtons?: Array<'microphone' | 'camera' | 'select-background' | 'invite' | 'settings'>;
hideAddRoomButton?: boolean;
hideConferenceSubject?: boolean;

View File

@@ -131,7 +131,7 @@ export default [
'displayJids',
'doNotStoreRoom',
'doNotFlipLocalVideo',
'dropbox',
'dropbox.appKey',
'e2eeLabels',
'e2ee',
'e2eping',
@@ -169,14 +169,12 @@ export default [
'hideRecordingLabel',
'hideParticipantsStats',
'hideConferenceTimer',
'hiddenDomain',
'hideAddRoomButton',
'hideEmailInSettings',
'hideLobbyButton',
'hosts',
'iAmRecorder',
'iAmSipGateway',
'iceTransportPolicy',
'ignoreStartMuted',
'inviteAppName',
'liveStreaming.enabled',
@@ -192,7 +190,11 @@ export default [
'notificationTimeouts',
'openSharedDocumentOnJoin',
'opusMaxAverageBitrate',
'p2p',
'p2p.backToP2PDelay',
'p2p.codecPreferenceOrder',
'p2p.enabled',
'p2p.iceTransportPolicy',
'p2p.mobileCodecPreferenceOrder',
'participantMenuButtonsWithNotifyClick',
'participantsPane',
'pcStatsInterval',

View File

@@ -1,6 +1,7 @@
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { getFeatureFlag } from '../flags/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
@@ -21,8 +22,12 @@ MiddlewareRegistry.register(store => next => action => {
case SET_CONFIG:
return _setConfig(store, next, action);
case SET_DYNAMIC_BRANDING_DATA:
return _setDynamicBrandingData(store, next, action);
case OVERWRITE_CONFIG:
return _updateSettings(store, next, action);
}
return next(action);
@@ -94,6 +99,79 @@ function _setConfig({ dispatch, getState }: IStore, next: Function, action: AnyA
return result;
}
/**
* Updates config based on dynamic branding data.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} in the specified {@code store}.
* @param {Action} action - The redux action which is being {@code dispatch}ed
* in the specified {@code store}.
* @private
* @returns {*} The return value of {@code next(action)}.
*/
function _setDynamicBrandingData({ dispatch }: IStore, next: Function, action: AnyAction) {
const config: IConfig = {};
const {
downloadAppsUrl,
liveStreamingDialogUrls = {},
preCallTest = {},
salesforceUrl,
userDocumentationUrl
} = action.value;
const { helpUrl, termsUrl, dataPrivacyUrl } = liveStreamingDialogUrls;
if (helpUrl || termsUrl || dataPrivacyUrl) {
config.liveStreaming = {};
if (helpUrl) {
config.liveStreaming.helpLink = helpUrl;
}
if (termsUrl) {
config.liveStreaming.termsLink = termsUrl;
}
if (dataPrivacyUrl) {
config.liveStreaming.dataPrivacyLink = dataPrivacyUrl;
}
}
if (downloadAppsUrl || userDocumentationUrl) {
config.deploymentUrls = {};
if (downloadAppsUrl) {
config.deploymentUrls.downloadAppsUrl = downloadAppsUrl;
}
if (userDocumentationUrl) {
config.deploymentUrls.userDocumentationURL = userDocumentationUrl;
}
}
if (salesforceUrl) {
config.salesforceUrl = salesforceUrl;
}
const { enabled, iceUrl } = preCallTest;
if (typeof enabled === 'boolean') {
config.prejoinConfig = {
preCallTestEnabled: enabled
};
}
if (iceUrl) {
config.prejoinConfig = config.prejoinConfig || {};
config.prejoinConfig.preCallTestICEUrl = iceUrl;
}
dispatch(updateConfig(config));
return next(action);
}
/**
* Updates settings based on some config values.
*

View File

@@ -176,12 +176,6 @@ export const PARTICIPANTS_ENABLED = 'participants.enabled';
*/
export const PIP_ENABLED = 'pip.enabled';
/**
* Flag indicating if Picture-in-Picture button should be shown while screen sharing.
* Default: disabled (false).
*/
export const PIP_WHILE_SCREEN_SHARING_ENABLED = 'pip-while-screen-sharing.enabled';
/**
* Flag indicating if the prejoin page should be enabled.
* Default: enabled (true).

View File

@@ -1,4 +1,5 @@
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { getConferenceState } from '../conference/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { I18NEXT_INITIALIZED, LANGUAGE_CHANGED } from './actionTypes';
@@ -28,6 +29,18 @@ MiddlewareRegistry.register(store => next => action => {
logger.log('Error setting dynamic language bundle', err);
});
}
// Update transcription language, if applicable.
if (action.type === SET_DYNAMIC_BRANDING_DATA) {
const { defaultTranscriptionLanguage } = action.value;
if (typeof defaultTranscriptionLanguage !== 'undefined') {
const { conference } = getConferenceState(store.getState());
conference?.setTranscriptionLanguage(defaultTranscriptionLanguage);
}
}
break;
}
}

View File

@@ -1,14 +1,11 @@
import { IReduxState, IStore } from '../../app/types';
import { setPictureInPictureEnabled } from '../../mobile/picture-in-picture/functions';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { PIP_WHILE_SCREEN_SHARING_ENABLED } from '../flags/constants';
import { getFeatureFlag } from '../flags/functions';
import JitsiMeetJS from '../lib-jitsi-meet';
import { setScreenshareMuted } from '../media/actions';
import { addLocalTrack, replaceLocalTrack } from './actions.any';
import { getLocalDesktopTrack, getTrackState, isLocalVideoTrackDesktop } from './functions.native';
import { getLocalDesktopTrack, getTrackState } from './functions.native';
export * from './actions.any';
@@ -26,14 +23,9 @@ export function toggleScreensharing(enabled: boolean, _ignore1?: boolean, _ignor
const state = getState();
if (enabled) {
const isSharing = isLocalVideoTrackDesktop(state);
if (!isSharing) {
_startScreenSharing(dispatch, state);
}
_startScreenSharing(dispatch, state);
} else {
dispatch(setScreenshareMuted(true));
setPictureInPictureEnabled(true);
}
};
}
@@ -47,12 +39,6 @@ export function toggleScreensharing(enabled: boolean, _ignore1?: boolean, _ignor
* @returns {void}
*/
async function _startScreenSharing(dispatch: IStore['dispatch'], state: IReduxState) {
const pipWhileScreenSharingEnabled = getFeatureFlag(state, PIP_WHILE_SCREEN_SHARING_ENABLED, false);
if (!pipWhileScreenSharingEnabled) {
setPictureInPictureEnabled(false);
}
try {
const tracks: any[] = await JitsiMeetJS.createLocalTracks({ devices: [ 'desktop' ] });
const track = tracks[0];
@@ -78,7 +64,5 @@ async function _startScreenSharing(dispatch: IStore['dispatch'], state: IReduxSt
}
} catch (error: any) {
console.log('ERROR creating screen-sharing stream ', error);
setPictureInPictureEnabled(true);
}
}

View File

@@ -359,8 +359,7 @@ export function createInitialAVTracks(options: ICreateInitialTracksOptions, reco
return (dispatch: IStore['dispatch'], _getState: IStore['getState']) => {
const {
devices,
timeout,
firePermissionPromptIsShownEvent
timeout
} = options;
dispatch(gumPending(devices, IGUMPendingState.PENDING_UNMUTE));
@@ -400,16 +399,14 @@ export function createInitialAVTracks(options: ICreateInitialTracksOptions, reco
if (devices.includes(MEDIA_TYPE.AUDIO)) {
gUMPromises.push(createLocalTracksF({
devices: [ MEDIA_TYPE.AUDIO ],
timeout,
firePermissionPromptIsShownEvent
timeout
}));
}
if (devices.includes(MEDIA_TYPE.VIDEO)) {
gUMPromises.push(createLocalTracksF({
devices: [ MEDIA_TYPE.VIDEO ],
timeout,
firePermissionPromptIsShownEvent
timeout
}));
}

View File

@@ -18,9 +18,6 @@ export * from './functions.any';
* @param {string|null} [options.micDeviceId] - Microphone device id or
* {@code undefined} to use app's settings.
* @param {number|undefined} [oprions.timeout] - A timeout for JitsiMeetJS.createLocalTracks used to create the tracks.
* @param {boolean} [options.firePermissionPromptIsShownEvent] - Whether lib-jitsi-meet
* should check for a {@code getUserMedia} permission prompt and fire a
* corresponding event.
* @param {IStore} store - The redux store in the context of which the function
* is to execute and from which state such as {@code config} is to be retrieved.
* @returns {Promise<JitsiLocalTrack[]>}

View File

@@ -32,9 +32,6 @@ export * from './functions.any';
* @param {string|null} [options.micDeviceId] - Microphone device id or
* {@code undefined} to use app's settings.
* @param {number|undefined} [oprions.timeout] - A timeout for JitsiMeetJS.createLocalTracks used to create the tracks.
* @param {boolean} [options.firePermissionPromptIsShownEvent] - Whether lib-jitsi-meet
* should check for a {@code getUserMedia} permission prompt and fire a
* corresponding event.
* @param {IStore} store - The redux store in the context of which the function
* is to execute and from which state such as {@code config} is to be retrieved.
* @param {boolean} recordTimeMetrics - If true time metrics will be recorded.
@@ -45,7 +42,6 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
const {
desktopSharingSourceDevice,
desktopSharingSources,
firePermissionPromptIsShownEvent,
timeout
} = options;
@@ -91,7 +87,6 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
effects,
facingMode: options.facingMode || getCameraFacingMode(state),
firefox_fake_device, // eslint-disable-line camelcase
firePermissionPromptIsShownEvent,
micDeviceId,
resolution,
timeout
@@ -146,7 +141,6 @@ export function createPrejoinTracks() {
if (requestedAudio || requestedVideo) {
tryCreateLocalTracks = createLocalTracksF({
devices: initialDevices,
firePermissionPromptIsShownEvent: true,
timeout
}, APP.store)
.catch(async (err: Error) => {
@@ -163,7 +157,6 @@ export function createPrejoinTracks() {
if (requestedAudio) {
gUMPromises.push(createLocalTracksF({
devices: [ MEDIA_TYPE.AUDIO ],
firePermissionPromptIsShownEvent: true,
timeout
}));
}
@@ -171,7 +164,6 @@ export function createPrejoinTracks() {
if (requestedVideo) {
gUMPromises.push(createLocalTracksF({
devices: [ MEDIA_TYPE.VIDEO ],
firePermissionPromptIsShownEvent: true,
timeout
}));
}

View File

@@ -15,7 +15,6 @@ export interface ITrackOptions {
desktopSharingSources?: string[];
devices?: string[];
facingMode?: string;
firePermissionPromptIsShownEvent?: boolean;
micDeviceId?: string | null;
timeout?: number;
}
@@ -76,7 +75,6 @@ export interface IShareOptions {
export interface ICreateInitialTracksOptions {
devices: Array<MediaType>;
firePermissionPromptIsShownEvent?: boolean;
timeout?: number;
}

View File

@@ -62,7 +62,7 @@ const IconButton: React.FC<IIconButtonProps> = ({
underlayColor = { underlayColor }>
<Icon
color = { color }
size = { 20 || size }
size = { size ?? 20 }
src = { src } />
</TouchableHighlight>
);

View File

@@ -16,7 +16,7 @@ import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
*/
export function notifyKickedOut(participant: any, submit?: Function) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!participant || participant?.isReplaced?.()) {
if (participant?.isReplaced?.()) {
submit?.();
return;
@@ -24,9 +24,9 @@ export function notifyKickedOut(participant: any, submit?: Function) {
dispatch(openDialog(AlertDialog, {
contentKey: {
key: 'dialog.kickTitle',
key: participant ? 'dialog.kickTitle' : 'dialog.kickSystemTitle',
params: {
participantDisplayName: getParticipantDisplayName(getState, participant.getId())
participantDisplayName: participant && getParticipantDisplayName(getState, participant.getId())
}
},
onSubmit: submit

View File

@@ -27,12 +27,17 @@ MiddlewareRegistry.register(store => next => action => {
const { dispatch } = store;
const { participant } = action;
// we need to first finish dispatching or the notification can be cleared out
const result = next(action);
const participantDisplayName
= getParticipantDisplayName(store.getState, participant.getId());
= participant && getParticipantDisplayName(store.getState, participant.getId());
dispatch(hangup(true, i18next.t('dialog.kickTitle', { participantDisplayName })));
dispatch(hangup(true,
participantDisplayName ? i18next.t('dialog.kickTitle', { participantDisplayName })
: i18next.t('dialog.kickSystemTitle')));
break;
return result;
}
}

View File

@@ -106,7 +106,8 @@ const StageParticipantNameLabel = () => {
classes.badgeContainer,
toolboxVisible && classes.containerElevated,
_isScreenShareParticipant && classes.screenSharing
) }>
) }
data-testid = 'stage-display-name' >
<DisplayNameBadge name = { nameToDisplay } />
</div>
);

View File

@@ -23,10 +23,14 @@ MiddlewareRegistry.register(store => next => action => {
backgroundImageUrl,
brandedIcons,
didPageUrl,
downloadAppsUrl,
inviteDomain,
labels,
liveStreamingDialogUrls,
salesforceUrl,
sharedVideoAllowedURLDomains,
supportUrl
supportUrl,
userDocumentationUrl
} = action.value;
action.value = {
@@ -35,10 +39,14 @@ MiddlewareRegistry.register(store => next => action => {
backgroundImageUrl,
brandedIcons,
didPageUrl,
downloadAppsUrl,
inviteDomain,
labels,
liveStreamingDialogUrls,
salesforceUrl,
sharedVideoAllowedURLDomains,
supportUrl
supportUrl,
userDocumentationUrl
};
// The backend may send an empty string, make sure we skip that.

View File

@@ -150,6 +150,7 @@ export interface IDynamicBrandingState {
customizationFailed: boolean;
customizationReady: boolean;
defaultBranding: boolean;
defaultTranscriptionLanguage?: boolean;
didPageUrl: string;
inviteDomain: string;
labels: Object | null;

View File

@@ -29,6 +29,7 @@ import { getBaseUrl } from '../base/util/helpers';
import { appendSuffix } from '../display-name/functions';
import { SUBMIT_FEEDBACK_ERROR, SUBMIT_FEEDBACK_SUCCESS } from '../feedback/actionTypes';
import { SET_FILMSTRIP_VISIBLE } from '../filmstrip/actionTypes';
import { iAmVisitor } from '../visitors/functions';
import './subscriber';
@@ -120,7 +121,8 @@ MiddlewareRegistry.register(store => next => action => {
),
avatarURL: loadableAvatarUrl,
breakoutRoom,
email
email,
visitor: iAmVisitor(state)
}
);
break;
@@ -151,8 +153,8 @@ MiddlewareRegistry.register(store => next => action => {
local: true
},
{
id: actor.getId(),
name: actor.getDisplayName()
id: actor?.getId(),
name: actor?.getDisplayName()
}
);
break;

View File

@@ -1,12 +1,9 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { PIP_WHILE_SCREEN_SHARING_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconArrowDown } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { isLocalVideoTrackDesktop } from '../../../base/tracks/functions.native';
import { enterPictureInPicture } from '../actions';
import { isPipEnabled } from '../functions';
@@ -58,9 +55,7 @@ class PictureInPictureButton extends AbstractButton<IProps> {
* }}
*/
function _mapStateToProps(state: IReduxState) {
const pipEnabled = isPipEnabled(state);
const pipWhileScreenSharingEnabled = getFeatureFlag(state, PIP_WHILE_SCREEN_SHARING_ENABLED, false);
const enabled = pipEnabled && (!isLocalVideoTrackDesktop(state) || pipWhileScreenSharingEnabled);
const enabled = isPipEnabled(state);
return {
_enabled: enabled

View File

@@ -1,13 +0,0 @@
/**
* The type of the Redux action which signals that the prompt for media
* permission is visible or not.
*
* {
* type: MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED,
* isVisible: {boolean},
* browser: {string}
* }
* @public
*/
export const MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED
= 'MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED';

View File

@@ -3,23 +3,6 @@ import { openDialog } from '../base/dialog/actions';
import PageReloadDialog from '../base/dialog/components/native/PageReloadDialog';
/**
* Signals that the prompt for media permission is visible or not.
*
* @param {boolean} _isVisible - If the value is true - the prompt for media
* permission is visible otherwise the value is false/undefined.
* @param {string} _browser - The name of the current browser.
* @public
* @returns {{
* type: MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED,
* browser: {string},
* isVisible: {boolean}
* }}
*/
export function mediaPermissionPromptVisibilityChanged(_isVisible: boolean, _browser: string) {
// Dummy.
}
/**
* Opens {@link PageReloadDialog}.
*

View File

@@ -1,26 +1,3 @@
import { MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED } from './actionTypes';
/**
* Signals that the prompt for media permission is visible or not.
*
* @param {boolean} isVisible - If the value is true - the prompt for media
* permission is visible otherwise the value is false/undefined.
* @param {string} browser - The name of the current browser.
* @public
* @returns {{
* type: MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED,
* browser: {string},
* isVisible: {boolean}
* }}
*/
export function mediaPermissionPromptVisibilityChanged(isVisible: boolean, browser: string) {
return {
type: MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED,
browser,
isVisible
};
}
/**
* Opens {@link PageReloadDialog}.

View File

@@ -1,123 +0,0 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate, translateToHTML } from '../../../base/i18n/functions';
import OverlayFrame from './OverlayFrame';
/**
* The type of the React {@code Component} props of
* {@link UserMediaPermissionsOverlay}.
*/
interface IProps extends WithTranslation {
_premeetingBackground?: any;
}
/**
* Implements a React Component for overlay with guidance how to proceed with
* gUM prompt.
*/
class UserMediaPermissionsOverlay extends Component<IProps> {
/**
* Determines whether this overlay needs to be rendered (according to a
* specific redux state). Called by {@link OverlayContainer}.
*
* @param {Object} state - The redux state.
* @returns {boolean} - If this overlay needs to be rendered, {@code true};
* {@code false}, otherwise.
*/
static needsRender(state: IReduxState) {
return state['features/overlay'].isMediaPermissionPromptVisible;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { _premeetingBackground, t } = this.props;
const style = _premeetingBackground ? {
background: _premeetingBackground,
backgroundPosition: 'center',
backgroundSize: 'cover'
} : {};
return (
<OverlayFrame style = { style }>
<div className = 'inlay'>
<span className = 'inlay__icon icon-microphone' />
<span className = 'inlay__icon icon-camera' />
<h3
aria-label = { t('startupoverlay.genericTitle') }
className = 'inlay__title'
role = 'alert' >
{
t('startupoverlay.genericTitle')
}
</h3>
<span
className = 'inlay__text'
role = 'alert' >
{
t('userMedia.grantPermissions')
}
</span>
</div>
<div className = 'policy overlay__policy'>
<p
className = 'policy__text'
role = 'alert'>
{ translateToHTML(t, 'startupoverlay.policyText') }
</p>
{
this._renderPolicyLogo()
}
</div>
</OverlayFrame>
);
}
/**
* Renders the policy logo.
*
* @private
* @returns {ReactElement|null}
*/
_renderPolicyLogo() {
const policyLogoSrc = interfaceConfig.POLICY_LOGO;
if (policyLogoSrc) {
return (
<div className = 'policy__logo'>
<img
alt = { this.props.t('welcomepage.logo.policyLogo') }
src = { policyLogoSrc } />
</div>
);
}
return null;
}
}
/**
* Maps (parts of) the redux state to the React {@code Component} props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The props passed to the component.
* @returns {Object}
*/
function mapStateToProps(state: IReduxState) {
const { premeetingBackground } = state['features/dynamic-branding'];
return {
_premeetingBackground: premeetingBackground
};
}
export default translate(connect(mapStateToProps)(UserMediaPermissionsOverlay));

View File

@@ -2,7 +2,6 @@ import { IReduxState } from '../app/types';
import PageReloadOverlay from './components/web/PageReloadOverlay';
import SuspendedOverlay from './components/web/SuspendedOverlay';
import UserMediaPermissionsOverlay from './components/web/UserMediaPermissionsOverlay';
/**
* Returns the overlay to be currently rendered.
@@ -13,8 +12,7 @@ import UserMediaPermissionsOverlay from './components/web/UserMediaPermissionsOv
export function getOverlayToRender(state: IReduxState) {
const overlays = [
PageReloadOverlay,
SuspendedOverlay,
UserMediaPermissionsOverlay
SuspendedOverlay
];
for (const overlay of overlays) {

View File

@@ -1,43 +0,0 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { assign } from '../base/redux/functions';
import { MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED } from './actionTypes';
export interface IOverlayState {
browser?: string;
isMediaPermissionPromptVisible?: boolean;
}
/**
* Reduces the redux actions of the feature overlay.
*
* FIXME: these pieces of state should probably be in a different place.
*/
ReducerRegistry.register<IOverlayState>('features/overlay', (state = {}, action): IOverlayState => {
switch (action.type) {
case MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED:
return _mediaPermissionPromptVisibilityChanged(state, action);
}
return state;
});
/**
* Reduces a specific redux action MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED of
* the feature overlay.
*
* @param {Object} state - The redux state of the feature overlay.
* @param {Action} action - The redux action to reduce.
* @private
* @returns {Object} The new state of the feature overlay after the reduction of
* the specified action.
*/
function _mediaPermissionPromptVisibilityChanged(
state: IOverlayState,
{ browser, isVisible }: { browser?: string; isVisible?: boolean; }) {
return assign(state, {
browser,
isMediaPermissionPromptVisible: isVisible
});
}

View File

@@ -274,7 +274,6 @@ export function showStartedRecordingNotification(
if (recordingSharingUrl
&& isVpaasMeeting(state)
&& iAmRecordingInitiator
&& showRecordingLink
&& !isSavingRecordingOnDropbox(state)) {
const region = getMeetingRegion(state);
const tenant = getVpaasTenant(state);
@@ -288,15 +287,17 @@ export function showStartedRecordingNotification(
}
// add the option to copy recording link
notifyProps.dialogProps = {
...notifyProps.dialogProps,
customActionNameKey: [ 'recording.copyLink' ],
customActionHandler: [ () => copyText(link) ],
titleKey: 'recording.on',
descriptionKey: 'recording.linkGenerated'
};
if (showRecordingLink) {
notifyProps.dialogProps = {
...notifyProps.dialogProps,
customActionNameKey: [ 'recording.copyLink' ],
customActionHandler: [ () => copyText(link) ],
titleKey: 'recording.on',
descriptionKey: 'recording.linkGenerated'
};
notifyProps.type = NOTIFICATION_TIMEOUT_TYPE.STICKY;
notifyProps.type = NOTIFICATION_TIMEOUT_TYPE.STICKY;
}
} catch (err) {
dispatch(showErrorNotification({
titleKey: 'recording.errorFetchingLink'

View File

@@ -4,7 +4,6 @@ import { connect } from 'react-redux';
// @ts-expect-error
import Filmstrip from '../../../../../modules/UI/videolayout/Filmstrip';
import { IReduxState } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import { getVerticalViewMaxWidth } from '../../../filmstrip/functions.web';
import { getToolboxHeight } from '../../../toolbox/functions.web';
import { isSharedVideoEnabled } from '../../functions';
@@ -39,11 +38,6 @@ interface IProps {
*/
isEnabled: boolean;
/**
* Is the video shared by the local user.
*/
isOwner: boolean;
/**
* Whether or not the user is actively resizing the filmstrip.
*/
@@ -124,17 +118,15 @@ class SharedVideo extends Component<IProps> {
* @returns {React$Element}
*/
render() {
const { isEnabled, isOwner, isResizing } = this.props;
const { isEnabled, isResizing } = this.props;
if (!isEnabled) {
return null;
}
const className = !isResizing && isOwner ? '' : 'disable-pointer';
return (
<div
className = { className }
className = { (isResizing && 'disable-pointer') || '' }
id = 'sharedVideo'
style = { this.getDimensions() }>
{this.getManager()}
@@ -152,19 +144,16 @@ class SharedVideo extends Component<IProps> {
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { ownerId, videoUrl } = state['features/shared-video'];
const { videoUrl } = state['features/shared-video'];
const { clientHeight, clientWidth } = state['features/base/responsive-ui'];
const { visible, isResizing } = state['features/filmstrip'];
const localParticipant = getLocalParticipant(state);
return {
clientHeight,
clientWidth,
filmstripVisible: visible,
filmstripWidth: getVerticalViewMaxWidth(state),
isEnabled: isSharedVideoEnabled(state),
isOwner: ownerId === localParticipant?.id,
isResizing,
videoUrl
};

View File

@@ -196,7 +196,7 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
// Regex to filter out all possible country codes after language code:
// this should catch all notations like 'en-GB' 'en_GB' and 'enGB'
// and be independent of the country code length
if (json.language.replace(/[-_A-Z].*/, '') !== language) {
if (_getPrimaryLanguageCode(json.language) !== _getPrimaryLanguageCode(language)) {
return next(action);
}
@@ -260,6 +260,17 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
return next(action);
}
/**
* Utility function to extract the primary language code like 'en-GB' 'en_GB'
* 'enGB' 'zh-CN' and 'zh-TW'.
*
* @param {string} language - The language to use for translation or user requested.
* @returns {string}
*/
function _getPrimaryLanguageCode(language: string) {
return language.replace(/[-_A-Z].*/, '');
}
/**
* Toggle the local property 'requestingTranscription'. This will cause Jicofo
* and Jigasi to decide whether the transcriber needs to be in the room.

View File

@@ -10,6 +10,7 @@ import styles from './styles';
interface IProps extends AbstractButtonProps {
backgroundColor?: string;
icon: any;
id?: string;
text: string;
@@ -21,6 +22,7 @@ interface IProps extends AbstractButtonProps {
* @returns {Component}
*/
class CustomOptionButton extends AbstractButton<IProps> {
backgroundColor = this.props.backgroundColor;
iconSrc = this.props.icon;
id = this.props.id;
text = this.props.text;
@@ -48,8 +50,7 @@ class CustomOptionButton extends AbstractButton<IProps> {
= (
<Image
source = {{ uri: this.iconSrc }}
style = { styles.iconImageStyles }
tintColor = { 'white' } />);
style = { styles.iconImageStyles } />);
}
return iconComponent;

View File

@@ -228,11 +228,10 @@ class OverflowMenu extends PureComponent<IProps, IState> {
return (
<>
{
_customToolbarButtons.map(({ id, text, icon, ...rest }) => (
_customToolbarButtons.map(({ id, text, icon, backgroundColor }) => (
<CustomOptionButton
{ ...rest }
{ ...topButtonProps }
backgroundColor = { backgroundColor }
/* eslint-disable react/jsx-no-bind */
handleClick = { () =>
dispatch(customOverflowMenuButtonPressed(id, text))

View File

@@ -2,6 +2,7 @@ import { IReduxState } from '../app/types';
import { isJwtFeatureEnabledStateless } from '../base/jwt/functions';
import { IGUMPendingState } from '../base/media/types';
import { IParticipantFeatures } from '../base/participants/types';
import { iAmVisitor } from '../visitors/functions';
/**
* Indicates if the audio mute button is disabled or not.
@@ -13,7 +14,8 @@ export function isAudioMuteButtonDisabled(state: IReduxState) {
const { available, muted, unmuteBlocked, gumPending } = state['features/base/media'].audio;
const { startSilent } = state['features/base/config'];
return Boolean(!available || startSilent || (muted && unmuteBlocked) || gumPending !== IGUMPendingState.NONE);
return Boolean(!available || startSilent || (muted && unmuteBlocked) || gumPending !== IGUMPendingState.NONE
|| iAmVisitor(state));
}
/**

View File

@@ -52,7 +52,7 @@ const hasCollabDetails = (state: IReduxState): boolean => Boolean(
* @returns {boolean}
*/
export const isWhiteboardEnabled = (state: IReduxState): boolean =>
(getWhiteboardConfig(state).enabled || hasCollabDetails(state))
(getWhiteboardConfig(state).enabled ?? hasCollabDetails(state))
&& getWhiteboardConfig(state).collabServerBaseUrl
&& getCurrentConference(state)?.getMetadataHandler()
?.isSupported();

View File

@@ -141,7 +141,7 @@ module:hook('muc-occupant-pre-join', function (event)
else
occupant.role = 'visitor';
end
elseif room.moderators_list:contains(resource) then
elseif room.moderators_list and room.moderators_list:contains(resource) then
-- remote participants, host is the main prosody
occupant.role = 'moderator';
end

View File

@@ -115,9 +115,10 @@ function handle_kick_participant (event)
end
local number = params["number"];
local participantId = params["participantId"];
if not number then
module:log("warn", "Missing number param");
if (not number and not participantId) or (number and participantId) then
module:log("warn", "Invalid parameters: exactly one of 'number' or 'participantId' must be provided.");
return { status_code = 400; };
end
@@ -134,10 +135,8 @@ function handle_kick_participant (event)
for _, occupant in room:each_occupant() do
local pr = occupant:get_presence();
local displayName = pr:get_child_text(
'nick', 'http://jabber.org/protocol/nick');
if is_sip_jigasi(pr) and displayName and starts_with(displayName, number) then
if is_participant_match(pr, number, participantId) then
room:set_role(true, occupant.nick, nil);
module:log('info', 'Occupant kicked %s from %s', occupant.nick, room.jid);
return { status_code = 200; }
@@ -148,6 +147,20 @@ function handle_kick_participant (event)
return { status_code = 404; };
end
function is_participant_match(pr, number, participantId)
if number then
local displayName = pr:get_child_text('nick', 'http://jabber.org/protocol/nick');
return is_sip_jigasi(pr) and displayName and starts_with(displayName, number);
elseif participantId then
local from = pr.attr.from;
local _, _, from_resource = jid.split(from);
if from_resource then
return from_resource == participantId;
end
end
return false;
end
module:log("info","Adding http handler for /kick-participant on %s", module.host);
module:depends("http");
module:provides("http", {

View File

@@ -115,7 +115,8 @@ module:hook("message/bare", function(event)
if room.polls.by_id[data.pollId] ~= nil then
module:log("error", "Poll already exists: %s", data.pollId);
return;
event.origin.send(st.error_reply(event.stanza, 'cancel', 'not-allowed', 'Poll already exists'));
return true;
end
local answers = {}

View File

@@ -1,4 +1,5 @@
# 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=
# To be able to match a domain to a specific address
@@ -13,3 +14,19 @@
# The path to the browser video capture file
#VIDEO_CAPTURE_FILE=tests/resources/FourPeople_1280x720_30.y4m
# The path to the helper iframe page that will be used for the iframeAPI tests
#IFRAME_PAGE_BASE=
# 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=
# 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=

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

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

View File

@@ -5,10 +5,15 @@ import { multiremotebrowser } from '@wdio/globals';
import { IConfig } from '../../react/features/base/config/configType';
import { urlObjectToString } from '../../react/features/base/util/uri';
import Filmstrip from '../pageobjects/Filmstrip';
import IframeAPI from '../pageobjects/IframeAPI';
import Notifications from '../pageobjects/Notifications';
import ParticipantsPane from '../pageobjects/ParticipantsPane';
import SettingsDialog from '../pageobjects/SettingsDialog';
import Toolbar from '../pageobjects/Toolbar';
import VideoQualityDialog from '../pageobjects/VideoQualityDialog';
import { LOG_PREFIX, logInfo } from './browserLogger';
import { IContext } from './participants';
import { IContext, IJoinOptions } from './types';
/**
* Participant.
@@ -19,9 +24,9 @@ export class Participant {
*
* @private
*/
private context: { roomName: string; };
private _name: string;
private _endpointId: string;
private _jwt?: string;
/**
* The default config to use when joining.
@@ -59,9 +64,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.
*/
constructor(name: string) {
constructor(name: string, jwt?: string) {
this._name = name;
this._jwt = jwt;
}
/**
@@ -69,7 +76,7 @@ export class Participant {
*
* @returns {Promise<string>} The endpoint ID.
*/
async getEndpointId() {
async getEndpointId(): Promise<string> {
if (!this._endpointId) {
this._endpointId = await this.driver.execute(() => { // eslint-disable-line arrow-body-style
return APP.conference.getMyUserId();
@@ -99,40 +106,70 @@ export class Participant {
* @param {string} message - The message to log.
* @returns {void}
*/
log(message: string) {
log(message: string): void {
logInfo(this.driver, message);
}
/**
* Joins conference.
*
* @param {IContext} context - The context.
* @param {boolean} skipInMeetingChecks - Whether to skip in meeting checks.
* @param {IContext} ctx - The context.
* @param {IJoinOptions} options - Options for joining.
* @returns {Promise<void>}
*/
async joinConference(context: IContext, skipInMeetingChecks = false) {
this.context = context;
const url = urlObjectToString({
room: context.roomName,
async joinConference(ctx: IContext, options: IJoinOptions = {}): Promise<void> {
const config = {
room: ctx.roomName,
configOverwrite: this.config,
interfaceConfigOverwrite: {
SHOW_CHROME_EXTENSION_BANNER: false
},
userInfo: {
displayName: this._name
}
}) || '';
};
if (!options.skipDisplayName) {
// @ts-ignore
config.userInfo = {
displayName: this._name
};
}
if (ctx.iframeAPI) {
config.room = 'iframeAPITest.html';
}
let url = urlObjectToString(config) || '';
if (ctx.iframeAPI) {
const baseUrl = new URL(this.driver.options.baseUrl || '');
// @ts-ignore
url = `${this.driver.iframePageBase}${url}&domain="${baseUrl.host}"&room="${ctx.roomName}"`;
if (baseUrl.pathname.length > 1) {
// remove leading slash
url = `${url}&tenant="${baseUrl.pathname.substring(1)}"`;
}
}
if (this._jwt) {
url = `${url}&jwt="${this._jwt}"`;
}
await this.driver.setTimeout({ 'pageLoad': 30000 });
await this.driver.url(url);
// drop the leading '/' so we can use the tenant if any
await this.driver.url(url.startsWith('/') ? url.substring(1) : url);
await this.waitForPageToLoad();
if (ctx.iframeAPI) {
const mainFrame = this.driver.$('iframe');
await this.driver.switchFrame(mainFrame);
}
await this.waitToJoinMUC();
await this.postLoadProcess(skipInMeetingChecks);
await this.postLoadProcess(options.skipInMeetingChecks);
}
/**
@@ -142,7 +179,7 @@ export class Participant {
* @returns {Promise<void>}
* @private
*/
private async postLoadProcess(skipInMeetingChecks: boolean) {
private async postLoadProcess(skipInMeetingChecks = false): Promise<void> {
const driver = this.driver;
const parallel = [];
@@ -189,9 +226,9 @@ export class Participant {
*
* @returns {Promise<void>}
*/
async waitForPageToLoad() {
async waitForPageToLoad(): Promise<void> {
return this.driver.waitUntil(
() => this.driver.execute(() => document.readyState === 'complete'),
async () => await this.driver.execute(() => document.readyState === 'complete'),
{
timeout: 30_000, // 30 seconds
timeoutMsg: 'Timeout waiting for Page Load Request to complete.'
@@ -199,14 +236,29 @@ export class Participant {
);
}
/**
* Checks if the participant is in the meeting.
*/
async isInMuc() {
return await this.driver.execute(() => typeof APP !== 'undefined' && APP.conference?.isJoined());
}
/**
* Checks if the participant is a moderator in the meeting.
*/
async isModerator() {
return await this.driver.execute(() => typeof APP !== 'undefined'
&& APP.store?.getState()['features/base/participants']?.local?.role === 'moderator');
}
/**
* Waits to join the muc.
*
* @returns {Promise<void>}
*/
async waitToJoinMUC() {
async waitToJoinMUC(): Promise<void> {
return this.driver.waitUntil(
() => this.driver.execute(() => APP.conference.isJoined()),
() => this.isInMuc(),
{
timeout: 10_000, // 10 seconds
timeoutMsg: 'Timeout waiting to join muc.'
@@ -219,11 +271,11 @@ export class Participant {
*
* @returns {Promise<void>}
*/
async waitForIceConnected() {
async waitForIceConnected(): Promise<void> {
const driver = this.driver;
return driver.waitUntil(async () =>
driver.execute(() => APP.conference.getConnectionState() === 'connected'), {
await driver.execute(() => APP.conference.getConnectionState() === 'connected'), {
timeout: 15_000,
timeoutMsg: 'expected ICE to be connected for 15s'
});
@@ -234,11 +286,11 @@ export class Participant {
*
* @returns {Promise<void>}
*/
async waitForSendReceiveData() {
async waitForSendReceiveData(): Promise<void> {
const driver = this.driver;
return driver.waitUntil(async () =>
driver.execute(() => {
await driver.execute(() => {
const stats = APP.conference.getStats();
const bitrateMap = stats?.bitrate || {};
const rtpStats = {
@@ -259,11 +311,11 @@ export class Participant {
* @param {number} number - The number of remote streams o wait for.
* @returns {Promise<void>}
*/
waitForRemoteStreams(number: number) {
waitForRemoteStreams(number: number): Promise<void> {
const driver = this.driver;
return driver.waitUntil(async () =>
driver.execute(count => APP.conference.getNumberOfParticipantsWithTracks() >= count, number), {
await driver.execute(count => APP.conference.getNumberOfParticipantsWithTracks() >= count, number), {
timeout: 15_000,
timeoutMsg: 'expected remote streams in 15s'
});
@@ -274,7 +326,7 @@ export class Participant {
*
* @returns {Toolbar}
*/
getToolbar() {
getToolbar(): Toolbar {
return new Toolbar(this);
}
@@ -283,7 +335,172 @@ export class Participant {
*
* @returns {Filmstrip}
*/
getFilmstrip() {
getFilmstrip(): Filmstrip {
return new Filmstrip(this);
}
/**
* Returns the notifications.
*/
getNotifications(): Notifications {
return new Notifications(this);
}
/**
* Returns the participants pane.
*
* @returns {ParticipantsPane}
*/
getParticipantsPane(): ParticipantsPane {
return new ParticipantsPane(this);
}
/**
* Returns the videoQuality Dialog.
*
* @returns {VideoQualityDialog}
*/
getVideoQualityDialog(): VideoQualityDialog {
return new VideoQualityDialog(this);
}
/**
* Returns the settings Dialog.
*
* @returns {SettingsDialog}
*/
getSettingsDialog(): SettingsDialog {
return new SettingsDialog(this);
}
/**
* Switches to the iframe API context
*/
async switchToAPI() {
await this.driver.switchFrame(null);
}
/**
* Switches to the meeting page context.
*/
async switchInPage() {
const mainFrame = this.driver.$('iframe');
await this.driver.switchFrame(mainFrame);
}
/**
* Returns the iframe API for this participant.
*/
getIframeAPI() {
return new IframeAPI(this);
}
/**
* Hangups the participant by leaving the page. base.html is an empty page on all deployments.
*/
async hangup() {
await this.driver.url('/base.html');
}
/**
* Returns the local display name.
*/
async getLocalDisplayName() {
const localVideoContainer = this.driver.$('span[id="localVideoContainer"]');
await localVideoContainer.moveTo();
const localDisplayName = localVideoContainer.$('span[id="localDisplayName"]');
return await localDisplayName.getText();
}
/**
* Gets avatar SRC attribute for the one displayed on local video thumbnail.
*/
async getLocalVideoAvatar() {
const avatar
= this.driver.$('//span[@id="localVideoContainer"]//img[contains(@class,"userAvatar")]');
return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
}
/**
* Gets avatar SRC attribute for the one displayed on large video.
*/
async getLargeVideoAvatar() {
const avatar = this.driver.$('//img[@id="dominantSpeakerAvatar"]');
return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
}
/**
* Returns resource part of the JID of the user who is currently displayed in the large video area.
*/
async getLargeVideoResource() {
return await this.driver.execute(() => APP.UI.getLargeVideoID());
}
/**
* Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed.
* There are 3 options for avatar:
* - defaultAvatar: true - the default avatar (with grey figure) is used
* - image: true - the avatar is an image set in the settings
* - defaultAvatar: false, image: false - the avatar is produced from the initials of the display name
*/
async assertThumbnailShowsAvatar(
participant: Participant, reverse = false, defaultAvatar = false, image = false): Promise<void> {
const id = participant === this
? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
const xpath = defaultAvatar
? `//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]`
: `//span[@id="${id}"]//${image ? 'img' : 'div'}[contains(@class,"userAvatar")]`;
await this.driver.$(xpath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Avatar is ${reverse ? '' : 'not'} displayed in the local thumbnail for ${participant.name}`
});
await this.driver.$(`//span[@id="${id}"]//video`).waitForDisplayed({
reverse: !reverse,
timeout: 2000,
timeoutMsg: `Video is ${reverse ? 'not' : ''} displayed in the local thumbnail for ${participant.name}`
});
}
/**
* Makes sure that the default avatar is used.
*/
async assertDefaultAvatarExist(participant: Participant): Promise<void> {
const id = participant === this
? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
await this.driver.$(
`//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]`)
.waitForExist({
timeout: 2000,
timeoutMsg: `Default avatar does not exist for ${participant.name}`
});
}
/**
* Makes sure that the local video is displayed in the local thumbnail and that the avatar is not displayed.
*/
async asserLocalThumbnailShowsVideo(): Promise<void> {
await this.assertThumbnailShowsAvatar(this, true);
}
/**
* Make sure a display name is visible on the stage.
* @param value
*/
async assertDisplayNameVisibleOnStage(value: string) {
const displayNameEl = this.driver.$('div[data-testid="stage-display-name"]');
expect(await displayNameEl.isDisplayed()).toBe(true);
expect(await displayNameEl.getText()).toBe(value);
}
}

View File

@@ -0,0 +1,129 @@
import WebSocket from 'ws';
/**
* Uses the webhook proxy service to proxy events to the testing clients.
*/
export default class WebhookProxy {
private url;
private secret;
private ws: WebSocket | undefined;
private cache = new Map();
private listeners = new Map();
private consumers = new Map();
/**
* Initializes the webhook proxy.
* @param url
* @param secret
*/
constructor(url: string, secret: string) {
this.url = url;
this.secret = secret;
}
/**
* Connects.
*/
connect() {
this.ws = new WebSocket(this.url, {
headers: {
Authorization: this.secret
}
});
this.ws.on('error', console.error);
this.ws.on('open', function open() {
console.log('WebhookProxy connected');
});
this.ws.on('message', (data: any) => {
const msg = JSON.parse(data.toString());
if (msg.eventType) {
if (this.consumers.has(msg.eventType)) {
this.consumers.get(msg.eventType)(msg);
this.consumers.delete(msg.eventType);
} else {
this.cache.set(msg.eventType, msg);
}
if (this.listeners.has(msg.eventType)) {
this.listeners.get(msg.eventType)(msg);
}
}
});
}
/**
* Adds event consumer. Consumers receive the event single time and we remove them from the list of consumers.
* @param eventType
* @param callback
*/
addConsumer(eventType: string, callback: (deventata: any) => void) {
if (this.cache.has(eventType)) {
callback(this.cache.get(eventType));
this.cache.delete(eventType);
return;
}
this.consumers.set(eventType, callback);
}
/**
* Clear any stored event.
*/
clearCache() {
this.cache.clear();
}
/**
* Waits for the event to be received.
* @param eventType
* @param timeout
*/
async waitForEvent(eventType: string, timeout = 4000): Promise<any> {
// we create the error here so we have a meaningful stack trace
const error = new Error(`Timeout waiting for event:${eventType}`);
return new Promise((resolve, reject) => {
const waiter = setTimeout(() => reject(error), timeout);
this.addConsumer(eventType, event => {
clearTimeout(waiter);
resolve(event);
});
});
}
/**
* Adds a listener for the event type.
* @param eventType
* @param callback
*/
addListener(eventType: string, callback: (data: any) => void) {
this.listeners.set(eventType, callback);
}
/**
* Adds a listener for the event type.
* @param eventType
*/
removeListener(eventType: string) {
this.listeners.delete(eventType);
}
/**
* Disconnects the webhook proxy.
*/
disconnect() {
if (this.ws) {
this.ws.close();
console.log('WebhookProxy disconnected');
this.ws = undefined;
}
}
}

View File

@@ -1,67 +1,141 @@
import fs from 'fs';
import jwt from 'jsonwebtoken';
import process from 'node:process';
import { v4 as uuidv4 } from 'uuid';
import { Participant } from './Participant';
export type IContext = {
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
roomName: string;
};
/**
* Generate a random room name.
*
* @returns {string} - The random room name.
*/
function generateRandomRoomName(): string {
return `jitsimeettorture-${crypto.randomUUID()}}`;
}
import { IContext, IJoinOptions } from './types';
/**
* Ensure that there is on participant.
*
* @param {IContext} context - The context.
* @param {IContext} ctx - The context.
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureOneParticipant(context: IContext): Promise<void> {
context.roomName = generateRandomRoomName();
export async function ensureOneParticipant(ctx: IContext, options?: IJoinOptions): Promise<void> {
ctx.p1 = new Participant('participant1');
context.p1 = new Participant('participant1');
await context.p1.joinConference(context, true);
await ctx.p1.joinConference(ctx, {
...options,
skipInMeetingChecks: true
});
}
/**
* Ensure that there are three participants.
*
* @param {Object} context - The context.
* @param {Object} ctx - The context.
* @returns {Promise<void>}
*/
export async function ensureThreeParticipants(context: IContext): Promise<void> {
context.roomName = generateRandomRoomName();
export async function ensureThreeParticipants(ctx: IContext): Promise<void> {
await joinTheModeratorAsP1(ctx);
const p1 = new Participant('participant1');
const p2 = new Participant('participant2');
const p3 = new Participant('participant3');
context.p1 = p1;
context.p2 = p2;
context.p3 = p3;
ctx.p2 = p2;
ctx.p3 = p3;
// these need to be all, so we get the error when one fails
await Promise.all([
p1.joinConference(context),
p2.joinConference(context),
p3.joinConference(context)
p2.joinConference(ctx),
p3.joinConference(ctx)
]);
await Promise.all([
p1.waitForRemoteStreams(2),
p2.waitForRemoteStreams(2),
p3.waitForRemoteStreams(2)
]);
}
/**
* Ensure that the first participant is moderator.
*
* @param {Object} ctx - The context.
* @param {IJoinOptions} options - The options to join.
* @returns {Promise<void>}
*/
async function joinTheModeratorAsP1(ctx: IContext, options?: IJoinOptions) {
const p1DisplayName = 'participant1';
let token;
// if it is jaas create the first one to be moderator and second not moderator
if (ctx.jwtPrivateKeyPath && !options?.skipFirstModerator) {
token = getModeratorToken(p1DisplayName);
}
// make sure the first participant is moderator, if supported by deployment
await _joinParticipant(p1DisplayName, ctx.p1, p => {
ctx.p1 = p;
}, {
...options,
skipInMeetingChecks: true
}, token);
}
/**
* Ensure that there are two participants.
*
* @param {Object} ctx - The context.
* @param {IJoinOptions} options - The options to join.
*/
export async function ensureTwoParticipants(ctx: IContext, options: IJoinOptions = {}): Promise<void> {
await joinTheModeratorAsP1(ctx, options);
const { skipInMeetingChecks } = options;
await Promise.all([
_joinParticipant('participant2', ctx.p2, p => {
ctx.p2 = p;
}, options),
skipInMeetingChecks ? Promise.resolve() : ctx.p1.waitForRemoteStreams(1),
skipInMeetingChecks ? Promise.resolve() : ctx.p2.waitForRemoteStreams(1)
]);
}
/**
* Creates a participant instance or prepares one for re-joining.
* @param name - The name of the participant.
* @param p - The participant instance to prepare or undefined if new one is needed.
* @param setter - The setter to use for setting the new participant instance into the context if needed.
* @param {boolean} options - Join options.
* @param {string?} jwtToken - The token to use if any.
*/
async function _joinParticipant( // eslint-disable-line max-params
name: string,
p: Participant,
setter: (p: Participant) => void,
options: IJoinOptions = {},
jwtToken?: string) {
if (p) {
if (ctx.iframeAPI) {
await p.switchInPage();
}
if (await p.isInMuc()) {
return;
}
if (ctx.iframeAPI) {
// when loading url make sure we are on the top page context or strange errors may occur
await p.switchToAPI();
}
// Change the page so we can reload same url if we need to, base.html is supposed to be empty or close to empty
await p.driver.url('/base.html');
// we want the participant instance re-recreated so we clear any kept state, like endpoint ID
}
const newParticipant = new Participant(name, jwtToken);
// set the new participant instance, pass it to setter
setter(newParticipant);
await newParticipant.joinConference(ctx, options);
}
/**
* Toggles the mute state of a specific Meet conference participant and verifies that a specific other Meet
* conference participants sees a specific mute state for the former.
@@ -72,9 +146,94 @@ export async function ensureThreeParticipants(context: IContext): Promise<void>
* the mute state of {@code testee}.
* @returns {Promise<void>}
*/
export async function toggleMuteAndCheck(testee: Participant, observer: Participant): Promise<void> {
export async function muteAudioAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickAudioMuteButton();
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
}
/**
* Unmute audio, checks if the local UI has been updated accordingly and then does the verification from
* the other observer participant perspective.
* @param testee
* @param observer
*/
export async function unmuteAudioAndCheck(testee: Participant, observer: Participant) {
await testee.getToolbar().clickAudioUnmuteButton();
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
}
/**
* Starts the video on testee and check on observer.
* @param testee
* @param observer
*/
export async function unmuteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickVideoUnmuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
}
/**
* Get a JWT token for a moderator.
*/
function getModeratorToken(displayName: string) {
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'
}
},
'room': '*'
};
// @ts-ignore
payload.context.user.moderator = true;
// @ts-ignore
return jwt.sign(payload, key, headers);
}
/**
* Parse a JID string.
* @param str the string to parse.
*/
export function parseJid(str: string): {
domain: string;
node: string;
resource: string | undefined;
} {
const parts = str.split('@');
const domainParts = parts[1].split('/');
return {
node: parts[0],
domain: domainParts[0],
resource: domainParts.length > 0 ? domainParts[1] : undefined
};
}

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

@@ -0,0 +1,34 @@
import type { Participant } from './Participant';
import WebhookProxy from './WebhookProxy';
export type IContext = {
conferenceJid: string;
iframeAPI: boolean;
jwtKid: string;
jwtPrivateKeyPath: string;
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
roomName: string;
skipSuiteTests: boolean;
webhooksProxy: WebhookProxy;
};
export type IJoinOptions = {
/**
* Whether to skip setting display name.
*/
skipDisplayName?: boolean;
/**
* Whether to skip setting the moderator role for the first participant (whether to use jwt for it).
*/
skipFirstModerator?: boolean;
/**
* Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
*/
skipInMeetingChecks?: boolean;
};

View File

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

View File

@@ -0,0 +1,34 @@
import { Participant } from '../helpers/Participant';
const CLOSE_BUTTON = 'modal-header-close-button';
const OK_BUTTON = 'modal-dialog-ok-button';
/**
* Base class for all dialogs.
*/
export default class BaseDialog {
participant: Participant;
/**
* Initializes for a participant.
*
* @param {Participant} participant - The participant.
*/
constructor(participant: Participant) {
this.participant = participant;
}
/**
* Clicks on the X (close) button.
*/
async clickCloseButton(): Promise<void> {
await this.participant.driver.$(`#${CLOSE_BUTTON}`).click();
}
/**
* Clicks on the ok button.
*/
async clickOkButton(): Promise<void> {
await this.participant.driver.$(`#${OK_BUTTON}`).click();
}
}

View File

@@ -1,5 +1,7 @@
import { Participant } from '../helpers/Participant';
import BaseDialog from './BaseDialog';
/**
* Filmstrip elements.
*/
@@ -20,12 +22,12 @@ export default class Filmstrip {
* mute icon for the conference participant identified by
* {@code testee}.
*
* @param {Participant} testee - The {@code WebParticipant} for whom we're checking the status of audio muted icon.
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
* otherwise, it will assert its presence.
* @returns {Promise<void>}
*/
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false) {
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
let id;
if (testee === this.participant) {
@@ -40,7 +42,93 @@ export default class Filmstrip {
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Audio mute icon is not displayed for ${testee.name}`
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name}`
});
}
/**
* Returns the remote display name for an endpoint.
* @param endpointId The endpoint id.
*/
async getRemoteDisplayName(endpointId: string) {
const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}_name"]`);
await remoteDisplayName.moveTo();
return await remoteDisplayName.getText();
}
/**
* Pins a participant by clicking on their thumbnail.
* @param participant The participant.
*/
async pinParticipant(participant: Participant) {
const id = participant === this.participant
? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
await this.participant.driver.$(`//span[@id="${id}"]`).click();
}
/**
* Gets avatar SRC attribute for the one displayed on small video thumbnail.
* @param endpointId
*/
async getAvatar(endpointId: string) {
const elem = this.participant.driver.$(
`//span[@id='participant_${endpointId}']//img[contains(@class,'userAvatar')]`);
return await elem.isExisting() ? elem.getAttribute('src') : null;
}
/**
* Grants moderator rights to a participant.
* @param participant
*/
async grantModerator(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'grantmoderatorlink', true);
}
/**
* Clicks on the link in the remote participant actions menu.
* @param participantId
* @param linkClassname
* @param dialogConfirm
* @private
*/
private async clickOnRemoteMenuLink(participantId: string, linkClassname: string, dialogConfirm: boolean) {
const thumbnail = this.participant.driver.$(
`//span[@id='participant_${participantId}']//span[@id='remotevideomenu']`);
await thumbnail.moveTo();
const popoverElement = this.participant.driver.$(
`//div[contains(@class, 'popover')]//div[contains(@class, '${linkClassname}')]`);
await popoverElement.waitForDisplayed();
await popoverElement.click();
if (dialogConfirm) {
await new BaseDialog(this.participant).clickOkButton();
}
}
/**
* Mutes the audio of a participant.
* @param participant
*/
async muteAudio(participant: Participant) {
const participantId = await participant.getEndpointId();
await this.participant.driver.$(`#participant-item-${participantId}`).moveTo();
await this.participant.driver.$(`button[data-testid="mute-audio-${participantId}"]`).click();
}
/**
* Mutes the video of a participant.
* @param participant
*/
async muteVideo(participant: Participant) {
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutevideolink', true);
}
}

View File

@@ -0,0 +1,91 @@
import { Participant } from '../helpers/Participant';
import { LOG_PREFIX } from '../helpers/browserLogger';
/**
* The Iframe API and helpers from iframeAPITest.html
*/
export default class IframeAPI {
private participant: Participant;
/**
* Initializes for a participant.
* @param participant
*/
constructor(participant: Participant) {
this.participant = participant;
}
/**
* Returns the json object from the iframeAPI helper.
* @param event
*/
async getEventResult(event: string): Promise<any> {
return await this.participant.driver.execute(
eventName => {
const result = window.jitsiAPI.test[eventName];
if (!result) {
return false;
}
return result;
}, event);
}
/**
* Adds an event listener to the iframeAPI.
* @param eventName The event name.
*/
async addEventListener(eventName: string) {
return await this.participant.driver.execute(
(event, prefix) => {
console.log(`${new Date().toISOString()} ${prefix} Adding listener for event: ${event}`);
window.jitsiAPI.addListener(event, evt => {
console.log(
`${new Date().toISOString()} ${prefix} Received ${event} event: ${JSON.stringify(evt)}`);
window.jitsiAPI.test[event] = evt;
});
}, eventName, LOG_PREFIX);
}
/**
* Returns an array of available rooms and details of it.
*/
async getRoomsInfo() {
return await this.participant.driver.execute(() => window.jitsiAPI.getRoomsInfo());
}
/**
* Returns the number of participants in the conference.
*/
async getNumberOfParticipants() {
return await this.participant.driver.execute(() => window.jitsiAPI.getNumberOfParticipants());
}
/**
* Executes command using iframeAPI.
* @param command The command.
* @param args The arguments.
*/
async executeCommand(command: string, ...args: any[]) {
return await this.participant.driver.execute(
(commandName, commandArgs) =>
window.jitsiAPI.executeCommand(commandName, ...commandArgs)
, command, args);
}
/**
* Returns the current state of the participant's pane.
*/
async isParticipantsPaneOpen() {
return await this.participant.driver.execute(() => window.jitsiAPI.isParticipantsPaneOpen());
}
/**
* Removes the embedded Jitsi Meet conference.
*/
async dispose() {
return await this.participant.driver.execute(() => window.jitsiAPI.dispose());
}
}

View File

@@ -0,0 +1,54 @@
import { Participant } from '../helpers/Participant';
const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute';
const JOIN_ONE_TEST_ID = 'notify.connectedOneMember';
const JOIN_TWO_TEST_ID = 'notify.connectedTwoMembers';
const JOIN_MULTIPLE_TEST_ID = 'notify.connectedThreePlusMembers';
const RAISE_HAND_NOTIFICATION_ID = 'notify.raisedHand';
/**
* Gathers all notifications logic in the UI and obtaining those.
*/
export default class Notifications {
private participant: Participant;
/**
* Represents the Audio Video Moderation menu in the participants pane.
* @param participant
*/
constructor(participant: Participant) {
this.participant = participant;
}
/**
* Waits for the raised hand notification to be displayed.
* The notification on moderators page when the participant tries to unmute.
*/
async waitForRaisedHandNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${RAISE_HAND_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* The notification on participants page when the moderator asks to unmute.
*/
async waitForAskToUnmuteNotification() {
const displayNameEl
= this.participant.driver.$(`div[data-testid="${ASK_TO_UNMUTE_NOTIFICATION_ID}"]`);
await displayNameEl.waitForExist({ timeout: 2000 });
await displayNameEl.waitForDisplayed();
}
/**
* Dismisses any join notifications.
*/
async dismissAnyJoinNotification() {
await Promise.allSettled(
[ `${JOIN_ONE_TEST_ID}-dismiss`, `${JOIN_TWO_TEST_ID}-dismiss`, `${JOIN_MULTIPLE_TEST_ID}-dismiss` ]
.map(async id => this.participant.driver.$(`#${id}"]`).click()));
}
}

View File

@@ -0,0 +1,165 @@
import { Participant } from '../helpers/Participant';
import AVModerationMenu from './AVModerationMenu';
/**
* Classname of the closed/hidden participants pane
*/
const PARTICIPANTS_PANE = 'participants_pane';
/**
* Represents the participants pane from the UI.
*/
export default class ParticipantsPane {
private participant: Participant;
/**
* Initializes for a participant.
*
* @param {Participant} participant - The participant.
*/
constructor(participant: Participant) {
this.participant = participant;
}
/**
* Gets the audio video moderation menu.
*/
getAVModerationMenu() {
return new AVModerationMenu(this.participant);
}
/**
* Checks if the pane is open.
*/
async isOpen() {
return this.participant.driver.$(`.${PARTICIPANTS_PANE}`).isExisting();
}
/**
* Clicks the "participants" toolbar button to open the participants pane.
*/
async open() {
await this.participant.getToolbar().clickParticipantsPaneButton();
const pane = this.participant.driver.$(`.${PARTICIPANTS_PANE}`);
await pane.waitForExist();
await pane.waitForStable();
await pane.waitForDisplayed();
}
/**
* Clicks the "participants" toolbar button to close the participants pane.
*/
async close() {
await this.participant.getToolbar().clickCloseParticipantsPaneButton();
await this.participant.driver.$(`.${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true });
}
/**
* Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant
* identified by {@code testee}.
*
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
* otherwise, it will assert its presence.
* @returns {Promise<void>}
*/
async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
const isOpen = await this.isOpen();
if (!isOpen) {
await this.open();
}
const id = `participant-item-${await testee.getEndpointId()}`;
const mutedIconXPath
= `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='videoMuted']`;
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
reverse,
timeout: 2000,
timeoutMsg: `Video mute icon is${reverse ? '' : ' not'} displayed for ${testee.name}`
});
if (!isOpen) {
await this.close();
}
}
/**
* Clicks the context menu button in the participants pane.
*/
async clickContextMenuButton() {
if (!await this.isOpen()) {
await this.open();
}
const menu = this.participant.driver.$('#participants-pane-context-menu');
await menu.waitForDisplayed();
await menu.click();
}
/**
* Trys to click allow video button.
* @param participantToUnmute
*/
async allowVideo(participantToUnmute: Participant) {
if (!await this.isOpen()) {
await this.open();
}
const participantId = await participantToUnmute.getEndpointId();
const participantItem = this.participant.driver.$(`#participant-item-${participantId}`);
await participantItem.waitForExist();
await participantItem.moveTo();
const unmuteButton = this.participant.driver
.$(`button[data-testid="unmute-video-${participantId}"]`);
await unmuteButton.waitForExist();
await unmuteButton.click();
}
/**
* Trys to click ask to unmute button.
* @param participantToUnmute
* @param fromContextMenu
*/
async askToUnmute(participantToUnmute: Participant, fromContextMenu: boolean) {
if (!await this.isOpen()) {
await this.open();
}
await this.participant.getNotifications().dismissAnyJoinNotification();
const participantId = await participantToUnmute.getEndpointId();
const participantItem = this.participant.driver.$(`#participant-item-${participantId}`);
await participantItem.waitForExist();
await participantItem.waitForStable();
await participantItem.waitForDisplayed();
await participantItem.moveTo();
if (fromContextMenu) {
const meetingParticipantMoreOptions = this.participant.driver
.$(`[data-testid="participant-more-options-${participantId}"]`);
await meetingParticipantMoreOptions.waitForExist();
await meetingParticipantMoreOptions.waitForDisplayed();
await meetingParticipantMoreOptions.waitForStable();
await meetingParticipantMoreOptions.moveTo();
await meetingParticipantMoreOptions.click();
}
const unmuteButton = this.participant.driver
.$(`[data-testid="unmute-audio-${participantId}"]`);
await unmuteButton.waitForExist();
await unmuteButton.click();
}
}

View File

@@ -0,0 +1,62 @@
import BaseDialog from './BaseDialog';
const EMAIL_FIELD = '#setEmail';
const SETTINGS_DIALOG_CONTENT = '.settings-pane';
const X_PATH_PROFILE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Profile"]';
/**
* The settings dialog.
*/
export default class SettingsDialog extends BaseDialog {
/**
* Waits for the settings dialog to be visible.
*/
async waitForDisplay() {
await this.participant.driver.$(SETTINGS_DIALOG_CONTENT).waitForDisplayed();
}
/**
* Displays a specific tab in the settings dialog.
* @param xpath
* @private
*/
private async openTab(xpath: string) {
const elem = this.participant.driver.$(xpath);
await elem.waitForClickable();
await elem.click();
}
/**
* Selects the Profile tab to be displayed.
*/
async openProfileTab() {
await this.openTab(X_PATH_PROFILE_TAB);
}
/**
* Enters the passed in email into the email field.
* @param email
*/
async setEmail(email: string) {
await this.openProfileTab();
await this.participant.driver.$(EMAIL_FIELD).setValue(email);
}
/**
* Returns the participant's email displayed in the settings dialog.
*/
async getEmail() {
await this.openProfileTab();
return await this.participant.driver.$(EMAIL_FIELD).getValue();
}
/**
* Clicks the OK button on the settings dialog to close the dialog and save any changes made.
*/
async submit() {
await this.clickOkButton();
}
}

View File

@@ -3,6 +3,15 @@ import { Participant } from '../helpers/Participant';
const AUDIO_MUTE = 'Mute microphone';
const AUDIO_UNMUTE = 'Unmute microphone';
const CLOSE_PARTICIPANTS_PANE = 'Close participants pane';
const OVERFLOW_MENU = 'More actions menu';
const OVERFLOW = 'More actions';
const PARTICIPANTS = 'Open participants pane';
const PROFILE = 'Edit your profile';
const RAISE_HAND = 'Raise your hand';
const VIDEO_QUALITY = 'Manage video quality';
const VIDEO_MUTE = 'Stop camera';
const VIDEO_UNMUTE = 'Start camera';
/**
* The toolbar elements.
@@ -49,8 +58,8 @@ export default class Toolbar {
*
* @returns {Promise<void>}
*/
async clickAudioMuteButton() {
await this.participant.log('Clicking on: Audio Mute Button');
async clickAudioMuteButton(): Promise<void> {
this.participant.log('Clicking on: Audio Mute Button');
await this.audioMuteBtn.click();
}
@@ -59,8 +68,168 @@ export default class Toolbar {
*
* @returns {Promise<void>}
*/
async clickAudioUnmuteButton() {
await this.participant.log('Clicking on: Audio Unmute Button');
async clickAudioUnmuteButton(): Promise<void> {
this.participant.log('Clicking on: Audio Unmute Button');
await this.audioUnMuteBtn.click();
}
/**
* The video mute button.
*/
get videoMuteBtn() {
return this.getButton(VIDEO_MUTE);
}
/**
* The video unmute button.
*/
get videoUnMuteBtn() {
return this.getButton(VIDEO_UNMUTE);
}
/**
* Clicks video mute button.
*
* @returns {Promise<void>}
*/
async clickVideoMuteButton(): Promise<void> {
this.participant.log('Clicking on: Video Mute Button');
await this.videoMuteBtn.click();
}
/**
* Clicks video unmute button.
*
* @returns {Promise<void>}
*/
async clickVideoUnmuteButton(): Promise<void> {
this.participant.log('Clicking on: Video Unmute Button');
await this.videoUnMuteBtn.click();
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
async clickCloseParticipantsPaneButton(): Promise<void> {
this.participant.log('Clicking on: Close Participants pane Button');
await this.getButton(CLOSE_PARTICIPANTS_PANE).click();
}
/**
* Clicks Participants pane button.
*
* @returns {Promise<void>}
*/
async clickParticipantsPaneButton(): Promise<void> {
this.participant.log('Clicking on: Participants pane Button');
await this.getButton(PARTICIPANTS).click();
}
/**
* Clicks on the video quality toolbar button which opens the
* dialog for adjusting max-received video quality.
*/
async clickVideoQualityButton(): Promise<void> {
return this.clickButtonInOverflowMenu(VIDEO_QUALITY);
}
/**
* Clicks on the profile toolbar button which opens or closes the profile panel.
*/
async clickProfileButton(): Promise<void> {
return this.clickButtonInOverflowMenu(PROFILE);
}
/**
* Clicks on the raise hand button that enables participants will to speak.
*/
async clickRaiseHandButton(): Promise<void> {
this.participant.log('Clicking on: Raise hand Button');
await this.getButton(RAISE_HAND).click();
}
/**
* Ensure the overflow menu is open and clicks on a specified button.
* @param accessibilityLabel The accessibility label of the button to be clicked.
* @private
*/
private async clickButtonInOverflowMenu(accessibilityLabel: string) {
await this.openOverflowMenu();
this.participant.log(`Clicking on: ${accessibilityLabel}`);
await this.getButton(accessibilityLabel).click();
await this.closeOverflowMenu();
}
/**
* Checks if the overflow menu is open and visible.
* @private
*/
private async isOverflowMenuOpen() {
return await this.participant.driver.$$(`[aria-label^="${OVERFLOW_MENU}"]`).length > 0;
}
/**
* Clicks on the overflow toolbar button which opens or closes the overflow menu.
* @private
*/
private async clickOverflowButton(): Promise<void> {
await this.getButton(OVERFLOW).click();
}
/**
* Ensure the overflow menu is displayed.
* @private
*/
private async openOverflowMenu() {
if (await this.isOverflowMenuOpen()) {
return;
}
await this.clickOverflowButton();
await this.waitForOverFlowMenu(true);
}
/**
* Ensures the overflow menu is not displayed.
* @private
*/
private async closeOverflowMenu() {
if (!await this.isOverflowMenuOpen()) {
return;
}
await this.clickOverflowButton();
await this.waitForOverFlowMenu(false);
}
/**
* Waits for the overflow menu to be visible or hidden.
* @param visible
* @private
*/
private async waitForOverFlowMenu(visible: boolean) {
await this.participant.driver.$(`[aria-label^="${OVERFLOW_MENU}"]`).waitForDisplayed({
reverse: !visible,
timeout: 3000,
timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}`
});
}
/**
* Gets the participant's avatar image element located in the toolbar.
*/
async getProfileImage() {
await this.openOverflowMenu();
const elem = this.participant.driver.$(`[aria-label^="${PROFILE}"] img`);
return await elem.isExisting() ? await elem.getAttribute('src') : null;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,418 @@
import { isEqual } from 'lodash-es';
import type { Participant } from '../../helpers/Participant';
import { ensureTwoParticipants, parseJid } from '../../helpers/participants';
/**
* Tests PARTICIPANT_LEFT webhook.
*/
async function checkParticipantLeftHook(p: Participant, reason: string) {
const { webhooksProxy } = ctx;
if (webhooksProxy) {
// PARTICIPANT_LEFT webhook
// @ts-ignore
const event: {
data: {
conference: string;
disconnectReason: string;
isBreakout: boolean;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.conferenceJid);
expect(event.data.disconnectReason).toBe(reason);
expect(event.data.isBreakout).toBe(false);
expect(event.data.participantId).toBe(await p.getEndpointId());
}
}
describe('Participants presence - ', () => {
it('joining the meeting', async () => {
// ensure 2 participants one moderator and one guest, we will load both with iframeAPI
await ensureTwoParticipants(ctx);
const { p1, p2, webhooksProxy } = ctx;
// let's populate endpoint ids
await Promise.all([
p1.getEndpointId(),
p2.getEndpointId()
]);
await p1.switchToAPI();
await p2.switchToAPI();
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p2.getIframeAPI().getEventResult('isModerator')).toBe(false);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
if (webhooksProxy) {
// USAGE webhook
// @ts-ignore
const event: {
data: [
{ participantId: string; }
];
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect('USAGE').toBe(event.eventType);
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
expect(event.data.filter(d => d.participantId === p1EpId
|| d.participantId === p2EpId).length).toBe(2);
}
// we will use it later
// TODO figure out why adding those just before grantModerator and we miss the events
await p1.getIframeAPI().addEventListener('participantRoleChanged');
await p2.getIframeAPI().addEventListener('participantRoleChanged');
});
it('participants info',
async () => {
const { p1, roomName, webhooksProxy } = ctx;
const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0];
expect(roomsInfo).toBeDefined();
expect(roomsInfo.isMainRoom).toBe(true);
expect(roomsInfo.id).toBeDefined();
const { node: roomNode } = parseJid(roomsInfo.id);
expect(roomNode).toBe(roomName);
const { node, resource } = parseJid(roomsInfo.jid);
ctx.conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/'));
const p1EpId = await p1.getEndpointId();
expect(node).toBe(roomName);
expect(resource).toBe(p1EpId);
expect(roomsInfo.participants.length).toBe(2);
expect(await p1.getIframeAPI().getNumberOfParticipants()).toBe(2);
if (webhooksProxy) {
// ROOM_CREATED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_CREATED');
expect('ROOM_CREATED').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.conferenceJid);
expect(event.data.isBreakout).toBe(false);
}
}
);
it('participants pane', async () => {
const { p1 } = ctx;
await p1.switchToAPI();
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
await p1.getIframeAPI().addEventListener('participantsPaneToggled');
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', true);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(true);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(true);
await p1.getIframeAPI().executeCommand('toggleParticipantsPane', false);
expect(await p1.getIframeAPI().isParticipantsPaneOpen()).toBe(false);
expect((await p1.getIframeAPI().getEventResult('participantsPaneToggled'))?.open).toBe(false);
});
it('grant moderator', async () => {
const { p1, p2, webhooksProxy } = ctx;
const p2EpId = await p2.getEndpointId();
await p1.getIframeAPI().executeCommand('grantModerator', p2EpId);
await p2.driver.waitUntil(async () => await p2.getIframeAPI().getEventResult('isModerator'), {
timeout: 3000,
timeoutMsg: 'Moderator role not granted'
});
const event1 = await p1.getIframeAPI().getEventResult('participantRoleChanged');
expect(event1?.id).toBe(p2EpId);
expect(event1?.role).toBe('moderator');
const event2 = await p2.getIframeAPI().getEventResult('participantRoleChanged');
expect(event2?.id).toBe(p2EpId);
expect(event2?.role).toBe('moderator');
if (webhooksProxy) {
// ROLE_CHANGED webhook
// @ts-ignore
const event: {
data: {
grantedBy: {
participantId: string;
};
grantedTo: {
participantId: string;
};
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROLE_CHANGED');
expect('ROLE_CHANGED').toBe(event.eventType);
expect(event.data.role).toBe('moderator');
expect(event.data.grantedBy.participantId).toBe(await p1.getEndpointId());
expect(event.data.grantedTo.participantId).toBe(await p2.getEndpointId());
}
});
it('kick participant', async () => {
const { p1, p2, roomName } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
await p1.switchInPage();
await p2.switchInPage();
const p1DisplayName = await p1.getLocalDisplayName();
const p2DisplayName = await p2.getLocalDisplayName();
await p1.switchToAPI();
await p2.switchToAPI();
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
const eventP1 = await p1.driver.waitUntil(async () =>
await p1.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on participant1 side'
});
const eventP2 = await p2.driver.waitUntil(async () =>
await p2.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on participant2 side'
});
await checkParticipantLeftHook(p2, 'kicked');
expect(eventP1).toBeDefined();
expect(eventP2).toBeDefined();
expect(isEqual(eventP1, {
kicked: {
id: p2EpId,
local: false,
name: p2DisplayName
},
kicker: {
id: p1EpId,
local: true,
name: p1DisplayName
}
})).toBe(true);
expect(isEqual(eventP2, {
kicked: {
id: 'local',
local: true,
name: p2DisplayName
},
kicker: {
id: p1EpId,
name: p1DisplayName
}
})).toBe(true);
const eventConferenceLeftP2 = await p2.driver.waitUntil(async () =>
await p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 2000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
});
it('join after kick', async () => {
const { p1, webhooksProxy } = ctx;
await p1.getIframeAPI().addEventListener('participantJoined');
await p1.getIframeAPI().addEventListener('participantMenuButtonClick');
webhooksProxy?.clearCache();
// join again
await ensureTwoParticipants(ctx);
const { p2 } = ctx;
if (webhooksProxy) {
// PARTICIPANT_JOINED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
moderator: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.conference).toBe(ctx.conferenceJid);
expect(event.data.isBreakout).toBe(false);
expect(event.data.moderator).toBe(false);
expect(event.data.name).toBe(await p2.getLocalDisplayName());
expect(event.data.participantId).toBe(await p2.getEndpointId());
}
await p1.switchToAPI();
const event = await p1.driver.waitUntil(async () =>
await p1.getIframeAPI().getEventResult('participantJoined'), {
timeout: 2000,
timeoutMsg: 'participantJoined not received'
});
const p2DisplayName = await p2.getLocalDisplayName();
expect(event).toBeDefined();
expect(event.id).toBe(await p2.getEndpointId());
expect(event.displayName).toBe(p2DisplayName);
expect(event.formattedDisplayName).toBe(p2DisplayName);
});
it('overwrite names', async () => {
const { p1, p2 } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const newP1Name = 'p1';
const newP2Name = 'p2';
const newNames: ({ id: string; name: string; })[] = [ {
id: p2EpId,
name: newP2Name
}, {
id: p1EpId,
name: newP1Name
} ];
await p1.getIframeAPI().executeCommand('overwriteNames', newNames);
await p1.switchInPage();
expect(await p1.getLocalDisplayName()).toBe(newP1Name);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EpId)).toBe(newP2Name);
});
it('hangup', async () => {
const { p1, p2, roomName } = ctx;
await p1.switchToAPI();
await p2.switchToAPI();
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('readyToClose');
await p2.getIframeAPI().executeCommand('hangup');
const eventConferenceLeftP2 = await p2.driver.waitUntil(async () =>
await p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 2000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
await checkParticipantLeftHook(p2, 'left');
const eventReadyToCloseP2 = await p2.driver.waitUntil(async () =>
await p2.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToCloseP2).toBeDefined();
});
it('dispose conference', async () => {
const { conferenceJid, p1, roomName, webhooksProxy } = ctx;
await p1.switchToAPI();
await p1.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('readyToClose');
await p1.getIframeAPI().executeCommand('hangup');
const eventConferenceLeft = await p1.driver.waitUntil(async () =>
await p1.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 2000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeft).toBeDefined();
expect(eventConferenceLeft.roomName).toBe(roomName);
await checkParticipantLeftHook(p1, 'left');
if (webhooksProxy) {
// ROOM_DESTROYED webhook
// @ts-ignore
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_DESTROYED');
expect('ROOM_DESTROYED').toBe(event.eventType);
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
}
const eventReadyToClose = await p1.driver.waitUntil(async () =>
await p1.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToClose).toBeDefined();
// dispose
await p1.getIframeAPI().dispose();
// check there is no iframe on the page
await p1.driver.$('iframe').waitForExist({
reverse: true,
timeout: 2000,
timeoutMsg: 'iframe is still on the page'
});
});
});

View File

@@ -1,35 +1,35 @@
/* global APP */
import type { Participant } from '../../helpers/Participant';
import { IContext, ensureThreeParticipants, toggleMuteAndCheck } from '../../helpers/participants';
import { ensureThreeParticipants, muteAudioAndCheck } from '../../helpers/participants';
describe('ActiveSpeaker ', () => {
const context = {} as IContext;
it('testActiveSpeaker', async () => {
await ensureThreeParticipants(context);
await ensureThreeParticipants(ctx);
await toggleMuteAndCheck(context.p1, context.p2);
await toggleMuteAndCheck(context.p2, context.p1);
await toggleMuteAndCheck(context.p3, context.p1);
const { p1, p2, p3 } = ctx;
await muteAudioAndCheck(p1, p2);
await muteAudioAndCheck(p2, p1);
await muteAudioAndCheck(p3, p1);
// participant1 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(context.p1, context.p2, context.p3);
await testActiveSpeaker(p1, p2, p3);
// participant3 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(context.p3, context.p2, context.p1);
await testActiveSpeaker(p3, p2, p1);
// participant2 becomes active speaker - check from participant1's perspective
await testActiveSpeaker(context.p2, context.p1, context.p3);
await testActiveSpeaker(p2, p1, p3);
// check the displayed speakers, there should be only one speaker
await assertOneDominantSpeaker(context.p1);
await assertOneDominantSpeaker(context.p2);
await assertOneDominantSpeaker(context.p3);
await assertOneDominantSpeaker(p1);
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
});
});
/**
* Tries to make given participant an active speaker by un-muting it.
* Tries to make given participant an active speaker by unmuting it.
* Verifies from {@code participant2}'s perspective that the active speaker
* has been displayed on the large video area. Mutes him back.
*
@@ -38,7 +38,6 @@ describe('ActiveSpeaker ', () => {
* @param {Participant} otherParticipant1 - <tt>Participant</tt> of the participant who will be observing and verifying
* active speaker change.
* @param {Participant} otherParticipant2 - Used only to print some debugging info.
* @returns {Promise<void>}
*/
async function testActiveSpeaker(
activeSpeaker: Participant, otherParticipant1: Participant, otherParticipant2: Participant) {
@@ -62,9 +61,10 @@ async function testActiveSpeaker(
const otherParticipant1Driver = otherParticipant1.driver;
await otherParticipant1Driver.waitUntil(
() => otherParticipant1Driver.execute((id: string) => APP.UI.getLargeVideoID() === id, speakerEndpoint),
async () => await otherParticipant1Driver.execute(
id => APP.UI.getLargeVideoID() === id, speakerEndpoint),
{
timeout: 30_1000, // 30 seconds
timeout: 30_000, // 30 seconds
timeoutMsg: 'Active speaker not displayed on large video.'
});
@@ -86,7 +86,6 @@ async function testActiveSpeaker(
* indicator displayed equals 1.
*
* @param {Participant} participant - The participant to check.
* @returns {Promise<void>}
*/
async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(

View File

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

View File

@@ -0,0 +1,202 @@
import {
ensureThreeParticipants,
ensureTwoParticipants,
unmuteVideoAndCheck
} from '../../helpers/participants';
const EMAIL = 'support@jitsi.org';
const HASH = '38f014e4b7dde0f64f8157d26a8c812e';
describe('Avatar - ', () => {
it('setup the meeting', async () => {
// Start p1
await ensureTwoParticipants(ctx, {
skipDisplayName: true,
// no default avatar if we have used to join a token with an avatar and no option to set it
skipFirstModerator: true
});
});
it('change and check', async () => {
const { p1, p2 } = ctx;
// check default avatar for p1 on p2
await p2.assertDefaultAvatarExist(p1);
await p1.getToolbar().clickProfileButton();
const settings = p1.getSettingsDialog();
await settings.waitForDisplay();
await settings.setEmail(EMAIL);
await settings.submit();
// check if the local avatar in the toolbar menu has changed
await p1.driver.waitUntil(
async () => (await p1.getToolbar().getProfileImage())?.includes(HASH), {
timeout: 3000, // give more time for the initial download of the image
timeoutMsg: 'Avatar has not changed for p1'
});
// check if the avatar in the local thumbnail has changed
expect(await p1.getLocalVideoAvatar()).toContain(HASH);
const p1EndpointId = await p1.getEndpointId();
await p2.driver.waitUntil(
async () => (await p2.getFilmstrip().getAvatar(p1EndpointId))?.includes(HASH), {
timeout: 5000,
timeoutMsg: 'Avatar has not changed for p1 on p2'
});
// check if the avatar in the large video has changed
expect(await p2.getLargeVideoAvatar()).toContain(HASH);
// we check whether the default avatar of participant2 is displayed on both sides
await p1.assertDefaultAvatarExist(p2);
await p2.assertDefaultAvatarExist(p2);
// the problem on FF where we can send keys to the input field,
// and the m from the text can mute the call, check whether we are muted
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
});
it('when video muted', async () => {
const { p1 } = ctx;
await ctx.p2.hangup();
// Mute p1's video
await p1.getToolbar().clickVideoMuteButton();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p1.driver.waitUntil(
async () => (await p1.getLargeVideoAvatar())?.includes(HASH), {
timeout: 2000,
timeoutMsg: 'Avatar on large video did not change'
});
const p1LargeSrc = await p1.getLargeVideoAvatar();
const p1ThumbSrc = await p1.getLocalVideoAvatar();
// Check if avatar on large video is the same as on local thumbnail
expect(p1ThumbSrc).toBe(p1LargeSrc);
// Join p2
await ensureTwoParticipants(ctx, {
skipDisplayName: true
});
const { p2 } = ctx;
// Verify that p1 is muted from the perspective of p2
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getFilmstrip().pinParticipant(p1);
// Check if p1's avatar is on large video now
await p2.driver.waitUntil(
async () => await p2.getLargeVideoAvatar() === p1LargeSrc, {
timeout: 2000,
timeoutMsg: 'Avatar on large video did not change'
});
// p1 pins p2's video
await p1.getFilmstrip().pinParticipant(p2);
// Check if avatar is displayed on p1's local video thumbnail
await p1.assertThumbnailShowsAvatar(p1, false, false, true);
// Unmute - now local avatar should be hidden and local video displayed
await unmuteVideoAndCheck(p1, p2);
await p1.asserLocalThumbnailShowsVideo();
// Now both p1 and p2 have video muted
await p1.getToolbar().clickVideoMuteButton();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await p2.getToolbar().clickVideoMuteButton();
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
// Start the third participant
await ensureThreeParticipants(ctx);
const { p3 } = ctx;
// Pin local video and verify avatars are displayed
await p3.getFilmstrip().pinParticipant(p3);
await p3.assertThumbnailShowsAvatar(p1, false, false, true);
await p3.assertThumbnailShowsAvatar(p2, false, true);
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
expect(await p3.getFilmstrip().getAvatar(p1EndpointId)).toBe(p1ThumbSrc);
// Click on p1's video
await p3.getFilmstrip().pinParticipant(p1);
// The avatar should be on large video and display name instead of an avatar, local video displayed
await p3.driver.waitUntil(
async () => await p3.getLargeVideoResource() === p1EndpointId, {
timeout: 2000,
timeoutMsg: `Large video did not switch to ${p1.name}`
});
await p3.assertDisplayNameVisibleOnStage(
await p3.getFilmstrip().getRemoteDisplayName(p1EndpointId));
// p2 has the default avatar
await p3.assertThumbnailShowsAvatar(p2, false, true);
await p3.assertThumbnailShowsAvatar(p3, true);
// Click on p2's video
await p3.getFilmstrip().pinParticipant(p2);
// The avatar should be on large video and display name instead of an avatar, local video displayed
await p3.driver.waitUntil(
async () => await p3.getLargeVideoResource() === p2EndpointId, {
timeout: 2000,
timeoutMsg: `Large video did not switch to ${p2.name}`
});
await p3.assertDisplayNameVisibleOnStage(
await p3.getFilmstrip().getRemoteDisplayName(p2EndpointId)
);
await p3.assertThumbnailShowsAvatar(p1, false, false, true);
await p3.assertThumbnailShowsAvatar(p3, true);
await p3.hangup();
// Unmute p1's and p2's videos
await p1.getToolbar().clickVideoUnmuteButton();
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
});
it('email persistence', async () => {
let { p1 } = ctx;
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
await p1.hangup();
await ensureTwoParticipants(ctx, {
skipDisplayName: true,
skipFirstModerator: true
});
p1 = ctx.p1;
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
});
});

View File

@@ -1,12 +1,16 @@
{
"include": ["**/*.ts", "../globals.d.ts"],
"include": [
"**/*.ts",
"../globals.d.ts",
"./globals.d.ts"
],
"extends": "../tsconfig.web",
"compilerOptions": {
"types": [
"node",
"@wdio/globals/types",
"@types/jasmine",
"@wdio/jasmine-framework"
"@types/mocha",
"@wdio/mocha-framework"
]
}
}

View File

@@ -1,9 +1,13 @@
import AllureReporter from '@wdio/allure-reporter';
import { multiremotebrowser } from '@wdio/globals';
import { Buffer } from 'buffer';
import path from 'node:path';
import process from 'node:process';
import pretty from 'pretty';
import WebhookProxy from './helpers/WebhookProxy';
import { getLogs, initLogger, logInfo } from './helpers/browserLogger';
import { IContext } from './helpers/types';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const allure = require('allure-commandline');
@@ -24,7 +28,11 @@ const chromeArgs = [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--use-file-for-fake-audio-capture=tests/resources/fakeAudioStream.wav'
// Avoids - "You are checking for animations on an inactive tab, animations do not run for inactive tabs"
// when executing waitForStable()
'--disable-renderer-backgrounding',
`--use-file-for-fake-audio-capture=${process.env.REMOTE_RESOURCE_PATH || 'tests/resources'}/fakeAudioStream.wav`
];
if (process.env.RESOLVER_RULES) {
@@ -56,7 +64,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
],
maxInstances: 1,
baseUrl: process.env.BASE_URL || 'https://alpha.jitsi.net/torture',
baseUrl: process.env.BASE_URL || 'https://alpha.jitsi.net/torture/',
tsConfigPath: './tsconfig.json',
// Default timeout for all waitForXXX commands.
@@ -69,10 +77,10 @@ export const config: WebdriverIO.MultiremoteConfig = {
// Default request retries count
connectionRetryCount: 3,
framework: 'jasmine',
framework: 'mocha',
jasmineOpts: {
defaultTimeoutInterval: 60_000
mochaOpts: {
timeout: 60_000
},
capabilities: {
@@ -105,6 +113,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
prefs: chromePreferences
},
'wdio:exclude': [
'specs/alone/**',
'specs/2way/**'
]
}
@@ -117,6 +126,8 @@ export const config: WebdriverIO.MultiremoteConfig = {
prefs: chromePreferences
},
'wdio:exclude': [
'specs/alone/**',
'specs/2way/**',
'specs/3way/**'
]
}
@@ -157,19 +168,64 @@ export const config: WebdriverIO.MultiremoteConfig = {
*
* @returns {Promise<void>}
*/
before() {
multiremotebrowser.instances.forEach((instance: string) => {
initLogger(multiremotebrowser.getInstance(instance), instance, TEST_RESULTS_DIR);
});
async before() {
await Promise.all(multiremotebrowser.instances.map(async (instance: string) => {
const bInstance = multiremotebrowser.getInstance(instance);
initLogger(bInstance, instance, TEST_RESULTS_DIR);
if (bInstance.isFirefox) {
return;
}
// if (process.env.GRID_HOST_URL) {
// TODO: make sure we use uploadFile only with chrome (it does not work with FF),
// we need to test it with the grid and FF, does it work there
const rpath = await bInstance.uploadFile('tests/resources/iframeAPITest.html');
// @ts-ignore
bInstance.iframePageBase = `file://${path.dirname(rpath)}`;
}));
const globalAny: any = global;
const roomName = `jitsimeettorture-${crypto.randomUUID()}`;
globalAny.ctx = {} as IContext;
globalAny.ctx.roomName = roomName;
globalAny.ctx.jwtPrivateKeyPath = process.env.JWT_PRIVATE_KEY_PATH;
globalAny.ctx.jwtKid = process.env.JWT_KID;
},
after() {
const { ctx }: any = global;
if (ctx.webhooksProxy) {
ctx.webhooksProxy.disconnect();
}
},
/**
* Gets executed before the suite starts (in Mocha/Jasmine only).
*
* @param {Object} suite - Suite details.
* @returns {Promise<void>}
*/
beforeSuite(suite) {
const { ctx }: any = global;
// 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 (path.basename(suite.file).startsWith('iFrameApi')) {
ctx.iframeAPI = true;
if (!ctx.webhooksProxy
&& process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET) {
ctx.webhooksProxy = new WebhookProxy(
`${process.env.WEBHOOKS_PROXY_URL}&room=${ctx.roomName}`,
process.env.WEBHOOKS_PROXY_SHARED_SECRET);
ctx.webhooksProxy.connect();
}
}
multiremotebrowser.instances.forEach((instance: string) => {
logInfo(multiremotebrowser.getInstance(instance),
`---=== Begin ${suite.file.substring(suite.file.lastIndexOf('/') + 1)} ===---`);
@@ -180,11 +236,13 @@ export const config: WebdriverIO.MultiremoteConfig = {
* Function to be executed before a test (in Mocha/Jasmine only).
*
* @param {Object} test - Test object.
* @returns {Promise<void>}
* @param {Object} context - The context object.
*/
beforeTest(test) {
beforeTest(test, context) {
ctx.skipSuiteTests && context.skip();
multiremotebrowser.instances.forEach((instance: string) => {
logInfo(multiremotebrowser.getInstance(instance), `---=== Start test ${test.fullName} ===---`);
logInfo(multiremotebrowser.getInstance(instance), `---=== Start test ${test.title} ===---`);
});
},
@@ -198,7 +256,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
*/
async afterTest(test, context, { error }) {
multiremotebrowser.instances.forEach((instance: string) =>
logInfo(multiremotebrowser.getInstance(instance), `---=== End test ${test.fullName} ===---`));
logInfo(multiremotebrowser.getInstance(instance), `---=== End test ${test.title} ===---`));
if (error) {
const allProcessing: Promise<any>[] = [];
@@ -217,7 +275,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
AllureReporter.addAttachment(`console-logs-${instance}`, getLogs(bInstance) || '', 'text/plain');
allProcessing.push(bInstance.getPageSource().then(source => {
AllureReporter.addAttachment(`html-source-${instance}`, source, 'text/plain');
AllureReporter.addAttachment(`html-source-${instance}`, pretty(source), 'text/plain');
}));
});
@@ -270,4 +328,4 @@ export const config: WebdriverIO.MultiremoteConfig = {
});
});
}
};
} as WebdriverIO.MultiremoteConfig;

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

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

View File

@@ -0,0 +1,39 @@
// wdio.firefox.conf.ts
// extends te main configuration file changing first participant to be Firefox
import { merge } from 'lodash-es';
import process from 'node:process';
// @ts-ignore
import { config as defaultConfig } from './wdio.conf.ts';
const ffArgs = [];
const ffPreferences = {
'intl.accept_languages': 'en-US',
'media.navigator.permission.disabled': true,
'media.navigator.streams.fake': true,
'media.autoplay.default': 0
};
if (process.env.HEADLESS === 'true') {
ffArgs.push('--headless');
}
export const config = merge(defaultConfig, {
exclude: [
'specs/2way/iFrameParticipantsPresence.spec.ts', // FF does not support uploading files (uploadFile)
'specs/3way/activeSpeaker.spec.ts' // FF does not support setting a file as mic input
],
capabilities: {
participant1: {
capabilities: {
browserName: 'firefox',
'moz:firefoxOptions': {
args: ffArgs,
prefs: ffPreferences
},
acceptInsecureCerts: process.env.ALLOW_INSECURE_CERTS === 'true'
}
}
}
}, { clone: false });

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

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

View File

@@ -253,7 +253,10 @@ function getDevServerConfig() {
],
server: process.env.CODESPACES ? 'http' : 'https',
static: {
directory: process.cwd()
directory: process.cwd(),
watch: {
ignored: file => file.endsWith('.log')
}
}
};
}